2049 lines
114 KiB
JavaScript
2049 lines
114 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 + 3D
|
||
// met bouwfase, inspector met Eigenschappen | Rapport), 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> Structuur</div>
|
||
<div id="house-nav-tree"></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>
|
||
`;
|
||
|
||
// Overzicht: plattegrond en 3D over de volle breedte; alle bediening zit in
|
||
// de ribbon (core/ribbon.js). Tabblad Rapport toont het rapport in plaats
|
||
// van de plattegrond.
|
||
const HOUSE_OVERVIEW_TEMPLATE = `
|
||
<div id="house-overview">
|
||
<div class="ho-views">
|
||
<div id="ho-report" class="ho-report"></div>
|
||
<div id="ho-pane-plan" class="ho-pane">
|
||
<div class="pane-label">PLATTEGROND</div>
|
||
<div id="ho-warn" class="ho-warn"></div>
|
||
<canvas id="ho-canvas"></canvas>
|
||
<div id="ho-hint" class="ho-hint"></div>
|
||
</div>
|
||
<div id="ho-pane-3d" class="ho-pane">
|
||
<div class="pane-label">3D</div>
|
||
</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
|
||
// Weergave van het overzicht (blijft bewaard bij wisselen van onderdeel):
|
||
// bouwfase (BuildStages) en de hoogte van de doorsnede (mm boven het loopvlak).
|
||
const view3D = { stage: BuildStages.FINAL, section: false, sectionMM: 1000, dak: true, lampen: true, elektra: true };
|
||
// 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)) {
|
||
// Hoogtes gewijzigd: tegenoverliggende wand spiegelen, haakse wanden
|
||
// vlak op de hoekhoogtes (UnitModel.syncWallHeights).
|
||
const unit = unitById(a).unit, before = unit.walls[b].wall;
|
||
const next = activeEditor.getStateJson();
|
||
unit.walls[b] = next;
|
||
const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null, JSON.stringify(w.topPts || null)].join('|');
|
||
if (h(before) !== h(next.wall)) UnitModel.syncWallHeights(unit, b);
|
||
}
|
||
else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson();
|
||
else if (kind === 'iw' && iwById(Number(a))) {
|
||
const iw = iwById(Number(a));
|
||
iw.wall = activeEditor.getStateJson();
|
||
if (iw.seam) iw.wall.materials = Object.assign({}, iw.wall.materials, { exterior: 'geen' }); // naadzijde blijft zonder plaat
|
||
}
|
||
}
|
||
|
||
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.',
|
||
lockExterior: 'De gevelbekleding volgt de woning (Overzicht › Gevel); per wand aan te passen via de wand in de plattegrond.',
|
||
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);
|
||
if (iwById(Number(a)).seam) HouseModel.computeParts(state, { skip3D: true }); // breedte naadwand-deel
|
||
else 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(); // rendert de boom en de ribbon zelf
|
||
return;
|
||
}
|
||
setPartRibbon(kind, a, b);
|
||
renderNav();
|
||
}
|
||
|
||
// Ribbon terwijl een onderdeel-editor open is: terug naar de woning en de
|
||
// woning-acties; de editor zelf heeft zijn eigen panelen.
|
||
function setPartRibbon(kind, a, b) {
|
||
const { big } = Ribbon;
|
||
const what = kind === 'floor' ? `Vloer unit ${a}` : kind === 'roof' ? `Dak unit ${a}` : kind === 'wall'
|
||
? `${(UnitModel.WALL_SIDES.find(sd => sd.key === b) || {}).label || 'Wand'} unit ${a}`
|
||
: (iwById(Number(a)) && iwById(Number(a)).seam ? 'Naadwand' : 'Binnenwand');
|
||
Ribbon.setSource('editor', [{
|
||
key: 'start', label: 'Start', order: 10,
|
||
groups: () => [
|
||
{ label: 'Navigatie', html: big('Terug naar woning', 'arrow-left-circle', 'data-hp="back" title="Overzicht van de woning"') },
|
||
{ label: 'Onderdeel', html: `<div class="rb-note"><b>${what}</b><br>Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).</div>` },
|
||
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-hp="add-unit"') + big('Binnenwand tekenen', 'pencil', 'data-hp="draw-iw"') }
|
||
],
|
||
wire: (body) => body.querySelectorAll('[data-hp]').forEach(btn => btn.addEventListener('click', () => {
|
||
if (btn.dataset.hp === 'back') showPart('overzicht');
|
||
else if (btn.dataset.hp === 'add-unit') addUnit();
|
||
else drawInteriorWall();
|
||
}))
|
||
}]);
|
||
Ribbon.activate('start');
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// Navigatieboom
|
||
// ------------------------------------------------------------
|
||
// parts (optioneel): al doorgerekende onderdelen (overzicht), anders zelf rekenen.
|
||
function renderNav(parts) {
|
||
parts = parts || HouseModel.computeParts(state, { skip3D: true });
|
||
const item = (part, label, meta, cls, iconName, active) =>
|
||
`<div class="hn-item ${cls || ''} ${(active !== undefined ? active : activePart === part) ? 'active' : ''}" data-part="${part}">
|
||
<span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
|
||
// "Woning" = overzicht zonder selectie: de instellingen van de hele woning.
|
||
let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door',
|
||
activePart === 'overzicht' && !selection);
|
||
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>`;
|
||
const seen = new Set();
|
||
parts.interior.forEach(p => {
|
||
if (seen.has(p.id)) return; // naadwand: twee delen, één item
|
||
seen.add(p.id);
|
||
if (p.seam) {
|
||
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
|
||
html += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
|
||
} else 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.select(selection);
|
||
return;
|
||
}
|
||
const row = e.target.closest('[data-part]');
|
||
if (!row) return;
|
||
if (row.dataset.part === 'overzicht') {
|
||
selection = null;
|
||
if (activePart !== 'overzicht') showPart('overzicht');
|
||
else activeEditor.select(null);
|
||
} else if (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: [] }));
|
||
HouseModel.ensureSeamWalls(state, id); // tussenwand met deur op de nieuwe naad
|
||
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');
|
||
}
|
||
|
||
// Binnenwand tekenen (ribbon): vanuit een onderdeel eerst terug naar het overzicht.
|
||
function drawInteriorWall() {
|
||
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');
|
||
|
||
// --- 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 SPAANPLAAT_COLOR = wallMaterials.spaanplaatMaterial.color.getHex(); // kaal (niet geschilderd)
|
||
const floorMaterials = FloorModel.createMaterials();
|
||
const roofMaterials = RoofModel.createMaterials();
|
||
const lightingMaterials = LightingModel.createMaterials();
|
||
const sandwichMaterials = SandwichModel.createMaterials();
|
||
const elektraMaterials = ElektraModel.createMaterials();
|
||
let houseGroup = null;
|
||
// Opgeslagen camerastand gaat voor; anders eenmalig passend maken.
|
||
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
|
||
// Doorsnede: alle wanden afgesneden op view3D.sectionMM boven het
|
||
// loopvlak (clipping-vlak, snede wit afgedekt); dak en lampen uit.
|
||
let planCaps = null;
|
||
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||
renderer.localClippingEnabled = true;
|
||
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||
// Egaal witte afdekking van de snede: per wandstuk een vlak op de
|
||
// snijhoogte over de volle dikte (incl. doek/latwerk of steen aan de
|
||
// buitenzijde en de hoekdoorloop), met de openingen die door de snede
|
||
// lopen uitgespaard. cutAbs = snijhoogte (mm, absoluut).
|
||
function buildPlanCaps(cutAbs) {
|
||
const toM = 0.001, g = new THREE.Group();
|
||
const S = WallModel.STONE;
|
||
// Bouwfase: buitenlagen (latwerk is open, dus pas vanaf de gevel) en beplating alleen als ze er staan.
|
||
const gevel = BuildStages.shows(view3D.stage, 'gevel'), beplating = BuildStages.shows(view3D.stage, 'beplating');
|
||
HouseModel.allWallPieces(parts).forEach(p => {
|
||
const y = cutAbs - p.place.floorTop; // snijhoogte in het stuk
|
||
if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return;
|
||
const W = p.place.width, T = p.place.thickness;
|
||
// Buitenlagen op de snijhoogte (alleen buitenwanden: skinExt/normal).
|
||
let out = 0;
|
||
if (gevel && p.place.normal && p.buitenMateriaal !== 'geen') {
|
||
const tr = p.trasraam;
|
||
out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||
}
|
||
const e = p.skinExt || {};
|
||
const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0);
|
||
const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
|
||
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
|
||
const spans = [];
|
||
let cur = xL;
|
||
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); });
|
||
if (xR > cur) spans.push([cur, xR]);
|
||
const sh = beplating ? 0 : p.config.sheathingThick;
|
||
const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0);
|
||
spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => {
|
||
const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial);
|
||
m.rotation.x = -Math.PI / 2;
|
||
m.position.set((a + b) / 2 * toM, (y - 0.5) * toM, (z0 + z1) / 2 * toM);
|
||
const holder = new THREE.Group();
|
||
holder.rotation.y = p.place.rotY;
|
||
holder.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
|
||
holder.add(m);
|
||
g.add(holder);
|
||
});
|
||
});
|
||
return g;
|
||
}
|
||
let fullRender = false;
|
||
let skyMode = Skybox.getMode();
|
||
const skyEnv = Skybox.createEnvironment(scene);
|
||
|
||
// Bouwfase (core/build-stages.js) + hoogte van de doorsnede.
|
||
const stageBar = BuildStages.createBar(pane3D, view3D.stage, (st) => {
|
||
view3D.stage = st;
|
||
scheduleRefresh({ keepProps: true });
|
||
});
|
||
stageBar.extra.innerHTML = `<span class="stage-title"><i class="bi bi-scissors"></i>Doorsnede</span>
|
||
<input type="range" class="stage-range" id="ho-section" min="100" max="4000" step="50" value="${view3D.sectionMM}" aria-label="Hoogte doorsnede">
|
||
<span class="stage-name" id="ho-section-val"></span>`;
|
||
const sectionInput = document.getElementById('ho-section');
|
||
function syncSectionRow() {
|
||
stageBar.extra.classList.toggle('show', view3D.section);
|
||
document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`;
|
||
}
|
||
// Snijhoogte (schuif hier en in Beeld › Doorsnede). Alleen het snijvlak
|
||
// en de afdekking verplaatsen, niet alles herbouwen.
|
||
function setSectionMM(v) {
|
||
if (!isFinite(v)) return;
|
||
view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v)));
|
||
sectionInput.value = view3D.sectionMM;
|
||
UI.setRange('hv-section-h', view3D.sectionMM);
|
||
syncSectionRow();
|
||
if (!parts || !view3D.section) return;
|
||
placeSection();
|
||
}
|
||
sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value)));
|
||
sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); });
|
||
syncSectionRow();
|
||
function placeSection() {
|
||
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||
cutPlane.constant = (floorTop + view3D.sectionMM) * 0.001;
|
||
if (planCaps) { houseGroup.remove(planCaps); disposeGroup(planCaps); }
|
||
planCaps = view3D.section ? buildPlanCaps(floorTop + view3D.sectionMM) : null;
|
||
if (planCaps) houseGroup.add(planCaps);
|
||
}
|
||
|
||
// Omgeving (core/skybox.js: geen/dag/nacht) en Render. Render = lampen en
|
||
// spots als echte lichtbronnen, in het licht van de gekozen omgeving;
|
||
// Nacht = omgevingslicht op 15% en de lampen altijd aan (100%). De
|
||
// tegelvloer ligt op het maaiveld, dus het ingegraven deel van vloer en
|
||
// sandwichpanelen verdwijnt eronder.
|
||
let lightsGroup = null;
|
||
// buildLights3D maakt per lamp eigen wash-materialen; de textuur is gedeeld (material.dispose laat die staan).
|
||
function disposeLights() {
|
||
if (!lightsGroup) return;
|
||
scene.remove(lightsGroup);
|
||
lightsGroup.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); if (o.isLight && o.dispose) o.dispose(); });
|
||
lightsGroup = null;
|
||
}
|
||
function applyRenderMode() {
|
||
const lampsOn = fullRender || skyMode === 'night';
|
||
scene.background = new THREE.Color(0x1a1a1a);
|
||
Skybox.apply(skyMode, { env: skyEnv, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: houseGroup });
|
||
ground.visible = fullRender && skyMode === 'none'; // zonder omgeving: vlak waarop het lamplicht valt
|
||
grid.visible = skyMode === 'none' && !fullRender;
|
||
disposeLights();
|
||
// De nep-lichtbundels (kegels) alleen zonder echte lampen.
|
||
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
|
||
if (lampsOn && parts && view3D.lampen && !view3D.section && BuildStages.shows(view3D.stage, 'afwerking')) {
|
||
const edge = view3D.dak ? parts.roofEdge : null;
|
||
lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, {
|
||
outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR,
|
||
// Nep-lichtvlekken op de gevel (wall-wash), tot het maaiveld.
|
||
walls: parts.units.flatMap(u => u.walls.flatMap(w => w.pieces)),
|
||
groundY: Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel
|
||
});
|
||
scene.add(lightsGroup);
|
||
}
|
||
}
|
||
// (Omgeving, Render en Tonen: ribbon Beeld, zie ribbonTabs.)
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
// Verplaatsbaar elektra-onderdeel (ElektraModel items) onder de cursor.
|
||
function elektraHit(x, z) {
|
||
const el = parts && parts.elektra;
|
||
if (!el || !el.enabled || !view3D.elektra) return null;
|
||
const tol = 10 / view.scale;
|
||
let best = null;
|
||
el.items.forEach(it => {
|
||
const d = Math.hypot(it.marker.x - x, it.marker.z - z);
|
||
if (d < tol && (!best || d < best.d)) best = { d, it };
|
||
});
|
||
return best ? best.it : null;
|
||
}
|
||
const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key);
|
||
|
||
function hitTest(x, z) {
|
||
const eh = elektraHit(x, z);
|
||
if (eh) return { type: 'elektra', id: eh.key };
|
||
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);
|
||
// Selectie wijzigen (plattegrond of boom): inspector naar Eigenschappen.
|
||
function select(sel) {
|
||
selection = sel;
|
||
syncRibbon(true);
|
||
drawPlan();
|
||
renderNav(parts);
|
||
}
|
||
|
||
// --- 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.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t));
|
||
canvas.style.cursor = t === 'iw' ? 'crosshair' : 'default';
|
||
updateHint();
|
||
drawPlan();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// Elektra slepen: langs de wanden (ElektraModel.snapToWall); bij loslaten
|
||
// vastleggen als handmatige plek (settings.elektra.place).
|
||
let elDrag = null, suppressClick = false;
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (tool !== 'select' || !parts || e.button !== 0) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
const it = elektraHit(w.x, w.z);
|
||
if (it) elDrag = { key: it.key, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
|
||
});
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (!elDrag) return;
|
||
if (!elDrag.moved && Math.hypot(e.clientX - elDrag.sx, e.clientY - elDrag.sy) < 4) return;
|
||
elDrag.moved = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
const r = canvas.getBoundingClientRect();
|
||
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
|
||
elDrag.snap = ElektraModel.snapToWall(parts.elektra, w.x, w.z);
|
||
drawPlan();
|
||
});
|
||
function onElektraDrop() {
|
||
if (!elDrag) return;
|
||
const d = elDrag;
|
||
elDrag = null;
|
||
canvas.style.cursor = tool === 'iw' ? 'crosshair' : 'default';
|
||
if (!d.moved || !d.snap) return;
|
||
suppressClick = true;
|
||
const pl = state.settings.elektra.place, prev = pl[d.key] || {};
|
||
pl[d.key] = { element: d.snap.element, side: d.snap.side, u: d.snap.u, y: prev.y };
|
||
selection = { type: 'elektra', id: d.key };
|
||
refresh();
|
||
}
|
||
window.addEventListener('mouseup', onElektraDrop);
|
||
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;
|
||
}
|
||
if (suppressClick) { suppressClick = false; return; }
|
||
const hit = hitTest(w.x, w.z);
|
||
if (!sameSel(hit, selection)) select(hit);
|
||
});
|
||
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();
|
||
|
||
// Binnenspots (plafond): open cirkeltje
|
||
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
|
||
(parts.lighting.indoor || []).forEach(sp => {
|
||
const q = toPx(sp.x, sp.z);
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 3.5, 0, Math.PI * 2); ctx.stroke();
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Elektra (ElektraModel): leidingen, meterkast, schakelaars met letter,
|
||
// wcd met groepnummer en bij elke lamp de letter van zijn schakelaar.
|
||
const el = parts.elektra;
|
||
if (el && el.enabled && view3D.elektra) {
|
||
const RC = ElektraPdf.RUN_COLORS, rgb = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
|
||
const inward = (q, n, d) => toPx(q.x + n.x * d, q.z + n.z * d);
|
||
ctx.save();
|
||
ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]);
|
||
el.runs.forEach(r => {
|
||
ctx.strokeStyle = rgb(RC[r.type] || RC.schakel, 0.8);
|
||
ctx.beginPath();
|
||
r.path.forEach((q, i) => { const p = toPx(q.x, q.z); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); });
|
||
ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
if (el.meterkast) {
|
||
const k = el.meterkast;
|
||
polyPath(k.corners);
|
||
ctx.fillStyle = '#5a5a5a'; ctx.fill();
|
||
ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke();
|
||
const c = inward({ x: (k.corners[0].x + k.corners[2].x) / 2, z: (k.corners[0].z + k.corners[2].z) / 2 }, k.face.n, 380);
|
||
ctx.fillStyle = '#eee'; ctx.font = 'bold 10px sans-serif';
|
||
ctx.fillText('MK', c.x, c.y);
|
||
}
|
||
ctx.strokeStyle = rgb(RC.schakel, 1); ctx.lineWidth = 1;
|
||
el.boxes.forEach(b => { const q = toPx(b.x, b.z); ctx.strokeRect(q.x - 3, q.y - 3, 6, 6); });
|
||
ctx.font = 'bold 10px sans-serif';
|
||
el.combos.forEach(c => {
|
||
const q = inward(c.pos, c.face.n, 260), text = c.switches.map(x => x.letter).join(' '), w = 8 + text.length * 6;
|
||
ctx.fillStyle = '#fff'; ctx.fillRect(q.x - w / 2, q.y - 7, w, 14);
|
||
ctx.strokeStyle = '#1a1a1a'; ctx.strokeRect(q.x - w / 2, q.y - 7, w, 14);
|
||
ctx.fillStyle = '#1a1a1a'; ctx.fillText(text, q.x, q.y + 0.5);
|
||
});
|
||
ctx.font = '9px sans-serif';
|
||
el.wcds.forEach(w => {
|
||
const q = inward(w.pos, w.face.n, 120), t = inward(w.pos, w.face.n, 420);
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(String(w.group), t.x, t.y);
|
||
});
|
||
ctx.font = 'bold 8px sans-serif';
|
||
el.apps.forEach(a => {
|
||
const it = el.items.find(x => x.key === a.key), q = toPx(it.marker.x, it.marker.z), t = inward(a.pos, a.face.n, 480);
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
|
||
ctx.fillRect(q.x - 9, q.y - 6, 18, 12); ctx.strokeRect(q.x - 9, q.y - 6, 18, 12);
|
||
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(a.code, q.x, q.y + 0.5);
|
||
ctx.font = '9px sans-serif'; ctx.fillText(String(a.group), t.x, t.y); ctx.font = 'bold 8px sans-serif';
|
||
});
|
||
ctx.fillStyle = '#ffe9a8'; ctx.font = '9px sans-serif';
|
||
el.switches.forEach(sw => sw.lamps.forEach(l => { const q = toPx(l.x, l.z); ctx.fillText(sw.letter, q.x + 7, q.y - 6); }));
|
||
// Geselecteerd onderdeel (ring; gestippeld = handmatig geplaatst) en sleep-voorbeeld.
|
||
const selIt = selection && selection.type === 'elektra' && el.items.find(x => x.key === selection.id);
|
||
if (selIt) {
|
||
const q = toPx(selIt.marker.x, selIt.marker.z);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(q.x, q.y, 13, 0, Math.PI * 2); ctx.stroke();
|
||
}
|
||
el.items.filter(x => x.manual).forEach(x => {
|
||
const q = toPx(x.marker.x, x.marker.z);
|
||
ctx.fillStyle = '#4da6ff'; ctx.beginPath(); ctx.arc(q.x + 10, q.y - 9, 2.5, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
if (elDrag && elDrag.snap) {
|
||
const sn = elDrag.snap, a = toPx(sn.x, sn.z), b = toPx(sn.x + sn.n.x * 200, sn.z + sn.n.z * 200);
|
||
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(b.x, b.y, 7, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = '#4da6ff'; ctx.textAlign = 'left';
|
||
ctx.fillText(`${sn.element} · ${sn.u} mm`, b.x + 10, b.y - 8);
|
||
ctx.textAlign = 'center';
|
||
}
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// RIBBON (core/ribbon.js): alle bediening van het overzicht. Vaste
|
||
// tabbladen voor de woning, plus een contextueel tabblad voor de
|
||
// selectie (unit, wandstuk, binnenwand/naadwand).
|
||
// ------------------------------------------------------------
|
||
const { num, sel, toggle } = UI;
|
||
const { big, small, kv } = Ribbon;
|
||
const after = () => scheduleRefresh({ keepProps: true }); // getal/schuif: ribbon blijft staan
|
||
const rerender = () => refresh(); // keuze: velden kunnen wijzigen
|
||
// Afgeleide waarden in de ribbon die na een verversing meelopen
|
||
// (bijv. de dakhelling), zonder het tabblad opnieuw op te bouwen.
|
||
let live = [];
|
||
function updateLive() { live.forEach(fn => fn()); }
|
||
const note = (text, isWarn) => `<div class="rb-note${isWarn ? ' rb-warn' : ''}">${text}</div>`;
|
||
const pair = (...items) => `<div class="rb-pair">${items.join('')}</div>`;
|
||
const thumb = (url, alt) => url ? `<img class="rb-thumb" src="${url}" alt="${alt}" title="${alt}">` : '';
|
||
const check = (id, label, checked) => `<label class="rb-check"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}>${label}</label>`;
|
||
const SIDE_SHORT = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
|
||
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
|
||
// Keuzes voor de vormgeving van het trasraam: plaat of een steen.
|
||
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
||
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
|
||
// Kliks op knoppen met data-ho-act / data-go / data-ho-tool (per tekening opnieuw).
|
||
function wireButtons(body) {
|
||
body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
|
||
body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go)));
|
||
body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => {
|
||
const a = b.dataset.hoAct;
|
||
if (a === 'add-unit') addUnit();
|
||
else if (a === 'project') openProjectProperties();
|
||
else if (a === 'save') exportDesign();
|
||
else if (a === 'costs') CostSettings.open();
|
||
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
|
||
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
|
||
else if (a === 'remove-iw') {
|
||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw));
|
||
selection = null;
|
||
refresh();
|
||
}
|
||
}));
|
||
}
|
||
|
||
// --- Vaste tabbladen ---
|
||
const startTab = {
|
||
key: 'start', label: 'Start', order: 10,
|
||
groups: () => [
|
||
{ label: 'Gereedschap', html:
|
||
big('Selecteren', 'cursor', `data-ho-tool="select" title="Klik = selecteren, dubbelklik = bewerken (Esc)"`, tool === 'select' ? 'active' : '') +
|
||
big('Binnenwand tekenen', 'pencil', `data-ho-tool="iw" title="Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken"`, tool === 'iw' ? 'active' : '') },
|
||
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-ho-act="add-unit" title="Aan de geselecteerde (of laatste) unit"') + big('Project', 'card-text', 'data-ho-act="project"') },
|
||
{ label: 'Overzicht', html: `<div data-live="start-dims" class="rb-cols"></div>` },
|
||
{ label: 'Werkwijze', html: note('Selecteer een unit, wand of binnenwand in de plattegrond of de structuur: het tabblad van die selectie verschijnt rechts. Dubbelklik om te bewerken. Oranje gestippeld = naad tussen units.') }
|
||
],
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
const dimsLive = () => {
|
||
const el = body.querySelector('[data-live="start-dims"]');
|
||
if (!el || !parts) return;
|
||
const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt;
|
||
el.innerHTML = kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm') + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') +
|
||
kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') +
|
||
kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²') + kv('Inhoud', f(d.inhoudM3, 0), 'm³');
|
||
};
|
||
live.push(dimsLive);
|
||
dimsLive();
|
||
}
|
||
};
|
||
|
||
const gevelTab = {
|
||
key: 'gevel', label: 'Gevel', order: 20,
|
||
groups: () => {
|
||
const st = state.settings;
|
||
const rabatColors = Object.fromEntries(Object.entries(WallModel.RABAT_COLORS).map(([k, c]) => [k, c.label]));
|
||
return [
|
||
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand aan te passen (selecteer de wand)' }) +
|
||
(st.facade.type === 'steen'
|
||
? pair(sel('hs-facade-stone', 'Steen', stoneOptions, st.facade.stone, { hint: `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` }), thumb(WallModel.stoneThumbUrl(st.facade.stone, 64), st.facade.stone))
|
||
: sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) +
|
||
pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }),
|
||
`<div class="rb-swatch" style="background:${WallModel.rabatHex(st.facade.rabatColor)}"></div>`)) },
|
||
{ label: 'Trasraam', html: toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen' }) +
|
||
(st.trasraam.enabled
|
||
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
|
||
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
|
||
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '')
|
||
: '') },
|
||
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
|
||
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
|
||
UI.range('hs-ground', 'Maaiveld', st.groundLevel, { min: 0, max: 800, step: 10, hint: 'Onder de bovenkant vloer; tegelvloer in de 3D bij Dag/Nacht' }) },
|
||
{ label: 'Per wand', html: note('Een eigen gevel voor één zijde van een unit: selecteer die wand in de plattegrond.') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
const st = state.settings;
|
||
UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender);
|
||
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
|
||
UI.onSel('hs-rabat-color', v => { st.facade.rabatColor = v; }, rerender);
|
||
UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender);
|
||
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
|
||
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
|
||
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
|
||
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
|
||
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
||
}
|
||
};
|
||
|
||
const dakTab = {
|
||
key: 'dak', label: 'Dak', order: 30,
|
||
groups: () => {
|
||
const r = state.settings.roof;
|
||
return [
|
||
{ label: 'Boeideel', html:
|
||
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
|
||
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
|
||
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) },
|
||
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
|
||
{ label: 'Daken per unit', html: `<div data-live="roof-list" class="rb-cols"></div>` },
|
||
{ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
const r = state.settings.roof, pos = { positive: true };
|
||
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
|
||
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
|
||
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
|
||
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
|
||
const list = () => {
|
||
const el = body.querySelector('[data-live="roof-list"]');
|
||
if (!el || !parts) return;
|
||
el.innerHTML = parts.units.map(u => {
|
||
const pl = u.roof.planes[0], deg = Math.abs(pl ? pl.theta : 0) * 180 / Math.PI;
|
||
const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || '';
|
||
return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`);
|
||
}).join('');
|
||
};
|
||
live.push(list);
|
||
list();
|
||
}
|
||
};
|
||
|
||
const binnenTab = {
|
||
key: 'binnen', label: 'Binnen', order: 40,
|
||
groups: () => {
|
||
const st = state.settings;
|
||
return [
|
||
{ label: 'Wanden', html: toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden. Uit: kale spaanplaat.' }) +
|
||
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '') },
|
||
{ label: 'Vloer', html: pair(sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])), st.floorFinish),
|
||
thumb(FloorModel.finishThumbUrl(st.floorFinish, 64), st.floorFinish)) },
|
||
{ label: 'Binnenwanden', html: big('Binnenwand tekenen', 'pencil', 'data-ho-tool="iw"') + note(`${state.interiorWalls.length} binnen-/naadwand(en). Selecteer er een voor positie, dikte en openingen.`) }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
const st = state.settings;
|
||
UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender);
|
||
UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after);
|
||
UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender);
|
||
}
|
||
};
|
||
|
||
const constructieTab = {
|
||
key: 'constructie', label: 'Constructie', order: 45,
|
||
groups: () => {
|
||
const sp = state.settings.spacing;
|
||
const bad = sp.exterior.max < 2 * sp.exterior.min || sp.interior.max < 2 * sp.interior.min;
|
||
return [
|
||
{ label: 'Stijlen buitenwanden', html: num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart' }) },
|
||
{ label: 'Stijlen binnenwanden', html: num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart' }) },
|
||
{ label: 'Verdeling', html: note(bad
|
||
? 'Max. is kleiner dan 2 × min.: niet elk vak is dan te verdelen; daar wijkt de verdeling af van min. of max.'
|
||
: 'Elk vak wordt gelijk verdeeld met een h.o.h. tussen min. en max. (max. ≥ 2 × min.: altijd mogelijk).', bad) },
|
||
{ label: 'Balken', html: kv('Wandbalk', `${WallModel.STANDARD_BEAM.width} × ${WallModel.STANDARD_BEAM.depth}`, 'mm') +
|
||
kv('Handelslengtes', WallModel.STOCK_LENGTHS_MM.map(l => l / 1000).join(' · ').replace(/\./g, ','), 'm') +
|
||
kv('Latwerk', `${WallModel.BATTEN.thick} × ${WallModel.BATTEN.width}, ${WallModel.BATTEN.spacing} h.o.h.`, 'mm') }
|
||
];
|
||
},
|
||
wire: () => {
|
||
live = [];
|
||
const sp = state.settings.spacing, pos = { positive: true };
|
||
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);
|
||
}
|
||
};
|
||
|
||
const lichtTab = {
|
||
key: 'licht', label: 'Verlichting', order: 50,
|
||
groups: () => {
|
||
const r = state.settings.roof, lt = state.settings.lighting;
|
||
const groups = [{ label: 'Buiten', html: sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode) +
|
||
(lt.mode === 'gevel' ? sel('hs-light-dir', 'Richting', LightingModel.DIRECTIONS, lt.direction) : '') }];
|
||
if (lt.mode === 'gevel') {
|
||
groups.push({ label: 'Gevellampen', html: UI.range('hs-lamp-h', 'Hoogte', lt.lampHeight, { min: 1200, max: 3000, step: 50, 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' }) });
|
||
groups.push({ label: 'Plaatsing', html: num('hs-lamp-w', 'Min. gevel', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) +
|
||
num('hs-lamp-end', 'Tot hoek', lt.endOffset) + num('hs-lamp-open', 'Tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen" (selecteer de wand)' }) });
|
||
} else if (lt.mode === 'spots') {
|
||
groups.push({ label: 'Boeideel-spots', html: num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' }) +
|
||
note('Alleen onder units met boeidelen.') });
|
||
}
|
||
groups.push({ label: 'Binnenspots', html: toggle('hs-indoor', 'In plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) +
|
||
(lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) +
|
||
num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) +
|
||
num('hs-indoor-off', 'Tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : '') });
|
||
groups.push({ label: 'Aantallen', html: `<div data-live="light-count" class="rb-cols"></div>` });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
const r = state.settings.roof, lt = state.settings.lighting, pos = { positive: true };
|
||
UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender);
|
||
UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender);
|
||
UI.onRange('hs-lamp-h', v => { if (v > 0) lt.lampHeight = v; }, after);
|
||
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);
|
||
UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos);
|
||
UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos);
|
||
UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender);
|
||
UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos);
|
||
UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos);
|
||
UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after);
|
||
const count = () => {
|
||
const el = body.querySelector('[data-live="light-count"]');
|
||
if (!el || !parts) return;
|
||
el.innerHTML = kv('Gevellampen', parts.lighting.lamps.length, 'st.') +
|
||
kv('Boeideel-spots', parts.roofEdge ? parts.roofEdge.spots.length : 0, 'st.') + kv('Binnenspots', (parts.lighting.indoor || []).length, 'st.');
|
||
};
|
||
live.push(count);
|
||
count();
|
||
}
|
||
};
|
||
|
||
const elektraTab = {
|
||
key: 'elektra', label: 'Elektra', order: 55,
|
||
groups: () => {
|
||
const e = state.settings.elektra;
|
||
const groups = [{ label: 'Elektra', html: toggle('he-on', 'Installatie', e.enabled, { hint: 'Meterkast, schakelaars, wcd en leidingen (automatisch, eerste versie)' }) }];
|
||
if (e.enabled) {
|
||
groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) +
|
||
num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) });
|
||
groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) +
|
||
num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) });
|
||
groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) =>
|
||
check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') });
|
||
const nManual = Object.keys(e.place).length;
|
||
groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie en hoogte.') +
|
||
(nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') });
|
||
groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge %', e.slack, { step: 1 }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) +
|
||
num('he-mk-res', 'Extra MK', e.meterkastReserve, { step: 50, hint: 'Per leiding vanuit de meterkast, mm' }) });
|
||
groups.push({ label: 'Strekkende meters', html: `<div data-live="el-cables" class="rb-cols"></div>` });
|
||
groups.push({ label: 'Groepen', html: `<div data-live="el-groups" class="rb-cols"></div>` });
|
||
}
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
const e = state.settings.elektra, pos = { positive: true };
|
||
UI.onToggle('he-on', v => { e.enabled = v; }, rerender);
|
||
UI.onNum('he-mk-w', v => { e.meterkast.w = v; }, after, pos);
|
||
UI.onNum('he-mk-h', v => { e.meterkast.h = v; }, after, pos);
|
||
UI.onNum('he-mk-d', v => { e.meterkast.d = v; }, after, pos);
|
||
UI.onNum('he-mk-y', v => { e.meterkast.y = v; }, after);
|
||
UI.onNum('he-sw-h', v => { e.switchHeight = v; }, after, pos);
|
||
UI.onNum('he-wcd-h', v => { e.wcdHeight = v; }, after, pos);
|
||
UI.onNum('he-wcd-n', v => { e.wcdPerUnit = Math.max(0, Math.round(v)); }, after);
|
||
UI.onNum('he-slack', v => { e.slack = v; }, after);
|
||
UI.onNum('he-res', v => { e.reserve = v; }, after);
|
||
UI.onNum('he-mk-res', v => { e.meterkastReserve = v; }, after);
|
||
Object.keys(ElektraModel.APPLIANCES).forEach(k => {
|
||
const cb = document.getElementById(`he-app-${k}`);
|
||
if (cb) cb.addEventListener('change', () => { e.apparaten[k] = cb.checked; refresh(); });
|
||
});
|
||
const rs = body.querySelector('[data-he-reset]');
|
||
if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); });
|
||
const show = () => {
|
||
const el = parts && parts.elektra;
|
||
const c = body.querySelector('[data-live="el-cables"]'), g = body.querySelector('[data-live="el-groups"]');
|
||
if (!el) return;
|
||
if (c) c.innerHTML = el.cables.map(x => kv(x.use, houseFmt(x.meters, 1), 'm')).join('') || note('Geen leidingen.');
|
||
if (g) g.innerHTML = el.groups.map(x => kv(`${x.nr} ${x.phase} AL${x.rcd}`, `${x.label} (${x.breaker})`)).join('') + el.warnings.map(w => note(w, true)).join('');
|
||
};
|
||
live.push(show);
|
||
show();
|
||
}
|
||
};
|
||
|
||
const rapportTab = {
|
||
key: 'rapport', label: 'Rapport', order: 60,
|
||
groups: () => [
|
||
{ label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') },
|
||
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') },
|
||
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') }
|
||
],
|
||
wire: (body) => { live = []; wireButtons(body); }
|
||
};
|
||
|
||
const beeldTab = {
|
||
key: 'beeld', label: 'Beeld', order: 70,
|
||
groups: () => [
|
||
{ label: 'Tonen', html: check('hv-dak', 'Dak', view3D.dak) + check('hv-lampen', 'Lampen', view3D.lampen) + check('hv-elektra', 'Elektra', view3D.elektra) },
|
||
{ label: 'Doorsnede', html: check('hv-section', 'Doorsnede', view3D.section) +
|
||
UI.range('hv-section-h', 'Hoogte', view3D.sectionMM, { min: 100, max: 4000, step: 50, hint: 'Snijhoogte boven de bovenkant vloer' }) },
|
||
{ label: 'Omgeving', html: `<div class="rb-seg">${Skybox.MODES.map(m => small(Skybox.LABELS[m][1], Skybox.LABELS[m][0], `data-sky="${m}"`, m === skyMode ? 'active' : '')).join('')}</div>` +
|
||
small('Render', 'camera', 'data-render title="Elke lamp en spot als echte lichtbron"', fullRender ? 'active' : '') }
|
||
],
|
||
wire: (body) => {
|
||
live = [];
|
||
const onCheck = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { fn(el.checked); scheduleRefresh({ keepProps: true }); }); };
|
||
onCheck('hv-dak', v => { view3D.dak = v; });
|
||
onCheck('hv-lampen', v => { view3D.lampen = v; });
|
||
onCheck('hv-elektra', v => { view3D.elektra = v; });
|
||
onCheck('hv-section', v => { view3D.section = v; syncSectionRow(); });
|
||
UI.onRange('hv-section-h', setSectionMM);
|
||
body.querySelectorAll('[data-sky]').forEach(b => b.addEventListener('click', () => {
|
||
skyMode = b.dataset.sky;
|
||
Skybox.setMode(skyMode);
|
||
body.querySelectorAll('[data-sky]').forEach(x => x.classList.toggle('active', x === b));
|
||
applyRenderMode();
|
||
}));
|
||
const rb = body.querySelector('[data-render]');
|
||
if (rb) rb.addEventListener('click', () => { fullRender = !fullRender; rb.classList.toggle('active', fullRender); applyRenderMode(); });
|
||
}
|
||
};
|
||
|
||
// --- Contextueel tabblad: de selectie ---
|
||
function contextTab() {
|
||
if (!selection) return null;
|
||
if (selection.type === 'unit' && unitById(selection.id)) return unitTab(unitById(selection.id));
|
||
if (selection.type === 'wall') return wallTab();
|
||
if (selection.type === 'iw' && iwById(selection.id)) return iwById(selection.id).seam ? seamTab(iwById(selection.id)) : iwTab(iwById(selection.id));
|
||
if (selection.type === 'elektra' && elektraItem(selection.id)) return elektraItemTab(elektraItem(selection.id));
|
||
return null;
|
||
}
|
||
|
||
// Geselecteerd elektra-onderdeel: plek langs de wand en hoogte; slepen in
|
||
// de plattegrond of hier invullen maakt de plek handmatig.
|
||
function elektraItemTab(it) {
|
||
const e = state.settings.elektra;
|
||
const setPlace = (patch) => {
|
||
const cur = e.place[it.key] || { element: it.element, side: it.side, u: Math.round(it.u) };
|
||
e.place[it.key] = Object.assign({}, cur, patch);
|
||
};
|
||
return {
|
||
key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Onderdeel', html: kv('Wand', it.element + (it.side > 0 ? ' (andere zijde)' : '')) + kv('Unit', it.unitId) + kv('Plaatsing', it.manual ? 'handmatig' : 'automatisch') },
|
||
{ label: 'Plaats', html: num('he-it-u', 'Langs wand', Math.round(it.u), { step: 10, hint: 'Hart, vanaf de linkerkant van de wand (buitenaanzicht)' }) +
|
||
num('he-it-y', it.kind === 'mk' ? 'Onderkant' : 'Hoogte', Math.round(it.y), { step: 10, hint: it.kind === 'mk' ? 'Vanaf bovenkant vloer' : 'Hart, vanaf bovenkant vloer' }) },
|
||
{ label: 'Verplaatsen', html: note('Sleep het onderdeel in de plattegrond naar een andere wand of plek.') +
|
||
(it.manual ? big('Automatisch', 'arrow-counterclockwise', 'data-he-auto title="Weer automatisch plaatsen"') : '') }
|
||
],
|
||
wire: (body) => {
|
||
live = [];
|
||
UI.onNum('he-it-u', v => setPlace({ u: v }), after);
|
||
UI.onNum('he-it-y', v => setPlace({ y: v }), after);
|
||
const b = body.querySelector('[data-he-auto]');
|
||
if (b) b.addEventListener('click', () => { delete e.place[it.key]; refresh(); });
|
||
}
|
||
};
|
||
}
|
||
|
||
function unitTab(u) {
|
||
const isRoot = state.units[0].id === u.id;
|
||
const f = u.unit.floor.floor;
|
||
// Dakvorm (UnitModel.roofForm): de invoer; de wanden volgen.
|
||
const form = UnitModel.roofForm(u.unit);
|
||
const sloped = () => form.type !== 'plat';
|
||
const LIM = UnitModel.LIMITS;
|
||
// Actuele parts opzoeken: na Breedte/Diepte (keepProps) wordt de tab niet opnieuw opgebouwd.
|
||
const partsOf = () => parts && parts.units.find(x => x.id === u.id);
|
||
// Horizontale lengte van de helling: over de volle vloermaat (goot boven de buitenkant gevel).
|
||
const runOf = () => {
|
||
const pu = partsOf();
|
||
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
|
||
const L = pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000;
|
||
return UnitModel.hasRidge(form.type) ? L / 2 : L;
|
||
};
|
||
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
|
||
// Mansarde: hellingen van het onder- en bovenvlak.
|
||
const mansardeDeg = () => {
|
||
const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn);
|
||
return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI);
|
||
};
|
||
return {
|
||
key: 'sel', label: `Unit ${u.id}`, icon: 'box', contextual: true, order: 100,
|
||
groups: () => {
|
||
const a = u.attach, axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
|
||
const pu = partsOf(), shapeNow = pu ? pu.roof.shape.type : form.type;
|
||
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeNow !== 'plat').map(([k, c]) => [k, c.label]));
|
||
const covNow = RoofModel.coveringFor(u.roof.covering, shapeNow);
|
||
const c = RoofModel.COVERINGS[covNow];
|
||
const missing = HouseModel.missingSeamWalls(state, u.id);
|
||
const groups = [
|
||
{ label: 'Afmetingen', html:
|
||
UI.range('hp-width', 'Breedte', f.width, { min: 2000, max: LIM.long, step: 50, hint: `West–oost (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
|
||
UI.range('hp-depth', 'Diepte', f.depth, { min: 2000, max: LIM.long, step: 50, hint: `Noord–zuid (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
|
||
sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners, { hint: 'Welke wanden doorlopen over de volle vloermaat' }) },
|
||
{ label: 'Dakvorm', html: sel('hp-roof-type', 'Vorm', UnitModel.ROOF_TYPES, form.type) +
|
||
(form.type === 'lessenaar' ? sel('hp-roof-dir', 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
|
||
(UnitModel.hasRidge(form.type) ? sel('hp-roof-ridge', 'Nok', UnitModel.RIDGES, form.ridge) : '') +
|
||
UI.range('hp-roof-low', sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
|
||
];
|
||
if (sloped()) groups.push({ label: 'Helling', html:
|
||
UI.range('hp-roof-high', UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
|
||
(form.type === 'mansarde' ? '' : UI.range('hp-roof-deg', 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' })) +
|
||
`<div data-live="roof-gable">${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}</div>` });
|
||
if (form.type === 'mansarde') groups.push({ label: 'Mansarde', html:
|
||
UI.range('hp-roof-knee-h', 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) +
|
||
UI.range('hp-roof-knee-in', 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) +
|
||
`<div data-live="roof-mansarde">${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}</div>` });
|
||
if (form.type === 'wolf') groups.push({ label: 'Wolfsdak', html:
|
||
UI.range('hp-roof-cut', 'Wolfseind', form.cut, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte waarop de kopgevels stoppen; daarboven driehoekige wolfseinden met de dakhelling tot de nok' }) });
|
||
groups.push({ label: 'Dak', html:
|
||
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet kan alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }),
|
||
thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) +
|
||
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) +
|
||
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
|
||
if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html:
|
||
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', 'Versch. oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Versch. zuid', u.roof.offsetZ || 0) });
|
||
groups.push({ label: 'Koppeling', html: isRoot
|
||
? note('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.')
|
||
: sel('hp-att-to', '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) });
|
||
groups.push({ label: 'Bewerken', html:
|
||
big('Vloer', 'grid-3x3', `data-go="floor:${u.id}" title="Vloer-editor: balken, doorvoeren"`) +
|
||
big('Dak', 'house', `data-go="roof:${u.id}" title="Dak-editor: dek, doorvoeren"`) +
|
||
(missing.length ? small('Naadwand toevoegen', 'plus-lg', `data-ho-act="add-seams" data-unit="${u.id}" title="Zonder naadwand: ${missing.map(m => `${m.a}|${m.b}`).join(', ')}"`) : '') +
|
||
(isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'rb-danger')) });
|
||
return groups;
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
// Unitmaat binnen de grenzen (de andere maat geklemd als beide te groot zijn).
|
||
const size = (k, v) => {
|
||
if (!(v >= 1000)) return;
|
||
f[k] = v;
|
||
UnitModel.clampSize(f, k === 'width' ? 'depth' : 'width');
|
||
UI.setRange('hp-width', f.width); UI.setRange('hp-depth', f.depth);
|
||
};
|
||
UI.onRange('hp-width', v => size('width', v), after);
|
||
UI.onRange('hp-depth', v => size('depth', v), after);
|
||
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
|
||
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
|
||
// Andere koppeling: naadwanden van deze unit gaan mee.
|
||
UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||
// Dakvorm: elke wijziging past de wanden aan (applyRoofForm).
|
||
const apply = () => {
|
||
form.low = Math.min(form.low, LIM.eave);
|
||
form.high = Math.min(form.high, LIM.ridge);
|
||
UnitModel.applyRoofForm(u.unit, form);
|
||
};
|
||
UI.onSel('hp-roof-type', v => {
|
||
form.type = v;
|
||
if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700;
|
||
if (v === 'lessenaar' && !form.dir) form.dir = 'voor';
|
||
if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z';
|
||
if (v === 'mansarde') {
|
||
// Standaard: steil onderdeel (~65°) over ruim 1/3 van de halve gevel, flauw (~27°) bovendeel.
|
||
const run = runOf();
|
||
form.kneeIn = Math.round(run * 0.35);
|
||
form.kneeH = Math.round(form.low + form.kneeIn * 2.1);
|
||
form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5)));
|
||
form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low));
|
||
}
|
||
if (v === 'wolf') {
|
||
if (form.high - form.low < 1000) form.high = form.low + 1500;
|
||
form.cutRatio = 0.6;
|
||
}
|
||
apply();
|
||
}, rerender);
|
||
UI.onSel('hp-roof-dir', v => { form.dir = v; apply(); }, rerender);
|
||
UI.onSel('hp-roof-ridge', v => { form.ridge = v; apply(); }, rerender);
|
||
// Na toepassen: de werkelijke (geklemde) maten in de velden; de
|
||
// verhoudingen (kneeRatio, cutRatio) blijven zoals ingesteld.
|
||
const syncDeg = () => {
|
||
const now = UnitModel.roofForm(u.unit);
|
||
if (now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
|
||
UI.setRange('hp-roof-deg', Math.round(degOf() * 10) / 10);
|
||
UI.setRange('hp-roof-knee-h', form.kneeH);
|
||
UI.setRange('hp-roof-knee-in', form.kneeIn);
|
||
UI.setRange('hp-roof-cut', form.cut);
|
||
const md = body.querySelector('[data-live="roof-mansarde"]');
|
||
if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '));
|
||
};
|
||
UI.onRange('hp-roof-low', v => {
|
||
if (!(v >= 500)) return;
|
||
form.low = v;
|
||
if (sloped() && form.high < v) { form.high = v; UI.setRange('hp-roof-high', v); }
|
||
apply(); syncDeg();
|
||
}, after);
|
||
UI.onRange('hp-roof-high', v => {
|
||
if (!(v >= 500)) return;
|
||
form.high = Math.max(v, form.low);
|
||
apply(); syncDeg();
|
||
}, after);
|
||
UI.onRange('hp-roof-deg', v => {
|
||
if (!(v >= 0.5) || v > 75) return;
|
||
form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf());
|
||
UI.setRange('hp-roof-high', form.high);
|
||
apply(); syncDeg();
|
||
}, after);
|
||
const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low)));
|
||
UI.onRange('hp-roof-knee-h', v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after);
|
||
UI.onRange('hp-roof-knee-in', v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after);
|
||
UI.onRange('hp-roof-cut', v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
|
||
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
|
||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = 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 wallTab() {
|
||
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) return null;
|
||
const su = unitById(pu.id);
|
||
return {
|
||
key: 'sel', label: `Wand ${piece.element}`, icon: 'bricks', contextual: true, order: 100,
|
||
groups: () => {
|
||
const lt = state.settings.lighting;
|
||
const fc = HouseModel.facadeFor(state, su, wl.side);
|
||
const ov = su.facade.walls[wl.side] || null;
|
||
const f0 = state.settings.facade;
|
||
const woningLabel = `Woning (${HouseModel.FACADE_TYPES[f0.type]}${f0.type === 'steen' ? ' ' + f0.stone : ''})`;
|
||
return [
|
||
{ label: piece.label, html:
|
||
kv('Lengte zijde', wl.width, 'mm') + kv('Wandstuk', piece.place.width, 'mm') + kv('Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm') +
|
||
kv('Openingen', piece.config.openings.length) + kv('Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}–${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : '') +
|
||
kv('Hoogte', `${piece.config.wallHeightLeft} / ${piece.config.wallHeightRight}`, 'mm') },
|
||
{ label: 'Gevel (hele zijde)', html:
|
||
sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning') +
|
||
(ov && ov.type === 'steen' ? pair(sel('hw-facade-stone', 'Steen', stoneOptions, fc.stone), thumb(WallModel.stoneThumbUrl(fc.stone, 64), fc.stone)) : '') },
|
||
{ label: 'Gevellampen', html: lt.mode === 'gevel'
|
||
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto',
|
||
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) +
|
||
kv('Op dit wandstuk', piece.lamps.length, 'st.')
|
||
: note('Gevellampen staan uit (tabblad Verlichting).') },
|
||
wl.dropped.length ? { label: 'Let op', html: note(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`, true) } : null,
|
||
{ label: 'Bewerken', html: big('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}" title="Wand-editor: openingen, hoogtes, regelwerk"`) }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
const fc = HouseModel.facadeFor(state, su, wl.side);
|
||
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
|
||
UI.onSel('hw-facade', v => {
|
||
if (v === 'woning') delete su.facade.walls[wl.side];
|
||
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : { type: v };
|
||
}, rerender);
|
||
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
|
||
}
|
||
};
|
||
}
|
||
|
||
function iwTab(iw) {
|
||
const p = parts.interior.find(x => x.id === iw.id);
|
||
if (!p) return null;
|
||
return {
|
||
key: 'sel', label: p.element, icon: 'distribute-horizontal', contextual: true, order: 100,
|
||
groups: () => [
|
||
{ label: 'Positie', html: 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) },
|
||
{ label: 'Opbouw', html: num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll) + kv('Lengte', p.place.width, 'mm') },
|
||
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'rb-danger') }
|
||
],
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
['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);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Naadwand: midden op de naad (twee delen, één per unit) of geheel in
|
||
// unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per
|
||
// deel de daklijn van zijn unit.
|
||
function seamTab(iw) {
|
||
const leaves = parts.interior.filter(x => x.id === iw.id);
|
||
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
|
||
const sheet = iw.wall.wall.sheathingThick;
|
||
return {
|
||
key: 'sel', label: `Naadwand ${iw.seam.a}|${iw.seam.b}`, icon: 'layout-split', contextual: true, order: 100,
|
||
groups: () => {
|
||
const plaatsen = { midden: 'Midden (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
|
||
const exposed = leaves.filter(l => l.skinFrom !== undefined);
|
||
return [
|
||
{ label: 'Opbouw', html: sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') +
|
||
(inUnit
|
||
? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' })
|
||
: num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' })) },
|
||
{ label: 'Delen', html: leaves.length
|
||
? leaves.map(l => kv(`${l.element}`, l.place.width, 'mm')).join('') + exposed.map(l => kv(`${l.element} buiten vanaf`, l.skinFrom, 'mm')).join('')
|
||
: note(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`, true) },
|
||
{ label: 'Uitleg', html: note(inUnit
|
||
? `Binnenwand geheel in unit ${inUnit}, tegen de naad, beplating aan beide zijden; hoogte volgt de daklijn van unit ${inUnit}.`
|
||
: 'Per unit een eigen deel tegen het andere aan; openingen gelden voor beide delen (de deur loopt door). Hoogte volgt per deel de daklijn.') },
|
||
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'rb-danger') }
|
||
];
|
||
},
|
||
wire: (body) => {
|
||
live = [];
|
||
wireButtons(body);
|
||
UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender);
|
||
UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Ribbon bijwerken (na een verversing of een andere selectie). focus =
|
||
// het selectie-tabblad openen.
|
||
function syncRibbon(focus) {
|
||
const tabs = [startTab, gevelTab, dakTab, binnenTab, constructieTab, lichtTab, elektraTab, rapportTab, beeldTab];
|
||
const ctx = contextTab();
|
||
if (ctx) tabs.push(ctx);
|
||
Ribbon.setSource('editor', tabs, { focus: focus && !!ctx });
|
||
}
|
||
// Waarschuwingen (overlappende units, ongeldige koppeling) op de plattegrond.
|
||
function renderWarnings() {
|
||
const msgs = (parts.overlaps.length ? [`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`] : [])
|
||
.concat(parts.rects.filter(r => r.error).map(r => `Unit ${r.id}: ${r.error}`));
|
||
document.getElementById('ho-warn').innerHTML = msgs.map(m => UI.warn(m)).join('');
|
||
}
|
||
|
||
// --- Rapport: in plaats van de plattegrond zolang tabblad Rapport open is ---
|
||
let reportOpen = false, reportDirty = true;
|
||
const overviewEl = document.getElementById('house-overview');
|
||
const offRibbon = Ribbon.onChange((key) => {
|
||
const open = key === 'rapport';
|
||
if (open === reportOpen) return;
|
||
reportOpen = open;
|
||
overviewEl.classList.toggle('show-report', open);
|
||
if (open && reportDirty && parts) renderReport();
|
||
requestAnimationFrame(onResize);
|
||
});
|
||
|
||
// Grafieken (HTML, geen bibliotheek). Kleuren: vaste volgorde, zie css
|
||
// (--series-1..3); waarden staan er altijd als tekst bij, de tabel
|
||
// eronder is de toegankelijke weergave.
|
||
const esc = UI.esc;
|
||
function stackedBars(title, iconName, series, rows, fmtVal, note) {
|
||
const max = Math.max(1, ...rows.map(r => r.total));
|
||
const legend = series.length > 1
|
||
? `<div class="viz-legend">${series.map((sr, i) => `<span><i class="viz-key s${i + 1}"></i>${sr.label}</span>`).join('')}</div>` : '';
|
||
const body = rows.map(r => `<div class="viz-row">
|
||
<div class="viz-label">${esc(r.label)}${r.sub ? `<small>${r.sub}</small>` : ''}</div>
|
||
<div class="viz-track"><div class="viz-bar" style="width:${(r.total / max * 100).toFixed(2)}%">${series.map((sr, i) => {
|
||
const v = r.values[sr.key] || 0;
|
||
return v > 0 ? `<span class="viz-seg s${series.length > 1 ? i + 1 : 1}" style="flex:${v}" title="${esc(`${r.label} · ${sr.label}: ${fmtVal(v)}`)}"></span>` : '';
|
||
}).join('')}</div></div>
|
||
<div class="viz-val">${fmtVal(r.total)}</div>
|
||
</div>`).join('');
|
||
return `<div class="ui-spec-card viz"><h2>${UI.icon(iconName)}${title}</h2>${legend}${body}${note ? `<div class="viz-note">${note}</div>` : ''}</div>`;
|
||
}
|
||
|
||
function renderReport() {
|
||
reportDirty = false;
|
||
const f = houseFmt;
|
||
const totals = HouseModel.computeTotals(parts);
|
||
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
|
||
const transport = HouseModel.computeTransport(parts);
|
||
const cost = CostModel.compute(parts, totals);
|
||
const kg = (v) => `${f(v, 0)} kg`;
|
||
const m = (mm) => f(mm / 1000, 2);
|
||
const cards = [
|
||
stackedBars('Transport per unit', 'truck', [
|
||
{ key: 'vloer', label: 'Vloer' }, { key: 'wanden', label: 'Wanden' }, { key: 'dak', label: 'Dak' }
|
||
], transport.map(t => ({
|
||
label: `Unit ${t.id}`, total: t.totalKg, values: t.kg,
|
||
sub: `${m(t.length)} × ${m(t.width)} × ${m(t.height)} m · ${HouseModel.TRANSPORT_CLASSES[t.cls].toLowerCase()}`
|
||
})), kg, `Buitenmaat l × b × h incl. gevelafwerking, zonder dakrand. Breedte tot ${HouseModel.TRANSPORT.widthRegular} mm regulier, tot ${HouseModel.TRANSPORT.widthPermit} mm met ontheffing (indicatief). Gewicht zonder kozijnen, isolatie en steen.`),
|
||
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
|
||
UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)),
|
||
stackedBars('Kostprijs per unit', 'cash-coin', [{ key: 'v', label: 'Kostprijs' }],
|
||
cost.units.map(u => ({ label: u.id ? `Unit ${u.id}` : 'Woning-breed', total: u.kostprijs, values: { v: u.kostprijs } })), (v) => CostModel.eur(v),
|
||
'Woning-breed: dakrand, fundering, verlichting, kraan.'),
|
||
...costCards(cost),
|
||
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(pw => pw.m2 > 0).map(pw => [pw.naam, f(pw.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.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
|
||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||
}
|
||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||
const sw = totals.saw, cs = cost.saw;
|
||
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
|
||
['Zaagmeters', f(sw.meters, 1), 'm'],
|
||
['Zaagtijd robot', CostModel.uur(cs.robotSec / 3600), 'uur'],
|
||
[`Zaagtijd handmatig (×${cost.settings.zagen.handFactor})`, CostModel.uur(cs.handSec / 3600), 'uur'],
|
||
['Zaagsneden', sw.cuts, 'st.'],
|
||
['Verschillende hoeken', sw.angles.length, '']
|
||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden']))));
|
||
document.getElementById('ho-report').innerHTML = `<div class="ho-report-head"><h1>${UI.icon('clipboard-data')}Rapport</h1><span>${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)</span></div><div class="ui-spec-grid">${cards.join('')}</div>`;
|
||
}
|
||
|
||
// Kostenraming (CostModel; kosten/tijden via Instellingen > Kosten & tijden).
|
||
function costCards(c) {
|
||
const s = c.summary, S = c.settings;
|
||
const eur = (n) => CostModel.eur(n);
|
||
return [
|
||
UI.specCard('Kostenraming (indicatief)', 'cash-coin',
|
||
s.rows.map(r => [r.sub ? `<b>${r.label}</b>` : r.label, r.sub ? `<b>${eur(r.amount)}</b>` : eur(r.amount), '']),
|
||
['Totaal incl. btw', eur(s.inclBtw), '']),
|
||
stackedBars('Materiaal per groep', 'box-seam', [{ key: 'v', label: 'Materiaal' }],
|
||
c.categories.filter(r => r.mat > 0).map(r => ({ label: r.name, total: r.mat, values: { v: r.mat } })), eur),
|
||
UI.specCard('Arbeid per werkzaamheid', 'tools', c.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600), 'manuur'])
|
||
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect), 'manuur'], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa), 'manuur']]), ['Totaal', CostModel.uur(s.hours), 'manuur'])
|
||
];
|
||
}
|
||
const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } });
|
||
|
||
// --- Alles opnieuw doorrekenen ---
|
||
// Verversen gebundeld per beeldje (schuiven en typen geven veel events).
|
||
let pending = null, firstSync = true;
|
||
function scheduleRefresh(opts) {
|
||
const keep = !!(opts && opts.keepProps);
|
||
if (pending) { pending.keepProps = pending.keepProps && keep; return; }
|
||
pending = { keepProps: keep };
|
||
requestAnimationFrame(() => { const o = pending; pending = null; if (canvas.isConnected) refresh(o); });
|
||
}
|
||
// Oude 3D vrijgeven (geometrie wordt per verversing nieuw opgebouwd;
|
||
// materialen worden gedeeld en blijven).
|
||
const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); });
|
||
|
||
function refresh(opts) {
|
||
// Voorvertoningen van de gekozen materialen alvast laden voor de PDF
|
||
// (legenda op het aanzichtenblad; HousePdf.materialThumbs).
|
||
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
|
||
// Binnenzijde geschilderd (laatste bouwfase): de gedeelde spaanplaat krijgt de verfkleur.
|
||
const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking');
|
||
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
|
||
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
|
||
reportDirty = true;
|
||
|
||
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
|
||
houseGroup = new THREE.Group();
|
||
planCaps = null;
|
||
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);
|
||
});
|
||
// Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
|
||
const section = view3D.section;
|
||
const clip = section ? [cutPlane] : [];
|
||
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);
|
||
p.group.traverse(o => {
|
||
if (!o.material) return;
|
||
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => {
|
||
if (m.clippingPlanes && m.clippingPlanes.length === clip.length) return;
|
||
m.clippingPlanes = clip;
|
||
m.needsUpdate = true;
|
||
});
|
||
});
|
||
houseGroup.add(p.group);
|
||
});
|
||
placeSection();
|
||
if (view3D.dak && !section) {
|
||
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 && !section) houseGroup.add(parts.lightingGroup);
|
||
if (parts.elektraGroup) houseGroup.add(parts.elektraGroup);
|
||
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).
|
||
// 2 mm extra lager: bij maaiveld 0 niet in hetzelfde vlak als de OSB (flikkeren).
|
||
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM;
|
||
signPos = null;
|
||
if (!section && BuildStages.shows(view3D.stage, 'afwerking')) houseGroup.add(buildLogoSign(groundY));
|
||
skyEnv.fitTo(houseGroup, groundY);
|
||
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;
|
||
}
|
||
|
||
// Selectie die na de herberekening niet meer bestaat (wandstuk hernoemd of vervallen): loslaten.
|
||
const stale = !!selection && !contextTab();
|
||
if (stale) selection = null;
|
||
if (stale || !(opts && opts.keepProps)) syncRibbon(firstSync && !!selection);
|
||
else updateLive();
|
||
firstSync = false;
|
||
renderWarnings();
|
||
if (reportOpen) renderReport();
|
||
drawPlan();
|
||
renderNav(parts);
|
||
}
|
||
|
||
// --- Bord met logo: paal + plaat naast het pad, enkele meters voor de
|
||
// voordeur, met de plaat naar buiten gericht (leesbaar vanaf de straat).
|
||
const SIGN = { ahead: 4000, aside: 1800, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
|
||
let signPos = null; // mm, voor botsen tijdens lopen
|
||
let signMaterial = null;
|
||
// Paal en rand: één keer aanmaken (buildLogoSign draait bij elke refresh).
|
||
const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 });
|
||
const signEdgeMat = new THREE.MeshStandardMaterial({ color: 0x212121, roughness: 0.6 });
|
||
function getSignMaterial() {
|
||
if (signMaterial) return signMaterial;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 1024; canvas.height = 340;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.anisotropy = 8;
|
||
if (window.TECTUM_LOGO) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const w = canvas.width * 0.86, h = w / window.TECTUM_LOGO.aspect;
|
||
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
|
||
tex.needsUpdate = true;
|
||
};
|
||
img.src = window.TECTUM_LOGO.png;
|
||
}
|
||
signMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5 });
|
||
return signMaterial;
|
||
}
|
||
function buildLogoSign(groundY) {
|
||
const { d, n } = frontDoor();
|
||
// Opzij langs de gevel (rechts gezien vanaf de straat), zodat het pad vrij blijft.
|
||
const t = { x: n.z, z: -n.x };
|
||
signPos = { x: d.x + n.x * SIGN.ahead + t.x * SIGN.aside, z: d.z + n.z * SIGN.ahead + t.z * SIGN.aside };
|
||
const toM = 0.001, plateH = SIGN.plateW / 3; // canvas 1024 x 340
|
||
const g = new THREE.Group();
|
||
const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat);
|
||
pole.position.y = SIGN.poleH / 2 * toM;
|
||
g.add(pole);
|
||
const edge = signEdgeMat;
|
||
const face = getSignMaterial();
|
||
// BoxGeometry-materialvolgorde: +x, -x, +y, -y, +z (voorkant), -z (achterkant).
|
||
const plate = new THREE.Mesh(new THREE.BoxGeometry(SIGN.plateW * toM, plateH * toM, SIGN.plateD * toM),
|
||
[edge, edge, edge, edge, face, face]);
|
||
plate.position.set(0, (SIGN.poleH - plateH / 2 - 100) * toM, (SIGN.poleW + SIGN.plateD) / 2 * toM);
|
||
g.add(plate);
|
||
g.traverse(o => { if (o.isMesh) o.receiveShadow = true; });
|
||
g.position.set(signPos.x * toM, groundY, signPos.z * toM);
|
||
g.rotation.y = Math.atan2(n.x, n.z); // lokale +z = gevelnormaal (naar buiten)
|
||
return g;
|
||
}
|
||
|
||
// --- Lopen (first person), eigen sneltoets Shift+T ---
|
||
// Start 3 m voor de voordeur, buiten; botsen tegen wanden (deuren zijn
|
||
// doorgangen), ondergrond = vloer binnen een unit, anders het maaiveld.
|
||
const WALK_RADIUS = 250; // mm, "lichaam"
|
||
function wallBlocked(xm, zm) {
|
||
if (!parts) return false;
|
||
const x = xm * 1000, z = zm * 1000;
|
||
if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true;
|
||
return HouseModel.allWallPieces(parts).some(p => {
|
||
if (p.place.baseY) return false; // opbouw boven een lager dak
|
||
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
|
||
const dx = x - p.place.cx, dz = z - p.place.cz;
|
||
const lx = dx * c - dz * s, lz = dx * s + dz * c;
|
||
const W = p.place.width, T = p.place.thickness;
|
||
const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone) : 0;
|
||
if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false;
|
||
// Door een deur(opening) mag je lopen.
|
||
return !(p.config.openings || []).some(op => op.y < 100 && op.y + Math.min(op.h, op.hR) > 1800 &&
|
||
lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6);
|
||
});
|
||
}
|
||
function groundAt(xm, zm) {
|
||
const x = xm * 1000, z = zm * 1000;
|
||
const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1);
|
||
const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0;
|
||
return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000;
|
||
}
|
||
const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt });
|
||
let orbitSaved = null;
|
||
// Voordeur (anders een buitendeur, anders midden van de noordgevel):
|
||
// midden van de opening aan de buitenzijde (mm) + gevelnormaal naar buiten.
|
||
function frontDoor() {
|
||
const ext = [];
|
||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||
let best = null;
|
||
['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => {
|
||
if (op.type !== type) return false;
|
||
best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 };
|
||
return true;
|
||
})));
|
||
if (!best) {
|
||
const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0];
|
||
best = { p, lx: 0 };
|
||
}
|
||
const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2);
|
||
return { d, n: { x: best.p.place.normal.x, z: best.p.place.normal.y } };
|
||
}
|
||
// Startpositie: 3 m voor de voordeur, kijkend naar de deur.
|
||
function walkStartPose() {
|
||
const { d, n } = frontDoor();
|
||
const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000;
|
||
return { x, z, yaw: Math.atan2(-n.x, -n.z) };
|
||
}
|
||
const walkHint = document.createElement('div');
|
||
walkHint.className = 'walk-hint';
|
||
walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · Shift+T = terug';
|
||
pane3D.appendChild(walkHint);
|
||
function setWalk(on) {
|
||
if (on === walk.active || !parts) return;
|
||
const view = document.getElementById('house-overview');
|
||
if (on) {
|
||
// Lopen gebeurt in de volle 3D (alleen render); vorige stand onthouden.
|
||
orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className };
|
||
view.classList.remove('fullscreen-2d');
|
||
view.classList.add('fullscreen-3d', 'render-only');
|
||
requestAnimationFrame(onResize);
|
||
controls.enableRotate = controls.enablePan = controls.enableZoom = false;
|
||
camera.fov = 65;
|
||
camera.updateProjectionMatrix();
|
||
walk.start(walkStartPose());
|
||
} else {
|
||
walk.stop();
|
||
controls.enableRotate = controls.enablePan = controls.enableZoom = true;
|
||
if (orbitSaved) {
|
||
view.className = orbitSaved.cls;
|
||
requestAnimationFrame(onResize);
|
||
camera.fov = orbitSaved.fov;
|
||
camera.updateProjectionMatrix();
|
||
camera.position.copy(orbitSaved.pos);
|
||
controls.target.copy(orbitSaved.target);
|
||
controls.update();
|
||
}
|
||
}
|
||
walkHint.classList.toggle('show', on && !shooter.active);
|
||
pane3D.classList.toggle('walking', on);
|
||
if (!on && shooter.active) shooter.stop();
|
||
}
|
||
|
||
// --- Easter egg: schieten (Ctrl+Shift+Alt+L), bovenop het lopen ---
|
||
const shooter = createShooter({ scene, camera, domElement: renderer.domElement, pane: pane3D, walk,
|
||
floorAt: groundAt, blocked: wallBlocked, targets: () => [houseGroup, ground] });
|
||
function toggleShooter() {
|
||
if (shooter.active) { setWalk(false); return; }
|
||
if (!parts) return;
|
||
setWalk(true);
|
||
walk.speedFactor = 1.3; // 30% sneller in de shooter-modus
|
||
shooter.start();
|
||
walkHint.classList.remove('show');
|
||
}
|
||
|
||
let rafId = null, lastT = performance.now();
|
||
function animate() {
|
||
rafId = requestAnimationFrame(animate);
|
||
const now = performance.now();
|
||
walk.update((now - lastT) / 1000);
|
||
shooter.update((now - lastT) / 1000);
|
||
lastT = now;
|
||
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) {
|
||
const el = document.getElementById('house-overview');
|
||
setWalk(false); // weergave wisselen beëindigt het lopen
|
||
fn(el);
|
||
requestAnimationFrame(onResize);
|
||
}
|
||
|
||
refresh();
|
||
animate();
|
||
setTool(pendingTool || 'select');
|
||
pendingTool = null;
|
||
|
||
return {
|
||
unmount() {
|
||
window.removeEventListener('resize', onResize);
|
||
window.removeEventListener('keydown', onKey);
|
||
window.removeEventListener('mouseup', onElektraDrop);
|
||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||
setWalk(false); // orbitcamera terug vóór het opslaan
|
||
walk.dispose();
|
||
shooter.dispose();
|
||
CameraMemory.save(overviewCamKey, camera, controls);
|
||
disposeGroup(houseGroup);
|
||
disposeLights();
|
||
signPoleMat.dispose(); signEdgeMat.dispose();
|
||
offRibbon();
|
||
offCosts();
|
||
Ribbon.setSource('editor', []);
|
||
renderer.dispose();
|
||
},
|
||
refresh,
|
||
select,
|
||
setTool,
|
||
toggleFullscreen3D: () => toggleView(UI.cycle3D),
|
||
toggleWalk: () => setWalk(!walk.active),
|
||
toggleShooter,
|
||
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'),
|
||
toggleWalk: forward('toggleWalk'),
|
||
toggleShooter: forward('toggleShooter'),
|
||
toggleFullscreen2D: forward('toggleFullscreen2D'),
|
||
openProjectProperties,
|
||
getStateJson,
|
||
loadStateJson
|
||
};
|
||
}
|
||
|
||
window.HouseEditor = {
|
||
mount: mountHouseEditor,
|
||
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
|
||
};
|