- core/skybox.js: panorama op de grond geprojecteerd (port GroundedSkybox), grastextuur in cirkel rond het object met overvloeiing, zon met schaduw, camera niet onder de grond - woning: maaiveld als instelling (standaard 350 mm onder bovenkant vloer) - assets opgeschoond (alleen grass.jpg + skybox.webp), skybox-data.js weg - start.sh + melding bij file:// Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1053 lines
54 KiB
JavaScript
1053 lines
54 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
// ============================================================
|
||
// house/house.js — Woning-editor (UI). Een woning = units die aan elkaar
|
||
// gekoppeld zijn + binnenwanden (later ook dak, chassis voor transport, ...).
|
||
//
|
||
// Opbouw van het scherm:
|
||
// - Links een navigatieboom: Overzicht, per unit de vloer en 4 wanden,
|
||
// binnenwanden, en acties (+ Unit, + Binnenwand).
|
||
// - Rechts het actieve onderdeel: het woning-overzicht (plattegrond +
|
||
// eigenschappen + 3D + totalen), of de ECHTE WallEditor/FloorEditor voor
|
||
// één onderdeel.
|
||
//
|
||
// Geen eigen wand-/vloer-/unitlogica: alles via HouseModel/UnitModel/
|
||
// WallModel/FloorModel en HousePdf.
|
||
// Plain script, geladen na house-model.js en house-pdf.js.
|
||
// ============================================================
|
||
|
||
const HOUSE_TEMPLATE = `
|
||
<div id="house-app">
|
||
<aside id="house-nav">
|
||
<div class="hn-title"><i class="bi bi-diagram-3"></i> Woning</div>
|
||
<div id="house-nav-tree"></div>
|
||
<div class="hn-actions">
|
||
<button type="button" id="hn-add-unit" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Unit</span></button>
|
||
<button type="button" id="hn-add-iw" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Binnenwand</span></button>
|
||
</div>
|
||
<div class="hn-soon">
|
||
<div class="hn-item disabled" title="Nog niet beschikbaar">Schuine daken</div>
|
||
<div class="hn-item disabled" title="Nog niet beschikbaar">Chassis (transport)</div>
|
||
</div>
|
||
<input type="file" id="h-file-import-json" accept="application/json" style="display:none;">
|
||
<div id="h-io-status" class="io-status"></div>
|
||
</aside>
|
||
<div id="house-part-host"></div>
|
||
</div>
|
||
|
||
<div id="house-project-overlay">
|
||
<div id="house-project-modal">
|
||
<div class="modal-header">
|
||
<h2>Project eigenschappen (woning)</h2>
|
||
<button type="button" id="house-project-close-x" class="modal-close-x">×</button>
|
||
</div>
|
||
<div class="controls-grid">
|
||
<div>
|
||
<div class="control-group"><label for="h-proj-naam">Projectnaam</label><div><input type="text" id="h-proj-naam"></div></div>
|
||
<div class="control-group"><label for="h-proj-plaats">Plaats</label><div><input type="text" id="h-proj-plaats"></div></div>
|
||
<div class="control-group"><label for="h-proj-straat">Straat</label><div><input type="text" id="h-proj-straat"></div></div>
|
||
</div>
|
||
<div>
|
||
<div class="control-group"><label for="h-proj-nummer">Projectnummer</label><div><input type="text" id="h-proj-nummer"></div></div>
|
||
<div class="control-group"><label for="h-proj-tekenaar">Tekenaar</label><div><input type="text" id="h-proj-tekenaar"></div></div>
|
||
<div class="control-group"><label for="h-proj-opmerking">Opmerking</label><div><input type="text" id="h-proj-opmerking"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div>
|
||
<div class="modal-footer">
|
||
<button type="button" id="house-project-cancel" class="io-btn">Annuleren</button>
|
||
<button type="button" id="house-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const HOUSE_OVERVIEW_TEMPLATE = `
|
||
<div id="house-overview">
|
||
<div class="ho-col ho-left">
|
||
<div id="ho-pane-plan" class="ho-pane">
|
||
<div class="pane-label">PLATTEGROND</div>
|
||
<div class="toolbar-3d">
|
||
<button type="button" id="ho-tool-select" class="btn-3d-toggle active" title="Selecteren"><i class="bi bi-cursor"></i>Selecteren</button>
|
||
<button type="button" id="ho-tool-iw" class="btn-3d-toggle" title="Binnenwand tekenen"><i class="bi bi-pencil"></i>Binnenwand</button>
|
||
</div>
|
||
<canvas id="ho-canvas"></canvas>
|
||
<div id="ho-hint" class="ho-hint"></div>
|
||
</div>
|
||
<div id="ho-props"></div>
|
||
</div>
|
||
<div class="ho-col ho-right">
|
||
<div id="ho-pane-3d" class="ho-pane">
|
||
<div class="pane-label">3D</div>
|
||
<div class="toolbar-3d" id="ho-toolbar-3d">
|
||
<button type="button" id="ho-btn-render" class="btn-3d-toggle" title="Avondscène: elke lamp en spot is een echte lichtbron"><i class="bi bi-moon-stars"></i>Render</button>
|
||
</div>
|
||
</div>
|
||
<div id="ho-specs"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (Overzicht, niets geselecteerd).';
|
||
const HOUSE_PROJECT_KEYS = ['naam', 'plaats', 'straat', 'nummer', 'tekenaar', 'opmerking'];
|
||
const HOUSE_SIDES = ['voor', 'achter', 'links', 'rechts'];
|
||
|
||
function houseFmt(n, decimals = 1) {
|
||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||
}
|
||
|
||
function mountHouseEditor(container, callbacks) {
|
||
const onSaved = callbacks && callbacks.onSaved;
|
||
container.innerHTML = HOUSE_TEMPLATE;
|
||
const host = document.getElementById('house-part-host');
|
||
|
||
let state = HouseModel.createDefaultState((callbacks && callbacks.template) || 'single');
|
||
let activePart = null; // 'overzicht' | 'floor:A' | 'wall:A:voor' | 'iw:3'
|
||
let activeEditor = null; // WallEditor/FloorEditor-instantie of het overzicht
|
||
// Selectie in het overzicht (blijft bewaard bij wisselen van onderdeel):
|
||
// null | { type: 'unit', id } | { type: 'wall', unit, side, element } | { type: 'iw', id }
|
||
let selection = null;
|
||
let pendingTool = null; // 'iw' = direct in teken-modus bij openen overzicht
|
||
// Camerastand per onderdeel onthouden (CameraMemory), per tabblad.
|
||
const camKey = (part) => `${(callbacks && callbacks.cameraKey) || 'woning'}:${part}`;
|
||
|
||
function setIoStatus(msg, cls) {
|
||
const el = document.getElementById('h-io-status');
|
||
el.textContent = msg;
|
||
el.className = 'io-status ' + (cls || '');
|
||
clearTimeout(setIoStatus._t);
|
||
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
|
||
}
|
||
|
||
const unitById = (id) => state.units.find(u => u.id === id);
|
||
const iwById = (id) => state.interiorWalls.find(w => w.id === id);
|
||
|
||
// ------------------------------------------------------------
|
||
// Onderdelen mounten
|
||
// ------------------------------------------------------------
|
||
function flushActivePart() {
|
||
if (!activeEditor || typeof activeEditor.getStateJson !== 'function' || !activePart) return;
|
||
const [kind, a, b] = activePart.split(':');
|
||
if (kind === 'floor' && unitById(a)) unitById(a).unit.floor = activeEditor.getStateJson();
|
||
else if (kind === 'wall' && unitById(a)) unitById(a).unit.walls[b] = activeEditor.getStateJson();
|
||
else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson();
|
||
else if (kind === 'iw' && iwById(Number(a))) iwById(Number(a)).wall = activeEditor.getStateJson();
|
||
}
|
||
|
||
function showPart(part) {
|
||
flushActivePart();
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
host.innerHTML = '';
|
||
activePart = part;
|
||
|
||
const [kind, a, b] = part.split(':');
|
||
if (kind === 'floor' && unitById(a)) {
|
||
activeEditor = FloorEditor.mount(host, { cameraKey: camKey(part) });
|
||
activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.floor));
|
||
} else if (kind === 'wall' && unitById(a)) {
|
||
const parts = HouseModel.computeParts(state, { skip3D: true });
|
||
const wl = parts.units.find(u => u.id === a).walls.find(w => w.side === b);
|
||
activeEditor = WallEditor.mount(host, {
|
||
cameraKey: camKey(part),
|
||
lockWidth: 'Binnen een woning volgt de wandbreedte uit de vloer van de unit.',
|
||
lockSpacing: SPACING_LOCK_MSG,
|
||
lockCladding: 'De richting van de gevelbekleding is (voorlopig) globaal: Overzicht, niets geselecteerd.',
|
||
seamCuts: wl.cutsU,
|
||
lamps: [].concat(...wl.pieces.map(p => p.lamps.map(l => ({ u: p.u0 + l.u, y: l.y }))))
|
||
});
|
||
activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.walls[b]));
|
||
} else if (kind === 'roof' && unitById(a)) {
|
||
HouseModel.applyGlobalSettings(state); // dakmaat volgt evt. de vloer
|
||
const u = unitById(a);
|
||
activeEditor = FloorEditor.mount(host, {
|
||
mode: 'roof',
|
||
cameraKey: camKey(part),
|
||
lockSize: u.roof.followFloor ? 'Dit dak volgt de vloermaat. Zet "Dak volgt vloer" uit bij de unit (overzicht) om de maat aan te passen.' : null
|
||
});
|
||
activeEditor.loadStateJson(HouseModel.clone(u.roof.deck));
|
||
} else if (kind === 'iw' && iwById(Number(a))) {
|
||
HouseModel.applyGlobalSettings(state);
|
||
HouseModel.interiorLayout(iwById(Number(a))); // breedte bijwerken
|
||
activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG });
|
||
activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall));
|
||
} else {
|
||
activePart = 'overzicht';
|
||
activeEditor = mountOverview();
|
||
}
|
||
renderNav();
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// Navigatieboom
|
||
// ------------------------------------------------------------
|
||
function renderNav() {
|
||
const parts = HouseModel.computeParts(state, { skip3D: true });
|
||
const item = (part, label, meta, cls, iconName) =>
|
||
`<div class="hn-item ${cls || ''} ${activePart === part ? 'active' : ''}" data-part="${part}">
|
||
<span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
|
||
let html = item('overzicht', 'Overzicht', '', 'hn-top', 'grid-1x2');
|
||
parts.units.forEach(u => {
|
||
const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht';
|
||
html += `<div class="hn-group">`;
|
||
html += `<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}"><span class="hn-label">${UI.icon('box')}Unit ${u.id}</span><span class="hn-meta">${u.rect.W} × ${u.rect.D}</span></div>`;
|
||
html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3');
|
||
html += item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house');
|
||
u.walls.forEach(w => {
|
||
const meta = w.removed ? 'vervalt' : w.pieces.map(p => p.element).join(', ');
|
||
html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks');
|
||
});
|
||
html += `</div>`;
|
||
});
|
||
if (parts.interior.length) {
|
||
html += `<div class="hn-group"><div class="hn-item hn-unit hn-static"><span class="hn-label">${UI.icon('distribute-horizontal')}Binnenwanden</span></div>`;
|
||
parts.interior.forEach(p => { html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal'); });
|
||
html += `</div>`;
|
||
}
|
||
document.getElementById('house-nav-tree').innerHTML = html;
|
||
}
|
||
|
||
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
|
||
const unitRow = e.target.closest('[data-select-unit]');
|
||
if (unitRow) {
|
||
selection = { type: 'unit', id: unitRow.dataset.selectUnit };
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else { activeEditor.refresh(); renderNav(); }
|
||
return;
|
||
}
|
||
const row = e.target.closest('[data-part]');
|
||
if (row && row.dataset.part !== activePart) showPart(row.dataset.part);
|
||
});
|
||
|
||
// ------------------------------------------------------------
|
||
// Acties: unit toevoegen/verwijderen, binnenwand
|
||
// ------------------------------------------------------------
|
||
function addUnit() {
|
||
flushActivePart();
|
||
const parent = (selection && selection.type === 'unit' && unitById(selection.id)) || state.units[state.units.length - 1];
|
||
const taken = new Set(state.units.filter(u => u.attach && u.attach.to === parent.id).map(u => u.attach.side));
|
||
if (parent.attach) taken.add({ voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' }[parent.attach.side]);
|
||
const side = ['rechts', 'achter', 'links', 'voor'].find(s => !taken.has(s)) || 'rechts';
|
||
const id = HouseModel.nextUnitId(state);
|
||
state.units.push(HouseModel.createUnitEntry(id, { to: parent.id, side, align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
|
||
selection = { type: 'unit', id };
|
||
showPart('overzicht');
|
||
}
|
||
|
||
function removeUnit(id) {
|
||
if (state.units[0].id === id) return;
|
||
if (state.units.some(u => u.attach && u.attach.to === id)) {
|
||
setIoStatus(`Unit ${id} kan niet weg: er zijn units aan gekoppeld.`, 'err');
|
||
return;
|
||
}
|
||
state.units = state.units.filter(u => u.id !== id);
|
||
selection = null;
|
||
showPart('overzicht');
|
||
}
|
||
|
||
document.getElementById('hn-add-unit').addEventListener('click', addUnit);
|
||
document.getElementById('hn-add-iw').addEventListener('click', () => {
|
||
pendingTool = 'iw';
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else activeEditor.setTool('iw');
|
||
});
|
||
|
||
// ------------------------------------------------------------
|
||
// OVERZICHT
|
||
// ------------------------------------------------------------
|
||
function mountOverview() {
|
||
host.innerHTML = HOUSE_OVERVIEW_TEMPLATE;
|
||
const canvas = document.getElementById('ho-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const propsEl = document.getElementById('ho-props');
|
||
|
||
// --- 3D ---
|
||
const pane3D = document.getElementById('ho-pane-3d');
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x1a1a1a);
|
||
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 400);
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.shadowMap.enabled = true;
|
||
pane3D.appendChild(renderer.domElement);
|
||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||
const overviewCamKey = camKey('overzicht');
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||
scene.add(ambient);
|
||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
dirLight.position.set(5, 8, 5);
|
||
scene.add(dirLight);
|
||
const grid = new THREE.GridHelper(40, 80, 0x444444, 0x222222);
|
||
scene.add(grid);
|
||
// Maaiveld: alleen in de volle render, zodat het licht op de grond valt.
|
||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ color: 0x3a3d40, roughness: 1 }));
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -0.002;
|
||
ground.visible = false;
|
||
scene.add(ground);
|
||
const wallMaterials = WallModel.createMaterials();
|
||
const floorMaterials = FloorModel.createMaterials();
|
||
const roofMaterials = RoofModel.createMaterials();
|
||
const lightingMaterials = LightingModel.createMaterials();
|
||
const sandwichMaterials = SandwichModel.createMaterials();
|
||
let houseGroup = null;
|
||
let dakVisible = true;
|
||
// Opgeslagen camerastand gaat voor; anders eenmalig passend maken.
|
||
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
|
||
let beplatingVisible = true, isolatieVisible = true, lampenVisible = true;
|
||
let fullRender = false;
|
||
let skyOn = Skybox.isOn();
|
||
const skyEnv = Skybox.createEnvironment(scene);
|
||
|
||
// Weergave-filter (core/view-filter.js). Beplating = wandplaten, vloer-
|
||
// en plafondplaten, dakleer en boeideel/daktrim.
|
||
createViewFilter(document.getElementById('ho-toolbar-3d'), [
|
||
{ key: 'beplating', label: 'Beplating' },
|
||
{ key: 'isolatie', label: 'Isolatie' },
|
||
{ key: 'dak', label: 'Dak' },
|
||
{ key: 'lampen', label: 'Lampen' }
|
||
], (v) => {
|
||
beplatingVisible = v.beplating;
|
||
isolatieVisible = v.isolatie;
|
||
dakVisible = v.dak;
|
||
lampenVisible = v.lampen;
|
||
refresh({ keepProps: true });
|
||
});
|
||
|
||
// Volle render: avondscène, lampen en spots als echte lichtbronnen.
|
||
let lightsGroup = null;
|
||
// Lucht en gras (core/skybox.js): alleen in de gewone weergave (de volle
|
||
// render is een avondscène). Het gras ligt op het maaiveld, dus het
|
||
// ingegraven deel van vloer en sandwichpanelen verdwijnt eronder.
|
||
function applyRenderMode() {
|
||
const sky = skyOn && !fullRender;
|
||
scene.background = new THREE.Color(fullRender ? 0x0b0f18 : 0x1a1a1a);
|
||
Skybox.apply(sky, { env: skyEnv, controls, sun: dirLight, shadowRoot: houseGroup });
|
||
ambient.intensity = fullRender ? 0.1 : 0.6;
|
||
dirLight.intensity = fullRender ? 0.12 : 0.8;
|
||
ground.visible = fullRender;
|
||
grid.visible = !fullRender && !sky;
|
||
if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; }
|
||
// De nep-lichtbundels (kegels) alleen in de snelle weergave; in de
|
||
// volle render schijnt er echt licht.
|
||
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !fullRender; });
|
||
if (fullRender && parts && lampenVisible) {
|
||
const edge = dakVisible ? parts.roofEdge : null;
|
||
lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0);
|
||
scene.add(lightsGroup);
|
||
}
|
||
}
|
||
Skybox.createToggle(document.getElementById('ho-toolbar-3d'), (on) => { skyOn = on; applyRenderMode(); });
|
||
document.getElementById('ho-btn-render').addEventListener('click', (e) => {
|
||
fullRender = !fullRender;
|
||
e.currentTarget.classList.toggle('active', fullRender);
|
||
applyRenderMode();
|
||
});
|
||
|
||
// --- Plattegrond: view-transform + hit-tests ---
|
||
let parts = null;
|
||
let view = { scale: 1, ox: 0, oy: 0 };
|
||
const toPx = (x, z) => ({ x: view.ox + x * view.scale, y: view.oy + z * view.scale });
|
||
const toWorld = (mx, my) => ({ x: (mx - view.ox) / view.scale, z: (my - view.oy) / view.scale });
|
||
|
||
function pieceHit(place, x, z, tolPx) {
|
||
const c = Math.cos(place.rotY), s = Math.sin(place.rotY);
|
||
const dx = x - place.cx, dz = z - place.cz;
|
||
const lx = dx * c - dz * s, lz = dx * s + dz * c;
|
||
const tol = tolPx / view.scale;
|
||
return Math.abs(lx) <= place.width / 2 + tol && Math.abs(lz) <= place.thickness / 2 + tol;
|
||
}
|
||
|
||
function hitTest(x, z) {
|
||
const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4));
|
||
if (iw) return { type: 'iw', id: iw.id };
|
||
for (const u of parts.units) {
|
||
for (const w of u.walls) {
|
||
const p = w.pieces.find(pc => pieceHit(pc.place, x, z, 3));
|
||
if (p) return { type: 'wall', unit: u.id, side: w.side, element: p.element };
|
||
}
|
||
}
|
||
const r = parts.rects.find(rc => x >= rc.x0 && x <= rc.x1 && z >= rc.z0 && z <= rc.z1);
|
||
return r ? { type: 'unit', id: r.id } : null;
|
||
}
|
||
|
||
const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element);
|
||
|
||
// --- Tekenen van binnenwanden ---
|
||
let tool = 'select';
|
||
let drawStart = null; // { x, z } eerste klik
|
||
let hover = null; // { x, z } gesnapt cursorpunt
|
||
|
||
function setTool(t) {
|
||
tool = t;
|
||
drawStart = null;
|
||
document.getElementById('ho-tool-select').classList.toggle('active', t === 'select');
|
||
document.getElementById('ho-tool-iw').classList.toggle('active', t === 'iw');
|
||
canvas.style.cursor = t === 'iw' ? 'crosshair' : 'default';
|
||
updateHint();
|
||
drawPlan();
|
||
}
|
||
document.getElementById('ho-tool-select').addEventListener('click', () => setTool('select'));
|
||
document.getElementById('ho-tool-iw').addEventListener('click', () => setTool('iw'));
|
||
|
||
function updateHint() {
|
||
document.getElementById('ho-hint').textContent = tool === 'iw'
|
||
? (drawStart ? 'Klik het eindpunt (Esc = annuleren). Snapt op 50 mm, haaks, en op wandvlakken.' : 'Klik het beginpunt van de binnenwand.')
|
||
: 'Klik = selecteren · dubbelklik = bewerken';
|
||
}
|
||
|
||
// Snappen: 50 mm raster, haaks op het beginpunt, en op wandvlakken
|
||
// (binnen/buitenzijde van wanden) binnen 12 px.
|
||
function snapPoint(x, z, from) {
|
||
let sx = Math.round(x / 50) * 50, sz = Math.round(z / 50) * 50;
|
||
if (from) { if (Math.abs(x - from.x) >= Math.abs(z - from.z)) sz = from.z; else sx = from.x; }
|
||
const tol = 12 / view.scale;
|
||
const facesX = [], facesZ = [];
|
||
HouseModel.allWallPieces(parts).forEach(p => {
|
||
const alongX = Math.abs(Math.sin(p.place.rotY)) < 0.5;
|
||
(alongX ? facesZ : facesX).push((alongX ? p.place.cz : p.place.cx) - p.place.thickness / 2, (alongX ? p.place.cz : p.place.cx) + p.place.thickness / 2);
|
||
});
|
||
const nearest = (v, list) => list.reduce((best, f) => Math.abs(f - v) < Math.abs(best - v) ? f : best, Infinity);
|
||
const fx = nearest(x, facesX), fz = nearest(z, facesZ);
|
||
if (!(from && sx === from.x) && Math.abs(fx - x) < tol) sx = fx;
|
||
if (!(from && sz === from.z) && Math.abs(fz - z) < tol) sz = fz;
|
||
return { x: Math.round(sx), z: Math.round(sz) };
|
||
}
|
||
|
||
function createInteriorWall(a, b) {
|
||
if (Math.hypot(b.x - a.x, b.z - a.z) < 300) { setIoStatus('Binnenwand te kort (min. 300 mm).', 'err'); return; }
|
||
const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1;
|
||
const height = Math.min(...state.units.map(u => Math.min(u.unit.walls.voor.wall.heightLeft, u.unit.walls.voor.wall.heightRight)));
|
||
state.interiorWalls.push({ id, x1: a.x, z1: a.z, x2: b.x, z2: b.z, wall: HouseModel.interiorWallDefaults(HouseModel.DEFAULT_INTERIOR_THICKNESS, height) });
|
||
selection = { type: 'iw', id };
|
||
refresh();
|
||
}
|
||
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (tool !== 'iw' || !parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
hover = snapPoint(w.x, w.z, drawStart);
|
||
drawPlan();
|
||
});
|
||
canvas.addEventListener('click', (e) => {
|
||
if (!parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
if (tool === 'iw') {
|
||
const p = snapPoint(w.x, w.z, drawStart);
|
||
if (!drawStart) drawStart = p;
|
||
else { const a = drawStart; drawStart = null; createInteriorWall(a, p); }
|
||
updateHint();
|
||
drawPlan();
|
||
return;
|
||
}
|
||
const hit = hitTest(w.x, w.z);
|
||
if (!sameSel(hit, selection)) { selection = hit; renderProps(); drawPlan(); renderNav(); }
|
||
});
|
||
canvas.addEventListener('dblclick', (e) => {
|
||
if (tool !== 'select' || !parts) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const hit = hitTest(w.x, w.z);
|
||
if (!hit) return;
|
||
if (hit.type === 'wall') showPart(`wall:${hit.unit}:${hit.side}`);
|
||
else if (hit.type === 'iw') showPart(`iw:${hit.id}`);
|
||
else if (hit.type === 'unit') showPart(`floor:${hit.id}`);
|
||
});
|
||
function onKey(e) {
|
||
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||
if (e.key === 'Escape') {
|
||
if (drawStart) { drawStart = null; updateHint(); drawPlan(); } else if (tool === 'iw') setTool('select');
|
||
} else if (e.key === 'Delete' && selection && selection.type === 'iw') {
|
||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id);
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey);
|
||
|
||
// --- Plattegrond tekenen ---
|
||
function polyPath(pts) {
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); });
|
||
ctx.closePath();
|
||
}
|
||
function pieceCorners(place, lx0, lx1) {
|
||
const t = place.thickness / 2;
|
||
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
|
||
}
|
||
|
||
function drawPlan() {
|
||
if (!parts) return;
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
canvas.width = rect.width;
|
||
canvas.height = rect.height;
|
||
const w = canvas.width, h = canvas.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
|
||
const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||
const scale = Math.min(w / ((bx1 - bx0) * 1.45), h / ((bz1 - bz0) * 1.45));
|
||
view = { scale, ox: w / 2 - ((bx0 + bx1) / 2) * scale, oy: h / 2 - ((bz0 + bz1) / 2) * scale };
|
||
|
||
ctx.strokeStyle = '#222'; ctx.lineWidth = 1;
|
||
const g = 500 * scale, gx = view.ox % g, gy = view.oy % g;
|
||
for (let x = gx; x < w; x += g) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||
for (let y = gy; y < h; y += g) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||
|
||
const overlapIds = new Set(parts.overlaps.flat());
|
||
parts.rects.forEach(r => {
|
||
const p0 = toPx(r.x0, r.z0);
|
||
const sel = selection && selection.type === 'unit' && selection.id === r.id;
|
||
ctx.fillStyle = sel ? 'rgba(77, 166, 255, 0.18)' : 'rgba(205, 168, 106, 0.12)';
|
||
ctx.fillRect(p0.x, p0.y, r.W * scale, r.D * scale);
|
||
ctx.strokeStyle = overlapIds.has(r.id) ? '#ff5544' : sel ? '#4da6ff' : '#666';
|
||
ctx.lineWidth = sel || overlapIds.has(r.id) ? 2 : 1;
|
||
ctx.strokeRect(p0.x, p0.y, r.W * scale, r.D * scale);
|
||
const c = toPx(r.cx, r.cz);
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
|
||
ctx.font = `bold ${Math.max(14, Math.min(48, r.D * scale * 0.3))}px sans-serif`;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(r.id, c.x, c.y);
|
||
ctx.textBaseline = 'alphabetic';
|
||
});
|
||
|
||
const drawPiece = (p, selected, color) => {
|
||
polyPath(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2));
|
||
ctx.fillStyle = selected ? '#4da6ff' : color;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#8a6836'; ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
p.config.openings.forEach(op => {
|
||
polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w));
|
||
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff');
|
||
ctx.fill();
|
||
});
|
||
};
|
||
parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => {
|
||
drawPiece(p, selection && selection.type === 'wall' && selection.element === p.element, '#c29b62');
|
||
})));
|
||
parts.interior.forEach(p => drawPiece(p, selection && selection.type === 'iw' && selection.id === p.id, '#d8c49a'));
|
||
|
||
// Naden
|
||
ctx.save();
|
||
ctx.strokeStyle = '#ff9f43'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]);
|
||
parts.seams.forEach(s => {
|
||
const a = toPx(s.x0, s.z0), b = toPx(s.x1, s.z1);
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
|
||
// Maatlabels buiten elk buiten-wandstuk (stijl: core/canvas-labels.js)
|
||
parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => {
|
||
const n = p.place.normal, off = 14;
|
||
const a = toPx(...Object.values(UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2)));
|
||
const b = toPx(...Object.values(UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2)));
|
||
const x0 = a.x + n.x * off, y0 = a.y + n.y * off, x1 = b.x + n.x * off, y1 = b.y + n.y * off;
|
||
drawCanvasDim(ctx, x0, y0, x1, y1, `${p.element}: ${p.place.width}mm`,
|
||
(x0 + x1) / 2 + (n.x < 0 ? -4 : n.x > 0 ? 12 : 0), (y0 + y1) / 2 + (n.y < 0 ? -4 : n.y > 0 ? 11 : 0));
|
||
})));
|
||
parts.interior.forEach(p => {
|
||
const c = toPx(p.place.cx, p.place.cz);
|
||
ctx.save();
|
||
ctx.translate(c.x, c.y);
|
||
if (p.place.rotY !== 0) ctx.rotate(-Math.PI / 2);
|
||
ctx.fillStyle = '#4da6ff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
||
ctx.fillText(`${p.element}: ${p.place.width}mm`, 0, -p.place.thickness / 2 * scale - 4);
|
||
ctx.restore();
|
||
});
|
||
|
||
// Dak: dakranden (gestippeld) + boeideel/daktrim-buitenrand + spots
|
||
ctx.save();
|
||
ctx.setLineDash([3, 4]);
|
||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1;
|
||
parts.units.forEach(u => {
|
||
const a = toPx(u.roof.rect.x0, u.roof.rect.z0);
|
||
ctx.strokeRect(a.x, a.y, u.roof.rect.W * scale, u.roof.rect.D * scale);
|
||
});
|
||
if (parts.roofEdge) {
|
||
parts.roofEdge.segments.forEach(sg => {
|
||
ctx.strokeStyle = sg.fascia ? 'rgba(232, 136, 61, 0.9)' : 'rgba(190, 195, 200, 0.9)';
|
||
const a = toPx(sg.outerA.x, sg.outerA.z), b = toPx(sg.outerB.x, sg.outerB.z);
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
});
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = '#ffe9a8';
|
||
parts.roofEdge.spots.forEach(sp => {
|
||
const q = toPx(sp.x, sp.z);
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 3, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
ctx.restore();
|
||
|
||
// Gevellampen: blokje op het gevelvlak
|
||
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1;
|
||
parts.lighting.lamps.forEach(l => {
|
||
const q = toPx(l.x + l.normal.x * 60, l.z + l.normal.z * 60);
|
||
ctx.fillRect(q.x - 3.5, q.y - 3.5, 7, 7);
|
||
ctx.strokeRect(q.x - 3.5, q.y - 3.5, 7, 7);
|
||
});
|
||
|
||
// Noordpijl (boven = noord; wanden/zijden heten naar windrichting)
|
||
ctx.save();
|
||
ctx.translate(w - 34, 78);
|
||
ctx.fillStyle = '#4da6ff'; ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(0, -18); ctx.lineTo(7, 6); ctx.lineTo(0, 1); ctx.lineTo(-7, 6); ctx.closePath(); ctx.fill();
|
||
ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center';
|
||
ctx.fillText('N', 0, 20);
|
||
ctx.restore();
|
||
|
||
// Teken-preview binnenwand
|
||
if (tool === 'iw' && hover) {
|
||
const t = HouseModel.DEFAULT_INTERIOR_THICKNESS / 2;
|
||
if (drawStart) {
|
||
const alongX = Math.abs(hover.x - drawStart.x) >= Math.abs(hover.z - drawStart.z);
|
||
const pts = alongX
|
||
? [{ x: drawStart.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z + t }, { x: drawStart.x, z: drawStart.z + t }]
|
||
: [{ x: drawStart.x - t, z: drawStart.z }, { x: drawStart.x + t, z: drawStart.z }, { x: drawStart.x + t, z: hover.z }, { x: drawStart.x - t, z: hover.z }];
|
||
polyPath(pts);
|
||
ctx.fillStyle = 'rgba(77, 166, 255, 0.35)'; ctx.fill();
|
||
ctx.strokeStyle = '#4da6ff'; ctx.stroke();
|
||
const len = Math.round(alongX ? Math.abs(hover.x - drawStart.x) : Math.abs(hover.z - drawStart.z));
|
||
const hp = toPx(hover.x, hover.z);
|
||
ctx.fillStyle = '#fff'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left';
|
||
ctx.fillText(len + ' mm', hp.x + 10, hp.y - 8);
|
||
}
|
||
const hp = toPx(hover.x, hover.z);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(hp.x - 7, hp.y); ctx.lineTo(hp.x + 7, hp.y); ctx.moveTo(hp.x, hp.y - 7); ctx.lineTo(hp.x, hp.y + 7); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// --- Eigenschappenpaneel (contextafhankelijk, opgebouwd met UI-kit) ---
|
||
const { num, sel, toggle, section, info, button, actions } = UI;
|
||
const cols = UI.grid; // (grid = de 3D-rasterhulp)
|
||
UI.wireTabs(propsEl);
|
||
const head = (iconName, title, sub) =>
|
||
`<div class="ui-panel-head"><h1>${UI.icon(iconName)}${title}</h1>${sub ? `<span class="ui-sub">${sub}</span>` : ''}</div>`;
|
||
const after = () => refresh({ keepProps: true }); // getal: panelen blijven staan
|
||
const rerender = () => refresh(); // keuze: velden kunnen wijzigen
|
||
|
||
function renderProps() {
|
||
const warn = (parts.overlaps.length ? UI.warn(`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`) : '') +
|
||
parts.rects.filter(r => r.error).map(r => UI.warn(`Unit ${r.id}: ${r.error}`)).join('');
|
||
|
||
if (selection && selection.type === 'unit' && unitById(selection.id)) renderUnitProps(unitById(selection.id), warn);
|
||
else if (selection && selection.type === 'wall') renderWallProps(warn);
|
||
else if (selection && selection.type === 'iw' && iwById(selection.id)) renderIwProps(iwById(selection.id), warn);
|
||
else renderHouseProps(warn);
|
||
}
|
||
|
||
function renderUnitProps(u, warn) {
|
||
const isRoot = state.units[0].id === u.id;
|
||
const a = u.attach;
|
||
const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
|
||
const f = u.unit.floor.floor;
|
||
const koppeling = isRoot
|
||
? info('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.')
|
||
: sel('hp-att-to', 'Gekoppeld aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) +
|
||
sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) +
|
||
sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) +
|
||
num('hp-att-offset', 'Verschuiving', a.offset || 0);
|
||
const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) +
|
||
(u.roof.followFloor ? '' :
|
||
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) +
|
||
num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) +
|
||
num('hp-roof-ox', 'Verschuiving oost', u.roof.offsetX || 0) +
|
||
num('hp-roof-oz', 'Verschuiving zuid', u.roof.offsetZ || 0)) +
|
||
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' });
|
||
|
||
propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} × ${f.depth} mm`) +
|
||
UI.tabs('unit', [
|
||
{ key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols(
|
||
num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }),
|
||
sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) },
|
||
{ key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, '') },
|
||
{ key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') }
|
||
]) +
|
||
actions(
|
||
button('Vloer bewerken', 'grid-3x3', `data-go="floor:${u.id}"`),
|
||
button('Dak bewerken', 'house', `data-go="roof:${u.id}"`),
|
||
isRoot ? '' : button('Unit verwijderen', 'trash', `data-remove-unit="${u.id}"`, 'ui-btn-danger'));
|
||
|
||
UI.onNum('hp-width', v => { if (v >= 1000) f.width = v; }, after);
|
||
UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after);
|
||
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
|
||
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
|
||
UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender);
|
||
UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender);
|
||
UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender);
|
||
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
|
||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
|
||
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
|
||
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
|
||
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
|
||
}
|
||
|
||
function renderWallProps(warn) {
|
||
const pu = parts.units.find(x => x.id === selection.unit);
|
||
const wl = pu && pu.walls.find(x => x.side === selection.side);
|
||
const piece = wl && wl.pieces.find(p => p.element === selection.element);
|
||
if (!piece) { selection = null; return renderProps(); }
|
||
const lt = state.settings.lighting;
|
||
const rows = [
|
||
['Lengte zijde', wl.width, 'mm'],
|
||
['Lengte wandstuk', piece.place.width, 'mm'],
|
||
['Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm'],
|
||
['Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)} – ${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : ''],
|
||
['Openingen', piece.config.openings.length, '']
|
||
];
|
||
const lampen = lt.mode === 'gevel'
|
||
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto',
|
||
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, alleen op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm naast elke opening.` }) +
|
||
UI.specCard('Op dit wandstuk', 'lightbulb', [['Gevellampen', piece.lamps.length, 'st.']])
|
||
: info('Gevellampen staan uit. Kies bij Woning › Verlichting het type "Gevellampen".');
|
||
propsEl.innerHTML = warn + head('bricks', piece.element, piece.label) +
|
||
cols(UI.specCard('Wandstuk', 'rulers', rows), section('Gevellampen', 'lightbulb', lampen)) +
|
||
(wl.dropped.length ? UI.warn(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`) : '') +
|
||
actions(button('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}"`));
|
||
UI.onSel('hw-light', v => { unitById(pu.id).lighting.walls[wl.side] = v; }, rerender);
|
||
}
|
||
|
||
function renderIwProps(iw, warn) {
|
||
const p = parts.interior.find(x => x.id === iw.id);
|
||
propsEl.innerHTML = warn + head('distribute-horizontal', p.element, `Binnenwand · ${p.place.width} mm`) +
|
||
cols(
|
||
section('Positie', 'crosshair', num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2)),
|
||
section('Opbouw', 'layers', num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll))) +
|
||
actions(
|
||
button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`),
|
||
button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger'));
|
||
['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after));
|
||
UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after);
|
||
UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after);
|
||
}
|
||
|
||
// Woning-instellingen (niets geselecteerd): alles wat voor de hele woning
|
||
// geldt, in tabbladen.
|
||
function renderHouseProps(warn) {
|
||
const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting;
|
||
const pos = { positive: true };
|
||
const verlichting = lt.mode === 'gevel'
|
||
? cols(
|
||
section('Gevellampen', 'lightbulb', sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) +
|
||
num('hs-lamp-h', 'Hoogte', lt.lampHeight, { hint: 'Vanaf bovenkant vloer' }) +
|
||
num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' })),
|
||
section('Plaatsing', 'distribute-horizontal', num('hs-lamp-w', 'Min. gevelbreedte', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) +
|
||
num('hs-lamp-end', 'Afstand tot hoek', lt.endOffset) +
|
||
num('hs-lamp-open', 'Afstand tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen"' })))
|
||
: lt.mode === 'spots'
|
||
? cols(section('Boeideel-spots', 'lightbulb', num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' })),
|
||
info('Spots komen alleen onder units met boeidelen.'))
|
||
: '';
|
||
|
||
propsEl.innerHTML = warn + head('house-door', 'Woning', `${state.units.length} unit(s) · ${state.interiorWalls.length} binnenwand(en)`) +
|
||
UI.tabs('woning', [
|
||
{ key: 'algemeen', label: 'Algemeen', icon: 'house-door', html:
|
||
info('Klik in de plattegrond op een unit, wand of binnenwand om te selecteren; dubbelklik om te bewerken. Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.') +
|
||
actions(button('Unit toevoegen', 'plus-lg', 'data-action="add-unit"'), button('Binnenwand tekenen', 'pencil', 'data-action="draw-iw"'),
|
||
button('Projectgegevens', 'card-text', 'data-action="project"')) },
|
||
{ key: 'gevel', label: 'Gevel', icon: 'bricks', html: cols(
|
||
section('Gevelbekleding', 'grip-horizontal', sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'WEO/Novalit, voorlopig voor alle buitenwanden' })) +
|
||
section('Trasraam', 'bricks', toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Cement-gebonden plaat onderaan de gevel' }) +
|
||
(st.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte', st.trasraam.height, { step: 10, hint: 'Vanaf bovenkant vloer' }) : '')),
|
||
section('Sandwichpanelen', 'bounding-box', toggle('hs-sandwich', 'Rondom woning', st.sandwich,
|
||
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` })) +
|
||
section('Maaiveld', 'tree', num('hs-ground', 'Onder bovenkant vloer', st.groundLevel, { step: 10, hint: 'Hoogte van het gras in de 3D-weergave met Lucht aan' }))) },
|
||
{ key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols(
|
||
section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })),
|
||
section('Binnenwanden', 'distribute-horizontal', num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart stijlen' }))) },
|
||
{ key: 'dak', label: 'Dakrand', icon: 'house', html: cols(
|
||
section('Boeideel', 'border-outer', num('hs-overhang', 'Overstek', r.overhang, { step: 10, hint: `${RoofModel.OVERHANG_MIN} – ${RoofModel.OVERHANG_MAX} mm` }) + num('hs-fascia-h', 'Hoogte', r.fasciaHeight, { step: 10 })),
|
||
section('Daktrim', 'border-top', num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }))) },
|
||
{ key: 'verlichting', label: 'Verlichting', icon: 'lightbulb', html:
|
||
cols(sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode), '') + verlichting }
|
||
]);
|
||
|
||
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
|
||
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
|
||
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
|
||
UI.onNum('hs-trasraam-h', v => { st.trasraam.height = v; }, after, pos);
|
||
UI.onNum('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
||
UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos);
|
||
UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos);
|
||
UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos);
|
||
UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos);
|
||
UI.onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after, pos);
|
||
UI.onNum('hs-fascia-h', v => { r.fasciaHeight = v; }, after, pos);
|
||
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
|
||
UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos);
|
||
UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos);
|
||
UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender);
|
||
UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender);
|
||
UI.onNum('hs-lamp-h', v => { lt.lampHeight = v; }, after, pos);
|
||
UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos);
|
||
UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos);
|
||
UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos);
|
||
UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos);
|
||
UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos);
|
||
}
|
||
|
||
propsEl.addEventListener('click', (e) => {
|
||
const go = e.target.closest('[data-go]');
|
||
if (go) { showPart(go.dataset.go); return; }
|
||
const ru = e.target.closest('[data-remove-unit]');
|
||
if (ru) { removeUnit(ru.dataset.removeUnit); return; }
|
||
const ri = e.target.closest('[data-remove-iw]');
|
||
if (ri) {
|
||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(ri.dataset.removeIw));
|
||
selection = null;
|
||
refresh();
|
||
return;
|
||
}
|
||
const act = e.target.closest('[data-action]');
|
||
if (!act) return;
|
||
if (act.dataset.action === 'add-unit') addUnit();
|
||
else if (act.dataset.action === 'draw-iw') setTool('iw');
|
||
else if (act.dataset.action === 'project') openProjectProperties();
|
||
});
|
||
|
||
// --- Specificaties/totalen (onder de 3D) ---
|
||
function renderSpecs(totals) {
|
||
const f = houseFmt;
|
||
const cards = [
|
||
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
|
||
UI.specCard('Hout & isolatie', 'tree', [
|
||
['Balken', totals.beamCount, 'st.'],
|
||
['Volume hout', f(totals.woodM3, 3), 'm³'],
|
||
['Inkoop wandbalken', WallModel.purchaseText(totals.purchase), ''],
|
||
['Snijverlies', `${f(totals.purchaseWasteMM / 1000, 2)} m · ${f(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0)}`, '%'],
|
||
[`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'],
|
||
['Glaswol', f(totals.isolatieM3, 3), 'm³']
|
||
]),
|
||
UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(p => p.m2 > 0).map(p => [p.naam, f(p.m2, 2), 'm²']))
|
||
];
|
||
if (totals.roofEdge) {
|
||
const e = totals.roofEdge, rows = [];
|
||
if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
|
||
if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
|
||
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.']);
|
||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||
}
|
||
document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
|
||
}
|
||
|
||
// --- Alles opnieuw doorrekenen ---
|
||
function refresh(opts) {
|
||
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, isolatieVisible, lampenVisible });
|
||
const totals = HouseModel.computeTotals(parts);
|
||
|
||
if (houseGroup) scene.remove(houseGroup);
|
||
houseGroup = new THREE.Group();
|
||
const toM = 0.001;
|
||
parts.units.forEach(u => {
|
||
u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
|
||
houseGroup.add(u.floorGroup);
|
||
});
|
||
HouseModel.allWallPieces(parts).forEach(p => {
|
||
p.group.rotation.y = p.place.rotY;
|
||
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
|
||
houseGroup.add(p.group);
|
||
});
|
||
if (dakVisible) {
|
||
parts.units.forEach(u => {
|
||
u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
|
||
houseGroup.add(u.roof.group);
|
||
});
|
||
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
|
||
}
|
||
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
|
||
if (parts.lightingGroup) houseGroup.add(parts.lightingGroup);
|
||
scene.add(houseGroup);
|
||
|
||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
|
||
const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||
const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM;
|
||
grid.position.set(cx, 0, cz);
|
||
// Maaiveld: settings.groundLevel onder het loopvlak (bovenkant vloer).
|
||
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel) * toM;
|
||
skyEnv.setPlacement(cx, groundY, cz);
|
||
ground.position.set(cx, groundY - 0.002, cz);
|
||
applyRenderMode();
|
||
if (!cameraFitted) {
|
||
const size = Math.max(bx1 - bx0, bz1 - bz0) * toM;
|
||
controls.target.set(cx, 1.2, cz);
|
||
camera.position.set(cx - size * 0.9, size * 0.8, cz - size * 1.3); // kijkt tegen de voorzijde aan
|
||
controls.update();
|
||
cameraFitted = true;
|
||
}
|
||
|
||
if (!(opts && opts.keepProps)) renderProps();
|
||
renderSpecs(totals);
|
||
drawPlan();
|
||
renderNav();
|
||
}
|
||
|
||
let rafId = null;
|
||
function animate() {
|
||
rafId = requestAnimationFrame(animate);
|
||
renderer.render(scene, camera);
|
||
}
|
||
function onResize() {
|
||
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||
drawPlan();
|
||
}
|
||
window.addEventListener('resize', onResize);
|
||
|
||
function toggleView(fn) {
|
||
fn(document.getElementById('house-overview'));
|
||
requestAnimationFrame(onResize);
|
||
}
|
||
|
||
refresh();
|
||
animate();
|
||
setTool(pendingTool || 'select');
|
||
pendingTool = null;
|
||
|
||
return {
|
||
unmount() {
|
||
window.removeEventListener('resize', onResize);
|
||
window.removeEventListener('keydown', onKey);
|
||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||
CameraMemory.save(overviewCamKey, camera, controls);
|
||
renderer.dispose();
|
||
},
|
||
refresh,
|
||
setTool,
|
||
toggleFullscreen3D: () => toggleView(UI.cycle3D),
|
||
toggleFullscreen2D: () => toggleView(UI.toggle2D)
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// Project eigenschappen (woning-niveau)
|
||
// ------------------------------------------------------------
|
||
function openProjectProperties() {
|
||
HOUSE_PROJECT_KEYS.forEach(k => { document.getElementById('h-proj-' + k).value = state.project[k] || ''; });
|
||
document.getElementById('house-project-overlay').classList.add('open');
|
||
}
|
||
function closeProjectProperties(save) {
|
||
if (save) HOUSE_PROJECT_KEYS.forEach(k => { state.project[k] = document.getElementById('h-proj-' + k).value; });
|
||
document.getElementById('house-project-overlay').classList.remove('open');
|
||
}
|
||
document.getElementById('house-project-save').addEventListener('click', () => closeProjectProperties(true));
|
||
document.getElementById('house-project-cancel').addEventListener('click', () => closeProjectProperties(false));
|
||
document.getElementById('house-project-close-x').addEventListener('click', () => closeProjectProperties(false));
|
||
document.getElementById('house-project-overlay').addEventListener('click', (e) => {
|
||
if (e.target.id === 'house-project-overlay') closeProjectProperties(false);
|
||
});
|
||
|
||
// ------------------------------------------------------------
|
||
// JSON + PDF
|
||
// ------------------------------------------------------------
|
||
function getStateJson() {
|
||
flushActivePart();
|
||
return HouseModel.clone(state);
|
||
}
|
||
|
||
function loadStateJson(newState) {
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
activePart = null;
|
||
state = HouseModel.normalize(HouseModel.clone(newState));
|
||
selection = null;
|
||
showPart('overzicht');
|
||
}
|
||
|
||
function exportDesign() {
|
||
const suggested = 'woning-ontwerp-' + new Date().toISOString().slice(0, 10);
|
||
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
|
||
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
|
||
filename = filename.trim() || suggested;
|
||
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
|
||
|
||
const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), ...getStateJson() };
|
||
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
try {
|
||
HousePdf.generate(state).save(filename.replace(/\.json$/i, '') + '.pdf');
|
||
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
|
||
} catch (err) {
|
||
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
|
||
}
|
||
if (typeof onSaved === 'function') onSaved(filename);
|
||
return filename;
|
||
}
|
||
|
||
function triggerImport() {
|
||
document.getElementById('h-file-import-json').click();
|
||
}
|
||
|
||
document.getElementById('h-file-import-json').addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
e.target.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
let design = JSON.parse(ev.target.result);
|
||
if (design && design.type === 'unit-design') {
|
||
UnitModel.validateState(design);
|
||
design = HouseModel.fromUnitDesign(design);
|
||
} else if (!design || design.type !== 'house-design') {
|
||
throw new Error('geen geldig woning- of unit-ontwerp (type ontbreekt of onjuist)');
|
||
} else {
|
||
HouseModel.validateState(design);
|
||
const { type, version, exportedAt, ...pure } = design;
|
||
design = pure;
|
||
}
|
||
loadStateJson(design);
|
||
setIoStatus('Woning geïmporteerd.', 'ok');
|
||
if (typeof onSaved === 'function') onSaved(file.name);
|
||
} catch (err) {
|
||
setIoStatus('Import mislukt: ' + err.message, 'err');
|
||
}
|
||
};
|
||
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
function forward(fn) {
|
||
return () => { if (activeEditor && typeof activeEditor[fn] === 'function') activeEditor[fn](); };
|
||
}
|
||
|
||
showPart('overzicht');
|
||
|
||
return {
|
||
unmount() {
|
||
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
|
||
activeEditor = null;
|
||
},
|
||
exportDesign,
|
||
triggerImport,
|
||
mirrorHorizontal: forward('mirrorHorizontal'),
|
||
toggleFullscreen3D: forward('toggleFullscreen3D'),
|
||
toggleFullscreen2D: forward('toggleFullscreen2D'),
|
||
openProjectProperties,
|
||
getStateJson,
|
||
loadStateJson
|
||
};
|
||
}
|
||
|
||
window.HouseEditor = {
|
||
mount: mountHouseEditor,
|
||
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
|
||
};
|