Files
Tectum/js/house/house-model.js
T
MachielandClaude Opus 5.5 ab0dca9bb5 v0.7.1: UI-overhaul, ribbon vervangen door de indeling van CAD/BIM-pakketten met elke functie op één plek: bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, projectgegevens, kosten & tijden) · Beeld · Help, de ontwerpen als tabs en de knop Opslaan; links de structuur met inklapbare groepen (onthouden, groep met de selectie klapt vanzelf open); werkbalk boven de viewports met gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, opties van het gereedschap ernaast, Unit toevoegen) en weergave (één Lagen-menu met bouwfase voor 2D en 3D, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / 3D met 1/2/3); rechts het eigenschappenpaneel (Inspector: selectie met kruimelpad, of de woning met kerncijfers en inklapbare secties Gevel, Dak, Binnen, Verlichting, Elektra, Constructie); statusbalk met hint en meldingen; wand- en vloer/dak-editor in dezelfde indeling (paneel rechts met tab Resultaat, terug met ← Woning of Esc); sneltoetsenoverzicht (?), Ctrl+O, Ctrl+\ panelen verbergen; rapport als masonry; dubbele functies weg (lagenbalk, Beeld-tab en Weergave-filter, export en kosten op twee plekken, Snel nieuw); oude onderdelen gelijkgetrokken (dialoogknoppen, materiaalknoppen, chassis als schakelaar, eenheden); weergavelagen (ViewLayers) i.p.v. bouwfasen, elk los aan/uit; render-export met Blender-script voor de lampen; elektraleidingen boven een vals plafond en haaks binnen de units bij een L/T-vorm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:16:43 +02:00

