/*! * 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', felsColor: 'antraciet' }, cladding: 'horizontaal', trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat', capColor: 'antraciet' }, 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', fels: 'Felsplaten' }; 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; const fels = o && o.type === 'fels' && WallModel.isFelsColor(o.fels) ? o.fels : f.felsColor; return { type, stone, stuc, fels, override: !!o }; } function facadeMaterials(state, fc) { const tr = state.settings.trasraam; const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish, tr.capColor) : null; const cladding = state.settings.cladding; if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null }; if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null }; // Felsplaten: opbouw als verticale rabat (latten liggend), de banen staan altijd. if (fc.type === 'fels') return { exterior: 'vochtdoek', stone: null, stuc: null, cladding: 'verticaal', trasraam, rabatColor: null, fels: fc.fels }; return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor, fels: null }; } 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; } // Model Reest (examples/ontwerp_reest.pdf): 12,9 × 7,85 m. Hoofdvolume 11,5 × 4,8 m onder één lessenaarsdak (hoog // aan de noordgevel) dat aan de westkop 1,4 m doorloopt als overkapping bij de voordeur, aan de zuidkant dicht met // een witte zijwand (PostModel sideWall, geen palen). 4,8 m is te diep voor één unit: twee units (3,4 + 1,4 m, de // naad ten zuiden van het glas in de westgevel) met elk een lessenaar in hetzelfde vlak. Aan de noordwestkant een // aanbouw van 3,89 × 3,05 m met plat dak (toilet, berging, entree), die met een open doorgang op de woonkamer // uitkomt; de voordeur zit onder de overkapping. Zuidgevel en dak in felsplaten (daar geen boeideel: de dakplaat // zet om, en de felsplaten lopen over de zijwand door), de rest verticale rabat; de aanbouw heeft daktrim. De // aanbouw is de eerste unit, zodat alle maten buitenwerks vanaf zijn noordwesthoek gelden, zoals de tekening. // De tekening heeft de zuidgevel van de aanbouw 240 mm zuidelijker (3290); in de configurator ligt hij op de naad. function createReest() { const st = newModelState('Reest'); const S = st.settings, HA = 2800, HN = 3500, HM = 2820, HS = 2540, DN = 3400, DS = 1400, V = 1400, FELS = 'antraciet'; Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS }); S.cladding = 'verticaal'; S.trasraam.enabled = false; Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 300 }); S.roof.posts = PostModel.normalizeSettings({ enabled: true, sideWall: true, sideColor: 'wit', maxSpan: 6000 }); 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: 3890, depth: 3050, height: HA, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: V }, { width: 11500, depth: DN, height: HM, windowOn: [] }); const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 11500, depth: DS, height: HS, windowOn: [] }); st.units.push(A, B, C); UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: HM, high: HN }); UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'voor', low: HS, high: HM }); [B, C].forEach(u => { u.roof.covering = 'fels_' + FELS; u.roof.extend = { f: 0, r: 0, b: 0, l: V }; }); A.roof.fascia = false; C.facade.walls.achter = { type: 'fels', fels: FELS }; [A, B, C].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.lighting.walls[side] = 'geen'; })); B.lighting.walls.links = 'openingen'; const { K, binnen, ops } = modelKit(st, HS, 90); // Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' = vanaf het noordeind van de wand. ops(A.unit.walls.voor, [['raam', 500, 700, 1400, 'right', 475, K('raam')], ['raam', 800, 1100, 1000, 'right', 2175, K('raam')]]); ops(A.unit.walls.achter, [['voordeur', 1100, 2100, 0, 'left', 230, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]); ops(B.unit.walls.voor, [ ['raam', 600, 2100, 0, 'right', 3390, K('raam')], ['deur', 900, 2100, 0, 'right', 3990, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })], ['deur', 900, 2100, 0, 'right', 4890, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })], ['raam', 600, 2100, 0, 'right', 5790, K('raam')], ['raam', 500, 1400, 700, 'right', 7525, K('raam')], ['raam', 500, 1400, 700, 'right', 9975, K('raam')]]); ops(B.unit.walls.links, [['raam', 1800, 2600, 0, 'left', 1480, K('raam')]]); ops(C.unit.walls.achter, [['raam', 1500, 500, 1600, 'left', 2050, K('raam')], ['raam', 1500, 500, 1600, 'left', 7440, K('raam')]]); ensureSeamWalls(st, 'A'); const seam = st.interiorWalls.find(w => w.seam); if (seam) { // Open doorgang entree → woonkamer (x 1855–3045), gemeten vanaf de binnenkant van de westgevel van B. const tB = WallModel.thicknessOf(WallModel.configFromState(B.unit.walls.links)); seam.wall.openings = [{ id: 1, type: 'deur', w: 1190, h: 2300, y: 0, anchor: 'left', offset: Math.round(1855 - V - tB), fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }]; } const DH = 2115; // Aanbouw: wanden tot het platte dak. Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant. const ann = modelKit(st, HA, 90); ann.iw(1335, 190, 1335, 1690); // toilet | berging ann.iw(190, 1735, 3700, 1735, [ // toilet, berging | entree ['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })], ['deur', 930, DH, 'left', 1260, binnen({ hinge: 'links', swing: 'buiten' })]]); // Hoofdvolume: wanden onder het laagste punt van het dak, slaapkamer en badkamer met een vlak plafond. const main = modelKit(st, HS - 40, 90); main.iw(8715, 3240, 8715, 7660, [['deur', 930, DH, 'left', 1890, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // woonkamer | slaapkamer, badkamer main.iw(8760, 6215, 12710, 6215, [['deur', 930, DH, 'left', 1815, // slaapkamer | badkamer Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'links' })]]); const { room, fx } = main; const plafond = { ceiling: HS - 40 }; room('A', 700, 1000, 'toilet'); room('A', 2500, 1000, 'berging', 'Berging'); room('A', 1800, 2400, 'hal', 'Entree'); room('B', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken'); room('B', 9000, 1500, 'slaapkamer', 'Slaapkamer', plafond); room('C', 9500, 600, 'badkamer', '', plafond); const xB = (x) => x - V, zB = (z) => z - 3050, zC = (z) => z - 3050 - DN; fx('toilet', 'A', 735, 640, 0, { w: 1100 }); fx('boiler', 'A', 3400, 1430, 90); fx('keuken', 'C', xB(3390), zC(7360), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.29, kookplaat: true, kookplaatPlek: 0.71 }); fx('eettafel', 'B', xB(2490), zB(5160), 0, { w: 1000, d: 1000 }, { vorm: 'rond', stoelen: 4 }); fx('bank', 'B', xB(5200), zB(4360), 270, { w: 1600, d: 800 }, { chaise: 'geen' }); fx('salontafel', 'B', xB(6740), zB(4200), 0, { w: 800, d: 800 }, { vorm: 'rond' }); fx('fauteuil', 'B', xB(6940), zB(5600), 90); fx('bed', 'B', xB(10390), zB(4320), 0, { w: 1600, d: 2100 }, { nachtkastjes: false }); fx('kledingkast', 'B', xB(12420), zB(4705), 90, { w: 2900, anchor: 'oost' }); fx('toilet', 'C', xB(9420), zC(7300), 180, { w: 1000 }); fx('wastafel', 'C', xB(10890), zC(7420), 180, { w: 800 }); fx('douche', 'C', xB(12260), zC(6960), 180, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 800 }); computeParts(st, { skip3D: true }); return st; } // Model Vecht: U-vorm 13 × 9,5 m (91,5 m²), de holte (4 m breed) open naar het noorden, de entree aan de // zuidkant (de onderkant van de U). Drie units: slaapvleugel west (4,6 × 9,5 m), tussenlid met entree en // toilet (4 × 3,6 m) en leefvleugel oost (4,4 × 7,6 m); de vleugels verschillen 1,9 m in lengte. De vleugels // hebben een lessenaar die naar de holte afloopt (laag 2800, buiten 3600), het tussenlid een plat dak op // dezelfde 2800: rond de holte één goothoogte. Het dak over de holte is de overkapping van het tussenlid // (Unit B › Overkapping › Noord): 0 = open holte, 2400 (standaard) = over de verandadeuren, 4000 = tot het eind // van de leefvleugel. De rand rust aan beide kanten op een vleugel; h.o.h. 4500, dus zonder palen. Buiten // zwarte verticale rabat, de gevels aan de holte wit stucwerk. Alles buitenwerks vanaf de noordwesthoek. function createVecht() { const st = newModelState('Vecht'); const S = st.settings, LO = 2800, HI = 3600, FELS = 'fels_antraciet'; Object.assign(S.facade, { type: 'rabat', rabatColor: 'zwart', stucColor: 'wit' }); S.cladding = 'verticaal'; S.trasraam.enabled = false; Object.assign(S.roof, { fasciaColor: 'zwart', overhang: 300, fasciaHeight: 300 }); S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 150, wood: 'eiken', maxSpan: 4500 }); 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: 4600, depth: 9500, height: LO, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'rechts', align: 'end', offset: 0 }, { width: 4000, depth: 3600, height: LO, windowOn: [] }); const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'end', offset: 0 }, { width: 4400, depth: 7600, height: LO, windowOn: [] }); st.units.push(A, B, C); UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'links', low: LO, high: HI }); UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'rechts', low: LO, high: HI }); A.roof.covering = C.roof.covering = FELS; B.roof.extend = { f: 2400, r: 0, b: 0, l: 0 }; const hof = { type: 'stuc', stuc: 'wit' }; A.facade.walls.rechts = Object.assign({}, hof); B.facade.walls.voor = Object.assign({}, hof); C.facade.walls.links = Object.assign({}, hof); const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90); // Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de // binnenkant van de noordgevel (z 256 in de unit). Wanden 256 mm, binnenwanden 91 mm. ops(A.unit.walls.voor, [['raam', 1600, 1500, 600, 'right', 2600, K('raam')]]); ops(A.unit.walls.links, [ ['raam', 1600, 500, 1700, 'left', 600, K('raam')], // boven het bed ['raam', 800, 600, 1500, 'left', 4644, K('raam')], // badkamer ['raam', 800, 1400, 700, 'left', 7344, K('raam')]]); // slaapkamer 2 ops(A.unit.walls.rechts, [ ['raam', 1400, 2100, 300, 'right', 944, K('raam')], // slaapkamer → holte ['raam', 1500, 2400, 0, 'right', 3994, K('raam')]]); // gang ops(A.unit.walls.achter, [['raam', 1200, 1400, 700, 'left', 900, K('raam')]]); ops(B.unit.walls.voor, [['raam', 2000, 2400, 0, 'right', 400, K('raam')]]); // zicht vanaf de voordeur op de holte ops(B.unit.walls.achter, [ ['voordeur', 1100, 2100, 0, 'left', 800, K('deur', { hinge: 'links', swing: 'buiten', glas: true })], ['raam', 400, 2100, 0, 'left', 2000, K('raam')], ['raam', 500, 700, 1400, 'left', 3100, K('raam')]]); ops(C.unit.walls.voor, [['raam', 2400, 2100, 300, 'right', 1000, K('raam')]]); // Verandadeuren naar de holte, onder de overkapping. ops(C.unit.walls.links, [ ['deur', 900, 2100, 0, 'left', 1744, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })], ['deur', 900, 2100, 0, 'left', 2644, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]); ops(C.unit.walls.rechts, [ ['raam', 2000, 900, 1900, 'right', 500, K('raam')], // boven de bank ['raam', 1000, 2100, 300, 'right', 2844, K('raam')], ['raam', 1200, 1100, 1000, 'right', 4744, K('raam')]]); ops(C.unit.walls.achter, [['raam', 1400, 1000, 1100, 'left', 1400, K('raam')]]); // boven de spoelbak const DH = 2115; // Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant. iw(256, 3902, 4344, 3902, [ // slaapkamer | badkamer, gang ['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'buiten' })], ['deur', 930, DH, 'left', 3124, binnen({ hinge: 'rechts', swing: 'binnen' })]]); iw(3300, 3948, 3300, 9244, [['deur', 930, DH, 'left', 2452, binnen({ hinge: 'links', swing: 'buiten' })]]); // badkamer, slaapkamer 2 | gang, techniek iw(256, 6293, 3254, 6293, [['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // badkamer | slaapkamer 2 iw(3346, 7405, 4500, 7405, [['deur', 800, DH, 'middle', 0, binnen({ hinge: 'links', swing: 'buiten' })]]); // gang | techniek iw(7260, 7555, 8500, 7555); // hal | toilet iw(7305, 7601, 7305, 9244, [['deur', 850, DH, 'left', 100, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet → hal ensureSeamWalls(st); const seamOf = (a, b) => st.interiorWalls.find(w => w.seam && sameSeam(w.seam, a, b)); // Noord–zuid-naadwand: 'left' = vanaf de binnenkant van de zuidgevel (z 9244). Openingen bij de noordkant van de hal. const sAB = seamOf('A', 'B'), sBC = seamOf('B', 'C'); if (sAB) sAB.wall.openings = [{ id: 1, type: 'deur', w: 1000, h: 2300, y: 0, anchor: 'left', offset: 1994, // open doorgang hal → gang (z 6250–7250) fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }]; if (sBC) sBC.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: 2014, // hal → keuken (z 6300–7230) fill: binnen({ hinge: 'rechts', swing: 'binnen', glas: true }) }]; const plafond = { ceiling: LO }; room('A', 2000, 1500, 'slaapkamer', 'Slaapkamer', plafond); room('A', 2300, 5100, 'badkamer'); room('A', 1500, 8800, 'slaapkamer', 'Slaapkamer 2', plafond); room('A', 3900, 5000, 'hal', 'Gang', plafond); room('A', 3900, 8000, 'wasruimte', 'Techniek/was', plafond); room('B', 1200, 1500, 'hal', 'Entree'); room('B', 3325, 2400, 'toilet'); room('C', 2000, 3500, 'woonkeuken', 'Woonkamer/keuken'); const xB = (x) => x - 4600, zB = (z) => z - 5900, xC = (x) => x - 8600, zC = (z) => z - 1900; fx('bed', 'A', 1306, 1656, 270, { w: 1600, d: 2100 }, { nachtkastjes: false }); fx('kledingkast', 'A', 1206, 3556, 180, { w: 1800 }); fx('douche', 'A', 856, 4398, 0, { w: 1200, d: 900 }, { glas: 'rechts', glasLengte: 900 }); fx('toilet', 'A', 601, 5250, 270); fx('wastafel', 'A', 900, 6017, 180, { w: 1000 }); fx('bed', 'A', 1300, 7339, 0, { w: 1400, d: 2000 }, { nachtkastjes: false }); fx('kledingkast', 'A', 2954, 8300, 90, { w: 1800 }); fx('wasmachine', 'A', 3700, 8944, 180); fx('boiler', 'A', 4200, 9019, 180); fx('toilet', 'B', xB(7925), zB(8899), 180); fx('fontein', 'B', xB(7925), zB(7726), 0); fx('keuken', 'C', xC(10944), zC(8944), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'links', spoelbak: 0.57, kookplaat: false }); fx('kookeiland', 'C', xC(10800), zC(7094), 0, { w: 2400, d: 900 }, { front: 'antraciet', kookplaat: true }); fx('eettafel', 'C', xC(10500), zC(5300), 0, { w: 1800, d: 1000 }, { vorm: 'ovaal', stoelen: 6 }); fx('bank', 'C', xC(12294), zC(3656), 90, { w: 2400, d: 900 }, { chaise: 'geen' }); fx('salontafel', 'C', xC(11200), zC(3500), 0, { w: 800, d: 800 }, { vorm: 'rond' }); fx('fauteuil', 'C', xC(10300), zC(2850), 45); fx('tvmeubel', 'C', xC(9056), zC(3100), 270, { w: 1400 }); 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 }, reest: { label: 'Woning Reest', icon: 'house-up', create: createReest }, vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht } }; 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; }); // Geen wolfsdak boven een overkapping: wordt een zadeldak met dezelfde goot en nok. if (houseRoofOn(state)) { if (state.settings.roof.house.form.type === 'wolf' && state.units.some(u => roofCovers(u, cover))) state.settings.roof.house.form.type = 'zadel'; } else state.units.forEach(u => { const f = UnitModel.roofForm(u.unit); if (f.type === 'wolf' && roofCovers(u, cover)) UnitModel.applyRoofForm(u.unit, Object.assign(f, { type: 'zadel' })); }); applyHouseRoof(state); applyRoofJoins(state); } // Overkapping (veranda, inpandig of uitpandig): het dak van de unit steekt buiten zijn vloer. Een vrije dakmaat // telt pas vanaf de kleinste paal-uitkraging; daaronder is het overstek. function roofCovers(u, cover) { if (!u.roof.followFloor) { const d = u.roof.deck.floor, f = u.unit.floor.floor, m = 400; return (d.width - f.width) / 2 + Math.abs(u.roof.offsetX || 0) > m || (d.depth - f.depth) / 2 + Math.abs(u.roof.offsetZ || 0) > m; } const c = cover[u.id] || {}, x = u.roof.extend || {}; return ['l', 'r', 'f', 'b'].some(k => (c[k] || 0) + (x[k] || 0) > EPS); } // Overkapping onder het dak van de unit (unitId) of ergens onder de woning (geen unitId): dan geen wolfsdak. function hasCover(state, unitId) { const cover = coverExtensions(state); return state.units.some(u => (!unitId || u.id === unitId) && roofCovers(u, cover)); } 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, fels, endCaps) { 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, fels, endCaps, 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 fels = buitenMateriaal === 'vochtdoek' ? (m.fels || 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, fels, 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; }); })); }); // Gevelstuk onder een dakrand (grootste overlap langs de rand); dot < 0 = gevel van een hoger deel ertegenover. const edgePiece = (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 = null, 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 = { p, dot }; }); return best; }; const facadeDepth = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : null; // null: geen gevel (veranda) }; const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); }; 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, covering: u.roof.covering, topAt: u.roof.topAt, ridges: u.roof.ridges })), facadeDepth, facadeFels) : 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); // Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen. if (!sg || (!sg.fascia && !sg.fels)) return; p.battenAbove = (p.skinAbove || 0) + sg.above - RoofModel.edgeHeight(roofEdge, sg); }); // Vrije uiteinden van binnenwanden: vlak voorbij het eind geen andere wand. const allPieces = extPieces.concat(interior); interior.forEach(p => { if (p.buitenMateriaal !== 'spaanplaat') return; const W = p.place.width / 2, against = (q) => allPieces.some(o => o !== p && pointInPoly(q, [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([i, j]) => UnitModel.localToWorld(o.place, i * o.place.width / 2, j * o.place.thickness / 2)))); p.endCaps = { left: !against(UnitModel.localToWorld(p.place, -W - 15, 0)), right: !against(UnitModel.localToWorld(p.place, W + 15, 0)) }; }); 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, p.fels, p.endCaps); 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.finishedOnly ? false : o.elektraRough) : null; if (elektraGroup) elektraGroup.visible = shows('elektra'); // Zijwand van een overkapping in het vlak van een gevel: die bekleding loopt erover door. const postOutside = (w, out) => { const line = out.x > 0 ? w.x1 : out.x < 0 ? w.x0 : out.z > 0 ? w.z1 : w.z0; const p = extPieces.find(pc => { const n = { x: pc.place.normal.x, z: pc.place.normal.y }, q = UnitModel.localToWorld(pc.place, 0, pc.place.thickness / 2); return n.x * out.x + n.z * out.z > 0.5 && Math.abs((out.x ? q.x : q.z) - line) < 2 && !pc.place.baseY; }); if (!p || (p.buitenMateriaal === 'cementplaat' && !p.stone && !p.stuc)) return null; const kind = p.fels ? 'fels' : p.stone ? 'steen' : p.stuc ? 'stuc' : 'rabat'; return { kind, depth: WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), finish: { fels: p.fels, stone: p.stone, stuc: p.stuc, rabatColor: kind === 'rabat' ? state.settings.facade.rabatColor : null, cladding: p.cladding } }; }; const posts = PostModel.compute(state.settings.roof.posts, units, groundY, postOutside); 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'); const parts = { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup }; if (build && o.finishedOnly) keepFinished(parts, o.wallMaterials); return parts; } // finishedOnly (render-export): alleen wat in de afgewerkte woning te zien is. Naast de buitenste vloerbalken en geen // elektraleidingen (hierboven): geen isolatie, latten en vochtdoek achter de gevel, geen lichtkegels, valse plafonds altijd. function keepFinished(parts, wallMaterials) { if (wallMaterials) allWallPieces(parts).forEach(p => ViewLayers.hideByMaterial(p.group, [wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial])); if (parts.lightingGroup) parts.lightingGroup.traverse(x => { if (x.userData.beam) x.visible = false; }); if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = true; } // Doorsnede: witte afdekking van de wanden. Hij volgt de bovenkant van elke wand, afgetopt op de snijhoogte cutAbs // (mm, absoluut): op de snede vlak en onderbroken door de openingen, daaronder over de wandtop (ook schuin), zodat // een wand die niet tot de snede reikt ook wit afgedekt is. Met gevelbekleding vanaf de buitenkant daarvan (latwerk // is open); zonder beplating tussen de platen. De afdekking ligt CAP_LIFT_MM boven wand of snede: alles wat daar // eindigt (regelwerk, platen) valt eronder en flikkert niet mee (dieptebuffer op afstand). const CAP_LIFT_MM = 1; function sectionCaps(parts, cutAbs, material, layers) { const toM = 0.001, g = new THREE.Group(); const S = WallModel.STONE; const gevel = ViewLayers.shows(layers, 'gevel'), beplating = ViewLayers.shows(layers, 'beplating'); allWallPieces(parts).forEach(p => { const y = cutAbs - p.place.floorTop; if (y <= 0) return; const W = p.place.width, T = p.place.thickness, tl = WallModel.topLine(p.config); const topAt = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))); const hAt = (x) => Math.min(y, topAt(x)); const outAt = (h) => { if (!gevel || !p.place.normal || p.buitenMateriaal === 'geen') return 0; const tr = p.trasraam; return tr && h < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc); }; const sh = beplating ? 0 : p.config.sheathingThick; const e = p.skinExt || {}, out0 = outAt(hAt(0)); const xL = -W / 2 - (e.left > 0 ? e.left + out0 : 0), xR = W / 2 + (e.right > 0 ? e.right + out0 : 0); // Knikpunten: de wandtop en waar die door de snijhoogte gaat; op de snede de openingen eruit. const xs = [...new Set([xL, xR, ...tl.pts.map(q => q.x), ...tl.crossings(y)].filter(x => x >= xL && x <= xR))].sort((a, b) => a - b); const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean) .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]); const spans = []; for (let i = 0; i < xs.length - 1; i++) { const a = xs[i], b = xs[i + 1]; if (b - a <= 1) continue; const onCut = topAt((a + b) / 2) >= y - 0.5; if (!onCut) { spans.push([a, b]); continue; } let cur = a; gaps.forEach(([g0, g1]) => { if (g1 <= cur || g0 >= b) return; if (g0 > cur) spans.push([cur, g0]); cur = Math.max(cur, g1); }); if (b > cur) spans.push([cur, b]); } const pos = []; spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => { const ha = hAt(a) + CAP_LIFT_MM, hb = hAt(b) + CAP_LIFT_MM, out = outAt((ha + hb) / 2); const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0); // Twee driehoeken, normaal omhoog. pos.push(a, ha, z0, a, ha, z1, b, hb, z1, a, ha, z0, b, hb, z1, b, hb, z0); }); if (!pos.length) return; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos.map(v => v * toM), 3)); const m = new THREE.Mesh(geo, material); m.rotation.y = p.place.rotY; m.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); g.add(m); }); return g; } // 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); } // Alle 3D-groepen van de woning (na placeGroups), bijv. voor een scène of een export. function rootGroups(parts) { return parts.units.map(u => u.floorGroup).concat(allWallPieces(parts).map(p => p.group), parts.units.map(u => u.roof.group), [parts.roofEdgeGroup, parts.postsGroup, parts.chimneysGroup, parts.sandwichGroup, parts.lightingGroup, parts.elektraGroup, parts.roomsGroup, parts.fixturesGroup]).filter(Boolean); } // Oorsprong voor exports (m): maaiveld onder het midden van de units. function groundOrigin(st, parts) { const rs = parts.rects; 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 ground = Math.max(...parts.units.map(u => u.layout.floorTop)) - (st.settings.groundLevel || 0); return { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 }; } 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) }, trasraamCap: { lengthM: sumP(p => p.stats.trasraamCapM || 0), color: (WallModel.TRIM_COLORS[(pieces.find(p => p.stats.trasraamCapM > 0 && p.trasraam) || { trasraam: {} }).trasraam.capColor] || {}).label || '' }, 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 }, { naam: 'Felsplaten gevel (staande naad)', m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0), m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0) * RoofModel.FELS.kgM2 } ], 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 || parts.posts.walls.length ? { 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, walls: parts.posts.walls.length, wallM2: parts.posts.wallM2, outsideM2: parts.posts.outsideM2 } : 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, ventLengthM: (parts.roofEdge.ventLengthMM || 0) / 1000, ventColor: parts.roofEdge.vent ? (WallModel.TRIM_COLORS[parts.roofEdge.ventColor] || {}).label || '' : '', 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; if (!WallModel.isFelsColor(state.settings.facade.felsColor)) state.settings.facade.felsColor = def.facade.felsColor; 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'; if (!WallModel.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet'; 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, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX, computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize }; })();