Attached script:
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.128/build/three.module.js",
"csg": "https://cdn.jsdelivr.net/gh/manthrax/THREE-CSGMesh/three-csg.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import CSG from "csg";
const viewList = ['1', '2', '3', '4', '5', '6', '7', '8'];
let scenes = {}, cameras = {}, renderers = {};
let currentPage = 1, maxPages = 8, activeWallCount = 4, activeWallCenters = [];
let extraGeoms = [], extraMats = [];
// DATA STORES
let featuresData = [
{ id: 'F1', cat: 'door', type: 'Standard', wallId: '1.1', w: 36, h: 80, pos: 50, sill: 0, finish: 'pine', isMetal: false, color: '#ffffff', deadbolt: true },
{ id: 'F2', cat: 'window', type: 'Single Hung', wallId: '1.2', w: 48, h: 48, pos: 50, sill: 36, finish: 'pine', isMetal: false, color: '#ffffff', deadbolt: false }
];
let hvacData = [{ id: 'H1', type: 'fireplace', wallId: '1.3', w: 60, d: 24, h: 60, pos: 50 }];
let interiorWallsData = [{ id: 'A1.1', anchorId: '1.4', pos: 50, length: 144, thick: 4.5, snapId: 'none', color: '#dddddd', angle: 0, copyUp: false }];
let electricalData = [];
let columnsData = [];
window.polyIrregPts = [
{x: -150, y: -150}, {x: 150, y: -150}, {x: 250, y: 50}, {x: 0, y: 200}, {x: -200, y: 50}
];
window.allWallsContext = [];
window.renumberAll = () => {
featuresData.forEach((f, i) => f.id = 'F' + (i+1));
hvacData.forEach((h, i) => h.id = 'H' + (i+1));
electricalData.forEach((e, i) => e.id = 'E' + (i+1));
columnsData.forEach((c, i) => c.id = 'C' + (i+1));
let floorMap = {};
let iwNewIdMap = {};
let floorCounters = {};
let unresolved = [...interiorWallsData];
let loops = 0;
while(unresolved.length > 0 && loops < 1000) {
loops++;
let iw = unresolved.shift();
let anchorFloor = null;
let oldAnchor = iw.anchorId;
let matchExt = oldAnchor.match(/^(\d+)\./);
if (matchExt) {
anchorFloor = parseInt(matchExt[1]);
} else if (oldAnchor.includes('_F')) {
anchorFloor = parseInt(oldAnchor.split('_F')[1]);
} else {
let matchInt = oldAnchor.match(/^A(\d+)\./);
if (matchInt) {
anchorFloor = parseInt(matchInt[1]);
} else {
for (let oldId in iwNewIdMap) {
if (iwNewIdMap[oldId] === oldAnchor || oldId === oldAnchor) {
anchorFloor = floorMap[oldId];
break;
}
}
}
}
if (anchorFloor !== null) {
floorMap[iw.id] = anchorFloor;
if (!floorCounters[anchorFloor]) floorCounters[anchorFloor] = 1;
let newId = 'A' + anchorFloor + '.' + (floorCounters[anchorFloor]++);
iwNewIdMap[iw.id] = newId;
iw.id = newId;
} else {
unresolved.push(iw);
}
}
interiorWallsData.forEach(iw => {
if (iwNewIdMap[iw.anchorId]) iw.anchorId = iwNewIdMap[iw.anchorId];
if (iw.snapId && iw.snapId !== 'none' && iwNewIdMap[iw.snapId]) iw.snapId = iwNewIdMap[iw.snapId];
});
[featuresData, hvacData, electricalData].forEach(arr => {
arr.forEach(item => {
if (iwNewIdMap[item.wallId]) item.wallId = iwNewIdMap[item.wallId];
});
});
};
// UI LOGIC
window.updateShapeUI = () => {
let shape = document.getElementById('bldgShape').value;
let polyUI = document.querySelector('.poly-ui');
let btnIrreg = document.getElementById('btnIrreg');
if (shape.startsWith('polygon')) {
polyUI.style.display = 'flex';
btnIrreg.style.display = shape === 'polygon_irreg' ? 'block' : 'none';
} else {
polyUI.style.display = 'none';
}
window.updateModel();
};
window.updateRoofUI = () => {
let type = document.querySelector('input[name="roofType"]:checked').value;
let pitchUI = document.getElementById('roofPitchUI');
if (type === 'flat') {
pitchUI.style.display = 'none';
document.getElementById('lblRidgeThick').style.display = 'none';
document.getElementById('roofRidgeThick').style.display = 'none';
document.getElementById('lblFlatThick').style.display = 'inline';
document.getElementById('roofFlatThick').style.display = 'inline';
} else {
pitchUI.style.display = 'flex';
document.getElementById('lblRidgeThick').style.display = 'inline';
document.getElementById('roofRidgeThick').style.display = 'inline';
document.getElementById('lblFlatThick').style.display = 'none';
document.getElementById('roofFlatThick').style.display = 'none';
}
window.updateModel();
};
window.updateFloorUI = () => {
let ns = parseInt(document.getElementById('numStories').value) || 1;
let container = document.getElementById('floorJoistContainer');
let existingVals = [];
for(let i=1; i<=10; i++) {
let el = document.getElementById('fjSize' + i);
if(el) existingVals[i] = el.value;
}
container.innerHTML = '';
for(let i=1; i<=ns; i++) {
let val = existingVals[i] || "11.25";
container.innerHTML += `<label style="display:flex; justify-content:space-between; align-items:center;">Story ${i} Joist Size:
<select id="fjSize${i}" onchange="window.updateModel()" style="width:140px;">
<option value="9.25" ${val==="9.25"?"selected":""}>2x10 (9.25")</option>
<option value="11.25" ${val==="11.25"?"selected":""}>2x12 (11.25")</option>
<option value="13.25" ${val==="13.25"?"selected":""}>14" I-Joist (13.25")</option>
<option value="15.25" ${val==="15.25"?"selected":""}>16" I-Joist (15.25")</option>
</select>
</label>`;
}
};
window.switchTab = (tabId, e) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
e.currentTarget.classList.add('active');
};
window.toggleOpUI = () => {
const cat = document.getElementById('opCat').value;
const typeSel = document.getElementById('opType');
typeSel.innerHTML = '';
if(cat === 'door') {
['Standard', 'Standard w/ Panes', 'Sliding', 'Bifold'].forEach(t => typeSel.add(new Option(t, t)));
document.getElementById('sillWrapper').style.display = 'none';
document.getElementById('deadboltWrapper').style.display = 'flex';
document.getElementById('opSill').value = 0;
} else {
['Single Hung', 'Double Hung', 'Sliding', 'Casement'].forEach(t => typeSel.add(new Option(t, t)));
document.getElementById('sillWrapper').style.display = 'flex';
document.getElementById('deadboltWrapper').style.display = 'none';
}
};
window.toggleElUI = () => {
const type = document.getElementById('elType').value;
const dims = document.querySelectorAll('.el-dim');
if (type === 'battery_wall' || type === 'battery_grade') {
dims.forEach(el => el.style.display = 'flex');
} else {
dims.forEach(el => el.style.display = 'none');
}
};
window.populateWallSelects = () => {
const selects = ['opWall', 'iwWall', 'elWall', 'hvacWall', 'iwSnap'];
let opts = window.allWallsContext ? window.allWallsContext.map(w => w.fullId) : [];
selects.forEach(id => {
const el = document.getElementById(id);
if(!el) return;
const cur = el.value; el.innerHTML = '';
if(id === 'iwSnap') el.add(new Option('None', 'none'));
opts.forEach(o => el.add(new Option('Wall ' + o, o)));
if(opts.includes(cur)) el.value = cur;
});
};
window.openIrregModal = () => { document.getElementById('modalIrreg').style.display = 'flex'; window.renderIrregList(); };
window.closeIrregModal = () => { document.getElementById('modalIrreg').style.display = 'none'; window.updateModel(); };
window.openFoundModal = () => document.getElementById('modalFound').style.display = 'flex';
window.closeFoundModal = () => { document.getElementById('modalFound').style.display = 'none'; window.updateModel(); };
window.openWallModal = () => document.getElementById('modalWall').style.display = 'flex';
window.closeWallModal = () => { document.getElementById('modalWall').style.display = 'none'; window.updateModel(); };
window.openFloorModal = () => document.getElementById('modalFloor').style.display = 'flex';
window.closeFloorModal = () => { document.getElementById('modalFloor').style.display = 'none'; window.updateModel(); };
window.openRoofModal = () => { document.getElementById('modalRoof').style.display = 'flex'; };
window.closeRoofModal = () => { document.getElementById('modalRoof').style.display = 'none'; window.updateModel(); };
window.addIrregPt = () => {
let x = parseFloat(document.getElementById('irregX').value)||0;
let z = parseFloat(document.getElementById('irregZ').value)||0;
window.polyIrregPts.push({x, y: z});
window.renderIrregList();
};
window.removeIrregPt = (i) => {
if(window.polyIrregPts.length <= 3) return alert("Minimum 3 points required.");
window.polyIrregPts.splice(i, 1);
window.renderIrregList();
};
window.renderIrregList = () => {
let list = document.getElementById('irregList');
list.innerHTML = '';
window.polyIrregPts.forEach((p, i) => {
list.innerHTML += `<div class="dynamic-item"><span>Pt ${i+1}: X:${p.x}, Z:${p.y}</span><button class="btn-action btn-danger" onclick="window.removeIrregPt(${i})">X</button></div>`;
});
};
window.openCustomizeModal = () => { window.populateWallSelects(); window.toggleOpUI(); window.toggleElUI(); document.getElementById('modalCustomize').style.display = 'flex'; renderAllLists(); };
window.closeCustomizeModal = () => { document.getElementById('modalCustomize').style.display = 'none'; window.updateModel(); };
window.removeFeature = (i) => { featuresData.splice(i,1); window.renumberAll(); renderAllLists(); };
window.removeHVAC = (i) => { hvacData.splice(i,1); window.renumberAll(); renderAllLists(); };
window.removeIntWall = (i) => { interiorWallsData.splice(i,1); window.renumberAll(); window.populateWallSelects(); renderAllLists(); };
window.removeElectrical = (i) => { electricalData.splice(i,1); window.renumberAll(); renderAllLists(); };
window.removeColumn = (i) => { columnsData.splice(i,1); window.renumberAll(); renderAllLists(); };
window.editFeature = (i) => {
let f = featuresData[i];
document.getElementById('opCat').value = f.cat; window.toggleOpUI();
document.getElementById('opType').value = f.type; document.getElementById('opWall').value = f.wallId;
document.getElementById('opPos').value = f.pos; document.getElementById('opW').value = f.w;
document.getElementById('opH').value = f.h; document.getElementById('opFinish').value = f.finish;
document.getElementById('opIsMetal').checked = f.isMetal; document.getElementById('opColor').value = f.color;
if(f.cat === 'window') document.getElementById('opSill').value = f.sill; else document.getElementById('opDeadbolt').checked = f.deadbolt;
window.removeFeature(i);
};
window.editHVAC = (i) => {
let h = hvacData[i];
document.getElementById('hvacType').value = h.type; document.getElementById('hvacWall').value = h.wallId;
document.getElementById('hvacPos').value = h.pos; document.getElementById('hvacW').value = h.w;
document.getElementById('hvacD').value = h.d || 24; document.getElementById('hvacH').value = h.h || 60;
window.removeHVAC(i);
};
window.editIntWall = (i) => {
let iw = interiorWallsData[i];
document.getElementById('iwWall').value = iw.anchorId;
document.getElementById('iwPos').value = iw.pos;
document.getElementById('iwLen').value = iw.length;
document.getElementById('iwThick').value = iw.thick;
document.getElementById('iwSnap').value = iw.snapId || 'none';
document.getElementById('iwColor').value = iw.color || '#dddddd';
document.getElementById('iwAngle').value = iw.angle || 0;
document.getElementById('iwCopyUp').checked = iw.copyUp || false;
window.removeIntWall(i);
};
window.editElectrical = (i) => {
let e = electricalData[i];
document.getElementById('elType').value = e.type; window.toggleElUI();
document.getElementById('elWall').value = e.wallId;
document.getElementById('elPos').value = e.pos;
if (e.w) document.getElementById('elW').value = e.w;
if (e.h) document.getElementById('elH').value = e.h;
if (e.d) document.getElementById('elD').value = e.d;
window.removeElectrical(i);
};
window.editColumn = (i) => {
let c = columnsData[i];
document.getElementById('colShape').value = c.shape; document.getElementById('colPlateShape').value = c.plateShape;
document.getElementById('colFloor').value = c.floor || 1;
document.getElementById('colTopThk').value = c.topThick; document.getElementById('colCount').value = c.count;
document.getElementById('colX').value = c.startX; document.getElementById('colZ').value = c.startZ;
document.getElementById('colSpacingX').value = c.spacingX; document.getElementById('colSpacingZ').value = c.spacingZ;
window.removeColumn(i);
};
function renderAllLists() {
let opL = document.getElementById('openingList'); opL.innerHTML = '';
featuresData.forEach((f, i) => { opL.innerHTML += `<div class="dynamic-item"><span><strong>${f.wallId}</strong> | ${f.type} ${f.cat}: ${f.w}"x${f.h}" @ ${f.pos}%</span><div class="dynamic-actions"><button class="btn-action btn-edit" onclick="window.editFeature(${i})">Edit</button><button class="btn-action btn-danger" onclick="window.removeFeature(${i})">X</button></div></div>`; });
let hvL = document.getElementById('hvacList'); hvL.innerHTML = '';
hvacData.forEach((f, i) => { hvL.innerHTML += `<div class="dynamic-item"><span><strong>${f.wallId}</strong> | ${f.type} @ ${f.pos}%</span><div class="dynamic-actions"><button class="btn-action btn-edit" onclick="window.editHVAC(${i})">Edit</button><button class="btn-action btn-danger" onclick="window.removeHVAC(${i})">X</button></div></div>`; });
let iwL = document.getElementById('intWallList'); iwL.innerHTML = '';
interiorWallsData.forEach((f, i) => {
let snapTxt = (f.snapId && f.snapId !== 'none') ? ` (Snaps to ${f.snapId})` : '';
let details = f.isSnapSegment ? `[AUTO-SNAP]` : `L:${f.length}" @ ${f.angle}°`;
let copyTxt = f.copyUp ? ` [Copied Up]` : '';
iwL.innerHTML += `<div class="dynamic-item"><span><strong>${f.id}</strong> on ${f.anchorId} | ${details} T:${f.thick}"${snapTxt}${copyTxt}</span><div class="dynamic-actions"><button class="btn-action btn-edit" onclick="window.editIntWall(${i})">Edit</button><button class="btn-action btn-danger" onclick="window.removeIntWall(${i})">X</button></div></div>`;
});
let elL = document.getElementById('electricalList'); elL.innerHTML = '';
electricalData.forEach((f, i) => {
let dims = (f.type.startsWith('battery')) ? ` (${f.w}x${f.h}x${f.d})` : '';
elL.innerHTML += `<div class="dynamic-item"><span><strong>${f.wallId}</strong> | ${f.type}${dims} @ ${f.pos}%</span><div class="dynamic-actions"><button class="btn-action btn-edit" onclick="window.editElectrical(${i})">Edit</button><button class="btn-action btn-danger" onclick="window.removeElectrical(${i})">X</button></div></div>`;
});
let colL = document.getElementById('columnList'); colL.innerHTML = '';
columnsData.forEach((c, i) => { colL.innerHTML += `<div class="dynamic-item"><span><strong>${c.id}</strong> | ${c.shape} | Flr: ${c.floor||1} | Count: ${c.count} @ X:${c.startX}, Z:${c.startZ}</span><div class="dynamic-actions"><button class="btn-action btn-edit" onclick="window.editColumn(${i})">Edit</button><button class="btn-action btn-danger" onclick="window.removeColumn(${i})">X</button></div></div>`; });
}
window.addOpening = () => { featuresData.push({ id: 'TEMP', cat: document.getElementById('opCat').value, type: document.getElementById('opType').value, wallId: document.getElementById('opWall').value, w: parseFloat(document.getElementById('opW').value)||36, h: parseFloat(document.getElementById('opH').value)||80, pos: parseFloat(document.getElementById('opPos').value)||50, sill: document.getElementById('opCat').value === 'window' ? (parseFloat(document.getElementById('opSill').value)||36) : 0, finish: document.getElementById('opFinish').value, isMetal: document.getElementById('opIsMetal').checked, color: document.getElementById('opColor').value, deadbolt: document.getElementById('opDeadbolt').checked }); window.renumberAll(); renderAllLists(); };
window.addHVAC = () => { hvacData.push({ id: 'TEMP', type: document.getElementById('hvacType').value, wallId: document.getElementById('hvacWall').value, pos: parseFloat(document.getElementById('hvacPos').value)||50, w: parseFloat(document.getElementById('hvacW').value)||60, d: parseFloat(document.getElementById('hvacD').value)||24, h: parseFloat(document.getElementById('hvacH').value)||60 }); window.renumberAll(); renderAllLists(); };
window.addIntWall = () => {
let snapId = document.getElementById('iwSnap').value;
let tempId1 = 'TEMP_' + Math.random();
interiorWallsData.push({
id: tempId1,
anchorId: document.getElementById('iwWall').value,
pos: parseFloat(document.getElementById('iwPos').value)||50,
length: parseFloat(document.getElementById('iwLen').value)||120,
thick: parseFloat(document.getElementById('iwThick').value)||4.5,
snapId: 'none',
color: document.getElementById('iwColor').value,
angle: parseFloat(document.getElementById('iwAngle').value)||0,
copyUp: document.getElementById('iwCopyUp').checked
});
if (snapId !== 'none') {
interiorWallsData.push({
id: 'TEMP_' + Math.random(),
anchorId: tempId1,
pos: 100,
length: 0,
thick: parseFloat(document.getElementById('iwThick').value)||4.5,
snapId: snapId,
isSnapSegment: true,
color: document.getElementById('iwColor').value,
angle: 0,
copyUp: document.getElementById('iwCopyUp').checked
});
}
window.renumberAll();
window.populateWallSelects();
renderAllLists();
};
window.addElectrical = () => {
let t = document.getElementById('elType').value;
let eObj = { id: 'TEMP', type: t, wallId: document.getElementById('elWall').value, pos: parseFloat(document.getElementById('elPos').value)||50 };
if (t === 'battery_wall' || t === 'battery_grade') {
eObj.w = parseFloat(document.getElementById('elW').value)||36;
eObj.h = parseFloat(document.getElementById('elH').value)||48;
eObj.d = parseFloat(document.getElementById('elD').value)||12;
}
electricalData.push(eObj);
window.renumberAll();
renderAllLists();
};
window.addColumn = () => { columnsData.push({ id: 'TEMP', shape: document.getElementById('colShape').value, plateShape: document.getElementById('colPlateShape').value, floor: parseInt(document.getElementById('colFloor').value)||1, topThick: parseFloat(document.getElementById('colTopThk').value)||2, count: parseInt(document.getElementById('colCount').value)||1, startX: parseFloat(document.getElementById('colX').value)||0, startZ: parseFloat(document.getElementById('colZ').value)||0, spacingX: parseFloat(document.getElementById('colSpacingX').value)||120, spacingZ: parseFloat(document.getElementById('colSpacingZ').value)||0 }); window.renumberAll(); renderAllLists(); }
// MATERIALS DICT
const MATS = {
glass: new THREE.MeshPhysicalMaterial({ color: 0x88ccff, transparent: true, opacity: 0.4, roughness: 0.1, metalness: 0.1, side: THREE.DoubleSide }),
metal: new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.4, metalness: 0.8, side: THREE.DoubleSide }),
knob: new THREE.MeshStandardMaterial({ color: 0xd4af37, roughness: 0.2, metalness: 0.9, side: THREE.DoubleSide }),
concrete: new THREE.MeshStandardMaterial({ color: 0x777777, roughness: 0.9, side: THREE.DoubleSide }),
wall: new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.8, side: THREE.DoubleSide }),
wood: new THREE.MeshStandardMaterial({ color: 0xb58957, roughness: 0.9, side: THREE.DoubleSide }),
subfloor: new THREE.MeshStandardMaterial({ color: 0xd2b48c, roughness: 1.0, side: THREE.DoubleSide }),
brick: new THREE.MeshStandardMaterial({ color: 0x9c4a36, roughness: 0.9, side: THREE.DoubleSide })
};
function getFrameMat(op) {
if(op.isMetal) return MATS.metal;
let baseHex = parseInt(op.color.replace('#',''), 16);
let m = new THREE.MeshStandardMaterial({ color: baseHex, roughness: 0.7, side: THREE.DoubleSide });
extraMats.push(m); return m;
}
// MILLWORK BUILDERS
function buildCasementTrim(w, h, thick) {
const grp = new THREE.Group();
const trimW = 3; const trimD = 0.75;
const left = new THREE.Mesh(new THREE.BoxGeometry(trimW, h + trimW, trimD), MATS.wall); left.position.set(-w/2 - trimW/2, trimW/2, thick/2 + trimD/2);
const right = new THREE.Mesh(new THREE.BoxGeometry(trimW, h + trimW, trimD), MATS.wall); right.position.set(w/2 + trimW/2, trimW/2, thick/2 + trimD/2);
const top = new THREE.Mesh(new THREE.BoxGeometry(w + trimW*2, trimW, trimD), MATS.wall); top.position.set(0, h/2 + trimW/2, thick/2 + trimD/2);
grp.add(left, right, top);
let intGrp = grp.clone(); intGrp.position.z = -thick - trimD; grp.add(intGrp);
return grp;
}
function buildStandardDoor(w, h, op, isExterior) {
const grp = new THREE.Group(); const fMat = getFrameMat(op);
const sW = 5.5, sT = 1.5;
const leftStile = new THREE.Mesh(new THREE.BoxGeometry(sW, h, sT), fMat); leftStile.position.set(-w/2 + sW/2, 0, 0);
let rCSG = CSG.fromMesh(new THREE.Mesh(new THREE.BoxGeometry(sW, h, sT)), 0);
const knobHole = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, sT*2, 16)); knobHole.rotation.x = Math.PI/2; knobHole.position.set(0, -h/2 + 36, 0);
rCSG = rCSG.subtract(CSG.fromMesh(knobHole, 1));
if(isExterior || op.deadbolt) {
const dbHole = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, sT*2, 16)); dbHole.rotation.x = Math.PI/2; dbHole.position.set(0, -h/2 + 42, 0);
rCSG = rCSG.subtract(CSG.fromMesh(dbHole, 2));
}
const rightStile = CSG.toMesh(rCSG, new THREE.Matrix4(), fMat); rightStile.position.set(w/2 - sW/2, 0, 0);
const rW = w - (sW * 2);
const topRail = new THREE.Mesh(new THREE.BoxGeometry(rW, sW, sT), fMat); topRail.position.set(0, h/2 - sW/2, 0);
const botRail = new THREE.Mesh(new THREE.BoxGeometry(rW, sW, sT), fMat); botRail.position.set(0, -h/2 + sW/2, 0);
const midRail = new THREE.Mesh(new THREE.BoxGeometry(rW, 7.5, sT), fMat); midRail.position.set(0, 0, 0);
const knob = new THREE.Mesh(new THREE.SphereGeometry(1.3, 16, 16), MATS.knob); knob.position.set(w/2 - sW/2, -h/2 + 36, sT);
const knob2 = knob.clone(); knob2.position.z = -sT;
const hg = new THREE.BoxGeometry(0.2, 4, 1.6);
for(let y of [-h/2 + 10, h/2 - 10]) { let hng = new THREE.Mesh(hg, MATS.metal); hng.position.set(-w/2 + 0.1, y, 0); grp.add(hng); }
grp.add(leftStile, rightStile, topRail, botRail, midRail, knob, knob2);
const panH = (h - (sW*2) - 7.5)/2;
if(op.type.includes('Panes')) {
const pane = new THREE.Mesh(new THREE.BoxGeometry(rW - 2, panH - 2, 0.25), MATS.glass); pane.position.set(0, h/2 - sW - panH/2, 0); grp.add(pane);
}
return grp;
}
function buildSliding(w, h, op) {
const grp = new THREE.Group(); const fMat = getFrameMat(op);
const pW = w/2 + 1, pD = 2;
for(let i=0; i<2; i++) {
let pGrp = new THREE.Group();
let frame = new THREE.Mesh(new THREE.BoxGeometry(pW, h, pD), fMat);
let hole = new THREE.Mesh(new THREE.BoxGeometry(pW - 2, h - 2, pD*2));
let fMesh = CSG.toMesh(CSG.fromMesh(frame, 0).subtract(CSG.fromMesh(hole, 1)), new THREE.Matrix4(), fMat);
let glass = new THREE.Mesh(new THREE.BoxGeometry(pW-2, h-2, 0.25), MATS.glass); pGrp.add(fMesh, glass);
let handle = new THREE.Mesh(new THREE.BoxGeometry(1, 8, 0.5), MATS.metal); handle.position.set(i===0 ? -pW/2 + 2 : pW/2 - 2, 0, pD/2 + 0.25); pGrp.add(handle);
pGrp.position.set(i===0 ? -w/4 : w/4, 0, i===0 ? -1 : 1); grp.add(pGrp);
}
return grp;
}
function buildWindowSashes(w, h, op) {
const grp = new THREE.Group(); const fMat = getFrameMat(op); const sT = 1.5;
if(op.type === 'Sliding') return buildSliding(w, h, op);
const sH = h/2 + 0.5;
for(let i=0; i<2; i++) {
let frame = new THREE.Mesh(new THREE.BoxGeometry(w, sH, sT), fMat);
let hole = new THREE.Mesh(new THREE.BoxGeometry(w - sT*2, sH - sT*2, sT*2));
let fMesh = CSG.toMesh(CSG.fromMesh(frame, 0).subtract(CSG.fromMesh(hole, 1)), new THREE.Matrix4(), fMat);
let glass = new THREE.Mesh(new THREE.BoxGeometry(w - sT*2, sH - sT*2, 0.25), MATS.glass); fMesh.add(glass);
fMesh.position.set(0, i===0 ? -h/4 : h/4, i===0 ? -0.5 : 0.5); grp.add(fMesh);
}
return grp;
}
function generateShapePoints(shapeType, W, L) {
let w2 = W/2, l2 = L/2, tw = W * 0.35, tl = L * 0.35; let pts = [];
switch(shapeType) {
case 'rectangular': pts = [new THREE.Vector2(-w2, -l2), new THREE.Vector2(w2, -l2), new THREE.Vector2(w2, l2), new THREE.Vector2(-w2, l2)]; break;
case 'l_shape': pts = [new THREE.Vector2(-w2, -l2), new THREE.Vector2(w2, -l2), new THREE.Vector2(w2, -l2+tl), new THREE.Vector2(-w2+tw, -l2+tl), new THREE.Vector2(-w2+tw, l2), new THREE.Vector2(-w2, l2)]; break;
case 'u_shape': pts = [new THREE.Vector2(-w2, -l2), new THREE.Vector2(w2, -l2), new THREE.Vector2(w2, l2), new THREE.Vector2(w2-tw, l2), new THREE.Vector2(w2-tw, -l2+tl), new THREE.Vector2(-w2+tw, -l2+tl), new THREE.Vector2(-w2+tw, l2), new THREE.Vector2(-w2, l2)]; break;
case 'h_shape': pts = [new THREE.Vector2(-w2, -l2), new THREE.Vector2(-w2+tw, -l2), new THREE.Vector2(-w2+tw, -tl/2), new THREE.Vector2(w2-tw, -tl/2), new THREE.Vector2(w2-tw, -l2), new THREE.Vector2(w2, -l2), new THREE.Vector2(w2, l2), new THREE.Vector2(w2-tw, l2), new THREE.Vector2(w2-tw, tl/2), new THREE.Vector2(-w2+tw, tl/2), new THREE.Vector2(-w2+tw, l2), new THREE.Vector2(-w2, l2)]; break;
case 'right_wing': pts = [ new THREE.Vector2(-W*0.5, -L*0.5), new THREE.Vector2(0, -L*0.5), new THREE.Vector2(0, -L*0.15), new THREE.Vector2(W*0.2, -L*0.15), new THREE.Vector2(W*0.2, -L*0.35), new THREE.Vector2(W*0.5, -L*0.35), new THREE.Vector2(W*0.5, L*0.35), new THREE.Vector2(W*0.2, L*0.35), new THREE.Vector2(W*0.2, L*0.15), new THREE.Vector2(0, L*0.15), new THREE.Vector2(0, L*0.5), new THREE.Vector2(-W*0.5, L*0.5) ]; break;
case 'left_wing': pts = [ new THREE.Vector2(0, -L*0.5), new THREE.Vector2(W*0.5, -L*0.5), new THREE.Vector2(W*0.5, L*0.5), new THREE.Vector2(0, L*0.5), new THREE.Vector2(0, L*0.15), new THREE.Vector2(-W*0.2, L*0.15), new THREE.Vector2(-W*0.2, L*0.35), new THREE.Vector2(-W*0.5, L*0.35), new THREE.Vector2(-W*0.5, -L*0.35), new THREE.Vector2(-W*0.2, -L*0.35), new THREE.Vector2(-W*0.2, -L*0.15), new THREE.Vector2(0, -L*0.15) ]; break;
case 'dual_wing': pts = [ new THREE.Vector2(-W*0.2, -L*0.5), new THREE.Vector2(W*0.2, -L*0.5), new THREE.Vector2(W*0.2, -L*0.15), new THREE.Vector2(W*0.35, -L*0.15), new THREE.Vector2(W*0.35, -L*0.35), new THREE.Vector2(W*0.5, -L*0.35), new THREE.Vector2(W*0.5, L*0.35), new THREE.Vector2(W*0.35, L*0.35), new THREE.Vector2(W*0.35, L*0.15), new THREE.Vector2(W*0.2, L*0.15), new THREE.Vector2(W*0.2, L*0.5), new THREE.Vector2(-W*0.2, L*0.5), new THREE.Vector2(-W*0.2, L*0.15), new THREE.Vector2(-W*0.35, L*0.15), new THREE.Vector2(-W*0.35, L*0.35), new THREE.Vector2(-W*0.5, L*0.35), new THREE.Vector2(-W*0.5, -L*0.35), new THREE.Vector2(-W*0.35, -L*0.35), new THREE.Vector2(-W*0.35, -L*0.15), new THREE.Vector2(-W*0.2, -L*0.15) ]; break;
case 'polygon_reg':
let sides = parseInt(document.getElementById('polySides').value) || 6;
let radius = parseFloat(document.getElementById('polyRadius').value) || 240;
for(let i=0; i<sides; i++) {
let ang = (i / sides) * Math.PI * 2;
pts.push(new THREE.Vector2(Math.cos(ang)*radius, Math.sin(ang)*radius));
}
break;
case 'polygon_irreg':
pts = window.polyIrregPts.map(p => new THREE.Vector2(p.x, p.y));
let area = 0;
for(let i=0; i<pts.length; i++) {
let p1 = pts[i], p2 = pts[(i+1)%pts.length];
area += (p2.x - p1.x) * (p2.y + p1.y);
}
if(area > 0) pts.reverse();
break;
}
return pts;
}
function getFloorRectangles(shape, W, L, wT) {
let rects = []; let w2 = W/2, l2 = L/2, tw = W*0.35, tl = L*0.35, wT2 = wT/2;
const addR = (x1, x2, z1, z2) => { let rw = Math.abs(x2 - x1), rl = Math.abs(z2 - z1), cx = (x1 + x2) / 2, cz = (z1 + z2) / 2; rects.push({cx, cz, rw, rl}); };
if(shape.startsWith('polygon')) {
let pts = generateShapePoints(shape, W, L);
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
pts.forEach(p => { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minZ = Math.min(minZ, p.y); maxZ = Math.max(maxZ, p.y); });
addR(minX-wT2, maxX+wT2, minZ-wT2, maxZ+wT2);
return rects;
}
if(shape === 'rectangular') { addR(-w2-wT2, w2+wT2, -l2-wT2, l2+wT2); }
else if(shape === 'l_shape') { addR(-w2-wT2, w2+wT2, -l2-wT2, -l2+tl+wT2); addR(-w2-wT2, -w2+tw+wT2, -l2+tl+wT2, l2+wT2); }
else if(shape === 'u_shape') { addR(-w2-wT2, -w2+tw+wT2, -l2-wT2, l2+wT2); addR(w2-tw-wT2, w2+wT2, -l2-wT2, l2+wT2); addR(-w2+tw+wT2, w2-tw-wT2, -l2-wT2, -l2+tl+wT2); }
else if(shape === 'h_shape') { addR(-w2-wT2, -w2+tw+wT2, -l2-wT2, l2+wT2); addR(w2-tw-wT2, w2+wT2, -l2-wT2, l2+wT2); addR(-w2+tw+wT2, w2-tw-wT2, -tl/2-wT2, tl/2+wT2); }
else if(shape === 'right_wing') { addR(-W*0.5-wT2, 0+wT2, -L*0.5-wT2, L*0.5+wT2); addR(W*0.2-wT2, W*0.5+wT2, -L*0.35-wT2, L*0.35+wT2); addR(0+wT2, W*0.2-wT2, -L*0.15-wT2, L*0.15+wT2); }
else if(shape === 'left_wing') { addR(0-wT2, W*0.5+wT2, -L*0.5-wT2, L*0.5+wT2); addR(-W*0.5-wT2, -W*0.2+wT2, -L*0.35-wT2, L*0.35+wT2); addR(-W*0.2+wT2, 0-wT2, -L*0.15-wT2, L*0.15+wT2); }
else if(shape === 'dual_wing') { addR(-W*0.2-wT2, W*0.2+wT2, -L*0.5-wT2, L*0.5+wT2); addR(W*0.35-wT2, W*0.5+wT2, -L*0.35-wT2, L*0.35+wT2); addR(-W*0.5-wT2, -W*0.35+wT2, -L*0.35-wT2, L*0.35+wT2); addR(W*0.2+wT2, W*0.35-wT2, -L*0.15-wT2, L*0.15+wT2); addR(-W*0.35+wT2, -W*0.2-wT2, -L*0.15-wT2, L*0.15+wT2); }
return rects;
}
function buildRoofPlan(shape, W, L, O, py, ry, type, pThick, rThick, cjSize) {
let group = new THREE.Group();
const addThickLine = (x1, z1, y1, x2, z2, y2, thk, depth, mat) => {
let dx = x2 - x1, dy = y2 - y1, dz = z2 - z1;
let len = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (len < 0.001) return;
let cx = (x1 + x2)/2, cy = (y1 + y2)/2, cz = (z1 + z2)/2;
let mesh = new THREE.Mesh(new THREE.BoxGeometry(thk, depth, len), mat);
mesh.position.set(cx, cy, cz);
mesh.lookAt(new THREE.Vector3(x2, y2, z2));
group.add(mesh);
};
let basePts = generateShapePoints(shape, W, L);
let edges = [];
for(let i=0; i<basePts.length; i++) {
let p1 = basePts[i], p2 = basePts[(i+1)%basePts.length];
let dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.sqrt(dx*dx + dy*dy);
let nx = dy/len, ny = -dx/len;
edges.push({ nx, ny });
}
let oPts = [];
for(let i=0; i<basePts.length; i++) {
let ePrev = edges[(i-1+edges.length)%edges.length];
let eCurr = edges[i];
let n1x = ePrev.nx, n1y = ePrev.ny;
let n2x = eCurr.nx, n2y = eCurr.ny;
let nx = n1x + n2x, ny = n1y + n2y;
let nLen = Math.sqrt(nx*nx + ny*ny);
if(nLen < 0.001) {
oPts.push(new THREE.Vector2(basePts[i].x + n1x*O, basePts[i].y + n1y*O));
} else {
nx /= nLen; ny /= nLen;
let dot = n1x * nx + n1y * ny;
let dist = dot !== 0 ? O / dot : O;
oPts.push(new THREE.Vector2(basePts[i].x + nx*dist, basePts[i].y + ny*dist));
}
}
if (type === 'flat') {
let minX = Math.min(...oPts.map(p=>p.x)) - 10;
let maxX = Math.max(...oPts.map(p=>p.x)) + 10;
let minZ = Math.min(...oPts.map(p=>p.y)) - 10;
let maxZ = Math.max(...oPts.map(p=>p.y)) + 10;
let roofShape = new THREE.Shape();
oPts.forEach((p, i) => { if(i===0) roofShape.moveTo(p.x, p.y); else roofShape.lineTo(p.x, p.y); });
let roofExtGeo = new THREE.ExtrudeGeometry(roofShape, { depth: cjSize, bevelEnabled: false });
roofExtGeo.rotateX(Math.PI/2);
let roofMesh = new THREE.Mesh(roofExtGeo);
roofMesh.position.y = py + cjSize/2;
roofMesh.updateMatrix();
let roofCSG = CSG.fromMesh(roofMesh, 0);
let jGeo = new THREE.BoxGeometry(2, cjSize, maxZ - minZ + 20);
let jCSG = null;
for(let x = minX; x <= maxX; x += 16) {
let jm = new THREE.Mesh(jGeo);
jm.position.set(x, py, (minZ+maxZ)/2);
jm.updateMatrix();
let c = CSG.fromMesh(jm, 1);
jCSG = jCSG ? jCSG.union(c) : c;
}
if (jCSG) {
try {
let finalJoists = CSG.toMesh(jCSG.intersect(roofCSG), new THREE.Matrix4(), MATS.wood);
finalJoists.userData.layer = 'roof';
group.add(finalJoists);
} catch(e) { console.warn('CSG Joist Error:', e); }
}
for(let i=0; i<oPts.length; i++) {
let p1 = oPts[i], p2 = oPts[(i+1)%oPts.length];
addThickLine(p1.x, p1.y, py, p2.x, p2.y, py, pThick, cjSize, MATS.wood);
}
group.userData.layer = 'roof';
return group;
}
let w2 = W/2, l2 = L/2, tw = W*0.35, tl = L*0.35;
let ridges = [], rafters = [];
if (shape.startsWith('polygon')) {
let cx = 0, cz = 0;
basePts.forEach(p => { cx += p.x; cz += p.y; });
cx /= basePts.length; cz /= basePts.length;
let scale = parseFloat(document.getElementById('polyRidgeScale').value) / 100 || 0.2;
let rPts = basePts.map(p => ({ x: cx + (p.x - cx)*scale, z: cz + (p.y - cz)*scale }));
for(let i=0; i<oPts.length; i++) {
let next = (i+1)%oPts.length;
ridges.push([rPts[i], rPts[next]]);
rafters.push([{x: oPts[i].x, z: oPts[i].y}, rPts[i]]);
}
}
else if (shape === 'rectangular') {
let rx1 = -w2-O + (l2+O), rx2 = w2+O - (l2+O);
if(rx1 > rx2) rx1 = rx2 = 0;
if (type === 'gable') { rx1 = -w2-O; rx2 = w2+O; }
let rP1 = {x: rx1, z: 0}, rP2 = {x: rx2, z: 0};
ridges.push([rP1, rP2]);
rafters.push([{x: -w2-O, z: -l2-O}, rP1], [{x: -w2-O, z: l2+O}, rP1]);
rafters.push([{x: w2+O, z: -l2-O}, rP2], [{x: w2+O, z: l2+O}, rP2]);
}
else if (shape === 'l_shape') {
let splitX = -w2+tw+O, splitZ = -l2+tl+O;
let cxCorner = -w2 + tw/2, czCorner = -l2 + tl/2;
let rxEnd = w2+O - (tl/2+O), rzEnd = l2+O - (tw/2+O);
if (type === 'gable') { rxEnd = w2+O; rzEnd = l2+O; }
let pCorner = {x: cxCorner, z: czCorner}, pTop = {x: cxCorner, z: rzEnd}, pRight = {x: rxEnd, z: czCorner};
ridges.push([pCorner, pTop], [pCorner, pRight]);
rafters.push([{x: -w2-O, z: l2+O}, pTop], [{x: splitX, z: l2+O}, pTop]);
rafters.push([{x: w2+O, z: -l2-O}, pRight], [{x: w2+O, z: splitZ}, pRight]);
rafters.push([{x: -w2-O, z: -l2-O}, pCorner], [{x: splitX, z: splitZ}, pCorner]);
}
else if (shape === 'u_shape') {
let sL = -w2+tw+O, sR = w2-tw-O, sZ = -l2+tl+O;
let cxL = -w2 + tw/2, cxR = w2 - tw/2, czCorner = -l2 + tl/2, rzEnd = l2+O - (tw/2+O);
if (type === 'gable') rzEnd = l2+O;
let pCL = {x: cxL, z: czCorner}, pCR = {x: cxR, z: czCorner};
let pTL = {x: cxL, z: rzEnd}, pTR = {x: cxR, z: rzEnd};
ridges.push([pCL, pTL], [pCL, pCR], [pCR, pTR]);
rafters.push([{x: -w2-O, z: l2+O}, pTL], [{x: sL, z: l2+O}, pTL]);
rafters.push([{x: w2+O, z: l2+O}, pTR], [{x: sR, z: l2+O}, pTR]);
rafters.push([{x: -w2-O, z: -l2-O}, pCL], [{x: sL, z: sZ}, pCL]);
rafters.push([{x: w2+O, z: -l2-O}, pCR], [{x: sR, z: sZ}, pCR]);
}
else if (shape === 'h_shape') {
let sL = -w2+tw+O, sR = w2-tw-O, sT = -tl/2-O, sB = tl/2+O;
let cxL = -w2 + tw/2, cxR = w2 - tw/2, rzT = -l2+O + (tw/2+O), rzB = l2+O - (tw/2+O);
if (type === 'gable') { rzT = -l2-O; rzB = l2+O; }
let pTL = {x: cxL, z: rzT}, pBL = {x: cxL, z: rzB}, pTR = {x: cxR, z: rzT}, pBR = {x: cxR, z: rzB};
let pML = {x: cxL, z: 0}, pMR = {x: cxR, z: 0};
ridges.push([pTL, pBL], [pTR, pBR], [pML, pMR]);
rafters.push([{x: -w2-O, z: -l2-O}, pTL], [{x: sL, z: -l2-O}, pTL]);
rafters.push([{x: -w2-O, z: l2+O}, pBL], [{x: sL, z: l2+O}, pBL]);
rafters.push([{x: w2+O, z: -l2-O}, pTR], [{x: sR, z: -l2-O}, pTR]);
rafters.push([{x: w2+O, z: l2+O}, pBR], [{x: sR, z: l2+O}, pBR]);
rafters.push([{x: sL, z: sT}, pML], [{x: sL, z: sB}, pML]);
rafters.push([{x: sR, z: sT}, pMR], [{x: sR, z: sB}, pMR]);
}
else if (shape === 'right_wing') {
let cR = W*0.2;
let sL = O, sR = cR - O, sT = -L*0.15 - O, sB = L*0.15 + O;
let cxM = -w2/4, rzMT = -l2 + W/4, rzMB = l2 - W/4;
let cxW = cR + (w2-cR)/2, rzWT = -L*0.35 + (w2-cR)/2, rzWB = L*0.35 - (w2-cR)/2;
if (type === 'gable') {
rzMT = -l2-O; rzMB = l2+O;
rzWT = -L*0.35-O; rzWB = L*0.35+O;
}
let pMT = {x: cxM, z: rzMT}, pMB = {x: cxM, z: rzMB};
let pWT = {x: cxW, z: rzWT}, pWB = {x: cxW, z: rzWB};
let pMC = {x: cxM, z: 0}, pWC = {x: cxW, z: 0};
ridges.push([pMT, pMB], [pWT, pWB], [pMC, pWC]);
rafters.push([{x: -w2-O, z: -l2-O}, pMT], [{x: sL, z: -l2-O}, pMT]);
rafters.push([{x: -w2-O, z: l2+O}, pMB], [{x: sL, z: l2+O}, pMB]);
rafters.push([{x: sR, z: -L*0.35-O}, pWT], [{x: w2+O, z: -L*0.35-O}, pWT]);
rafters.push([{x: sR, z: L*0.35+O}, pWB], [{x: w2+O, z: L*0.35+O}, pWB]);
rafters.push([{x: sL, z: sT}, pMC], [{x: sL, z: sB}, pMC]);
rafters.push([{x: sR, z: sT}, pWC], [{x: sR, z: sB}, pWC]);
}
else if (shape === 'left_wing') {
let cL = -W*0.2;
let sL = cL + O, sR = -O, sT = -L*0.15 - O, sB = L*0.15 + O;
let cxM = w2/4, rzMT = -l2 + W/4, rzMB = l2 - W/4;
let cxW = (-w2 + cL)/2, rzWT = -L*0.35 + (w2+cL)/2, rzWB = L*0.35 - (w2+cL)/2;
if (type === 'gable') {
rzMT = -l2-O; rzMB = l2+O;
rzWT = -L*0.35-O; rzWB = L*0.35+O;
}
let pMT = {x: cxM, z: rzMT}, pMB = {x: cxM, z: rzMB};
let pWT = {x: cxW, z: rzWT}, pWB = {x: cxW, z: rzWB};
let pMC = {x: cxM, z: 0}, pWC = {x: cxW, z: 0};
ridges.push([pMT, pMB], [pWT, pWB], [pMC, pWC]);
rafters.push([{x: sR, z: -l2-O}, pMT], [{x: w2+O, z: -l2-O}, pMT]);
rafters.push([{x: sR, z: l2+O}, pMB], [{x: w2+O, z: l2+O}, pMB]);
rafters.push([{x: -w2-O, z: -L*0.35-O}, pWT], [{x: sL, z: -L*0.35-O}, pWT]);
rafters.push([{x: -w2-O, z: L*0.35+O}, pWB], [{x: sL, z: L*0.35+O}, pWB]);
rafters.push([{x: sL, z: sT}, pWC], [{x: sL, z: sB}, pWC]);
rafters.push([{x: sR, z: sT}, pMC], [{x: sR, z: sB}, pMC]);
}
else if (shape === 'dual_wing') {
let sML = -W*0.2 - O, sMR = W*0.2 + O;
let sLL = -w2 - O, sLR = -W*0.35 + O;
let sRL = W*0.35 - O, sRR = w2 + O;
let sT = -L*0.15 - O, sB = L*0.15 + O;
let cxM = 0, rzMT = -l2 + W*0.2, rzMB = l2 - W*0.2;
let cxLW = (-w2 - W*0.35)/2, lwHW = (w2 - W*0.35)/2;
let rzLWT = -L*0.35 + lwHW, rzLWB = L*0.35 - lwHW;
let cxRW = (w2 + W*0.35)/2, rwHW = (w2 - W*0.35)/2;
let rzRWT = -L*0.35 + rwHW, rzRWB = L*0.35 - rwHW;
if (type === 'gable') {
rzMT = -l2-O; rzMB = l2+O;
rzLWT = -L*0.35-O; rzLWB = L*0.35+O;
rzRWT = -L*0.35-O; rzRWB = L*0.35+O;
}
let pMT = {x: cxM, z: rzMT}, pMB = {x: cxM, z: rzMB};
let pLWT = {x: cxLW, z: rzLWT}, pLWB = {x: cxLW, z: rzLWB};
let pRWT = {x: cxRW, z: rzRWT}, pRWB = {x: cxRW, z: rzRWB};
let pLMC = {x: cxM, z: 0}, pLWC = {x: cxLW, z: 0};
let pRMC = {x: cxM, z: 0}, pRWC = {x: cxRW, z: 0};
ridges.push([pMT, pMB], [pLWT, pLWB], [pRWT, pRWB], [pLMC, pLWC], [pRMC, pRWC]);
rafters.push([{x: sML, z: -l2-O}, pMT], [{x: sMR, z: -l2-O}, pMT]);
rafters.push([{x: sML, z: l2+O}, pMB], [{x: sMR, z: l2+O}, pMB]);
rafters.push([{x: sLL, z: -L*0.35-O}, pLWT], [{x: sLR, z: -L*0.35-O}, pLWT]);
rafters.push([{x: sLL, z: L*0.35+O}, pLWB], [{x: sLR, z: L*0.35+O}, pLWB]);
rafters.push([{x: sRL, z: -L*0.35-O}, pRWT], [{x: sRR, z: -L*0.35-O}, pRWT]);
rafters.push([{x: sRL, z: L*0.35+O}, pRWB], [{x: sRR, z: L*0.35+O}, pRWB]);
rafters.push([{x: sLR, z: sT}, pLWC], [{x: sLR, z: sB}, pLWC]);
rafters.push([{x: sML, z: sT}, pLMC], [{x: sML, z: sB}, pLMC]);
rafters.push([{x: sMR, z: sT}, pRMC], [{x: sMR, z: sB}, pRMC]);
rafters.push([{x: sRL, z: sT}, pRWC], [{x: sRL, z: sB}, pRWC]);
}
// Draw main skeleton ridges and hips/valleys
ridges.forEach(r => addThickLine(r[0].x, r[0].z, ry, r[1].x, r[1].z, ry, rThick, cjSize, MATS.wood));
rafters.forEach(r => addThickLine(r[0].x, r[0].z, py, r[1].x, r[1].z, ry, rThick, cjSize, MATS.wood));
let gableEnds = new Array(oPts.length).fill(false);
if (type === 'gable') {
for(let i=0; i<oPts.length; i++) {
let p1 = oPts[i], p2 = oPts[(i+1)%oPts.length];
let isGable = false;
ridges.forEach(r => {
[r[0], r[1]].forEach(pt => {
let d1 = Math.hypot(pt.x - p1.x, pt.z - p1.y);
let d2 = Math.hypot(pt.x - p2.x, pt.z - p2.y);
let segLen = Math.hypot(p2.x - p1.x, p2.y - p1.y);
if (Math.abs(d1 + d2 - segLen) < 1.0) isGable = true;
});
});
gableEnds[i] = isGable;
}
}
// --- NEW: Universal Rafter Generation (Commons, Hip Jacks, Valley Jacks) ---
let rafterSpacing = 24;
let pitchRatio = (parseFloat(document.getElementById('roofRise').value) || 6) / 12;
let boundaries = [];
for(let i=0; i<oPts.length; i++) boundaries.push({p1: oPts[i], p2: oPts[(i+1)%oPts.length]});
ridges.forEach(r => boundaries.push({p1: {x: r[0].x, y: r[0].z}, p2: {x: r[1].x, y: r[1].z}}));
rafters.forEach(r => boundaries.push({p1: {x: r[0].x, y: r[0].z}, p2: {x: r[1].x, y: r[1].z}}));
function getRoofZ(X, Z) {
let minH = Infinity;
for(let i=0; i<oPts.length; i++) {
if (gableEnds[i]) continue;
let p1 = oPts[i], p2 = oPts[(i+1)%oPts.length];
let dx = p2.x - p1.x, dy = p2.y - p1.y;
let len = Math.hypot(dx, dy);
let nx = -dy/len, ny = dx/len;
let dist = (X - p1.x)*nx + (Z - p1.y)*ny;
if (dist > -0.1) {
let h = py + dist * pitchRatio;
if (h < minH) minH = h;
}
}
return Math.min(minH, ry);
}
function raycast(S, N) {
let minT = Infinity, closestPt = null;
boundaries.forEach(b => {
let dx = b.p2.x - b.p1.x, dy = b.p2.y - b.p1.y;
let cross = N.x * dy - N.y * dx;
if (Math.abs(cross) < 0.001) return;
let t = ((b.p1.x - S.x) * dy - (b.p1.y - S.y) * dx) / cross;
let u = ((b.p1.x - S.x) * N.y - (b.p1.y - S.y) * N.x) / cross;
if (t > 0.1 && u >= -0.001 && u <= 1.001) {
if (t < minT) { minT = t; closestPt = { x: S.x + t * N.x, y: S.y + t * N.y }; }
}
});
return closestPt;
}
let rDict = {};
function addRaft(pA, pB) {
if (Math.hypot(pA.x-pB.x, pA.y-pB.y) < 1.0) return;
let cx = (pA.x+pB.x)/2, cy = (pA.y+pB.y)/2;
let key = Math.round(cx)+","+Math.round(cy);
if(rDict[key]) return;
rDict[key] = true;
let yA = getRoofZ(pA.x, pA.y);
let yB = getRoofZ(pB.x, pB.y);
addThickLine(pA.x, pA.y, yA, pB.x, pB.y, yB, rThick, cjSize, MATS.wood);
}
let isPoly = shape.startsWith('polygon');
function stepAlongLine(p1, p2, isDiag, cb) {
let dx = p2.x - p1.x, dy = p2.y - p1.y;
let len = Math.hypot(dx, dy);
if (len < 0.1) return;
if (isDiag) {
for(let d = rafterSpacing/2; d < len; d += rafterSpacing) {
cb({ x: p1.x + (dx/len)*d, y: p1.y + (dy/len)*d });
}
} else if (Math.abs(dx) > Math.abs(dy)) {
let minB = Math.min(p1.x, p2.x), maxB = Math.max(p1.x, p2.x);
for(let x = Math.ceil(minB/rafterSpacing)*rafterSpacing; x < maxB; x += rafterSpacing) {
if(Math.abs(x - minB)<1 || Math.abs(x - maxB)<1) continue;
cb({ x: x, y: p1.y + ((x - p1.x)/dx)*dy });
}
} else {
let minB = Math.min(p1.y, p2.y), maxB = Math.max(p1.y, p2.y);
for(let y = Math.ceil(minB/rafterSpacing)*rafterSpacing; y < maxB; y += rafterSpacing) {
if(Math.abs(y - minB)<1 || Math.abs(y - maxB)<1) continue;
cb({ x: p1.x + ((y - p1.y)/dy)*dx, y: y });
}
}
}
// Pass 1: Run rafters upwards from the Eaves (Catches Common & Hip Jack rafters)
for(let i=0; i<oPts.length; i++) {
let p1 = oPts[i], p2 = oPts[(i+1)%oPts.length];
addThickLine(p1.x, p1.y, py, p2.x, p2.y, py, pThick, cjSize, MATS.wood);
if (gableEnds[i]) continue;
let dx = p2.x - p1.x, dy = p2.y - p1.y;
let len = Math.hypot(dx, dy);
let normX = -dy/len, normY = dx/len;
stepAlongLine(p1, p2, isPoly, (S) => {
let hit = raycast(S, {x: normX, y: normY});
if (hit) addRaft(S, hit);
});
}
// Pass 2: Run rafters downwards from the Ridges (Catches Common & Valley Jack rafters)
ridges.forEach(r => {
let p1 = {x: r[0].x, y: r[0].z}, p2 = {x: r[1].x, y: r[1].z};
let dx = p2.x - p1.x, dy = p2.y - p1.y;
if (Math.hypot(dx, dy) < 0.1) return;
let n1, n2;
if (isPoly) {
let len = Math.hypot(dx, dy);
n1 = {x: -dy/len, y: dx/len};
n2 = {x: dy/len, y: -dx/len};
} else {
if (Math.abs(dx) > Math.abs(dy)) { n1 = {x:0, y:1}; n2 = {x:0, y:-1}; }
else { n1 = {x:1, y:0}; n2 = {x:-1, y:0}; }
}
stepAlongLine(p1, p2, isPoly, (S) => {
let hit1 = raycast(S, n1);
if (hit1) addRaft(S, hit1);
let hit2 = raycast(S, n2);
if (hit2) addRaft(S, hit2);
});
});
// --- CEILING JOISTS ---
let minX = Math.min(...oPts.map(p=>p.x)) - 10;
let maxX = Math.max(...oPts.map(p=>p.x)) + 10;
let minZ = Math.min(...oPts.map(p=>p.y)) - 10;
let maxZ = Math.max(...oPts.map(p=>p.y)) + 10;
let ceilingShape = new THREE.Shape();
oPts.forEach((p, i) => { if(i===0) ceilingShape.moveTo(p.x, p.y); else ceilingShape.lineTo(p.x, p.y); });
let ceilingExtGeo = new THREE.ExtrudeGeometry(ceilingShape, { depth: cjSize, bevelEnabled: false });
ceilingExtGeo.rotateX(Math.PI/2);
let ceilingMesh = new THREE.Mesh(ceilingExtGeo);
ceilingMesh.position.y = py + cjSize/2;
ceilingMesh.updateMatrix();
let ceilingCSG = CSG.fromMesh(ceilingMesh, 0);
let ceilJGeo = new THREE.BoxGeometry(2, cjSize, maxZ - minZ + 20);
let ceilJCSG = null;
for(let x = minX; x <= maxX; x += 16) {
let jm = new THREE.Mesh(ceilJGeo);
jm.position.set(x, py, (minZ+maxZ)/2);
jm.updateMatrix();
let c = CSG.fromMesh(jm, 1);
ceilJCSG = ceilJCSG ? ceilJCSG.union(c) : c;
}
if (ceilJCSG) {
try {
let finalCeilJoists = CSG.toMesh(ceilJCSG.intersect(ceilingCSG), new THREE.Matrix4(), MATS.wood);
finalCeilJoists.userData.layer = 'roof';
group.add(finalCeilJoists);
} catch(e) { console.warn('CSG Ceiling Joist Error:', e); }
}
group.userData.layer = 'roof';
return group;
}
function init() {
viewList.forEach(v => {
scenes[v] = new THREE.Scene(); scenes[v].background = new THREE.Color(0xf4f4f4);
cameras[v] = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10000);
let cont = document.getElementById('vp'+v);
renderers[v] = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderers[v].setSize(cont.clientWidth||800, cont.clientHeight||600);
cont.appendChild(renderers[v].domElement);
scenes[v].add(new THREE.AmbientLight(0xffffff, 0.7));
let dl = new THREE.DirectionalLight(0xffffff, 0.6); dl.position.set(1000, 2000, 1500); scenes[v].add(dl);
});
window.updateRoofUI();
window.updateFloorUI();
window.addEventListener('resize', window.resizeActiveViewport);
setTimeout(() => { window.updateModel(); }, 100);
animate();
}
window.updateModel = () => {
// --- VENEER & UI INPUTS ---
const showVeneer = document.getElementById('brickVeneerToggle')?.checked || false;
const veneerW = parseFloat(document.getElementById('veneerW')?.value) || 3.25;
const veneerGap = parseFloat(document.getElementById('veneerGap')?.value) || 1.0;
extraGeoms.forEach(g => g.dispose()); extraMats.forEach(m => m.dispose());
extraGeoms = []; extraMats = [];
viewList.forEach(v => { let g = scenes[v].children.filter(c => c.name==='bldg'); g.forEach(x => scenes[v].remove(x)); });
activeWallCenters = [];
const bldg = new THREE.Group(); bldg.name = 'bldg';
const mW = parseFloat(document.getElementById('bldgW').value)||480;
const mL = parseFloat(document.getElementById('bldgL').value)||360;
const wH = parseFloat(document.getElementById('wallH').value)||120;
const wT = parseFloat(document.getElementById('wallT').value)||8;
const ff2G = parseFloat(document.getElementById('ffToGrade').value)||24;
const foundW = parseFloat(document.getElementById('foundW').value)||10;
const footW = parseFloat(document.getElementById('footW').value)||16;
const overhang = parseFloat(document.getElementById('roofOverhang').value)||24;
const footD = 12;
const ns = parseInt(document.getElementById('numStories').value) || 1;
const sfThk = parseFloat(document.getElementById('sfThk').value) || 1.5;
const cjSize = parseFloat(document.getElementById('cjSize').value) || 11.25;
const showPlanes = document.getElementById('showPlanes').checked;
let fjSizes = [];
for(let i=1; i<=ns; i++) {
fjSizes.push(parseFloat(document.getElementById('fjSize'+i)?.value || 11.25));
}
let storyBases = [ff2G];
for(let i=1; i<ns; i++) {
storyBases.push(storyBases[i-1] + wH + fjSizes[i] + sfThk);
}
const roofType = document.querySelector('input[name="roofType"]:checked').value;
const pitch = parseFloat(document.getElementById('roofRise').value) || 6;
const rThick = parseFloat(document.getElementById('roofRidgeThick').value) || 1.5;
const fThick = parseFloat(document.getElementById('roofFlatThick').value) || 1.5;
let shape = document.getElementById('bldgShape').value;
let isPoly = shape.startsWith('polygon');
let pts = generateShapePoints(shape, mW, mL);
activeWallCount = pts.length;
// 1. BASE EXTERIOR WALLS
let baseExtWalls = [];
for(let i=0; i<activeWallCount; i++) {
let p1 = pts[i], p2 = pts[(i+1)%activeWallCount];
let dx = p2.x - p1.x, dz = p2.y - p1.y, len = Math.sqrt(dx*dx + dz*dz);
baseExtWalls.push({ id: (i+1).toString(), len: len, angle: Math.atan2(dz, dx), midX: (p1.x+p2.x)/2, midZ: (p1.y+p2.y)/2, nX: -dz/len, nZ: dx/len, thick: wT, isInt: false });
}
// 2. BASE INTERIOR WALLS
let baseIntWalls = [];
let unresolvedInt = [...interiorWallsData];
let loopCount = 0;
while(unresolvedInt.length > 0 && loopCount < 1000) {
let iw = unresolvedInt.shift();
let anchorFloor = null;
let anchor = null;
let matchExt = iw.anchorId.match(/^(\d+)\./);
let matchInt = iw.anchorId.match(/^A(\d+)\./);
if (matchExt) {
anchorFloor = parseInt(matchExt[1]);
let extId = iw.anchorId.split('.')[1];
anchor = baseExtWalls.find(w => w.id === extId);
} else if (matchInt) {
let baseId = iw.anchorId.split('_F')[0];
anchor = baseIntWalls.find(w => w.id === baseId);
if (anchor) {
if (iw.anchorId.includes('_F')) {
anchorFloor = parseInt(iw.anchorId.split('_F')[1]);
} else {
anchorFloor = anchor.floor;
}
}
}
if(anchor) {
let localX = -anchor.len/2 + anchor.len*(iw.pos/100);
let startX = anchor.midX + localX*Math.cos(anchor.angle) + anchor.nX*(anchor.thick/2);
let startZ = anchor.midZ + localX*Math.sin(anchor.angle) + anchor.nZ*(anchor.thick/2);
let iAng = Math.atan2(anchor.nZ, anchor.nX) + ((iw.angle||0) * Math.PI / 180);
let length = iw.length;
if (iw.isSnapSegment && iw.snapId && iw.snapId !== 'none') {
let target = null;
let snapMatchExt = iw.snapId.match(/^(\d+)\./);
if (snapMatchExt) {
let snapExtId = iw.snapId.split('.')[1];
target = baseExtWalls.find(w => w.id === snapExtId);
} else {
let baseSnapId = iw.snapId.split('_F')[0];
target = baseIntWalls.find(w => w.id === baseSnapId);
}
if (target) {
let dx = startX - target.midX;
let dz = startZ - target.midZ;
let dist = dx * target.nX + dz * target.nZ;
let absDist = Math.abs(dist) - (target.thick / 2) + (iw.thick / 2);
length = Math.max(1, absDist);
let sign = dist >= 0 ? -1 : 1;
let dirX = sign * target.nX;
let dirZ = sign * target.nZ;
iAng = Math.atan2(dirZ, dirX);
} else {
unresolvedInt.push(iw);
loopCount++;
continue;
}
}
let endX = startX + Math.cos(iAng)*length;
let endZ = startZ + Math.sin(iAng)*length;
baseIntWalls.push({
id: iw.id, floor: anchorFloor, copyUp: iw.copyUp,
len: length, angle: iAng, midX: (startX+endX)/2, midZ: (startZ+endZ)/2,
nX: Math.cos(iAng+Math.PI/2), nZ: Math.sin(iAng+Math.PI/2),
thick: iw.thick, isInt: true, eX: endX, eZ: endZ, color: iw.color
});
} else {
unresolvedInt.push(iw);
}
loopCount++;
}
// 3. FLATTEN TO ALL WALLS BY STORY
let allWalls = [];
for(let s=1; s<=ns; s++) {
let sBase = storyBases[s-1];
let jY = storyBases[s-1] - sfThk - fjSizes[s-1];
baseExtWalls.forEach(bw => {
allWalls.push({ ...bw, fullId: s + '.' + bw.id, story: s, sBase, jY });
});
baseIntWalls.forEach(bw => {
if (s === bw.floor) {
allWalls.push({ ...bw, fullId: bw.id, story: s, sBase, jY });
} else if (bw.copyUp && s > bw.floor) {
let copiedId = bw.id + '_F' + s;
allWalls.push({ ...bw, fullId: copiedId, story: s, sBase, jY });
}
});
}
window.allWallsContext = allWalls;
let floorRects = getFloorRectangles(shape, mW, mL, wT);
let topOfFound = Math.max(1, ff2G - sfThk - fjSizes[0]);
let floorBoundCSG = null;
if(isPoly) {
let floorShape = new THREE.Shape();
pts.forEach((p, i) => { if(i===0) floorShape.moveTo(p.x, p.y); else floorShape.lineTo(p.x, p.y); });
let floorBoundGeo = new THREE.ExtrudeGeometry(floorShape, { depth: 100, bevelEnabled: false });
floorBoundGeo.rotateX(Math.PI/2);
let floorBoundMesh = new THREE.Mesh(floorBoundGeo);
floorBoundMesh.position.y = 100;
floorBoundMesh.updateMatrix();
floorBoundCSG = CSG.fromMesh(floorBoundMesh, 0);
extraGeoms.push(floorBoundGeo);
}
for(let s=0; s<ns; s++) {
let curJDepth = fjSizes[s];
let sfY = storyBases[s] - sfThk/2;
let jY = storyBases[s] - sfThk - curJDepth/2;
floorRects.forEach(rect => {
let sfGeo = new THREE.BoxGeometry(rect.rw, sfThk, rect.rl);
let sfMesh = new THREE.Mesh(sfGeo, MATS.subfloor); sfMesh.position.set(rect.cx, sfY, rect.cz);
if(isPoly) { sfMesh.updateMatrix(); sfMesh = CSG.toMesh(CSG.fromMesh(sfMesh, 0).intersect(floorBoundCSG), sfMesh.matrix, MATS.subfloor); }
sfMesh.userData.layer = 'subfloor'; bldg.add(sfMesh);
let gGeo = new THREE.BoxGeometry(rect.rw, curJDepth, 5.5);
let gMesh = new THREE.Mesh(gGeo, MATS.wood); gMesh.position.set(rect.cx, jY, rect.cz);
if(isPoly) { gMesh.updateMatrix(); gMesh = CSG.toMesh(CSG.fromMesh(gMesh, 0).intersect(floorBoundCSG), gMesh.matrix, MATS.wood); }
gMesh.userData.layer = 'joist'; bldg.add(gMesh);
let jSpacing = 16, numJoists = Math.floor(rect.rw / jSpacing), startX = rect.cx - rect.rw/2 + jSpacing/2;
let jGeo = new THREE.BoxGeometry(2, curJDepth, rect.rl);
let jCSG = null;
for(let i=0; i<=numJoists; i++) {
let jx = startX + i*jSpacing; if (jx > rect.cx + rect.rw/2) break;
let jMesh = new THREE.Mesh(jGeo, MATS.wood); jMesh.position.set(jx, jY, rect.cz);
if(isPoly) { jMesh.updateMatrix(); let csg = CSG.fromMesh(jMesh, i); jCSG = jCSG ? jCSG.union(csg) : csg; }
else { jMesh.userData.layer = 'joist'; bldg.add(jMesh); }
}
if(isPoly && jCSG) {
let finalJ = CSG.toMesh(jCSG.intersect(floorBoundCSG), new THREE.Matrix4(), MATS.wood);
finalJ.userData.layer = 'joist'; bldg.add(finalJ);
extraGeoms.push(finalJ.geometry);
}
extraGeoms.push(sfGeo, gGeo, jGeo);
});
}
let rH = 0;
if (roofType !== 'flat') {
let span = mW;
if (shape.startsWith('polygon')) span = (parseFloat(document.getElementById('polyRadius').value)||240) * 2;
else if (shape === 'rectangular') span = Math.min(mW, mL);
else span = mW * 0.35;
rH = (span / 2 + overhang) * (pitch / 12);
document.getElementById('ridgeHgt').value = rH.toFixed(1) + '"';
} else {
document.getElementById('ridgeHgt').value = '-';
}
let py = storyBases[ns-1] + wH;
let ry = py + rH;
let customRoofPlan = buildRoofPlan(shape, mW, mL, overhang, py, ry, roofType, fThick, rThick, cjSize);
bldg.add(customRoofPlan);
let masterWallCSG = null;
let masterVeneerCSG = null;
let masterFoundCSG = null;
allWalls.forEach((w, idx) => {
activeWallCenters.push({ pos: new THREE.Vector3(w.midX, w.sBase + wH + 10, w.midZ), label: w.fullId });
// Foundation logic is strictly for the first story
if(w.story === 1) {
if(!w.isInt) {
let fShift = (foundW/2) - (w.thick/2), cx = w.midX + w.nX*fShift, cz = w.midZ + w.nZ*fShift;
let fwGeo = new THREE.BoxGeometry(w.len + foundW, topOfFound, foundW); let fwMesh = new THREE.Mesh(fwGeo, MATS.concrete);
fwMesh.position.set(cx, topOfFound/2, cz); fwMesh.rotation.y = -w.angle; fwMesh.updateMatrix();
let ftGeo = new THREE.BoxGeometry(w.len + footW, footD, footW); let ftMesh = new THREE.Mesh(ftGeo, MATS.concrete);
ftMesh.position.set(cx, -footD/2, cz); ftMesh.rotation.y = -w.angle; ftMesh.updateMatrix();
let foundBlockCSG = CSG.fromMesh(fwMesh, 0).union(CSG.fromMesh(ftMesh, 0));
masterFoundCSG = masterFoundCSG ? masterFoundCSG.union(foundBlockCSG) : foundBlockCSG;
extraGeoms.push(fwGeo, ftGeo);
} else {
let pGeo = new THREE.BoxGeometry(foundW, topOfFound, foundW); let pMesh = new THREE.Mesh(pGeo, MATS.concrete); pMesh.position.set(w.eX, topOfFound/2, w.eZ); pMesh.updateMatrix();
let pfGeo = new THREE.BoxGeometry(footW, footD, footW); let pfMesh = new THREE.Mesh(pfGeo, MATS.concrete); pfMesh.position.set(w.eX, -footD/2, w.eZ); pfMesh.updateMatrix();
let fIntBlockCSG = CSG.fromMesh(pMesh, 0).union(CSG.fromMesh(pfMesh, 0));
masterFoundCSG = masterFoundCSG ? masterFoundCSG.union(fIntBlockCSG) : fIntBlockCSG;
extraGeoms.push(pGeo, pfGeo);
}
}
if(!w.isInt) {
let rjShift = 1 - (w.thick/2); let rjGeo = new THREE.BoxGeometry(w.len, fjSizes[w.story-1], 2); let rjMesh = new THREE.Mesh(rjGeo, MATS.wood);
rjMesh.position.set(w.midX + w.nX * rjShift, w.jY, w.midZ + w.nZ * rjShift); rjMesh.rotation.y = -w.angle; rjMesh.userData.layer = 'joist'; bldg.add(rjMesh);
extraGeoms.push(rjGeo);
} else {
let djGeo = new THREE.BoxGeometry(w.len, fjSizes[w.story-1], 3.0);
let djMesh = new THREE.Mesh(djGeo, MATS.wood);
djMesh.position.set(w.midX, w.jY, w.midZ);
djMesh.rotation.y = -w.angle;
djMesh.userData.layer = 'joist';
bldg.add(djMesh);
extraGeoms.push(djGeo);
}
let wallGeo = new THREE.BoxGeometry(w.len + (w.isInt ? w.thick : w.thick), wH, w.thick);
let wallMesh = new THREE.Mesh(wallGeo, MATS.wall);
wallMesh.position.set(w.midX, w.sBase + wH/2, w.midZ);
wallMesh.rotation.y = -w.angle;
wallMesh.updateMatrix();
let wallCSG = CSG.fromMesh(wallMesh, idx);
extraGeoms.push(wallGeo);
// --- BRICK VENEER CSG ---
let vCSG = null;
if (showVeneer && !w.isInt) {
// For story 1, base is perfectly at Y = 0 (top of footing). For higher stories, it covers the joist gap down to the top of the wall below.
let vBottom = (w.story === 1) ? 0 : (storyBases[w.story-2] + wH);
let vTop = w.sBase + wH;
let vH = vTop - vBottom;
let vY = vBottom + vH / 2;
// Calculate outward shift (nX, nZ point inwardly, so subtract to push outward)
let vOffset = (w.thick / 2) + veneerGap + (veneerW / 2);
let vX = w.midX - w.nX * vOffset;
let vZ = w.midZ - w.nZ * vOffset;
// Expand length slightly to naturally intersect and close outside corners cleanly
let vLen = w.len + w.thick + 2 * (veneerGap + veneerW);
let vGeo = new THREE.BoxGeometry(vLen, vH, veneerW);
let vMesh = new THREE.Mesh(vGeo, MATS.brick);
vMesh.position.set(vX, vY, vZ);
vMesh.rotation.y = -w.angle;
vMesh.updateMatrix();
vCSG = CSG.fromMesh(vMesh, idx + 10000);
extraGeoms.push(vGeo);
}
let cuts = featuresData.filter(op => op.wallId === w.fullId);
cuts.forEach(op => {
let localX = -w.len/2 + w.len*(op.pos/100);
let cX = w.midX + localX*Math.cos(w.angle);
let cZ = w.midZ + localX*Math.sin(w.angle);
let headHeight = 80;
let cy = op.cat === 'window' ? (w.sBase + headHeight - (op.h/2)) : (w.sBase + (op.h/2));
// Make cut deep enough to seamlessly pierce both inner frame and outer veneer
let cutDepth = Math.max(40, w.thick * 4 + (showVeneer ? veneerGap * 2 + veneerW * 2 : 0));
let cutMesh = new THREE.Mesh(new THREE.BoxGeometry(op.w, op.h, cutDepth));
cutMesh.position.set(cX, cy, cZ);
cutMesh.rotation.y = -w.angle;
cutMesh.updateMatrix();
let csgCut = CSG.fromMesh(cutMesh, 1);
wallCSG = wallCSG.subtract(csgCut);
if (vCSG) vCSG = vCSG.subtract(csgCut);
let assm = op.cat === 'door' ? (op.type === 'Sliding' ? buildSliding(op.w, op.h, op) : buildStandardDoor(op.w, op.h, op, !w.isInt)) : buildWindowSashes(op.w, op.h, op);
assm.add(buildCasementTrim(op.w, op.h, w.thick)); assm.position.set(cX, cy, cZ); assm.rotation.y = -w.angle; assm.userData.layer = 'detail';
assm.traverse(c => { c.userData.layer = 'detail'; });
bldg.add(assm);
});
// HVAC Fireplace Cutouts applied to the wall CSG (and veneer if present)
let hvacCuts = hvacData.filter(hv => hv.wallId === w.fullId && hv.type === 'fireplace');
hvacCuts.forEach(hv => {
let localX = -w.len/2 + w.len*(hv.pos/100);
let cX = w.midX + localX*Math.cos(w.angle);
let cZ = w.midZ + localX*Math.sin(w.angle);
let hvH = hv.h || 60;
let hvD = hv.d || 24;
let taperH = 30;
let chimH = 144;
let insideX = w.nX, insideZ = w.nZ;
let insideFace = w.thick / 2;
let interiorProtrusion = 4;
let centerOffset = insideFace + interiorProtrusion - (hvD / 2);
let baseX = cX + insideX * centerOffset;
let baseZ = cZ + insideZ * centerOffset;
let cutBase = new THREE.Mesh(new THREE.BoxGeometry(hv.w, hvH, hvD));
cutBase.position.set(baseX, w.sBase + hvH/2, baseZ);
cutBase.rotation.y = -w.angle; cutBase.updateMatrix();
let taperGeo = new THREE.CylinderGeometry(0.5 * Math.SQRT2, 1.0 * Math.SQRT2, taperH, 4);
taperGeo.rotateY(Math.PI/4);
taperGeo.scale(hv.w / 2, 1, hvD / 2);
let cutTaper = new THREE.Mesh(taperGeo);
cutTaper.position.set(baseX, w.sBase + hvH + taperH/2, baseZ);
cutTaper.rotation.y = -w.angle; cutTaper.updateMatrix();
let cutChim = new THREE.Mesh(new THREE.BoxGeometry(hv.w/2, chimH, hvD/2));
cutChim.position.set(baseX, w.sBase + hvH + taperH + chimH/2, baseZ);
cutChim.rotation.y = -w.angle; cutChim.updateMatrix();
let csgBase = CSG.fromMesh(cutBase, 2);
let csgTaper = CSG.fromMesh(cutTaper, 2);
let csgChim = CSG.fromMesh(cutChim, 2);
wallCSG = wallCSG.subtract(csgBase).subtract(csgTaper).subtract(csgChim);
if (vCSG) vCSG = vCSG.subtract(csgBase).subtract(csgTaper).subtract(csgChim);
});
masterWallCSG = masterWallCSG ? masterWallCSG.union(wallCSG) : wallCSG;
if (vCSG) masterVeneerCSG = masterVeneerCSG ? masterVeneerCSG.union(vCSG) : vCSG;
});
if(masterWallCSG) {
let finalMasterWall = CSG.toMesh(masterWallCSG, new THREE.Matrix4(), MATS.wall);
finalMasterWall.userData.layer = 'wall';
bldg.add(finalMasterWall);
extraGeoms.push(finalMasterWall.geometry);
}
// Add unified brick veneer to the scene
if(masterVeneerCSG) {
let finalMasterVeneer = CSG.toMesh(masterVeneerCSG, new THREE.Matrix4(), MATS.brick);
finalMasterVeneer.userData.layer = 'wall';
bldg.add(finalMasterVeneer);
extraGeoms.push(finalMasterVeneer.geometry);
}
if(masterFoundCSG) {
let finalMasterFound = CSG.toMesh(masterFoundCSG, new THREE.Matrix4(), MATS.concrete);
finalMasterFound.userData.layer = 'foundation';
bldg.add(finalMasterFound);
extraGeoms.push(finalMasterFound.geometry);
}
// HVAC Renderings
hvacData.forEach(hv => {
let w = allWalls.find(x => x.fullId === hv.wallId); if(!w) return;
let localX = -w.len/2 + w.len*(hv.pos/100);
let cx = w.midX + localX*Math.cos(w.angle);
let cz = w.midZ + localX*Math.sin(w.angle);
let mesh;
let hvD = hv.d || 24;
let hvH = hv.h || 60;
// nX/nZ point inside, -nX/-nZ point outside
let insideX = w.nX, insideZ = w.nZ;
let outsideX = -w.nX, outsideZ = -w.nZ;
// Safely fetch veneer settings in case they are missing from scope
const showVeneer = document.getElementById('brickVeneerToggle')?.checked || false;
const veneerW = parseFloat(document.getElementById('veneerW')?.value) || 3.25;
const veneerGap = parseFloat(document.getElementById('veneerGap')?.value) || 1.0;
if (hv.type === 'fireplace') {
let fpGroup = new THREE.Group();
let taperH = 30;
let chimH = 144;
let fpBaseH = w.sBase + hvH;
let insideFace = w.thick / 2;
let interiorProtrusion = 0; // INSET 4 INCHES INTO THE ROOM
let centerOffset = insideFace + interiorProtrusion - (hvD / 2);
let baseX = cx + insideX * centerOffset;
let baseZ = cz + insideZ * centerOffset;
let baseMesh = new THREE.Mesh(new THREE.BoxGeometry(hv.w, fpBaseH, hvD), MATS.wall);
baseMesh.position.set(0, fpBaseH/2, 0);
baseMesh.userData.layer = 'foundation';
// --- TAPER GEOMETRY ---
// Scaled to match the new chimney footprint (50% W, 75% D)
let taperGeo = new THREE.CylinderGeometry(0.5 * Math.SQRT2, 1.0 * Math.SQRT2, taperH, 4);
taperGeo.rotateY(Math.PI/4);
taperGeo.scale(hv.w / 2, 1, hvD / 2);
// Shift top vertices to keep interior face flush
// Offset is half the depth difference (1.0 - 0.75) / 2 = 0.125
let posAttr = taperGeo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
if (posAttr.getY(i) > 0) {
posAttr.setZ(i, posAttr.getZ(i) + (hvD * 0.125));
}
}
taperGeo.computeVertexNormals();
let taperMesh = new THREE.Mesh(taperGeo, MATS.wall);
taperMesh.position.set(0, fpBaseH + taperH/2, 0);
taperMesh.userData.layer = 'foundation';
// --- CHIMNEY (50% W, 75% D) ---
// Offset Z by 12.5% of depth to maintain flush interior face
let chimMesh = new THREE.Mesh(new THREE.BoxGeometry(hv.w * 0.5, chimH, hvD * 0.75), MATS.wall);
chimMesh.position.set(0, fpBaseH + taperH + chimH/2, hvD * 0.125);
chimMesh.userData.layer = 'foundation';
// HEARTH & RIM JOISTS (Remain same)
let hearthD = 18;
let hearthH = w.sBase + 12;
let hearthMesh = new THREE.Mesh(new THREE.BoxGeometry(hv.w, hearthH, hearthD), MATS.wall);
hearthMesh.position.set(0, hearthH/2, (hvD/2) + (hearthD/2));
hearthMesh.userData.layer = 'foundation';
let curJDepth = fjSizes[(w.story || 1) - 1] || 11.25;
let rjLeft = new THREE.Mesh(new THREE.BoxGeometry(2, curJDepth, hearthD), MATS.wood);
rjLeft.position.set(-(hv.w/2 + 1), w.jY, (hvD/2) + (hearthD/2));
rjLeft.userData.layer = 'joist';
let rjRight = new THREE.Mesh(new THREE.BoxGeometry(2, curJDepth, hearthD), MATS.wood);
rjRight.position.set((hv.w/2 + 1), w.jY, (hvD/2) + (hearthD/2));
rjRight.userData.layer = 'joist';
let rjFront = new THREE.Mesh(new THREE.BoxGeometry(hv.w + 4, curJDepth, 2), MATS.wood);
rjFront.position.set(0, w.jY, (hvD/2) + hearthD + 1);
rjFront.userData.layer = 'joist';
let slabThick = 12;
let slabW = hv.w + 16;
let slabD = hvD + hearthD + 2 + 12;
let fpSlab = new THREE.Mesh(new THREE.BoxGeometry(slabW, slabThick, slabD), MATS.concrete);
fpSlab.position.set(0, -slabThick/2, (hearthD + 2)/2);
fpSlab.userData.layer = 'foundation';
// --- FIREPLACE FACE FRAMING ---
let frameW = 3.5;
let frameD = 5.5;
let frameZ = (hvD / 2) + (frameD / 2);
let whiteMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
let topFrameY = fpBaseH - (frameW / 2);
let fpFrameTop = new THREE.Mesh(new THREE.BoxGeometry(hv.w, frameW, frameD), whiteMat);
fpFrameTop.position.set(0, topFrameY, frameZ);
fpFrameTop.userData.layer = 'wall';
let vertStartY = hearthH;
let vertEndY = fpBaseH - frameW;
let vertH = vertEndY - vertStartY;
let vertY = vertStartY + (vertH / 2);
let fpFrameLeft = new THREE.Mesh(new THREE.BoxGeometry(frameW, vertH, frameD), whiteMat);
fpFrameLeft.position.set(-(hv.w / 2) + (frameW / 2), vertY, frameZ);
fpFrameLeft.userData.layer = 'wall';
let fpFrameRight = new THREE.Mesh(new THREE.BoxGeometry(frameW, vertH, frameD), whiteMat);
fpFrameRight.position.set((hv.w / 2) - (frameW / 2), vertY, frameZ);
fpFrameRight.userData.layer = 'wall';
fpGroup.add(
baseMesh, taperMesh, chimMesh, hearthMesh,
rjLeft, rjRight, rjFront, fpSlab,
fpFrameTop, fpFrameLeft, fpFrameRight
);
fpGroup.traverse(c => {
if(c.isMesh && !c.userData.layer) c.userData.layer = 'detail';
});
fpGroup.position.set(baseX, 0, baseZ);
fpGroup.rotation.y = -w.angle;
bldg.add(fpGroup);
// --- LOCAL CSG POST-PROCESSING TRIM ---
// (Note: CSG logic remains robust as long as the base and hearth geometry are consistent)
let cutMinY = -14;
let cutMaxY = fpBaseH + 2;
let cutH = cutMaxY - cutMinY;
let cutY = cutMinY + cutH / 2;
let cutBase = new THREE.Mesh(new THREE.BoxGeometry(hv.w + 0.1, cutH, hvD + 0.1));
cutBase.position.set(baseX, cutY, baseZ);
cutBase.rotation.y = -w.angle;
cutBase.updateMatrix();
let hearthCutMaxY = hearthH + 2;
let hearthCutH = hearthCutMaxY - cutMinY;
let hearthCutY = cutMinY + hearthCutH / 2;
let cutHearthW = hv.w + 4;
let cutHearthD = hearthD + 2;
let hearthCutCX = baseX + insideX * ((hvD/2) + cutHearthD/2);
let hearthCutCZ = baseZ + insideZ * ((hvD/2) + cutHearthD/2);
let cutHearth = new THREE.Mesh(new THREE.BoxGeometry(cutHearthW + 0.1, hearthCutH, cutHearthD + 0.1));
cutHearth.position.set(hearthCutCX, hearthCutY, hearthCutCZ);
cutHearth.rotation.y = -w.angle;
cutHearth.updateMatrix();
let csgBase = CSG.fromMesh(cutBase, 0);
let csgHearth = CSG.fromMesh(cutHearth, 0);
let combinedVoid = csgBase.union(csgHearth);
let voidGrp = new THREE.Group();
voidGrp.add(cutBase.clone(), cutHearth.clone());
voidGrp.updateMatrixWorld(true);
let voidBox = new THREE.Box3().setFromObject(voidGrp);
for (let i = bldg.children.length - 1; i >= 0; i--) {
let child = bldg.children[i];
if (['joist', 'subfloor', 'foundation', 'wall'].includes(child.userData.layer)) {
child.updateMatrixWorld(true);
let childBox = new THREE.Box3().setFromObject(child);
if (voidBox.intersectsBox(childBox)) {
try {
let childCSG = CSG.fromMesh(child, 0);
let resultCSG = childCSG.subtract(combinedVoid);
if (resultCSG.polygons.length === 0) {
bldg.remove(child);
} else {
let newMesh = CSG.toMesh(resultCSG, new THREE.Matrix4(), child.material);
newMesh.userData.layer = child.userData.layer;
bldg.remove(child);
bldg.add(newMesh);
}
} catch(e) { console.warn('CSG Trim Skip:', e); }
}
}
}
}else if (hv.type === 'ac') {
let acGroup = new THREE.Group();
// Main chassis
let acBody = new THREE.Mesh(new THREE.BoxGeometry(hv.w, hvH, hvD), MATS.metal);
// Dark interior fan area (top)
let fanRadius = Math.min(hv.w, hvD) * 0.4;
let fanHole = new THREE.Mesh(new THREE.CylinderGeometry(fanRadius, fanRadius, 0.5, 16), new THREE.MeshStandardMaterial({color: 0x111111, roughness: 0.9}));
fanHole.position.y = hvH/2;
// Fan blades
let bladeMat = new THREE.MeshStandardMaterial({color: 0x222222});
let fanBlade1 = new THREE.Mesh(new THREE.BoxGeometry(fanRadius * 1.8, 0.2, 3), bladeMat);
fanBlade1.position.y = hvH/2 + 0.1;
fanBlade1.rotation.y = Math.PI/4;
let fanBlade2 = new THREE.Mesh(new THREE.BoxGeometry(3, 0.2, fanRadius * 1.8), bladeMat);
fanBlade2.position.y = hvH/2 + 0.1;
fanBlade2.rotation.y = Math.PI/4;
// Top protective grill ring
let grill = new THREE.Mesh(new THREE.TorusGeometry(fanRadius, 0.5, 4, 16), MATS.metal);
grill.rotation.x = Math.PI/2;
grill.position.y = hvH/2 + 0.5;
// Side electrical control box
let controlBox = new THREE.Mesh(new THREE.BoxGeometry(6, 14, 4), MATS.wall);
controlBox.position.set(hv.w/2 + 1, 0, hvD/4);
acGroup.add(acBody, fanHole, fanBlade1, fanBlade2, grill, controlBox);
let outsideFace = w.thick / 2;
let acClearance = 2; // 2 inches from exterior face
let extraOffset = (showVeneer && !w.isInt) ? (veneerGap + veneerW) : 0;
let centerOffset = outsideFace + acClearance + extraOffset + (hvD / 2);
// Assembly center
acGroup.position.set(cx + outsideX * centerOffset, 4 + hvH/2, cz + outsideZ * centerOffset);
mesh = acGroup;
// Draw 4" thick slab underneath AC
let slab = new THREE.Mesh(new THREE.BoxGeometry(hv.w + 4, 4, hvD + 4), MATS.concrete);
slab.position.set(cx + outsideX * centerOffset, 2, cz + outsideZ * centerOffset);
slab.rotation.y = -w.angle; slab.userData.layer = 'detail'; bldg.add(slab);
} else if (hv.type === 'central') {
let centralGroup = new THREE.Group();
// Main Air Handler Cabinet
let cabH = hvH * 0.7;
let cab = new THREE.Mesh(new THREE.BoxGeometry(hv.w, cabH, hvD), MATS.metal);
cab.position.y = -hvH/2 + cabH/2;
// Upper Supply Air Plenum
let plenH = hvH * 0.3;
let plenum = new THREE.Mesh(new THREE.BoxGeometry(hv.w * 0.85, plenH, hvD * 0.85), MATS.metal);
plenum.position.y = -hvH/2 + cabH + plenH/2;
// Front Access Panel
let panelGeo = new THREE.BoxGeometry(hv.w * 0.7, cabH * 0.5, 1);
let panelMat = new THREE.MeshStandardMaterial({color: 0x999999, roughness: 0.5});
let panel = new THREE.Mesh(panelGeo, panelMat);
panel.position.set(0, -hvH/2 + cabH * 0.6, hvD/2 + 0.2);
// Side piping (Insulated Refrigerant line)
let pipeGeo = new THREE.CylinderGeometry(0.8, 0.8, 6, 8);
pipeGeo.rotateZ(Math.PI/2);
let lineSetMat = new THREE.MeshStandardMaterial({color: 0x111111});
let lineSet = new THREE.Mesh(pipeGeo, lineSetMat);
lineSet.position.set(hv.w/2 + 2, -hvH/2 + cabH * 0.3, 0);
// PVC Exhaust/Condensate pipe
let pvcGeo = new THREE.CylinderGeometry(0.6, 0.6, 6, 8);
pvcGeo.rotateZ(Math.PI/2);
let pvcMat = new THREE.MeshStandardMaterial({color: 0xffffff});
let pvc = new THREE.Mesh(pvcGeo, pvcMat);
pvc.position.set(hv.w/2 + 2, -hvH/2 + cabH * 0.2, hvD/4);
centralGroup.add(cab, plenum, panel, lineSet, pvc);
let insideFace = w.thick / 2;
let centerOffset = insideFace + (hvD / 2);
// Rests properly on the subfloor base instead of floating
centralGroup.position.set(cx + insideX * centerOffset, w.sBase + hvH/2 + 1, cz + insideZ * centerOffset);
mesh = centralGroup;
} else if (hv.type === 'thermostat') {
mesh = new THREE.Mesh(new THREE.BoxGeometry(4, 3, 1), MATS.metal);
let insideFace = w.thick / 2;
let centerOffset = insideFace + 0.5; // d=1, so 0.5 is flush
mesh.position.set(cx + insideX * centerOffset, w.sBase + 60, cz + insideZ * centerOffset);
} else if (hv.type === 'duct') {
mesh = new THREE.Mesh(new THREE.BoxGeometry(hv.w, 8, 12), MATS.metal);
let insideFace = w.thick / 2;
let centerOffset = insideFace + 6; // default depth 12 / 2
mesh.position.set(cx + insideX * centerOffset, w.sBase + 4, cz + insideZ * centerOffset);
}
if(mesh) {
mesh.rotation.y = -w.angle;
mesh.traverse(c => { if(c.isMesh && !c.userData.layer) c.userData.layer = 'detail'; });
bldg.add(mesh);
}
});
electricalData.forEach(el => {
let w = allWalls.find(x => x.fullId === el.wallId); if(!w) return;
let localX = -w.len/2 + w.len*(el.pos/100);
let cx = w.midX + localX*Math.cos(w.angle), cz = w.midZ + localX*Math.sin(w.angle);
// nX/nZ point inside, -nX/-nZ point outside
let insideX = w.nX, insideZ = w.nZ;
let outsideX = -w.nX, outsideZ = -w.nZ;
if (el.type === 'battery_wall' || el.type === 'battery_grade') {
let elW = el.w || 36, elH = el.h || 48, elD = el.d || 12;
if(el.type === 'battery_wall') elD = 6; // Wall mounted strictly 6" depth
let centerOffset, cy;
let isGrade = el.type === 'battery_grade';
let extraOffset = (showVeneer && !w.isInt) ? (veneerGap + veneerW) : 0;
if (isGrade) {
let clearance = 2; // 2 inches off the wall
centerOffset = w.thick / 2 + clearance + extraOffset + elD / 2;
cy = 4 + elH / 2; // Rests on a 4" thick slab
// Draw 4" thick slab underneath battery pack
let slab = new THREE.Mesh(new THREE.BoxGeometry(elW + 4, 4, elD + 4), MATS.concrete);
slab.position.set(cx + outsideX * centerOffset, 2, cz + outsideZ * centerOffset);
slab.rotation.y = -w.angle; slab.userData.layer = 'detail'; bldg.add(slab);
} else {
centerOffset = w.thick / 2 + extraOffset + elD / 2; // Flush against outside wall
cy = w.sBase + 36 + elH / 2; // Wall mounted height
}
// Build an array of batteries within the case bounds
let bGrp = new THREE.Group();
// Slightly transparent case
let caseGeo = new THREE.BoxGeometry(elW, elH, elD);
let caseMat = new THREE.MeshStandardMaterial({ color: 0x999999, transparent: true, opacity: 0.5, side: THREE.DoubleSide });
let caseMesh = new THREE.Mesh(caseGeo, caseMat);
bGrp.add(caseMesh);
// Inner cells grid
let cols = Math.max(1, Math.floor(elW / 8));
let rows = Math.max(1, Math.floor(elH / 16));
let cellW = (elW - 4) / cols - 1;
let cellH = (elH - 4) / rows - 1;
let cellD = elD - 4;
let cellGeo = new THREE.BoxGeometry(cellW, cellH, cellD);
let cellMat = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.2 });
for(let c = 0; c < cols; c++) {
for(let r = 0; r < rows; r++) {
let cell = new THREE.Mesh(cellGeo, cellMat);
let px = -elW/2 + 2 + cellW/2 + c*(cellW+1);
let py = -elH/2 + 2 + cellH/2 + r*(cellH+1);
cell.position.set(px, py, 0);
bGrp.add(cell);
}
}
bGrp.position.set(cx + outsideX * centerOffset, cy, cz + outsideZ * centerOffset);
bGrp.rotation.y = -w.angle;
bGrp.userData.layer = 'detail';
bldg.add(bGrp);
} else {
// Standard electrical items (inside wall)
let h = el.type === 'switch' ? 6 : el.type === 'outlet' ? 4 : 8, wd = 3, d = 1;
let cy = w.sBase + (el.type === 'switch' ? 48 : el.type === 'outlet' ? 18 : 84);
let elMesh = new THREE.Mesh(new THREE.BoxGeometry(wd, h, d), MATS.metal);
// Properly calculated inside normals for interior flush mounting
let centerOffset = w.thick / 2 + d / 2;
elMesh.position.set(cx + insideX * centerOffset, cy, cz + insideZ * centerOffset);
elMesh.rotation.y = -w.angle; elMesh.userData.layer = 'detail'; bldg.add(elMesh);
}
});
if(showPlanes) {
const fGeo = new THREE.PlaneGeometry(mW*2, mL*2); fGeo.rotateX(-Math.PI/2); extraGeoms.push(fGeo);
const grade = new THREE.Mesh(fGeo, new THREE.MeshStandardMaterial({ color: 0x446644, side: THREE.DoubleSide })); grade.position.y = 0; grade.userData.layer = 'plane'; bldg.add(grade);
}
columnsData.forEach(col => {
let floorIdx = Math.min((col.floor || 1) - 1, ns - 1);
let colSBase = storyBases[floorIdx] || storyBases[0];
let colHeight = wH;
let colGeo = new THREE.CylinderGeometry(4, 4, colHeight, 16);
let plateThk = col.topThick||2;
let plateGeo = new THREE.BoxGeometry(12, plateThk, 12);
let pierGeo = new THREE.BoxGeometry(16, ff2G + footD, 16);
extraGeoms.push(colGeo, plateGeo, pierGeo);
for(let i=0; i<col.count; i++) {
let px = col.startX + (i*col.spacingX), pz = col.startZ + (i*col.spacingZ);
let m = new THREE.Mesh(colGeo, MATS.metal);
m.position.set(px, colSBase + colHeight/2, pz);
m.userData.layer = 'detail';
bldg.add(m);
let bPlate = new THREE.Mesh(plateGeo, MATS.metal);
bPlate.position.set(px, colSBase + plateThk/2, pz);
bPlate.userData.layer = 'detail';
bldg.add(bPlate);
let tPlate = new THREE.Mesh(plateGeo, MATS.metal);
tPlate.position.set(px, colSBase + colHeight - plateThk/2, pz);
tPlate.userData.layer = 'detail';
bldg.add(tPlate);
if (col.floor === 1 || !col.floor) {
let pier = new THREE.Mesh(pierGeo, MATS.concrete);
pier.position.set(px, (ff2G+footD)/2 - footD, pz);
pier.userData.layer = 'foundation';
bldg.add(pier);
}
}
});
viewList.forEach(v => {
let clone = bldg.clone();
clone.traverse(child => {
if(!child.userData || !child.userData.layer) return;
if(v === '1') {
if(['roof', 'plane'].includes(child.userData.layer)) {
child.visible = false;
} else if (child.isMesh) {
child.material = child.material;
child.material.transparent = true;
child.material.opacity = 0.8;
let edgesGeo = new THREE.EdgesGeometry(child.geometry, 1);
let edgesMat = new THREE.LineBasicMaterial({color: 0x000000});
let edges = new THREE.LineSegments(edgesGeo, edgesMat);
child.add(edges);
}
}
else if (v === '7') {
if(!['joist', 'foundation'].includes(child.userData.layer)) child.visible = false;
else if (child.isMesh) {
let edgesGeo = new THREE.EdgesGeometry(child.geometry, 1);
let edgesMat = new THREE.LineBasicMaterial({color: 0x000000});
let edges = new THREE.LineSegments(edgesGeo, edgesMat);
extraMats.push(edgesMat); extraGeoms.push(edgesGeo);
child.add(edges);
}
}
else if (v === '8') {
if(child.userData.layer !== 'roof') child.visible = false;
}
});
scenes[v].add(clone);
});
let max = Math.max(mW, mL, wH * ns) * 1.5, cyPos = ff2G + (wH * ns)/2;
cameras['1'].position.set(0, max, 0); cameras['1'].lookAt(0, 0, 0);
cameras['2'].position.set(0, cyPos, max); cameras['2'].lookAt(0, cyPos, 0);
cameras['3'].position.set(max, cyPos, 0); cameras['3'].lookAt(0, cyPos, 0);
cameras['4'].position.set(0, cyPos, -max); cameras['4'].lookAt(0, cyPos, 0);
cameras['5'].position.set(-max, cyPos, 0); cameras['5'].lookAt(0, cyPos, 0);
cameras['6'].position.set(max*0.8, max*0.8, max*0.8); cameras['6'].lookAt(0, 0, 0);
cameras['7'].position.set(0, max, 0); cameras['7'].lookAt(0, 0, 0);
cameras['8'].position.set(0, max, 0); cameras['8'].lookAt(0, 0, 0);
window.resizeActiveViewport();
};
window.resizeActiveViewport = () => {
let activeVps = [currentPage.toString()];
let max = Math.max(parseFloat(document.getElementById('bldgW').value), parseFloat(document.getElementById('bldgL').value));
let s = Math.max(max * 0.7, 50);
activeVps.forEach(v => {
let vp = document.getElementById('vp' + v);
if(!vp || vp.clientWidth === 0) return;
let aspect = vp.clientWidth / vp.clientHeight;
renderers[v].setSize(vp.clientWidth, vp.clientHeight);
let cam = cameras[v]; cam.left = -s * aspect; cam.right = s * aspect; cam.top = s; cam.bottom = -s; cam.updateProjectionMatrix();
});
let lblLayer = document.getElementById('lbl'+currentPage);
if(lblLayer) lblLayer.innerHTML = '';
if(currentPage === 1 && lblLayer) {
let cam = cameras['1'], vp = document.getElementById('vp1');
activeWallCenters.forEach(d => {
let sp = d.pos.clone().project(cam);
if(sp.z >= -1 && sp.z <= 1) {
let div = document.createElement('div'); div.className = 'wall-label';
div.style.left = `${(sp.x*0.5 + 0.5)*vp.clientWidth}px`; div.style.top = `${(-sp.y*0.5 + 0.5)*vp.clientHeight}px`;
div.textContent = d.label; lblLayer.appendChild(div);
}
});
}
};
window.changePage = (dir) => {
document.getElementById('sheet'+currentPage).classList.remove('active');
currentPage += dir; if(currentPage < 1) currentPage = maxPages; if(currentPage > maxPages) currentPage = 1;
document.getElementById('sheet'+currentPage).classList.add('active');
setTimeout(window.resizeActiveViewport, 10);
};
function animate() {
requestAnimationFrame(animate);
let activeVps = [currentPage.toString()];
activeVps.forEach(v => {
let vp = document.getElementById('vp'+v);
if(vp && vp.clientWidth > 0) renderers[v].render(scenes[v], cameras[v]);
});
}
window.onload = init;
</script>