2073 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.HouseModel = (() => {
const EPS = 0.5;
const ALIGN_LABELS = {
x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' },
z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' }
};
const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' };
const DEFAULT_INTERIOR_THICKNESS = 100;
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
function createEmptyProject() {
return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' };
}
function nextUnitId(state) {
const used = new Set(state.units.map(u => u.id));
for (let i = 0; i < 26; i++) {
const id = String.fromCharCode(65 + i);
if (!used.has(id)) return id;
}
return 'U' + (state.units.length + 1);
}
function createDefaultSettings() {
return {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit' },
cladding: 'horizontaal',
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
interiorPaint: true,
interiorColor: '#f2efe9',
ceilingColor: '#f4f3ef',
sandwich: true,
groundLevel: 0, // mm onder het loopvlak
floorFinish: 'visgraat_001',
roof: RoofModel.normalizeSettings(),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS),
elektra: ElektraModel.normalizeSettings()
};
}
function createUnitEntry(id, attach, unitOpts) {
const unit = UnitModel.createDefaultState(unitOpts);
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
}
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk' };
function facadeFor(state, u, side, piece) {
const f = state.settings.facade;
const ow = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
const o = piece && FACADE_TYPES[piece.type] ? piece : ow;
const type = o && FACADE_TYPES[o.type] ? o.type : f.type;
const stone = o && o.type === 'steen' && WallModel.isStoneFinish(o.stone) ? o.stone : f.stone;
const stuc = o && o.type === 'stuc' && WallModel.isStucColor(o.stuc) ? o.stuc : f.stucColor;
return { type, stone, stuc, override: !!o };
}
function facadeMaterials(state, fc) {
const tr = state.settings.trasraam;
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
const cladding = state.settings.cladding;
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null };
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null };
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
}
function newModelState(naam) {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [], chimneys: [] };
st.project.naam = naam || '';
return st;
}
// TIJDELIJK (testen): nieuwe woning = voorbeeldontwerp (json/premade/vlinderwoning.json); false = één unit.
const TEST_DEFAULT = true;
function createVlinderwoning() {
const st = newModelState('Vlinderwoning');
st.settings.trasraam.finish = 'K490';
const A = createUnitEntry('A', null, { width: 10000, depth: 4000, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10000, depth: 3600, windowOn: [] });
const C = createUnitEntry('C', { to: 'A', side: 'rechts', align: 'start', offset: 0 }, { width: 4000, depth: 4000, windowOn: [] });
st.units.push(A, B, C);
const h = (u, k, l, r) => Object.assign(u.unit.walls[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
const HI = 3400, LO = 2700;
h(A, 'voor', HI, HI); h(A, 'achter', LO, LO); h(A, 'links', HI, LO); h(A, 'rechts', LO, HI);
h(B, 'voor', LO, LO); h(B, 'achter', HI, HI); h(B, 'links', LO, HI); h(B, 'rechts', HI, LO);
['voor', 'achter', 'links', 'rechts'].forEach(k => h(C, k, LO, LO));
A.roof.covering = B.roof.covering = 'riet_002';
C.roof.covering = 'dakleer';
const ops = (u, side, list) => {
u.unit.walls[side].openings = list.map(([type, w, hh, y, anchor, offset], i) => ({
id: i + 1, type, w, h: hh, y, anchor, offset,
fill: Object.assign(KozijnModel.defaultFill(type), type === 'raam' ? { rooster: y >= 900 } : { glas: true })
}));
};
ops(A, 'voor', [['raam', 1200, 1100, 1000, 'left', 700], ['voordeur', 1150, 2300, 0, 'left', 2700], ['raam', 500, 2300, 0, 'left', 3950],
['raam', 2200, 600, 2000, 'left', 5200], ['raam', 900, 2100, 300, 'right', 1800], ['raam', 900, 2100, 300, 'right', 600]]);
ops(A, 'links', [['raam', 1400, 1200, 900, 'middle', 0]]);
ops(B, 'achter', [['raam', 600, 2600, 250, 'left', 800], ['raam', 600, 2600, 250, 'left', 1700], ['deur', 900, 2300, 0, 'middle', -1600],
['raam', 2000, 2300, 0, 'middle', 0], ['deur', 900, 2300, 0, 'middle', 1600], ['raam', 1200, 1200, 1000, 'right', 700]]);
ops(B, 'links', [['raam', 1600, 500, 1700, 'middle', 0]]);
ops(B, 'rechts', [['raam', 1000, 1300, 900, 'left', 500], ['raam', 1000, 1300, 900, 'right', 500]]);
ops(C, 'voor', [['raam', 2600, 1500, 700, 'middle', 0]]);
ops(C, 'rechts', [['raam', 1100, 2100, 250, 'left', 500], ['raam', 1100, 2100, 250, 'right', 500]]);
ops(C, 'achter', [['deur', 900, 2200, 0, 'left', 500], ['raam', 2000, 2200, 0, 'right', 400]]);
ensureSeamWalls(st);
computeParts(st, { skip3D: true });
return st;
}
// Bouwstenen voor de standaardmodellen. Gevelopeningen: [type, w, h, y, anchor, offset, fill];
// binnenwanden: [type, w, h, anchor, offset, fill] vanaf de vloer.
function modelKit(st, H, T) {
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {});
const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {});
const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); };
const iw = (x1, z1, x2, z2, openings, t) => {
const wall = interiorWallDefaults(t || T, H);
wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill }));
st.interiorWalls.push({ id: st.interiorWalls.length + 1, x1, z1, x2, z2, wall });
};
// zaadpunt t.o.v. de noordwesthoek van de unit
const room = (unitId, x, z, type, name, extra) =>
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }, extra || {}));
// Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost).
// anchor 'oost': x opgeven vanaf de westgevel, het meubel schuift mee met de oostgevel.
const fx = (type, unitId, x, z, rot, size, opts) => {
const f = FixtureModel.create(st, type, unitId, x, z, rot);
if (size && size.anchor === 'oost') f.x = Math.round(st.units.find(u => u.id === unitId).unit.floor.floor.width - x);
Object.assign(f, size || {});
Object.assign(f.opts, opts || {});
st.fixtures.push(f);
};
return { K, binnen, ops, iw, room, fx };
}
// Model Amstel (examples/ontwerp_amstel.pdf). Alles vanaf de westgevel gemaat, zodat de woning in de lengte kan groeien.
function createAmstel() {
const st = newModelState('Amstel');
const S = st.settings, H = 2800;
Object.assign(S.facade, { type: 'stuc', stucColor: 'wit', rabatColor: 'naturel' });
S.cladding = 'verticaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'wit', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
S.lighting.mode = 'gevel';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: 11240, depth: 3600, height: H, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'plat', low: H, high: H }, cover: true });
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90);
// Noordgevel: 'right' = vanaf de westhoek.
ops(A.unit.walls.voor, [
['deur', 1474, 2650, 0, 'right', 150, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 1474, 2650, 0, 'right', 1776, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['raam', 1000, 2650, 0, 'left', 3560, K('raam')]]);
[A.unit.walls.links, A.unit.walls.rechts].forEach(ws => ops(ws, [
['raam', 1392, 2650, 0, 'middle', -772, K('raam')], ['raam', 1392, 2650, 0, 'middle', 772, K('raam')]]));
ops(A.unit.walls.achter, [['raam', 480, 2650, 0, 'right', 80, K('raam')]]);
ops(B.unit.walls.links, [['raam', 1000, 2650, 0, 'left', 250, K('raam')]]);
ops(B.unit.walls.achter, [
['raam', 500, 700, 1400, 'left', 2230, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 3080, K('deur', { hinge: 'links', swing: 'buiten', volglas: true })],
['raam', 500, 2650, 0, 'left', 6490, K('raam')],
['raam', 500, 2650, 0, 'right', 1150, K('raam')]]);
B.unit.walls.achter.splits = [{ u: 6390, facade: { type: 'rabat' } }];
B.facade.walls.rechts = { type: 'rabat' };
B.lighting.walls.achter = 'openingen';
const D = 2115;
iw(2555, 3700, 2555, 4800, [['deur', 850, D, 'left', 80, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | logeerkamer
iw(1980, 4845, 3070, 4845); // boven boilerkast en garderobe
iw(2555, 4890, 2555, 5655); // boilerkast | garderobe
iw(1935, 4800, 1935, 6944, [['deur', 600, D, 'left', 170, binnen({ hinge: 'links', swing: 'buiten' })]]); // logeerkamer | boilerkast, toilet
iw(1980, 5700, 3070, 5700); // toilet noord
iw(3025, 5745, 3025, 6944, [['deur', 930, D, 'left', 80, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // toilet | entree (draait de hal in)
iw(4215, 4800, 4215, 6944); // entree | badkamer
iw(4260, 4845, 6300, 4845); // hal | badkamer
iw(6345, 3700, 6345, 6944, [ // slaapkamer
['deur', 930, D, 'left', 80, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', 980, D, 'left', 1990, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'links' })]]);
ensureSeamWalls(st);
const seam = st.interiorWalls.find(w => w.seam);
if (seam) {
const tA = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.links));
seam.wall.openings = [{ id: 1, type: 'deur', w: 2000, h: 2150, y: 0, anchor: 'left', offset: Math.round(4450 - 1000 - tA),
fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true, out: 'zwart', in: 'zwart' }) }];
}
room('A', 5600, 1200, 'woonkeuken', 'Woonkamer/keuken');
room('B', 1000, 2000, 'slaapkamer', 'Logeerkamer');
room('B', 3600, 2600, 'hal', 'Entree');
room('B', 2480, 2800, 'toilet');
room('B', 5300, 2700, 'badkamer');
room('B', 8300, 1800, 'slaapkamer', 'Slaapkamer');
room('B', 2245, 1650, 'techniek', 'Boilerkast');
fx('keuken', 'A', 4746, 556, 0, { w: 2660 }, { front: 'antraciet', hogeKast: 'links', spoelbak: 0.49 });
fx('kookeiland', 'A', 4746, 2000, 0, { w: 2660, d: 600 }, { front: 'antraciet', kookplaat: true });
fx('eettafel', 'A', 1600, 1820, 90, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
fx('bank', 'A', 9560, 731, 0, { w: 2600, d: 950, anchor: 'oost' }, { chaise: 'rechts', chaiseLengte: 1700 });
fx('fauteuil', 'A', 7400, 1750, 290, { anchor: 'oost' });
fx('salontafel', 'A', 9140, 2040, 0, { w: 800, d: 800, anchor: 'oost' }, { vorm: 'rond' });
fx('tvmeubel', 'A', 9300, 3300, 180, { w: 1800, anchor: 'oost' });
fx('toilet', 'B', 4605, 2840, 270);
fx('douche', 'B', 4860, 1740, 270, { w: 900, d: 1200 }, { glas: 'links', glasLengte: 800 });
fx('wastafel', 'B', 5850, 3114, 180, { w: 800 });
fx('toilet', 'B', 2480, 2999, 180, { w: 1000 });
fx('fontein', 'B', 2105, 2350, 270);
fx('boiler', 'B', 2285, 1670, 90);
fx('bed', 'B', 706, 2294, 180, { w: 900, d: 2100 }, { nachtkastjes: false });
fx('bed', 'B', 8000, 2294, 180, { w: 1600, d: 2100, anchor: 'oost' }, { nachtkastjes: false });
fx('kledingkast', 'B', 10044, 1650, 90, { w: 3020, anchor: 'oost' });
computeParts(st, { skip3D: true });
return st;
}
// Model Wetering (examples/ontwerp_wetering.pdf): 10,5 × 4,8 m, één rechthoek zonder naadwand (open woonkamer
// over de volle diepte). 4,8 m is te diep voor één unit, dus twee units (2,6 + 2,2 m). Alles vanaf de westgevel en
// de noordgevel gemaat (buitenwerks); de entreegevel met openslaande deuren en voordeur is het zuiden.
function createWetering() {
const st = newModelState('Wetering');
const S = st.settings, H = 2800, W = 10500, DA = 2600, DB = 2200;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'grijs' });
S.cladding = 'verticaal';
S.trasraam.enabled = false;
S.roof.fasciaColor = 'zwart';
S.lighting.mode = 'gevel';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: W, depth: DA, height: H, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'plat', low: H, high: H } });
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [['raam', 500, 700, 1400, 'right', 5670, K('raam')]]);
ops(A.unit.walls.links, [['raam', 700, 2100, 0, 'left', 300, K('raam')], ['raam', 700, 2100, 0, 'left', 1500, K('raam')]]);
ops(A.unit.walls.rechts, [['raam', 500, 1400, 700, 'right', 270, K('raam')]]);
ops(B.unit.walls.rechts, [['raam', 500, 1400, 700, 'right', 540, K('raam')]]);
ops(B.unit.walls.achter, [
['deur', 824, 2100, 0, 'left', 1200, K('deur', { hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 824, 2100, 0, 'left', 2176, K('deur', { hinge: 'rechts', swing: 'buiten', volglas: true })],
['voordeur', 1100, 2100, 0, 'left', 5350, K('deur', { hinge: 'rechts', swing: 'buiten', volglas: true })]]);
B.lighting.walls.achter = 'openingen';
const D = 2115;
iw(4675, 190, 4675, 2210); // keuken | badkamer
iw(4630, 2255, 7120, 2255, [['deur', 930, D, 'left', 1390, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // badkamer | entree
iw(7165, 190, 7165, 4610, [ // slaapkamer
['deur', 980, 2130, 'left', 360, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'rechts' })],
['deur', 930, D, 'left', 2277, binnen({ hinge: 'links', swing: 'buiten' })]]);
iw(6455, 3765, 7120, 3765); // kapstoknis entree
// Geen naadwand: woonkamer en slaapkamer lopen over beide units door (Unit › Naadwand toevoegen zet hem terug).
room('A', 2000, 1500, 'woonkeuken', 'Woonkamer/keuken');
room('A', 5900, 1500, 'badkamer');
room('A', 8700, 1500, 'slaapkamer', 'Slaapkamer');
const zB = (z) => z - DA;
fx('keuken', 'A', 4330, 1245, 90, { w: 2110 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.85, kookplaat: true, kookplaatPlek: 0.2 });
fx('keuken', 'A', 4630, 2600, 0, { w: 1200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false });
fx('boiler', 'A', 5515, 2525, 0);
fx('eettafel', 'A', 1620, 1160, 0, { w: 1000, d: 1000 }, { vorm: 'ovaal', stoelen: 4 });
fx('bank', 'B', 615, zB(3640), 270, { w: 1600, d: 850 }, { chaise: 'geen' });
fx('salontafel', 'B', 1770, zB(3510), 0, { w: 600, d: 600 }, { vorm: 'rond' });
fx('fauteuil', 'B', 2760, zB(4060), 150);
fx('douche', 'A', 5245, 640, 270, { w: 900, d: 1050 }, { glas: 'links', glasLengte: 800 });
fx('toilet', 'A', 5065, 1760, 270, { w: 900 });
fx('wastafel', 'A', 6150, 420, 0, { w: 600 }, { spiegel: false });
fx('bed', 'A', 9260, 2050, 90, { w: 1600, d: 2100, anchor: 'oost' }, { nachtkastjes: false });
fx('kledingkast', 'B', 8760, zB(4310), 180, { w: 3100 });
computeParts(st, { skip3D: true });
return st;
}
// Model IJssel (examples/ontwerp_ijssel.pdf): L-vorm 14 × 4,6 m met een vleugel van 4,6 × 3 m aan de zuidoostkant,
// samen 14 × 7,6 m. Twee units: hoofdvolume met zadeldak west–oost, vleugel met zadeldak noord–zuid. Gelijke
// goot, nok en overspanning, dus de daken sluiten onder 45° aan (dakaansluiting, zie roofJoins): kil in de
// binnenhoek en, omdat de vleugel gelijk ligt met de oostgevel, een hoekkeper (de oostkop wordt een schild).
// Geen naadwanden; de wand tussen hal/badkamer en slaapkamer is een gewone binnenwand. Binnenwanden volgen het
// dak nog niet, dus alle ruimtes krijgen een vlak plafond op wandhoogte. Alles buitenwerks vanaf de noordwesthoek.
function createIJssel() {
const st = newModelState('IJssel');
const S = st.settings, LO = 3000, HI = 4600, H = 2900, D = 4600, ROOF = 'riet_001';
Object.assign(S.facade, { type: 'stuc', stucColor: 'wit' });
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
S.roof.fasciaColor = 'zwart';
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: 14000, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
st.units.push(A, B);
UnitModel.applyRoofForm(A.unit, { type: 'zadel', ridge: 'x', low: LO, high: HI });
UnitModel.applyRoofForm(B.unit, { type: 'zadel', ridge: 'z', low: LO, high: HI });
[A, B].forEach(u => { u.roof.covering = ROOF; });
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [['raam', 1500, 500, 1600, 'right', 5445, K('raam')], ['raam', 500, 700, 1400, 'right', 9950, K('raam')]]);
ops(A.unit.walls.links, [
['deur', 1424, 2100, 0, 'left', 610, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 1424, 2100, 0, 'left', 2376, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]);
ops(A.unit.walls.rechts, [['raam', 1400, 1100, 1000, 'right', 608, K('raam')], ['raam', 1200, 500, 1600, 'right', 3101, K('raam')]]);
ops(A.unit.walls.achter, [
['raam', 1800, 2800, 0, 'left', 3215, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 8240, K('deur', { hinge: 'rechts', swing: 'buiten', glas: true })]]);
ops(B.unit.walls.achter, [['raam', 500, 1400, 700, 'left', 410, K('raam')], ['raam', 500, 1400, 700, 'right', 1100, K('raam')]]);
A.lighting.walls.achter = 'openingen';
const DH = 2115;
iw(9580, 190, 9580, 3220, [], 160); // keuken | toilet, hal
iw(8060, 3265, 9660, 3265); // woonkamer | hal
iw(8105, 3310, 8105, 4410, [['deur', 930, DH, 'left', 86, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // hal → woonkamer
iw(9660, 1633, 10760, 1633, [['deur', 930, DH, 'left', 85, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet | hal
iw(10805, 190, 10805, 4410, [['deur', 930, DH, 'left', 1725, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // hal | berging, badkamer
iw(10850, 2965, 13810, 2965); // berging | badkamer
iw(9590, 4455, 13810, 4455, [ // hal, badkamer | slaapkamer
['deur', 930, DH, 'left', 115, binnen({ hinge: 'links', swing: 'buiten' })],
['deur', 980, DH, 'left', 2350, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
const plafond = { ceiling: H };
room('A', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken', plafond);
room('A', 10210, 900, 'toilet');
room('A', 12300, 1500, 'berging', 'Berging', plafond);
room('A', 9000, 3800, 'hal', 'Hal', plafond);
room('A', 12300, 3700, 'badkamer');
room('B', 2000, 1400, 'slaapkamer', 'Slaapkamer', plafond);
const xB = (x) => x - 9400, zB = (z) => z - D;
fx('bank', 'A', 2030, 690, 0, { w: 3600, d: 1000 }, { chaise: 'links', chaiseLengte: 1700 });
fx('salontafel', 'A', 2410, 1980, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'A', 3120, 2950, 150);
fx('tvmeubel', 'A', 1060, 4210, 180, { w: 1200 });
fx('eettafel', 'A', 6100, 1115, 0, { w: 2000, d: 1000 }, { vorm: 'ovaal', stoelen: 6 });
fx('keuken', 'A', 9200, 1705, 90, { w: 3030 }, { front: 'antraciet', hogeKast: 'rechts', spoelbak: 0.16, kookplaat: true, kookplaatPlek: 0.8 });
fx('toilet', 'A', 10210, 535, 0, { w: 1100 });
fx('boiler', 'A', 11040, 415, 0);
fx('douche', 'A', 11300, 3710, 0, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 1400 });
fx('wastafel', 'A', 12300, 3240, 0, { w: 800 });
fx('toilet', 'A', 13355, 3355, 0, { w: 900 });
fx('bed', 'B', xB(11400), zB(6360), 180, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('kledingkast', 'B', xB(13510), zB(5955), 90, { w: 2910, anchor: 'oost' });
computeParts(st, { skip3D: true });
return st;
}
// Model Waal (examples/ontwerp_waal.pdf): 12,85 × 7 m, twee units van 3,5 m met één flauw zadeldak over de
// woning (nok west–oost op de naad). Het dak loopt aan de oostkop 1,5 m door als veranda (10,5 m²) op drie
// eiken palen (PostModel). Geen naadwand: slaapkamer en woonkamer/keuken lopen over beide units door; bij de
// naad staat aan elke kant een binnenwand (badkamer/keuken | hal). Alles buitenwerks vanaf de noordwesthoek.
function createWaal() {
const st = newModelState('Waal');
const S = st.settings, LO = 2800, HI = 3400, W = 12850, D = 3500;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'grijs' });
S.cladding = 'verticaal';
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
Object.assign(S.roof, { fasciaColor: 'wit', overhang: RoofModel.OVERHANG_MAX, fasciaHeight: 350 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: W, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI } });
[A, B].forEach(u => { u.roof.extend = { f: 0, r: 1500, b: 0, l: 0 }; });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 800, 500, 1600, 'right', 3490, K('raam')],
['raam', 800, 1100, 1000, 'right', 6530, K('raam')],
['raam', 1400, 1100, 1000, 'right', 8510, K('raam')]]);
ops(A.unit.walls.links, [['raam', 500, 1400, 700, 'left', 1170, K('raam')]]);
ops(B.unit.walls.links, [['raam', 500, 1400, 700, 'left', 390, K('raam')], ['raam', 1400, 1100, 1000, 'left', 1570, K('raam')]]);
// Oostkop onder de veranda: schuifdeur bij de keuken, openslaande deuren bij de woonkamer.
ops(A.unit.walls.rechts, [['deur', 1800, 2100, 0, 'right', 880,
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
ops(B.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 860, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 1760, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
ops(B.unit.walls.achter, [
['raam', 500, 1400, 700, 'left', 3530, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 4310, K('deur', { hinge: 'links', swing: 'buiten', glas: true })],
...[7130, 8230, 10470, 11570].map(x => ['raam', 500, 2100, 0, 'left', x, K('raam')])]);
B.lighting.walls.achter = 'openingen';
const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(3235, 190, 3235, 3430, [['deur', 980, 2130, 'left', 1530, // slaapkamer | badkamer
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(5325, 190, 5325, 3430); // badkamer | keuken
iw(3280, 3510, 6535, 3510, [], 160); // badkamer, keuken | hal (over de naad)
iw(3235, 3590, 3235, 4655, [['deur', 930, DH, 'left', 75, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal → slaapkamer
iw(6490, 3590, 6490, 6810, [['deur', 930, DH, 'left', 72, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal, entree | woonkamer
iw(190, 4700, 6445, 4700, [ // slaapkamer, hal | berging, toilet, entree
['deur', 930, DH, 'left', 120, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', 930, DH, 'left', 4200, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
iw(2248, 4745, 2248, 5819); // berging | toilet
iw(2203, 5864, 3607, 5864); // toilet | berging, entree
iw(2938, 5909, 2938, 6810); // berging | entree
iw(3562, 4745, 3562, 5819, [['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet → entree
const plafond = { ceiling: LO };
room('A', 9500, 1800, 'woonkeuken', 'Woonkamer/keuken', plafond);
room('A', 1500, 1800, 'slaapkamer', 'Slaapkamer', plafond);
room('A', 4300, 2200, 'badkamer', '', plafond);
room('B', 4800, 600, 'hal', 'Hal', plafond);
room('B', 2900, 1800, 'toilet', '', plafond);
room('B', 4700, 2700, 'hal', 'Entree', plafond);
room('B', 1200, 2400, 'berging', 'Berging', plafond);
const zB = (z) => z - D;
fx('kledingkast', 'A', 1690, 490, 0, { w: 3000 });
fx('bed', 'A', 1240, 2770, 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('douche', 'A', 4585, 640, 90, { w: 900, d: 1390 }, { glas: 'rechts', glasLengte: 700 });
fx('wastafel', 'A', 5050, 1920, 90, { w: 800 });
fx('toilet', 'A', 3790, 3085, 180, { w: 1000 });
fx('keuken', 'A', 5670, 1810, 270, { w: 3240 }, { front: 'antraciet', hogeKast: 'links', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.45 });
fx('keuken', 'A', 7025, 490, 0, { w: 2110 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.5, kookplaat: false });
fx('eettafel', 'A', 10230, 1730, 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
fx('bank', 'B', 9820, zB(5365), 90, { w: 2500, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
fx('fauteuil', 'B', 8420, zB(3910), 330);
fx('salontafel', 'B', 8180, zB(5180), 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('tvmeubel', 'B', 6735, zB(5815), 270, { w: 1500, d: 400 });
fx('kledingkast', 'B', 6145, zB(5777), 90, { w: 2060 });
fx('toilet', 'B', 2638, zB(5312), 270, { w: 1000 });
fx('boiler', 'B', 1810, zB(4970), 0);
fx('wasmachine', 'B', 2500, zB(6510), 180);
computeParts(st, { skip3D: true });
return st;
}
// Model Maas (examples/ontwerp_maas.pdf): vierkant 9,2 × 9,2 m onder één flauw zadeldak (nok west–oost op de
// naad). Twee units van 4,6 m diep: noord 6,7 m breed (oost uitgelijnd), zuid 9,2 m. De overkapping van het
// woningdak maakt van de noordwesthoek een inpandige veranda (2,5 × 4,6 m) op eiken palen. Maten buitenwerks vanaf
// de noordwesthoek van de woning; unit A begint 2,5 m oostelijker (X = interieur-coördinaten).
function createMaas() {
const st = newModelState('Maas');
const S = st.settings, LO = 2800, HI = 3500, D = 4600, V = 2500;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel' });
S.cladding = 'verticaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: 300, fasciaHeight: 300 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: 9200 - V, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI }, cover: true });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 800, 1500, 600, 'right', 970, K('raam')],
['raam', 800, 1500, 600, 'right', 2715, K('raam')],
['raam', 800, 1100, 1000, 'right', 4815, K('raam')]]);
// Westgevel onder de veranda: openslaande deuren met een vast zijlicht aan beide kanten (samen 3000 × 2100).
ops(A.unit.walls.links, [
['raam', 600, 2100, 0, 'left', 770, K('raam')],
['deur', 900, 2100, 0, 'left', 1370, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'left', 2270, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['raam', 600, 2100, 0, 'left', 3170, K('raam')]]);
ops(B.unit.walls.voor, [['raam', 1100, 1500, 600, 'right', 315, K('raam')]]);
ops(B.unit.walls.links, [['raam', 800, 1500, 600, 'left', 200, K('raam')]]);
ops(B.unit.walls.rechts, [['raam', 800, 1100, 1000, 'right', 210, K('raam')]]);
ops(B.unit.walls.achter, [
['raam', 500, 700, 1400, 'left', 3525, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 5675, K('deur', { hinge: 'links', swing: 'buiten' })],
['raam', 500, 1500, 600, 'left', 8375, K('raam')]]);
ensureSeamWalls(st);
const seam = st.interiorWalls.find(w => w.seam);
if (seam) {
const tA = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.links));
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: 2115, y: 0, anchor: 'left', offset: Math.round(5790 - V - tA),
fill: binnen({ hinge: 'rechts', swing: 'binnen' }) }];
}
const DH = 2115, X = (x) => x - V;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(X(3250), 4700, X(3250), 9010, [ // slaapkamer | hal, toilet, badkamer
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', 980, 2130, 'left', 2780, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(X(3295), 5740, X(5620), 5740, [['deur', 930, DH, 'left', 665, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal | toilet, schacht
iw(X(5075), 5785, X(5075), 6865); // toilet | schacht
iw(X(3295), 6910, X(5530), 6910); // toilet, schacht | badkamer
iw(X(5575), 5785, X(5575), 9010); // schacht, badkamer | entree
iw(X(6865), 4700, X(6865), 9010, [ // entree | logeerkamer, berging
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })],
['deur', 930, DH, 'left', 3185, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
iw(X(6910), 6915, X(9010), 6915); // logeerkamer | berging
const plafond = { ceiling: LO };
room('A', 2500, 2000, 'woonkeuken', 'Woonkamer/keuken');
room('B', 1500, 2000, 'slaapkamer', 'Slaapkamer', plafond);
room('B', 4100, 1700, 'toilet');
room('B', 4000, 3300, 'badkamer');
room('B', 6200, 2500, 'hal', 'Entree', plafond);
room('B', 7900, 1100, 'slaapkamer', 'Logeerkamer', plafond);
room('B', 7900, 3300, 'berging', 'Berging', plafond);
const zB = (z) => z - D;
fx('keuken', 'A', X(7620), 490, 0, { w: 1580 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.58, kookplaat: false });
fx('keuken', 'A', X(8710), 2290, 90, { w: 4200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.43 });
fx('eettafel', 'A', X(5860), 1090, 0, { w: 1000, d: 1000 }, { vorm: 'ovaal', stoelen: 4 });
fx('bank', 'A', X(4020), 3770, 180, { w: 2550, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
fx('salontafel', 'A', X(4200), 2710, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'A', X(3480), 1460, 330);
fx('bed', 'B', 1240, zB(6520), 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('kledingkast', 'B', 1700, zB(8710), 180, { w: 3000 });
fx('toilet', 'B', 3600, zB(6325), 270, { w: 1000 });
fx('wastafel', 'B', 3760, zB(7205), 0, { w: 800 });
fx('toilet', 'B', 3760, zB(8700), 180, { w: 1000 });
fx('douche', 'B', 5090, zB(8265), 180, { w: 900, d: 1500 }, { glas: 'rechts', glasLengte: 700 });
fx('bed', 'B', 7960, zB(6430), 90, { w: 900, d: 2100 }, { nachtkastjes: false });
fx('wasmachine', 'B', 7280, zB(7260), 0);
fx('boiler', 'B', 8700, zB(7260), 0);
computeParts(st, { skip3D: true });
return st;
}
// Model Rijn (examples/ontwerp_rijn.pdf): L-vorm 13 × 10 m, hoofdvolume 13 × 6,54 m (twee units van 3,27 m) met
// een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die
// op de naad de nok vormen (een woningdak zou ook de vleugel overspannen); de vleugel heeft een plat dak op
// goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand.
// Steenstrips rondom, in de oostgevel een schoorsteen (ChimneyModel) over de naad. Maten buitenwerks vanaf de
// noordwesthoek, zoals de tekening (de steenstrips komen daar in de configurator buiten de units nog bij).
function createRijn() {
const st = newModelState('Rijn');
const S = st.settings, LO = 2800, HI = 3500, D = 3270, BRICK = 'R752', SCHOORSTEEN = 'R777';
Object.assign(S.facade, { type: 'steen', stone: BRICK });
Object.assign(S.trasraam, { enabled: true, height: 500, finish: 'R669' });
Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 350 });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'antraciet';
const A = createUnitEntry('A', null, { width: 13000, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 4200, depth: 3460, height: LO, windowOn: [] });
st.units.push(A, B, C);
UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'achter', low: LO, high: HI });
UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: LO, high: HI });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 500, 1400, 700, 'right', 1045, K('raam')],
['raam', 500, 1400, 700, 'right', 3630, K('raam')],
['raam', 1200, 500, 1600, 'right', 4760, K('raam')],
['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]);
// De oostwand van A begint na de noordwand: offset vanaf het wandeinde.
const tN = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', Math.round(785 - tN), K('raam')]]);
ops(B.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 730, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 1630, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
ops(B.unit.walls.links, [['raam', 1400, 1100, 1000, 'left', 465, K('raam')]]);
ops(B.unit.walls.achter, [
['raam', 1100, 2100, 0, 'left', 4680, K('raam')],
['raam', 1800, 2100, 0, 'left', 10490, K('raam')]]);
ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, K('raam')]]);
ops(C.unit.walls.rechts, [['voordeur', 1100, 2100, 0, 'right', 70, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]);
// Schoorsteen 800 mm breed midden in de oostgevel, hart op de naad (z 3270), 180 mm voor de gevel.
st.chimneys.push(Object.assign(ChimneyModel.create(st.chimneys, 'B', 'rechts', 3270), {
w: 800, out: 180, above: 600, outside: { type: 'steen', stone: SCHOORSTEEN, stuc: 'wit' }
}));
const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(190, 3266, 6513, 3266, [['deur', 930, DH, 'left', 2800, binnen({ hinge: 'links', swing: 'buiten' })]], 160); // slaapkamer, badkamer | keuken (langs de nok)
iw(4380, 190, 4380, 3186, [['deur', 980, 2130, 'left', 1136, // slaapkamer | badkamer
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(6466, 190, 6466, 3186); // badkamer | woonkamer
iw(5800, 1135, 6421, 1135); // muurtje naast de douche
iw(2762, 3346, 2762, 6440, [['deur', 930, DH, 'left', 104, binnen({ hinge: 'links', swing: 'buiten' })]]); // berging | keuken
iw(1880, 6640, 1880, 7655, [['deur', 930, DH, 'left', 40, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | entree
iw(190, 7700, 3107, 7700, [['deur', 930, DH, 'left', 1820, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet, entree | logeerkamer
iw(3062, 7745, 3062, 8335); // kapstoknis entree
iw(3107, 8290, 4010, 8290); // kapstoknis | kast logeerkamer
ensureSeamWalls(st, 'C');
const seam = st.interiorWalls.find(w => w.seam);
if (seam) {
const tC = WallModel.thicknessOf(WallModel.configFromState(C.unit.walls.links));
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: Math.round(2915 - tC),
fill: binnen({ hinge: 'links', swing: 'buiten' }) }];
}
const plafond = { ceiling: LO };
room('A', 9500, 1500, 'woonkeuken', 'Woonkamer/keuken');
room('A', 2600, 1700, 'slaapkamer', 'Slaapkamer', plafond);
room('A', 5400, 2000, 'badkamer', '', plafond);
room('B', 1400, 1500, 'berging', 'Berging', plafond);
room('C', 1000, 500, 'toilet');
room('C', 2400, 500, 'hal', 'Entree');
room('C', 1500, 2300, 'slaapkamer', 'Logeerkamer');
const zB = (z) => z - D, zC = (z) => z - 2 * D;
fx('kledingkast', 'A', 493, 1688, 270, { w: 2990 });
fx('bed', 'A', 2615, 1240, 0, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('douche', 'A', 5725, 640, 0, { w: 1400, d: 900 }, { glas: 'links', glasLengte: 900 });
fx('wastafel', 'A', 6191, 2000, 90, { w: 800 });
fx('toilet', 'A', 4998, 2680, 180);
fx('bank', 'A', 11195, 800, 0, { w: 3000, d: 1000 }, { chaise: 'rechts', chaiseLengte: 2100 });
fx('fauteuil', 'A', 8880, 1720, 315);
fx('salontafel', 'A', 10410, 2280, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('keuken', 'B', 4945, zB(4350), 90, { w: 2000 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.15, kookplaat: true, kookplaatPlek: 0.85 });
fx('kookeiland', 'B', 5545, zB(4350), 270, { w: 2000, d: 600 }, { front: 'antraciet', kookplaat: false });
fx('keuken', 'B', 3110, zB(5080), 270, { w: 1200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false });
fx('eettafel', 'B', 10010, zB(4990), 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
fx('wasmachine', 'B', 620, zB(6140), 180);
fx('wasmachine', 'B', 1450, zB(6140), 180); // droger
fx('boiler', 'B', 2350, zB(6215), 180);
fx('toilet', 'C', 535, zC(7150), 270);
fx('bed', 'C', 650, zC(8745), 0, { w: 900, d: 2000 }, { nachtkastjes: false });
fx('kledingkast', 'C', 3703, zC(9072), 90, { w: 1450 });
computeParts(st, { skip3D: true });
return st;
}
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering },
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
};
function createDefaultState(template) {
if (MODELS[template]) return MODELS[template].create();
if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning();
const state = newModelState();
state.units.push(createUnitEntry('A', null, template === 'L' ? { width: 8000, depth: 3600 } : { width: 6000, depth: 3000 }));
if (template === 'L') {
state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 4500, depth: 3600, windowOn: [] }));
state.units[0].unit.walls.voor.openings = [
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('voordeur') }
];
state.units[1].unit.walls.achter.openings = [
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450, fill: KozijnModel.defaultFill('raam') },
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') },
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450, fill: KozijnModel.defaultFill('raam') }
];
addDefaultToilet(state);
}
ensureSeamWalls(state);
computeParts(state, { skip3D: true });
return state;
}
function addDefaultToilet(state) {
const A = state.units[0], f = A.unit.floor.floor;
const T = (side) => WallModel.thicknessOf(WallModel.configFromState(A.unit.walls[side]));
const t = DEFAULT_INTERIOR_THICKNESS, netW = 1100, netD = 1350;
const xIn = f.width - T('rechts'), zIn = T('voor');
const h = Math.min(A.unit.walls.voor.wall.heightLeft, A.unit.walls.voor.wall.heightRight);
const wWest = interiorWallDefaults(t, h), wZuid = interiorWallDefaults(t, h);
const d = WallModel.OPENING_TYPES.deur;
wZuid.openings = [{ id: 1, type: 'deur', w: 800, h: d.h, y: 0, anchor: 'middle', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit' }) }];
const x = Math.round(xIn - netW - t / 2), z = Math.round(zIn + netD + t / 2);
state.interiorWalls.push(
{ id: 1, x1: x, z1: Math.round(zIn), x2: x, z2: Math.round(zIn + netD), wall: wWest },
{ id: 2, x1: Math.round(xIn - netW - t), z1: z, x2: Math.round(xIn), z2: z, wall: wZuid });
state.rooms.push(
RoomModel.applyType({ id: 1, unitId: 'A', x: Math.round(xIn - netW / 2), z: Math.round(zIn + netD / 2), name: '' }, 'toilet'),
RoomModel.applyType({ id: 2, unitId: 'A', x: Math.round(f.width / 3), z: Math.round(f.depth / 2), name: '' }, 'woonkamer'),
RoomModel.applyType({ id: 3, unitId: 'B', x: 2000, z: 1800, name: '' }, 'slaapkamer'));
}
function unitRects(state) {
const size = {};
state.units.forEach(u => {
const f = FloorModel.computeFrame(u.unit.floor.floor);
size[u.id] = { W: f.width, D: f.depth };
});
const rects = {};
const make = (id, x0, z0, error) => {
const { W, D } = size[id];
rects[id] = { id, x0, z0, x1: x0 + W, z1: z0 + D, cx: x0 + W / 2, cz: z0 + D / 2, W, D, error: error || null };
};
let pending = state.units.slice();
let progress = true;
while (pending.length && progress) {
progress = false;
pending = pending.filter(u => {
if (!u.attach) {
if (Object.keys(rects).length === 0) { make(u.id, 0, 0); progress = true; return false; }
return true;
}
const P = rects[u.attach.to];
if (!P) return true;
const { W, D } = size[u.id];
const a = u.attach, off = a.offset || 0;
const alongX = (align) => align === 'middle' ? P.cx - W / 2 + off : align === 'end' ? P.x1 - off - W : P.x0 + off;
const alongZ = (align) => align === 'middle' ? P.cz - D / 2 + off : align === 'end' ? P.z1 - off - D : P.z0 + off;
if (a.side === 'achter') make(u.id, alongX(a.align), P.z1);
else if (a.side === 'voor') make(u.id, alongX(a.align), P.z0 - D);
else if (a.side === 'rechts') make(u.id, P.x1, alongZ(a.align));
else make(u.id, P.x0 - W, alongZ(a.align));
progress = true;
return false;
});
}
pending.forEach(u => {
const maxX = Math.max(0, ...Object.values(rects).map(r => r.x1));
make(u.id, maxX + 1000, 0, 'Koppeling ongeldig (verwijst naar zichzelf of een ontbrekende unit).');
});
return state.units.map(u => rects[u.id]);
}
// extA/extB: eindigt de naad op de rand van de buur (binnenhoek, L-vorm), dan loopt het wandstuk met de
// dikte van diens haakse wand door; anders blijft er een gat van wanddikte × wanddikte.
function computeSeams(rects, thickness) {
const T = thickness || (() => 0);
const cuts = {};
rects.forEach(r => { cuts[r.id] = { voor: [], achter: [], links: [], rechts: [] }; });
const seams = [];
const overlap = (a0, a1, b0, b1) => [Math.max(a0, b0), Math.min(a1, b1)];
for (let i = 0; i < rects.length; i++) {
for (let j = 0; j < rects.length; j++) {
if (i === j) continue;
const A = rects[i], B = rects[j];
if (Math.abs(A.z1 - B.z0) < EPS) {
const [o0, o1] = overlap(A.x0, A.x1, B.x0, B.x1);
if (o1 - o0 > EPS) {
cuts[A.id].achter.push([o0 - A.cx, o1 - A.cx, B.x0 > A.x0 + EPS ? T(B.id, 'links') : 0, B.x1 < A.x1 - EPS ? T(B.id, 'rechts') : 0]);
cuts[B.id].voor.push([o0 - B.cx, o1 - B.cx, A.x0 > B.x0 + EPS ? T(A.id, 'links') : 0, A.x1 < B.x1 - EPS ? T(A.id, 'rechts') : 0]);
seams.push({ a: A.id, b: B.id, x0: o0, z0: A.z1, x1: o1, z1: A.z1 });
}
}
if (Math.abs(A.x1 - B.x0) < EPS) {
const [o0, o1] = overlap(A.z0, A.z1, B.z0, B.z1);
if (o1 - o0 > EPS) {
cuts[A.id].rechts.push([o0 - A.cz, o1 - A.cz, B.z0 > A.z0 + EPS ? T(B.id, 'voor') : 0, B.z1 < A.z1 - EPS ? T(B.id, 'achter') : 0]);
cuts[B.id].links.push([o0 - B.cz, o1 - B.cz, A.z0 > B.z0 + EPS ? T(A.id, 'voor') : 0, A.z1 < B.z1 - EPS ? T(A.id, 'achter') : 0]);
seams.push({ a: A.id, b: B.id, x0: A.x1, z0: o0, x1: A.x1, z1: o1 });
}
}
}
}
return { cuts, seams };
}
function overlappingPairs(rects) {
const pairs = [];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const A = rects[i], B = rects[j];
if (Math.min(A.x1, B.x1) - Math.max(A.x0, B.x0) > EPS && Math.min(A.z1, B.z1) - Math.max(A.z0, B.z0) > EPS) {
pairs.push([A.id, B.id]);
}
}
}
return pairs;
}
function interiorWallDefaults(thickness, height) {
const wall = WallEditor.getDefaultState();
const t = thickness || DEFAULT_INTERIOR_THICKNESS;
const h = height || wall.wall.heightAll;
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
wall.materials = { interior: 'spaanplaat', exterior: 'spaanplaat' };
wall.openings = [];
setThickness(wall, t);
return wall;
}
const skinSides = (wallState) => ((wallState.materials || {}).exterior === 'geen' ? 1 : 2);
function setThickness(wallState, thickness) {
const depth = Math.max(20, Math.round(thickness - skinSides(wallState) * wallState.wall.sheathingThick));
wallState.wall.studDepth = depth;
wallState.wall.plateDepth = depth;
}
function thicknessOf(wallState) {
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + skinSides(wallState) * wallState.wall.sheathingThick;
}
// Twee delen (modules worden los vervoerd), één wand-state: openingen gelden voor het deel in seam.a
// en worden in het andere deel gespiegeld. Alleen bij een nieuwe woning en "unit toevoegen" worden
// ontbrekende naadwanden aangemaakt; een verwijderde naadwand blijft weg.
const DEFAULT_SEAM_LEAF_MM = 100;
function seamWallDefaults(height) {
const wall = WallEditor.getDefaultState();
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll;
wall.materials = { interior: 'spaanplaat', exterior: 'geen' };
const d = WallModel.OPENING_TYPES.deur;
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit' }) }];
setThickness(wall, DEFAULT_SEAM_LEAF_MM);
return wall;
}
const sameSeam = (s, a, b) => (s.a === a && s.b === b) || (s.a === b && s.b === a);
function ensureSeamWalls(state, onlyUnitId) {
const { seams } = computeSeams(unitRects(state));
seams.forEach(sm => {
if (onlyUnitId && sm.a !== onlyUnitId && sm.b !== onlyUnitId) return;
if (state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b))) return;
const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1;
const height = Math.min(...[sm.a, sm.b].map(uid => {
const w = state.units.find(u => u.id === uid).unit.walls.voor.wall;
return Math.min(w.heightLeft, w.heightRight);
}));
state.interiorWalls.push({ id, seam: { a: sm.a, b: sm.b }, wall: seamWallDefaults(height) });
});
}
function syncSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
state.interiorWalls = state.interiorWalls.filter(iw => !iw.seam || (iw.seam.a !== unitId && iw.seam.b !== unitId) ||
seams.some(sm => sameSeam(iw.seam, sm.a, sm.b)));
ensureSeamWalls(state, unitId);
}
function missingSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
return seams.filter(sm => (sm.a === unitId || sm.b === unitId) && !state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b)));
}
function interiorLayout(iw) {
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
const alongX = Math.abs(dx) >= Math.abs(dz);
const width = Math.max(300, Math.round(alongX ? Math.abs(dx) : Math.abs(dz)));
iw.wall.wall.width = width;
return {
width, alongX,
cx: (iw.x1 + iw.x2) / 2, cz: (iw.z1 + iw.z2) / 2,
rotY: alongX ? 0 : -Math.PI / 2
};
}
function enforceLimits(state) {
const L = UnitModel.LIMITS;
const houseRoof = houseRoofOn(state);
state.units.forEach(u => {
UnitModel.clampSize(u.unit.floor.floor);
if (houseRoof) return;
const f = UnitModel.roofForm(u.unit);
if (f.low > L.eave + 0.5 || f.high > L.ridge + 0.5) UnitModel.applyRoofForm(u.unit, f);
});
}
function applyGlobalSettings(state) {
const sp = state.settings.spacing;
enforceLimits(state);
const cover = coverExtensions(state);
state.units.forEach(u => {
Object.entries(u.unit.walls).forEach(([side, ws]) => {
ws.wall.minSpacing = sp.exterior.min;
ws.wall.maxSpacing = sp.exterior.max;
ws.materials = Object.assign({}, ws.materials, facadeMaterials(state, facadeFor(state, u, side)));
});
if (u.roof.followFloor) {
// Overkapping van het woningdak plus de eigen verlenging (veranda) van deze unit.
const c = cover[u.id] || {}, x = u.roof.extend || {};
const e = Object.fromEntries(['l', 'r', 'f', 'b'].map(k => [k, (c[k] || 0) + (x[k] || 0)]));
u.roof.deck.floor.width = u.unit.floor.floor.width + e.l + e.r;
u.roof.deck.floor.depth = u.unit.floor.floor.depth + e.f + e.b;
u.roof.offsetX = (e.r - e.l) / 2;
u.roof.offsetZ = (e.b - e.f) / 2;
}
});
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
applyHouseRoof(state);
applyRoofJoins(state);
}
const EXTEND_MAX = 4000;
function normalizeExtend(e) {
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
}
const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form);
const ROOF_HEIGHT_KEYS = ['heightAll', 'heightLeft', 'heightRight', 'heightMid', 'topPts'];
// Wolfsdak alleen op een rechthoek: bij een L-/T-vorm zouden de wolfseinden kilkepers vragen.
function houseRoofFits(state, rects) {
const rs = rects || unitRects(state);
if (rs.some(r => r.error) || overlappingPairs(rs).length) return false;
const w = Math.max(...rs.map(r => r.x1)) - Math.min(...rs.map(r => r.x0));
const d = Math.max(...rs.map(r => r.z1)) - Math.min(...rs.map(r => r.z0));
return Math.abs(w * d - rs.reduce((t, r) => t + (r.x1 - r.x0) * (r.z1 - r.z0), 0)) < 1e4;
}
function houseRoofShape(state, rects) {
if (!houseRoofOn(state)) return null;
// Wolfsdak op een L-/T-vorm: als zadeldak (zelfde goot en nok), zonder de invoer te wijzigen.
const input = state.settings.roof.house.form;
const form = input.type === 'wolf' && !houseRoofFits(state, rects) ? Object.assign({}, input, { type: 'zadel' }) : input;
const rs = (rects || unitRects(state)).filter(r => !r.error);
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
const axis = form.type === 'lessenaar' ? (form.dir === 'voor' || form.dir === 'achter' ? 'z' : 'x')
: UnitModel.hasRidge(form.type) ? (form.ridge === 'z' ? 'x' : 'z') : 'z';
const [p0, p1] = axis === 'x' ? [bx0, bx1] : [bz0, bz1];
const [q0, q1] = axis === 'x' ? [bz0, bz1] : [bx0, bx1];
const eMin = Math.max(...state.units.map(u => Math.max(...Object.values(u.unit.walls).map(ws => WallModel.thicknessOf(WallModel.configFromState(ws))))));
const line = UnitModel.formLine(form, p1 - p0, eMin, form.dir === 'voor' || form.dir === 'links');
const profile = line.map(q => ({ p: p0 + q.u, h: q.h }));
const shape = profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)
? RoofModel.shapeFromProfile(axis, profile)
: { type: 'plat', axis: 'z', profile: [{ p: 0, h: profile[0].h }] };
if (shape.hip) Object.assign(shape.hip, { q0, q1 });
return Object.assign(shape, { line, span: p1 - p0 });
}
function coverExtensions(state, rectsIn) {
const out = {};
const hr = state.settings.roof.house;
if (!houseRoofOn(state) || !hr.cover) return out;
const rs = (rectsIn || unitRects(state)).filter(r => !r.error);
if (rs.length < 2) return out;
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
const taken = rs.map(r => ({ x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 }));
const free = (q) => q.x1 - q.x0 > EPS && q.z1 - q.z0 > EPS && !taken.some(t => Math.min(t.x1, q.x1) - Math.max(t.x0, q.x0) > EPS && Math.min(t.z1, q.z1) - Math.max(t.z0, q.z0) > EPS);
rs.forEach(r => {
const e = { l: 0, r: 0, f: 0, b: 0 };
[['r', { x0: r.x1, x1: bx1, z0: r.z0, z1: r.z1 }], ['l', { x0: bx0, x1: r.x0, z0: r.z0, z1: r.z1 }]].forEach(([k, q]) => {
if (!free(q)) return;
e[k] = Math.round(q.x1 - q.x0);
taken.push(q);
});
const x0 = r.x0 - e.l, x1 = r.x1 + e.r;
[['b', { x0, x1, z0: r.z1, z1: bz1 }], ['f', { x0, x1, z0: bz0, z1: r.z0 }]].forEach(([k, q]) => {
if (!free(q)) return;
e[k] = Math.round(q.z1 - q.z0);
taken.push(q);
});
if (e.l || e.r || e.f || e.b) out[r.id] = e;
});
return out;
}
function unitShapeOf(shape, rect) {
const dp = shape.axis === 'x' ? rect.cx : rect.cz, dq = shape.axis === 'x' ? rect.cz : rect.cx;
const out = { type: shape.type, axis: shape.axis, profile: shape.profile.map(q => ({ p: q.p - dp, h: q.h })), house: true };
if (shape.hip) out.hip = { h: shape.hip.h, q0: shape.hip.q0 - dq, q1: shape.hip.q1 - dq };
return out;
}
function applyHouseRoof(state) {
const rects = unitRects(state);
const shape = houseRoofShape(state, rects);
if (!shape) return;
const form = state.settings.roof.house.form, ln = shape.line;
if (form.type === 'wolf' && ln.length === 4) form.cut = Math.round(ln[1].h);
const planes = RoofModel.shapeLines(shape);
state.units.forEach((u, i) => {
const r = rects[i];
if (r.error) return;
const layout = UnitModel.computeLayout(u.unit, {});
layout.walls.forEach(wl => {
const s = UnitModel.WALL_SIDES.find(x => x.key === wl.side);
const alongX = s.axis === 'x';
const start = (alongX ? wl.cx + r.cx : wl.cz + r.cz) - s.dir * wl.width / 2;
const perp = alongX ? wl.cz + r.cz : wl.cx + r.cx, out = alongX ? wl.normal.y : wl.normal.x;
const fns = [];
[perp + out * wl.thickness / 2, perp - out * wl.thickness / 2].forEach(f => planes.forEach(pl => {
const own = shape.axis === s.axis, b = own ? pl.bp : pl.bq, c = own ? pl.bq : pl.bp;
fns.push({ a: pl.a + b * start + c * f, b: b * s.dir });
}));
const W = wl.width, at = (x) => Math.min(...fns.map(fn => fn.a + fn.b * x));
const xs = [0, W];
fns.forEach((f1, j) => fns.slice(j + 1).forEach(f2 => {
if (Math.abs(f1.b - f2.b) < 1e-9) return;
const x = (f2.a - f1.a) / (f1.b - f2.b);
if (x > 1 && x < W - 1) xs.push(x);
}));
const pts = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((a, b) => a - b).map(x => ({ x, h: at(x) }));
const inner = pts.slice(1, -1).filter((q, k) => {
const a = pts[k], b = pts[k + 2];
return q.h > a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h) + 0.5;
});
setWallTop(u.unit.walls[wl.side].wall, W, pts[0].h, pts[pts.length - 1].h, inner);
});
});
}
function setWallTop(wall, W, h0, h1, inner) {
const hL = Math.round(h0), hR = Math.round(h1);
const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5;
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null });
if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) }));
else delete wall.topPts;
}
// Dakaansluiting (L/T): een vleugel met nokdak (zadel, wolfsdak), nok haaks op de naad, tegen de
// langszijde van een hoofdvolume met nokdak loopt het hoofddak in tot zijn nok (kilkepers). Het wolfseind van de
// vleugel aan de naad vervalt. Ligt de vleugel gelijk met een kop van het hoofdvolume, dan wordt die kop een
// schild in de vlakken van de vleugel (hoekkeper, goot loopt door; een wolfseind daar vervalt); hooguit één kop,
// anders heeft het hoofdvolume geen kopgevel meer om zijn eigen dakvorm uit af te leiden. Een vleugel die in een
// wolfseind van het hoofdvolume zou steken (niet gelijk met de kop) sluit niet aan.
// Vlakken in wereldmaten; h = min(verre vlakken, wolfseinden, schildvlakken, max(min(nabije vlakken), vleugels)),
// zie RoofModel.computeJoinedRoof.
const JOIN_TYPES = ['zadel', 'wolf'];
function roofJoins(state, rects) {
const out = {};
if (houseRoofOn(state)) return out;
const rs = rects || unitRects(state);
if (rs.some(r => r.error) || overlappingPairs(rs).length) return out;
const at = (pl, [x, z]) => pl.a + pl.bx * x + pl.bz * z;
const info = state.units.map((u, i) => {
const r = rs[i], shape = RoofModel.computeShape(u.unit, UnitModel.computeLayout(u.unit, {}));
const planes = [];
if (!JOIN_TYPES.includes(shape.type)) return { u, r, shape, planes };
const sx = shape.axis === 'x';
const s = shape.type === 'wolf' && shape.hip
? Object.assign({}, shape, { hip: Object.assign({}, shape.hip, sx ? { q0: -r.D / 2, q1: r.D / 2 } : { q0: -r.W / 2, q1: r.W / 2 }) })
: shape;
const corners = [[r.x0, r.z0], [r.x1, r.z0], [r.x0, r.z1], [r.x1, r.z1]];
RoofModel.shapeLines(s).forEach(l => {
const [bx, bz] = sx ? [l.bp, l.bq] : [l.bq, l.bp];
const pl = { a: l.a - bx * r.cx - bz * r.cz, bx, bz };
// Wolfsdak: onder- en bovenstuk liggen (op afronding na) in één vlak.
if (!planes.some(q => corners.every(c => Math.abs(at(q, c) - at(pl, c)) < 2))) planes.push(pl);
});
return { u, r, shape, planes };
});
const groups = new Map(), role = {};
info.forEach(M => info.forEach(W => {
if (M === W || !M.planes.length || !W.planes.length || M.shape.axis === W.shape.axis) return;
if (role[M.u.id] === 'vleugel' || role[W.u.id]) return;
const seamX = M.shape.axis === 'z'; // naad evenwijdig aan de nok van M
const [m0, m1, w0, w1] = seamX ? [M.r.x0, M.r.x1, W.r.x0, W.r.x1] : [M.r.z0, M.r.z1, W.r.z0, W.r.z1];
const [a0, a1, b0, b1] = seamX ? [M.r.z0, M.r.z1, W.r.z0, W.r.z1] : [M.r.x0, M.r.x1, W.r.x0, W.r.x1];
const seam = Math.abs(a1 - b0) < EPS ? a1 : Math.abs(b1 - a0) < EPS ? a0 : null;
if (seam === null || w0 < m0 - EPS || w1 > m1 + EPS || (w0 < m0 + EPS && w1 > m1 - EPS)) return;
const xz = (t) => seamX ? [t, seam] : [seam, t];
const dir = seam === a1 ? 1 : -1; // vleugel aan de +kant (dwars op de naad) van M
const across = (pl) => seamX ? pl.bz : pl.bx, along = (pl) => seamX ? pl.bx : pl.bz;
const g = groups.get(M) || { M, seamX, wings: [], flush: null, drop: [] };
const end = Math.abs(w1 - m1) < EPS ? 1 : Math.abs(w0 - m0) < EPS ? 0 : -1;
const toEnd = (pl) => end ? along(pl) < 0 : along(pl) > 0; // daalt naar die kop
const flush = end >= 0 && !g.flush ? {
planes: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 && toEnd(pl)),
drop: M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && toEnd(pl)),
side: seamX ? (end ? 'rechts' : 'links') : (end ? 'achter' : 'voor')
} : null;
const wingTop = Math.max(...W.shape.profile.map(q => q.h));
const hips = M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && !g.drop.includes(pl) && !(flush && flush.drop.includes(pl)));
if (hips.some(pl => Math.min(at(pl, xz(w0)), at(pl, xz(w1))) < wingTop - 1)) return;
if (flush) { g.flush = flush; g.drop.push(...flush.drop); }
g.wings.push({ W, sign: -dir, roof: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 || across(pl) * dir < 0) });
groups.set(M, g);
role[M.u.id] = 'hoofd';
role[W.u.id] = 'vleugel';
}));
groups.forEach(g => {
const planes = [], idx = (pl) => planes.includes(pl) ? planes.indexOf(pl) : planes.push(pl) - 1;
const cross = (pl) => g.seamX ? pl.bz : pl.bx, terms = [];
[-1, 1].forEach(sg => {
const side = g.M.planes.filter(pl => Math.abs(cross(pl)) > 1e-9 && Math.sign(cross(pl)) === sg);
const ws = g.wings.filter(w => w.sign === sg);
if (!ws.length) side.forEach(pl => terms.push(idx(pl)));
else terms.push(['max', ['min', ...side.map(idx)], ...ws.map(w => ['min', ...w.roof.map(idx)])]);
});
g.M.planes.filter(pl => Math.abs(cross(pl)) <= 1e-9 && !g.drop.includes(pl)).forEach(pl => terms.push(idx(pl)));
if (g.flush) g.flush.planes.forEach(pl => terms.push(idx(pl)));
const expr = ['min', ...terms];
[g.M, ...g.wings.map(w => w.W)].forEach(X => { out[X.u.id] = { planes, expr, flush: X === g.M && g.flush ? g.flush.side : null }; });
});
return out;
}
// Dubbel lessenaardak = zadeldak: twee units met elk een lessenaar dwars op hun naad, allebei hoog aan de naad, met
// op de mm gelijke goot, nok en hoogte op de naad. Dan een stalen nokbalk onder de naad, net als bij een zadeldak.
// Eén lessenaar, of twee die niet zo op elkaar aansluiten, krijgen er geen. Een schoorsteen op de nok maakt een
// sparing in het dak; de balk stopt dan vóór de schoorsteen (RoofModel.computeRidgeBeam).
function doubleLessenaarPairs(state, rects, cuts) {
const out = {};
const info = state.units.map((u, i) => {
const r = rects[i];
if (r.error || u.roof.steelRidge === false) return null;
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const sh = RoofModel.computeShape(u.unit, layout);
if (sh.type !== 'lessenaar' || sh.profile.length !== 2) return null;
const c = sh.axis === 'x' ? r.cx : r.cz;
const [A, B] = sh.profile.map(q => ({ p: q.p + c, h: q.h })).sort((a, b) => a.p - b.p);
return { u, r, axis: sh.axis, A, B, floorTop: layout.floorTop, slope: (B.h - A.h) / (B.p - A.p) };
});
const at = (L, p) => L.A.h + (p - L.A.p) * L.slope;
info.forEach(M => info.forEach(N => {
if (!M || !N || M === N || M.axis !== N.axis || out[M.u.id] || out[N.u.id]) return;
const ax = M.axis, lo = (r) => ax === 'x' ? r.x0 : r.z0, hi = (r) => ax === 'x' ? r.x1 : r.z1;
const seam = hi(M.r);
if (Math.abs(seam - lo(N.r)) > EPS || !(M.slope > 0 && N.slope < 0) || Math.abs(M.floorTop - N.floorTop) > EPS) return;
if (Math.abs(M.A.h - N.B.h) > EPS || Math.abs(M.B.h - N.A.h) > EPS || Math.abs(at(M, seam) - at(N, seam)) > EPS) return;
const qlo = (r) => ax === 'x' ? r.z0 : r.x0, qhi = (r) => ax === 'x' ? r.z1 : r.x1;
const q0 = Math.max(qlo(M.r), qlo(N.r)), q1 = Math.min(qhi(M.r), qhi(N.r));
if (q1 - q0 < 100) return;
out[M.u.id] = {
axis: ax, a0: lo(M.r), a1: hi(N.r), q0, q1, span: [q0, q1],
profile: [{ p: M.A.p, h: M.A.h }, { p: seam, h: at(M, seam) }, { p: N.B.p, h: N.B.h }]
};
}));
return out;
}
// De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug.
function applyRoofJoins(state) {
const rects = unitRects(state);
const joins = roofJoins(state, rects);
state.units.forEach((u, i) => {
const join = joins[u.id], side = join && join.flush;
if (Object.entries(u.unit.walls).some(([k, ws]) => ws.wall.joinTop && k !== side)) {
Object.values(u.unit.walls).forEach(ws => { delete ws.wall.joinTop; });
UnitModel.applyRoofForm(u.unit, UnitModel.roofForm(u.unit));
}
if (!side) return;
const r = rects[i];
const wl = UnitModel.computeLayout(u.unit, {}).walls.find(w => w.side === side);
const s = UnitModel.WALL_SIDES.find(x => x.key === side), alongX = s.axis === 'x';
const start = (alongX ? wl.cx + r.cx : wl.cz + r.cz) - s.dir * wl.width / 2;
const perp = alongX ? wl.cz + r.cz : wl.cx + r.cx, out = alongX ? wl.normal.y : wl.normal.x;
const faces = [perp + out * wl.thickness / 2, perp - out * wl.thickness / 2];
const pos = (x, f) => alongX ? { x: start + s.dir * x, z: f } : { x: f, z: start + s.dir * x };
const W = wl.width, xs = [0, W];
// Knikken alleen waar twee vlakken langs een wandvlak even hoog zijn.
faces.forEach(f => join.planes.forEach((p1, j) => join.planes.slice(j + 1).forEach(p2 => {
const h = (pl, x) => { const q = pos(x, f); return pl.a + pl.bx * q.x + pl.bz * q.z; };
const d0 = h(p1, 0) - h(p2, 0), d1 = h(p1, W) - h(p2, W);
if (Math.abs(d1 - d0) > 1e-9) { const x = -d0 / (d1 - d0) * W; if (x > 1 && x < W - 1) xs.push(x); }
})));
const hAt = (x) => Math.min(...faces.map(f => { const q = pos(x, f); return RoofModel.joinValue(join, q); }));
const pts = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((a, b) => a - b).map(x => ({ x, h: hAt(x) }));
const inner = pts.slice(1, -1).filter((q, k) => {
const a = pts[k], b = pts[k + 2];
return Math.abs(q.h - (a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h))) > 0.5;
});
const wall = u.unit.walls[side].wall;
setWallTop(wall, W, pts[0].h, pts[pts.length - 1].h, inner);
wall.joinTop = true;
});
}
function setHouseRoof(state, on) {
const hr = state.settings.roof.house;
if (!!on === houseRoofOn(state)) return;
if (on) {
state.units.forEach(u => {
u.roof.unitWalls = Object.fromEntries(Object.entries(u.unit.walls).map(([side, ws]) =>
[side, clone(Object.fromEntries(ROOF_HEIGHT_KEYS.filter(k => ws.wall[k] !== undefined).map(k => [k, ws.wall[k]])))]));
});
if (!hr.form) hr.form = UnitModel.roofForm(state.units[0].unit);
} else {
state.units.forEach(u => {
if (!u.roof.unitWalls) return;
Object.entries(u.roof.unitWalls).forEach(([side, saved]) => {
const w = u.unit.walls[side] && u.unit.walls[side].wall;
if (!w) return;
delete w.topPts;
Object.assign(w, { heightMid: null }, clone(saved));
});
delete u.roof.unitWalls;
});
}
hr.enabled = !!on;
}
function assignFacades(pieces) {
const segs = [];
pieces.forEach(p => {
const W = p.place.width, T = p.place.thickness;
const normal = { x: p.place.normal.x, z: p.place.normal.y };
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x';
// Binnenhoek-doorloop: zit dat stuk over de hele hoogte in de buur-unit, dan begint de gevel pas daarna;
// loopt de gevel erboven door (lager dak van de buur), dan telt het hele stuk mee.
const e = p.skinExt || {}, tt = p.trimTop || {};
const maxY = WallModel.topLine(p.config).maxY + (p.skinAbove || 0);
const trim = (k) => e[k] < 0 && !(tt[k] > 0 && tt[k] < maxY - 1) ? -e[k] : 0;
const tL = trim('left'), tR = trim('right');
const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2);
segs.push({ piece: p, trimL: tL, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) });
});
groupFacadeRuns(segs).forEach(run => {
const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1;
const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0;
const first = run.segs.reduce((best, sg) => Math.abs(sg.sAt0 - sLeft) < Math.abs(best.sAt0 - sLeft) ? sg : best);
const extLeft = Math.max(0, (first.piece.skinExt && first.piece.skinExt.left) || 0);
// Vanaf het trasraam van een gewoon stuk, zodat opbouw- en naadwandstukken erop aansluiten.
const ref = run.segs.find(sg => !sg.piece.place.baseY && sg.piece.skinFrom === undefined);
const gridAbs = ref ? ref.piece.place.floorTop + WallModel.skinStartY(ref.piece.trasraam) : undefined;
const floorAbs = ref ? ref.piece.place.floorTop : undefined;
run.segs.forEach(sg => {
const p = sg.piece;
sg.piece.facade = {
u0: Math.abs(sg.sAt0 - sLeft) + extLeft - sg.trimL, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5,
gridY0: gridAbs === undefined ? undefined : gridAbs - p.place.floorTop,
yOff: floorAbs === undefined ? 0 : p.place.floorTop - floorAbs
};
});
});
}
function computeParts(state, opts) {
const o = opts || {};
const build = !o.skip3D;
const layers = ViewLayers.normalize(o.layers);
const shows = (key) => layers[key];
const hideFrame = (group, mats) => { if (group && mats && !shows('regelwerk')) ViewLayers.hideByMaterial(group, [mats.woodMaterial, mats.steelMaterial]); };
applyGlobalSettings(state);
const rects = unitRects(state);
const rectById = {};
rects.forEach(r => { rectById[r.id] = r; });
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
const { cuts, seams } = computeSeams(rects, wallThickness);
const houseShape = houseRoofShape(state, rects);
const coverExt = coverExtensions(state, rects);
const joins = roofJoins(state, rects);
const chimneysPlaced = ChimneyModel.place(ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id)), rects, {
wallThickness,
faceDepth: (id, side) => {
const fc = facadeFor(state, state.units.find(u => u.id === id), side), m = facadeMaterials(state, fc);
return WallModel.claddingOffset(m.exterior, m.stone, m.stuc);
},
overhang: RoofModel.normalizeSettings(state.settings.roof).overhang
});
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
}
function wallOf(u, rect, layout, wl, raised) {
const ws0 = u.unit.walls[wl.side];
const chimneyCuts = ChimneyModel.wallCuts(chimneysPlaced, rect, wl, Math.round(ws0.wall.studWidth || WallModel.STANDARD_BEAM.width), rects);
const ws = chimneyCuts.length ? Object.assign({}, ws0, { openings: (ws0.openings || []).concat(chimneyCuts) }) : ws0;
const wp = UnitModel.wallPieces(wl, ws, raised);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
const base = p.baseY || 0;
const own = UnitModel.splitFacadeAt(wl, p.u0, p.u1);
const m = own ? facadeMaterials(state, facadeFor(state, u, wl.side, own)) : (ws.materials || {});
const buitenMateriaal = m.exterior || 'vochtdoek';
const cladding = m.cladding || 'horizontaal';
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
const stuc = buitenMateriaal === 'cementplaat' && !stone ? (m.stuc || null) : null;
const trasraam = buitenMateriaal === 'vochtdoek' || stone || stuc ? (m.trasraam || null) : null;
return Object.assign(p, {
element: `${u.id}-W${wl.nr}${p.suffix}`,
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${p.suffix})` : ''),
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, facadeOwn: own,
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop + base, baseY: base }
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
});
const cutsU = [];
wl.cutsU.forEach(([a, b]) => {
const r = pieces.find(p => p.baseY && p.u0 < b - EPS && p.u1 > a + EPS);
if (!r) { cutsU.push([a, b]); return; }
if (r.u0 > a + EPS) cutsU.push([a, r.u0]);
cutsU.push([Math.max(a, r.u0), Math.min(b, r.u1), r.baseY]);
if (r.u1 < b - EPS) cutsU.push([r.u1, b]);
});
return Object.assign({}, wl, { cutsU, pieces, dropped: wp.dropped, removed: pieces.length === 0 });
}
const units = state.units.map(u => {
const rect = rectById[u.id];
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const rims = { front: cuts[u.id].voor.length > 0, back: cuts[u.id].achter.length > 0 };
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
materials: o.floorMaterials, osbVisible: shows('beplating'), outerJoistsOnly: !!o.finishedOnly && shows('beplating'),
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
}) : null;
hideFrame(floorGroup, o.floorMaterials);
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
const shape = houseShape ? unitShapeOf(houseShape, rect) : RoofModel.computeShape(u.unit, layout);
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
const roofW = deckFrame.width, roofD = deckFrame.depth;
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
const roofRect = { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 };
const join = joins[u.id] && {
planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })),
expr: joins[u.id].expr
};
const roofCuts = ChimneyModel.roofCuts(chimneysPlaced, rect,
{ x0: rect.cx + roofRect.x0, x1: rect.cx + roofRect.x1, z0: rect.cz + roofRect.z0, z1: rect.cz + roofRect.z1 });
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims, roofCuts);
const dl = !join && ridgePairs[u.id];
if (dl) {
const c = dl.axis === 'x' ? rect.cx : rect.cz, cq = dl.axis === 'x' ? rect.cz : rect.cx;
const zadel = { type: 'zadel', axis: dl.axis, profile: dl.profile.map(q => ({ p: q.p - c, h: q.h })) };
const both = dl.axis === 'x' ? { x0: dl.a0 - c, x1: dl.a1 - c, z0: dl.q0 - cq, z1: dl.q1 - cq } : { x0: dl.q0 - cq, x1: dl.q1 - cq, z0: dl.a0 - c, z1: dl.a1 - c };
RoofModel.sharedRidgeBeam(u.roof, ur, zadel, both, dl.span.map(q => q - cq), roofCuts);
}
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', wolf: 'wolfsdak' }[shape.type];
const multi = ur.planes.length > 1;
ur.planes.forEach((pl, i) => {
pl.element = `${u.id}-D${i + 1}`;
const deg = Math.round(Math.abs(pl.theta) * 180 / Math.PI * 10) / 10;
pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
});
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
materials: o.roofMaterials,
osbVisible: shows('beplating'),
dakleerVisible: shows('dakbedekking'),
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
}) : null;
hideFrame(roofGroup, o.roofMaterials);
const ext = coverExt[u.id];
if (roofGroup && ext && shows('afwerking')) {
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
const strips = [];
if (ext.r) strips.push([rect.x1, rect.x1 + ext.r, fz, bz]);
if (ext.l) strips.push([rect.x0 - ext.l, rect.x0, fz, bz]);
if (ext.b) strips.push([rect.x0, rect.x1, rect.z1, bz]);
if (ext.f) strips.push([rect.x0, rect.x1, fz, rect.z0]);
const pos = [];
strips.forEach(([x0, x1, z0, z1]) => {
const P = [[x0, z0], [x1, z0], [x1, z1], [x0, z1]].map(([x, z]) => [(x - roofCx) * 0.001, (ur.undersideAt(x - rect.cx, z - rect.cz) - 3) * 0.001, (z - roofCz) * 0.001]);
[0, 2, 1, 0, 3, 2].forEach(i => pos.push(...P[i]));
});
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.computeVertexNormals();
const col = (RoofModel.FASCIA_COLORS[state.settings.roof.fasciaColor] || RoofModel.FASCIA_COLORS.wit).hex;
roofGroup.add(new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: col, roughness: 0.8, side: THREE.DoubleSide })));
}
const roof = {
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs, cover: ext || null,
unitRoof: ur,
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
ceilingAt: (x, z) => layout.floorTop + ur.undersideAt(x - rect.cx, z - rect.cz),
topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz),
ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })),
top: layout.floorTop + ur.maxTop,
group: roofGroup
};
return {
id: u.id, rect, layout, floorData, floorSpecs, floorGroup, walls, roof,
floorElement: `${u.id}-V1`
};
});
// Opbouw alleen bij een rechte voet (het lage dak ligt langs de hele naad even hoog); anders loopt
// de naadwand door met buitenafwerking.
const raisedCap = {};
(function raiseSeamWalls() {
const zones = {};
seams.forEach(sm => {
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
const seamPerp = horiz ? sm.z0 : sm.x0;
const s0 = horiz ? sm.x0 : sm.z0, s1 = horiz ? sm.x1 : sm.z1;
[[sm.a, sm.b, horiz ? 'achter' : 'rechts'], [sm.b, sm.a, horiz ? 'voor' : 'links']].forEach(([uid, oid, side]) => {
const u = units.find(x => x.id === uid), other = units.find(x => x.id === oid);
const ts = [s0, s1].concat(other.roof.ridges.filter(r => r.axis === (horiz ? 'x' : 'z') && r.value > s0 && r.value < s1).map(r => r.value));
const tops = ts.map(t => horiz ? other.roof.topAt(t, seamPerp) : other.roof.topAt(seamPerp, t));
if (Math.max(...tops) - Math.min(...tops) > 2) return; // schuine voet: terugval
const base = Math.round(tops[0] - u.layout.floorTop);
const wl = u.walls.find(w => w.side === side);
const sd = UnitModel.WALL_SIDES.find(x => x.key === side);
const c0 = sd.axis === 'x' ? u.rect.cx : u.rect.cz;
const start = (sd.axis === 'x' ? wl.cx : wl.cz) - wl.width / 2;
const toU = (t) => sd.dir > 0 ? t - c0 - start : start + wl.width - (t - c0);
const z0 = Math.min(toU(s0), toU(s1)), z1 = Math.max(toU(s0), toU(s1));
wl.cutsU.forEach(([a, b]) => {
let lo = Math.max(a, z0), hi = Math.min(b, z1);
if (lo < EPS) lo = wl.endInset.left;
if (hi > wl.width - EPS) hi = wl.width - wl.endInset.right;
if (hi - lo > EPS) ((zones[uid] = zones[uid] || {})[side] = zones[uid][side] || []).push({ u0: lo, u1: hi, base, other: oid });
});
});
});
Object.keys(zones).forEach(uid => {
const u = units.find(x => x.id === uid);
const su = state.units.find(x => x.id === uid);
Object.keys(zones[uid]).forEach(side => {
const i = u.walls.findIndex(w => w.side === side);
const wl = u.layout.walls.find(w => w.side === side);
const next = wallOf(su, u.rect, u.layout, wl, zones[uid][side]);
u.walls[i] = next;
zones[uid][side].forEach(z => {
if (next.pieces.some(p => p.baseY && Math.abs(p.u0 - z.u0) < EPS)) raisedCap[`${uid}|${z.other}`] = z.base;
});
});
});
})();
const interior = [];
let bwNr = 0, nwNr = 0;
state.interiorWalls.forEach(iw => {
if (iw.seam) { nwNr++; seamLeaves(iw, nwNr).forEach(l => interior.push(l)); return; }
bwNr++;
const lay = interiorLayout(iw);
const host = units.find(u => lay.cx >= u.rect.x0 && lay.cx <= u.rect.x1 && lay.cz >= u.rect.z0 && lay.cz <= u.rect.z1);
const floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop));
const config = WallModel.configFromState(iw.wall);
const frame = WallModel.computeFrame(config);
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
interior.push({
id: iw.id, element: `BW-${bwNr}`, label: `Binnenwand ${bwNr}`, config, frame, buitenMateriaal,
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
});
});
// Steekt een deel boven het dak van de andere unit uit, dan krijgt dat stuk aan de naadzijde
// buitenafwerking (skinFrom); het regelwerk blijft dat van de naadwand.
function seamLeaves(iw, nr) {
const sm = seams.find(x => sameSeam(x, iw.seam.a, iw.seam.b));
if (!sm) return [];
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
// side +1: unit a (lage kant), de naad aan zijn +kant.
const seamSide = (side) => horiz ? (side > 0 ? 'achter' : 'voor') : (side > 0 ? 'rechts' : 'links');
const unitState = (uid) => state.units.find(u => u.id === uid).unit;
const baseConfig = WallModel.configFromState(iw.wall);
const maxD = Math.max(baseConfig.studDepth, baseConfig.plateDepth);
const T1 = WallModel.thicknessOf(baseConfig, 'geen');
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const T2 = WallModel.thicknessOf(baseConfig, 'spaanplaat');
// Opbouwstukken (baseY) staan boven de naadwand, niet ernaast.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) extPieces.push(p); })));
const along = (q) => horiz ? q.x : q.z, perpOf = (q) => horiz ? q.z : q.x;
const seamPerp = horiz ? sm.z0 : sm.x0;
const s0 = horiz ? sm.x0 : sm.z0, s1 = horiz ? sm.x1 : sm.z1;
const leaves = [];
[[sm.a, 1], [sm.b, -1]].forEach(([uid, side]) => {
if (inUnit && uid !== inUnit) return;
const T = inUnit ? T2 : T1;
const line = seamPerp - side * T / 2;
const blocked = [];
extPieces.forEach(p => {
const cs = [[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([i, j]) => UnitModel.localToWorld(p.place, i * p.place.width / 2, j * p.place.thickness / 2));
const pMin = Math.min(...cs.map(perpOf)), pMax = Math.max(...cs.map(perpOf));
if (line > pMin + 0.5 && line < pMax - 0.5) blocked.push([Math.min(...cs.map(along)), Math.max(...cs.map(along))]);
});
let cursor = s0, best = null;
blocked.sort((p, q) => p[0] - q[0]).concat([[s1, s1]]).forEach(([a, b]) => {
const hi = Math.min(a, s1);
if (hi - cursor > (best ? best[1] - best[0] : 0)) best = [cursor, hi];
cursor = Math.max(cursor, b);
});
if (!best || best[1] - best[0] < 300) return;
const width = Math.round(best[1] - best[0]), mid = (best[0] + best[1]) / 2;
const rotY = horiz ? (side > 0 ? 0 : Math.PI) : (side > 0 ? Math.PI / 2 : -Math.PI / 2);
const place = {
cx: horiz ? mid : line, cz: horiz ? line : mid, rotY, width, thickness: T,
normal: { x: Math.round(Math.sin(rotY)), y: Math.round(Math.cos(rotY)) },
floorTop: units.find(u => u.id === uid).layout.floorTop
};
leaves.push({ uid, side, place });
});
if (!leaves.length) return [];
const heightsOf = (lf) => {
const u = units.find(x => x.id === lf.uid);
const prof = RoofModel.wallProfile(unitState(lf.uid), u.layout, seamSide(lf.side));
const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz;
const W = lf.place.width;
const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0));
const cap = raisedCap[`${lf.uid}|${lf.uid === sm.a ? sm.b : sm.a}`];
const lim = (h) => cap !== undefined ? Math.min(h, cap) : h;
const hL = lim(RoofModel.profileHeight(prof, pL)), hR = lim(RoofModel.profileHeight(prof, pR));
const lo = Math.min(pL, pR), hi = Math.max(pL, pR);
const inner = prof.slice(1, -1).filter(q => q.p > lo + 1 && q.p < hi - 1)
.map(q => ({ f: Math.abs(q.p - pL) / (hi - lo), h: Math.round(lim(q.h)) })).sort((a, b) => a.f - b.f);
return {
heightLeft: Math.round(hL), heightRight: Math.round(hR),
heightMid: null, peakX: undefined, topPts: inner.length ? inner : undefined
};
};
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
const exposedFrom = (lf, config) => {
const other = units.find(u => u.id === (lf.uid === sm.a ? sm.b : sm.a));
const W = lf.place.width;
const ends = [-W / 2, W / 2].map(lx => along(UnitModel.localToWorld(lf.place, lx, 0)));
const lo = Math.min(...ends), hi = Math.max(...ends);
const ts = ends.concat(other.roof.ridges.filter(r => r.axis === (horiz ? 'x' : 'z') && r.value > lo && r.value < hi).map(r => r.value));
const from = Math.min(...ts.map(t => horiz ? other.roof.topAt(t, seamPerp) : other.roof.topAt(seamPerp, t))) - lf.place.floorTop;
return from < WallModel.topLine(config).maxY - 50 ? Math.max(0, Math.round(from)) : null;
};
const first = leaves[0];
const firstParams = paramsOf(first);
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: null, topPts: firstParams.topPts });
const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []);
const toLocalU = (pl, q) => {
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
return (q.x - pl.cx) * c - (q.z - pl.cz) * sn + pl.width / 2;
};
return leaves.map((lf, k) => {
let config = firstConfig;
if (k > 0) {
const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0);
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
// Gespiegeld: de schuine bovenzijde klapt om. Het kozijn (fill) staat alleen in het eerste deel.
const flip = ua > ub;
return { id: op.id, type: op.type, koppel: op.koppel, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
const frame = WallModel.computeFrame(config);
const skinFrom = exposedFrom(lf, config);
return {
id: iw.id, seam: iw.seam, seamUnit: lf.uid,
element: `NW-${nr}${lf.uid}`, label: `Naadwand ${nr} (unit ${lf.uid})`,
config, frame, place: lf.place,
// Regelwerk op lokaal z = 0, beplating alleen achter: schuiven zodat het regelwerk op de naad ligt.
shiftZ: inUnit ? 0 : (T1 - maxD) / 2,
...(skinFrom === null
? { buitenMateriaal: inUnit ? 'spaanplaat' : 'geen' }
: { buitenMateriaal: 'vochtdoek', skinFrom, spaanplaatBelow: !!inUnit, cladding: state.settings.cladding || 'horizontaal', trasraam: null })
};
});
}
// Buitenlagen tot de bovenkant van het dak, per punt: een steil vlak (wolfseind) is verticaal dikker.
function skinTopLine(p, roof) {
const cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg), zF = p.place.thickness / 2;
const xs = new Set([0, W].concat((cfg.wallTop || []).map(q => q.x)));
for (let x = 100; x < W; x += 100) xs.add(x);
const pts = [...xs].sort((a, b) => a - b).map(x => {
const top = tl.at(x - W / 2), w = UnitModel.localToWorld(p.place, x - W / 2, zF), wallTop = p.place.floorTop + top;
return { x, h: top + Math.max(0, roof.topAt(w.x, w.z) - Math.min(wallTop, roof.ceilingAt(w.x, w.z))) };
});
const L = pts[0], R = pts[pts.length - 1];
const inner = WallModel.wallTopPoints({ topPts: pts.slice(1, -1).map(q => ({ f: q.x / W, h: q.h })) }, W, Math.round(L.h), Math.round(R.h));
const line = [L, ...inner, R];
const at = (x) => { let i = 0; while (i < line.length - 2 && x > line[i + 1].x) i++; const a = line[i], b = line[i + 1]; return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); };
// Alleen een bolle daklijn: een kil of sprong (binnenhoek L-vorm) zou de lijn erboven trekken; dan vast skinAbove.
if (pts.some(q => at(q.x) > q.h + 5)) return null;
return { hL: Math.round(L.h), hR: Math.round(R.h), pts: inner };
}
function topWedge(p, roof) {
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
const xs = new Set([-half, half].concat(tl.kinks));
for (let x = -half + 250; x < half; x += 250) xs.add(x);
const flues = cfg.openings.filter(op => op.type === 'schoorsteen').map(op => [op.x - half, op.x + op.w - half]);
flues.forEach(([a, b]) => { xs.add(a); xs.add(b); });
const pts = [...xs].sort((a, b) => a - b).map(x => {
if (flues.some(([a, b]) => x > a + 0.5 && x < b - 0.5)) return { x, o: 0, i: 0 }; // geen hout in de schoorsteen
const top = p.place.floorTop + tl.at(x);
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
});
return pts.some(q => Math.max(q.o, q.i) >= 5) ? { pts } : null;
}
// Eerst de gevels toewijzen (doek en latwerk lopen over de hele gevel), daarna pas bouwen.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
// Ligt er vóór de gevel nog dak (overkapping, groter dak), dan stoppen de buitenlagen op de onderkant
// daarvan, anders steken ze erdoorheen. Alleen een dak boven de wand telt (niet het lage dak naast een opbouw).
const roofInFront = (p, wallTop) => {
const out = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) + 100;
const W = p.place.width, probes = [-0.4, 0, 0.4].map(f => UnitModel.localToWorld(p.place, f * W, p.place.thickness / 2 + out));
let best = null;
probes.forEach(q => units.forEach(o => {
const r = o.roof.rect;
if (!(q.x > r.x0 + 1 && q.x < r.x1 - 1 && q.z > r.z0 + 1 && q.z < r.z1 - 1)) return;
const c = o.roof.ceilingAt(q.x, q.z);
if (c < wallTop - 10) return;
if (best === null || c < best) best = c;
}));
return best;
};
units.forEach(u => {
u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
const wallTop = p.place.floorTop + WallModel.topLine(p.config).at(0);
const covered = roofInFront(p, wallTop);
p.skinAbove = covered !== null
? Math.max(0, Math.round(covered - 3 - wallTop))
: Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
p.skinTop = covered !== null ? null : skinTopLine(p, u.roof);
p.underRoof = covered !== null;
p.config.topWedge = topWedge(p, u.roof);
// Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop).
const e = p.skinExt || {}, W = p.place.width;
p.trimTop = {};
[['left', -1], ['right', 1]].forEach(([k, d]) => {
if (!(e[k] < 0)) return;
const q = UnitModel.localToWorld(p.place, d * (W / 2 + e[k] / 2), p.place.thickness / 2 + 20);
const nb = units.find(o => o.id !== u.id && q.x > o.rect.x0 && q.x < o.rect.x1 && q.z > o.rect.z0 && q.z < o.rect.z1);
p.trimTop[k] = nb ? Math.round(nb.roof.topAt(q.x, q.z) - p.place.floorTop) : 0;
});
}));
});
const facadeDepth = (a, b, nrm) => {
const horiz = Math.abs(nrm.z) > 0.5;
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
let best = 0, bestOv = 1;
extPieces.forEach(p => {
const pn = { x: p.place.normal.x, z: p.place.normal.y };
const dot = pn.x * nrm.x + pn.z * nrm.z;
if (Math.abs(dot) < 0.5) return;
const q0 = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2), q1 = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
if (Math.abs((horiz ? q0.z : q0.x) - line) > 2) return;
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
if (ov <= bestOv) return;
bestOv = ov;
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
});
return best;
};
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
units.map(u => ({
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
topAt: u.roof.topAt, ridges: u.roof.ridges
})), facadeDepth) : null;
const pointInPoly = (q, poly) => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if ((a.z > q.z) !== (b.z > q.z) && q.x < (b.x - a.x) * (q.z - a.z) / (b.z - a.z) + a.x) inside = !inside;
}
return inside;
};
if (roofEdge) extPieces.forEach(p => {
const nrm = { x: p.place.normal.x, z: p.place.normal.y };
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
if (!sg) return;
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
});
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
if (built.group && p.shiftZ) {
const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001;
outer.add(built.group);
built.group = outer;
}
Object.assign(p, built);
});
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const chimneys = ChimneyModel.resolve(chimneysPlaced, units, roofEdge, groundY, roofEdge ? roofEdge.overhang : 0);
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: shows('afwerking'), spotsVisible: shows('lampen')
}) : null;
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
RoomModel.lowerSpots(rooms, lighting.indoor);
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
if (lightingGroup) lightingGroup.visible = shows('lampen');
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.elektraRough) : null;
if (elektraGroup) elektraGroup.visible = shows('elektra');
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
}) : null;
if (chimneysGroup) chimneysGroup.visible = shows('gevel');
const sandwich = state.settings.sandwich && units.length
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
visible: shows('gevel')
}) : null;
const roomsGroup = build ? RoomModel.build3D(rooms) : null;
if (roomsGroup) roomsGroup.visible = shows('ruimtes') && shows('afwerking');
const fixtures = FixtureModel.compute(state.fixtures || [], units);
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
if (fixturesGroup) fixturesGroup.visible = shows('inrichting');
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
}
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
function placeGroups(parts) {
const toM = 0.001;
parts.units.forEach(u => {
if (u.floorGroup) u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
if (u.roof.group) u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
});
allWallPieces(parts).forEach(p => {
if (!p.group) return;
p.group.rotation.y = p.place.rotY;
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
});
}
function allWallPieces(parts) {
const ext = [];
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
return ext.concat(parts.interior);
}
function computeDimensions(parts, groundLevel) {
const m2 = (mm2) => mm2 / 1e6;
const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const floorMM2 = rects.reduce((a, r) => a + r.W * r.D, 0);
const ext = [], all = allWallPieces(parts);
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
// baseY = opbouw boven een lager dak: geen voetafdruk.
const footprintMM2 = all.filter(p => !p.place.baseY).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
const wallArea = (p, y0) => {
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
const poly = [{ x: -W / 2, y: y0 }, { x: W / 2, y: y0 }, ...tl.pts.slice().reverse()];
return Math.max(0, polygonArea(poly)) + (p.skinAbove || 0) * W;
};
const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + WallModel.openingArea(op), 0);
const facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined));
const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0);
const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0);
const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0);
const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0);
const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0) + (u.roof.specs.felsM2 || 0), 0);
const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
const inhoudMM3 = parts.units.reduce((a, u) => {
const r = u.rect, n = 8;
let sum = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) sum += u.roof.topAt(r.x0 + (i + 0.5) * r.W / n, r.z0 + (j + 0.5) * r.D / n);
return a + r.W * r.D * (sum / (n * n));
}, 0);
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
const top = parts.roofEdge ? parts.roofEdge.top : Math.max(...parts.units.map(u => u.roof.top));
return {
lengthX: bx1 - bx0, lengthZ: bz1 - bz0, height: top - (floorTop - (groundLevel || 0)),
brutoM2: m2(floorMM2), leefbaarM2: m2(floorMM2 - footprintMM2),
gevelBrutoM2: m2(gevelBruto), gevelOpeningM2: m2(gevelOpen), gevelNettoM2: m2(gevelBruto - gevelOpen), glasM2: m2(glas),
binnenwandM2: binnen, dakM2, dakPlanM2: m2(dakPlanMM2), inhoudM3: inhoudMM3 / 1e9
};
}
function dimensionRows(d, fmt) {
return [
['Buitenmaten (b × d)', `${Math.round(d.lengthX)} × ${Math.round(d.lengthZ)}`, 'mm'],
['Bouwhoogte (vanaf maaiveld)', Math.round(d.height), 'mm'],
['Bruto vloeroppervlak (buitenwerks)', fmt(d.brutoM2, 2), 'm²'],
['Leefbare ruimte (netto vloer)', fmt(d.leefbaarM2, 2), 'm²'],
['Gevel buitenkant (bruto)', fmt(d.gevelBrutoM2, 2), 'm²'],
['Openingen in gevel', fmt(d.gevelOpeningM2, 2), 'm²'],
['Gevel buitenkant (netto)', fmt(d.gevelNettoM2, 2), 'm²'],
['Glas (ramen)', fmt(d.glasM2, 2), 'm²'],
['Wanden binnenkant (alle)', fmt(d.binnenwandM2, 2), 'm²'],
['Dak (dakoppervlak)', fmt(d.dakM2, 2), 'm²'],
['Dak (plattegrond)', fmt(d.dakPlanM2, 2), 'm²'],
['Inhoud (bruto)', fmt(d.inhoudM3, 1), 'm³']
];
}
// Breedteklassen indicatief (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing, daarboven begeleiding.
const TRANSPORT = { widthRegular: 2550, widthPermit: 3500 };
const TRANSPORT_CLASSES = { regulier: 'Regulier', ontheffing: 'Ontheffing', begeleiding: 'Ontheffing + begeleiding' };
function computeTransport(parts) {
const kgOf = (p) => (p.specs && p.specs.totalKg) || 0;
return parts.units.map(u => {
const leaves = parts.interior.filter(p => p.seamUnit === u.id);
const kg = {
vloer: u.floorSpecs.totalKg,
wanden: u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + kgOf(p), 0), 0) + leaves.reduce((a, p) => a + kgOf(p), 0),
dak: u.roof.specs.totalKg
};
const out = (side) => Math.max(0, ...((u.walls.find(w => w.side === side) || { pieces: [] }).pieces
.filter(p => !p.baseY)
.map(p => Math.max(WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc),
p.trasraam && WallModel.isStoneFinish(p.trasraam.finish) ? WallModel.STONE.THICK_MM : 0))));
const ew = u.rect.W + out('links') + out('rechts'), ed = u.rect.D + out('voor') + out('achter');
const width = Math.min(ew, ed);
return {
id: u.id, kg, totalKg: kg.vloer + kg.wanden + kg.dak,
length: Math.max(ew, ed), width, height: u.roof.top,
cls: width <= TRANSPORT.widthRegular ? 'regulier' : width <= TRANSPORT.widthPermit ? 'ontheffing' : 'begeleiding'
};
});
}
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const saw = { lengthMM: 0, cuts: 0, angles: new Map() };
function addPiece(kind, width, depth, length, cutAngles) {
const ca = cutAngles || [0, 0];
saw.lengthMM += length;
saw.cuts += ca.length;
ca.forEach(a => saw.angles.set(a, (saw.angles.get(a) || 0) + 1));
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
k.count++; k.lengthMM += length;
kinds.set(kind, k);
const L = Math.round(length);
const key = `${width}|${depth}|${L}`;
const g = sawList.get(key) || { width, depth, length: L, count: 0 };
g.count++;
sawList.set(key, g);
}
parts.units.forEach(u => {
const fc = u.floorData.calcData;
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.joistLen));
['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
u.roof.planes.forEach(pl => {
const rc = pl.data.calcData;
const ang = Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
});
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) cap.beams.forEach(bm => addPiece('dakbalk', cap.joistW, cap.joistH, bm.length, [bm.angle, bm.angle]));
});
const wallBom = [];
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); }));
const purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
const stonePiece = pieces.find(p => p.trasraam && WallModel.isStoneFinish(p.trasraam.finish));
const stoneCode = stonePiece ? stonePiece.trasraam.finish : null;
const stoneCodes = [...new Set(pieces.filter(p => p.stone).map(p => p.stone))].join(', ');
const sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0);
const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat);
const weights = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
}));
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
if (parts.posts.count) weights.push({ label: 'Palen overkapping', element: 'PAAL', kg: parts.posts.kg });
const isolatie = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0)
}));
if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) });
const kindList = Array.from(kinds.values());
return {
kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
saw: {
meters: saw.lengthMM / 1000, cuts: saw.cuts,
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
},
beamCount: kindList.reduce((a, k) => a + k.count, 0),
purchase,
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0),
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
isolatie,
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
plaatwerk: [
{
naam: 'Spaanplaat (wanden)',
m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2),
m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3),
kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{ naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) },
{ naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) },
{ naam: `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm (bovenop de sporen)`, m2: sumU(u => u.roof.specs.beschotM2 || 0), m3: sumU(u => u.roof.specs.beschotVolM3 || 0), kg: sumU(u => u.roof.specs.beschotKg || 0) },
{ naam: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 0) },
{ naam: `Felsplaten (staande naad, ${RoofModel.FELS.thickMM} mm)`, m2: sumU(u => u.roof.specs.felsM2 || 0), m3: null, kg: sumU(u => u.roof.specs.felsKg || 0) },
{
naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0),
m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{
naam: `Steenstrips gevel${stoneCodes ? ' (' + stoneCodes + ')' : ''}`,
m2: sumP(p => p.stats.stoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
},
{
naam: `Steenstrips trasraam${stoneCode ? ' (' + stoneCode + ')' : ''}`,
m2: sumP(p => p.stats.trasraamStoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
},
{
naam: `Isolatieblok steen (${WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM} mm)`,
m2: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)) / ((WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM) / 1000),
m3: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)), kg: 0
},
{
naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0),
m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0)
},
{
naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null,
kg: parts.sandwich ? parts.sandwich.kg : 0
},
{ naam: 'Vals plafond (ruimtes)', m2: parts.rooms.rooms.reduce((a, r) => a + r.ceilM2, 0), m3: null, kg: 0 },
{ naam: 'FIBO-wandpanelen (ruimtes)', m2: parts.rooms.rooms.reduce((a, r) => a + r.fiboM2, 0), m3: null, kg: 0 },
{
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
m3: null, kg: 0 // gewicht volgt
}
],
weights,
totalKg: weights.reduce((a, r) => a + r.kg, 0),
nokbalken: parts.units.filter(u => u.roof.unitRoof.ridgeBeam).map(u => {
const rb = u.roof.unitRoof.ridgeBeam;
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
}),
posts: parts.posts.count ? {
count: parts.posts.count, size: parts.posts.size, wood: (PostModel.WOODS[parts.posts.wood] || {}).label || '',
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg
} : null,
roofEdge: parts.roofEdge ? {
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
fasciaHeight: parts.roofEdge.fasciaHeight,
fasciaColor: (RoofModel.FASCIA_COLORS[parts.roofEdge.fasciaColor] || {}).label || '',
overhang: parts.roofEdge.overhang,
soffitM2: parts.roofEdge.soffitM2,
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
trimHeight: parts.roofEdge.trimHeight,
spots: parts.roofEdge.spots.length
} : null,
lamps: {
total: parts.lighting.lamps.length,
indoor: (parts.lighting.indoor || []).length,
both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
up: parts.lighting.lamps.filter(l => l.direction === 'up').length
}
};
}
function validateState(design) {
if (!Array.isArray(design.units) || !design.units.length) throw new Error('veld "units" ontbreekt of is leeg');
const ids = new Set();
design.units.forEach((u, i) => {
if (!u.id || ids.has(u.id)) throw new Error(`unit #${i + 1}: id ontbreekt of is dubbel`);
ids.add(u.id);
if (u.attach && !['voor', 'achter', 'links', 'rechts'].includes(u.attach.side)) throw new Error(`unit ${u.id}: ongeldige koppelzijde`);
try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); }
});
(design.interiorWalls || []).forEach((iw, i) => {
if (iw.seam) {
if (!ids.has(iw.seam.a) || !ids.has(iw.seam.b)) throw new Error(`naadwand #${i + 1}: unit ${iw.seam.a} of ${iw.seam.b} bestaat niet`);
if (iw.seam.in !== undefined && !['a', 'b'].includes(iw.seam.in)) throw new Error(`naadwand #${i + 1}: ongeldige plaats`);
} else {
['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); });
}
try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); }
});
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
if (design.chimneys !== undefined && !Array.isArray(design.chimneys)) throw new Error('veld "chimneys" moet een lijst zijn');
}
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
function fromUnitDesign(design) {
const { type, version, exportedAt, project, ...unit } = design;
return normalize({ project: Object.assign(createEmptyProject(), project || {}), units: [{ id: 'A', attach: null, unit }], interiorWalls: [] });
}
function normalize(state) {
state.project = Object.assign(createEmptyProject(), state.project || {});
state.interiorWalls = state.interiorWalls || [];
state.rooms = RoomModel.normalize(state.rooms, state.units.map(u => u.id));
state.fixtures = FixtureModel.normalize(state.fixtures, state.units.map(u => u.id));
state.chimneys = ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id));
const def = createDefaultSettings();
state.settings = state.settings || {};
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding;
// v0.4.15: gevelbekleding per woning; een wand met cementplaat wordt een
// steen-override (de rest volgt de woning, standaard rabat).
const hadFacade = !!state.settings.facade;
state.settings.facade = Object.assign(def.facade, state.settings.facade || {});
if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat';
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
if (!WallModel.isStucColor(state.settings.facade.stucColor)) state.settings.facade.stucColor = def.facade.stucColor;
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
state.settings.sandwich = state.settings.sandwich !== false;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
state.settings.interiorPaint = state.settings.interiorPaint !== false;
// v0.4.15: maaiveld standaard gelijk met de bovenkant vloer (was 350 mm eronder).
if (!(state.settings.groundLevel >= 0) || (!state.settings.floorFinish && state.settings.groundLevel === 350)) state.settings.groundLevel = def.groundLevel;
if (!FloorModel.FINISHES[state.settings.floorFinish]) state.settings.floorFinish = def.floorFinish;
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
state.settings.roof = RoofModel.normalizeSettings(oldRoof);
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting;
const lightIn = Object.assign({}, state.settings.lighting || {});
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
state.units.forEach(u => {
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
UnitModel.dropMansarde(u.unit);
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer';
u.roof.extend = normalizeExtend(u.roof.extend);
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
u.facade = { walls: (u.facade && u.facade.walls) || {} };
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
if ((ws.materials || {}).exterior === 'cementplaat' && !u.facade.walls[side]) u.facade.walls[side] = { type: 'steen' };
});
});
return state;
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();