1460 lines
84 KiB
JavaScript
1460 lines
84 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
|
||
window.HouseModel = (() => {
|
||
const EPS = 0.5;
|
||
|
||
const ALIGN_LABELS = {
|
||
x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' },
|
||
z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' }
|
||
};
|
||
const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' };
|
||
|
||
const DEFAULT_INTERIOR_THICKNESS = 100;
|
||
|
||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||
|
||
function createEmptyProject() {
|
||
return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' };
|
||
}
|
||
|
||
function nextUnitId(state) {
|
||
const used = new Set(state.units.map(u => u.id));
|
||
for (let i = 0; i < 26; i++) {
|
||
const id = String.fromCharCode(65 + i);
|
||
if (!used.has(id)) return id;
|
||
}
|
||
return 'U' + (state.units.length + 1);
|
||
}
|
||
|
||
function createDefaultSettings() {
|
||
return {
|
||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit' },
|
||
cladding: 'horizontaal',
|
||
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
|
||
interiorPaint: true,
|
||
interiorColor: '#f2efe9',
|
||
ceilingColor: '#f4f3ef',
|
||
sandwich: true,
|
||
groundLevel: 0, // mm onder het loopvlak
|
||
floorFinish: 'visgraat_001',
|
||
roof: RoofModel.normalizeSettings(),
|
||
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS),
|
||
elektra: ElektraModel.normalizeSettings()
|
||
};
|
||
}
|
||
|
||
function createUnitEntry(id, attach, unitOpts) {
|
||
const unit = UnitModel.createDefaultState(unitOpts);
|
||
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
|
||
}
|
||
|
||
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk' };
|
||
function facadeFor(state, u, side, piece) {
|
||
const f = state.settings.facade;
|
||
const ow = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
|
||
const o = piece && FACADE_TYPES[piece.type] ? piece : ow;
|
||
const type = o && FACADE_TYPES[o.type] ? o.type : f.type;
|
||
const stone = o && o.type === 'steen' && WallModel.isStoneFinish(o.stone) ? o.stone : f.stone;
|
||
const stuc = o && o.type === 'stuc' && WallModel.isStucColor(o.stuc) ? o.stuc : f.stucColor;
|
||
return { type, stone, stuc, override: !!o };
|
||
}
|
||
function facadeMaterials(state, fc) {
|
||
const tr = state.settings.trasraam;
|
||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
||
const cladding = state.settings.cladding;
|
||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null };
|
||
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null };
|
||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
|
||
}
|
||
|
||
function newModelState(naam) {
|
||
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
|
||
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) => {
|
||
const wall = interiorWallDefaults(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) =>
|
||
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }));
|
||
// 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 });
|
||
|
||
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 });
|
||
|
||
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;
|
||
}
|
||
|
||
// 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 }
|
||
};
|
||
|
||
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) {
|
||
const e = cover[u.id] || { l: 0, r: 0, f: 0, b: 0 };
|
||
u.roof.deck.floor.width = u.unit.floor.floor.width + e.l + e.r;
|
||
u.roof.deck.floor.depth = u.unit.floor.floor.depth + e.f + e.b;
|
||
u.roof.offsetX = (e.r - e.l) / 2;
|
||
u.roof.offsetZ = (e.b - e.f) / 2;
|
||
}
|
||
});
|
||
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
||
applyHouseRoof(state);
|
||
}
|
||
|
||
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 === 'mansarde' && ln.length === 5) Object.assign(form, { kneeIn: Math.round(ln[1].u), kneeH: Math.round(ln[1].h) });
|
||
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;
|
||
});
|
||
const wall = u.unit.walls[wl.side].wall;
|
||
const hL = Math.round(pts[0].h), hR = Math.round(pts[pts.length - 1].h);
|
||
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;
|
||
});
|
||
});
|
||
}
|
||
|
||
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 stage = BuildStages.clamp(o.stage);
|
||
const shows = (key) => BuildStages.shows(stage, key);
|
||
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);
|
||
|
||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc) {
|
||
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,
|
||
painted: state.settings.interiorPaint && shows('afwerking'), stage, 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 ws = u.unit.walls[wl.side];
|
||
const wp = UnitModel.wallPieces(wl, ws, raised);
|
||
const pieces = wp.pieces.map(p => {
|
||
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
|
||
const base = p.baseY || 0;
|
||
const own = UnitModel.splitFacadeAt(wl, p.u0, p.u1);
|
||
const m = own ? facadeMaterials(state, facadeFor(state, u, wl.side, own)) : (ws.materials || {});
|
||
const buitenMateriaal = m.exterior || 'vochtdoek';
|
||
const cladding = m.cladding || 'horizontaal';
|
||
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
|
||
const stuc = buitenMateriaal === 'cementplaat' && !stone ? (m.stuc || null) : null;
|
||
const trasraam = buitenMateriaal === 'vochtdoek' || stone || stuc ? (m.trasraam || null) : null;
|
||
return Object.assign(p, {
|
||
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
||
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${p.suffix})` : ''),
|
||
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, facadeOwn: own,
|
||
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop + base, baseY: base }
|
||
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
|
||
});
|
||
const cutsU = [];
|
||
wl.cutsU.forEach(([a, b]) => {
|
||
const r = pieces.find(p => p.baseY && p.u0 < b - EPS && p.u1 > a + EPS);
|
||
if (!r) { cutsU.push([a, b]); return; }
|
||
if (r.u0 > a + EPS) cutsU.push([a, r.u0]);
|
||
cutsU.push([Math.max(a, r.u0), Math.min(b, r.u1), r.baseY]);
|
||
if (r.u1 < b - EPS) cutsU.push([r.u1, b]);
|
||
});
|
||
return Object.assign({}, wl, { cutsU, pieces, dropped: wp.dropped, removed: pieces.length === 0 });
|
||
}
|
||
|
||
const units = state.units.map(u => {
|
||
const rect = rectById[u.id];
|
||
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
|
||
const rims = { front: cuts[u.id].voor.length > 0, back: cuts[u.id].achter.length > 0 };
|
||
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
||
materials: o.floorMaterials, osbVisible: shows('beplating'),
|
||
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
||
}) : null;
|
||
|
||
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 ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
|
||
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
|
||
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', 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('gevel'),
|
||
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
||
}) : null;
|
||
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, 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 })
|
||
};
|
||
});
|
||
}
|
||
|
||
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 pts = [...xs].sort((a, b) => a - b).map(x => {
|
||
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.underRoof = covered !== null;
|
||
p.config.topWedge = topWedge(p, u.roof);
|
||
// Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop).
|
||
const e = p.skinExt || {}, W = p.place.width;
|
||
p.trimTop = {};
|
||
[['left', -1], ['right', 1]].forEach(([k, d]) => {
|
||
if (!(e[k] < 0)) return;
|
||
const q = UnitModel.localToWorld(p.place, d * (W / 2 + e[k] / 2), p.place.thickness / 2 + 20);
|
||
const nb = units.find(o => o.id !== u.id && q.x > o.rect.x0 && q.x < o.rect.x1 && q.z > o.rect.z0 && q.z < o.rect.z1);
|
||
p.trimTop[k] = nb ? Math.round(nb.roof.topAt(q.x, q.z) - p.place.floorTop) : 0;
|
||
});
|
||
}));
|
||
});
|
||
const facadeDepth = (a, b, nrm) => {
|
||
const horiz = Math.abs(nrm.z) > 0.5;
|
||
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
|
||
let best = 0, bestOv = 1;
|
||
extPieces.forEach(p => {
|
||
const pn = { x: p.place.normal.x, z: p.place.normal.y };
|
||
const dot = pn.x * nrm.x + pn.z * nrm.z;
|
||
if (Math.abs(dot) < 0.5) return;
|
||
const q0 = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2), q1 = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
|
||
if (Math.abs((horiz ? q0.z : q0.x) - line) > 2) return;
|
||
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
|
||
if (ov <= bestOv) return;
|
||
bestOv = ov;
|
||
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||
});
|
||
return best;
|
||
};
|
||
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||
units.map(u => ({
|
||
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
||
topAt: u.roof.topAt, ridges: u.roof.ridges
|
||
})), facadeDepth) : null;
|
||
const pointInPoly = (q, poly) => {
|
||
let inside = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const a = poly[i], b = poly[j];
|
||
if ((a.z > q.z) !== (b.z > q.z) && q.x < (b.x - a.x) * (q.z - a.z) / (b.z - a.z) + a.x) inside = !inside;
|
||
}
|
||
return inside;
|
||
};
|
||
if (roofEdge) extPieces.forEach(p => {
|
||
const nrm = { x: p.place.normal.x, z: p.place.normal.y };
|
||
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
||
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
|
||
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||
if (!sg) return;
|
||
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
|
||
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
|
||
});
|
||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||
extPieces.concat(interior).forEach(p => {
|
||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc);
|
||
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 lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
||
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
||
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
|
||
}) : 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 = o.lampenVisible !== false && shows('afwerking');
|
||
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
|
||
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
|
||
|
||
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('afwerking');
|
||
const fixtures = FixtureModel.compute(state.fixtures || [], units);
|
||
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
||
if (fixturesGroup) fixturesGroup.visible = shows('afwerking');
|
||
|
||
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, rooms, roomsGroup, fixtures, fixturesGroup };
|
||
}
|
||
|
||
function allWallPieces(parts) {
|
||
const ext = [];
|
||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||
return ext.concat(parts.interior);
|
||
}
|
||
|
||
function computeDimensions(parts, groundLevel) {
|
||
const m2 = (mm2) => mm2 / 1e6;
|
||
const rects = parts.rects;
|
||
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
||
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
|
||
const floorMM2 = rects.reduce((a, r) => a + r.W * r.D, 0);
|
||
const ext = [], all = allWallPieces(parts);
|
||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||
// baseY = opbouw boven een lager dak: geen voetafdruk.
|
||
const footprintMM2 = all.filter(p => !p.place.baseY).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
|
||
const wallArea = (p, y0) => {
|
||
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
|
||
const poly = [{ x: -W / 2, y: y0 }, { x: W / 2, y: y0 }, ...tl.pts.slice().reverse()];
|
||
return Math.max(0, polygonArea(poly)) + (p.skinAbove || 0) * W;
|
||
};
|
||
const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + WallModel.openingArea(op), 0);
|
||
const facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined));
|
||
const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0);
|
||
const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0);
|
||
const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0);
|
||
const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0);
|
||
const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0) + (u.roof.specs.felsM2 || 0), 0);
|
||
const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
|
||
const inhoudMM3 = parts.units.reduce((a, u) => {
|
||
const r = u.rect, n = 8;
|
||
let sum = 0;
|
||
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) sum += u.roof.topAt(r.x0 + (i + 0.5) * r.W / n, r.z0 + (j + 0.5) * r.D / n);
|
||
return a + r.W * r.D * (sum / (n * n));
|
||
}, 0);
|
||
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||
const top = parts.roofEdge ? parts.roofEdge.top : Math.max(...parts.units.map(u => u.roof.top));
|
||
return {
|
||
lengthX: bx1 - bx0, lengthZ: bz1 - bz0, height: top - (floorTop - (groundLevel || 0)),
|
||
brutoM2: m2(floorMM2), leefbaarM2: m2(floorMM2 - footprintMM2),
|
||
gevelBrutoM2: m2(gevelBruto), gevelOpeningM2: m2(gevelOpen), gevelNettoM2: m2(gevelBruto - gevelOpen), glasM2: m2(glas),
|
||
binnenwandM2: binnen, dakM2, dakPlanM2: m2(dakPlanMM2), inhoudM3: inhoudMM3 / 1e9
|
||
};
|
||
}
|
||
function dimensionRows(d, fmt) {
|
||
return [
|
||
['Buitenmaten (b × d)', `${Math.round(d.lengthX)} × ${Math.round(d.lengthZ)}`, 'mm'],
|
||
['Bouwhoogte (vanaf maaiveld)', Math.round(d.height), 'mm'],
|
||
['Bruto vloeroppervlak (buitenwerks)', fmt(d.brutoM2, 2), 'm²'],
|
||
['Leefbare ruimte (netto vloer)', fmt(d.leefbaarM2, 2), 'm²'],
|
||
['Gevel buitenkant (bruto)', fmt(d.gevelBrutoM2, 2), 'm²'],
|
||
['Openingen in gevel', fmt(d.gevelOpeningM2, 2), 'm²'],
|
||
['Gevel buitenkant (netto)', fmt(d.gevelNettoM2, 2), 'm²'],
|
||
['Glas (ramen)', fmt(d.glasM2, 2), 'm²'],
|
||
['Wanden binnenkant (alle)', fmt(d.binnenwandM2, 2), 'm²'],
|
||
['Dak (dakoppervlak)', fmt(d.dakM2, 2), 'm²'],
|
||
['Dak (plattegrond)', fmt(d.dakPlanM2, 2), 'm²'],
|
||
['Inhoud (bruto)', fmt(d.inhoudM3, 1), 'm³']
|
||
];
|
||
}
|
||
|
||
// Breedteklassen indicatief (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing, daarboven begeleiding.
|
||
const TRANSPORT = { widthRegular: 2550, widthPermit: 3500 };
|
||
const TRANSPORT_CLASSES = { regulier: 'Regulier', ontheffing: 'Ontheffing', begeleiding: 'Ontheffing + begeleiding' };
|
||
function computeTransport(parts) {
|
||
const kgOf = (p) => (p.specs && p.specs.totalKg) || 0;
|
||
return parts.units.map(u => {
|
||
const leaves = parts.interior.filter(p => p.seamUnit === u.id);
|
||
const kg = {
|
||
vloer: u.floorSpecs.totalKg,
|
||
wanden: u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + kgOf(p), 0), 0) + leaves.reduce((a, p) => a + kgOf(p), 0),
|
||
dak: u.roof.specs.totalKg
|
||
};
|
||
const out = (side) => Math.max(0, ...((u.walls.find(w => w.side === side) || { pieces: [] }).pieces
|
||
.filter(p => !p.baseY)
|
||
.map(p => Math.max(WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc),
|
||
p.trasraam && WallModel.isStoneFinish(p.trasraam.finish) ? WallModel.STONE.THICK_MM : 0))));
|
||
const ew = u.rect.W + out('links') + out('rechts'), ed = u.rect.D + out('voor') + out('achter');
|
||
const width = Math.min(ew, ed);
|
||
return {
|
||
id: u.id, kg, totalKg: kg.vloer + kg.wanden + kg.dak,
|
||
length: Math.max(ew, ed), width, height: u.roof.top,
|
||
cls: width <= TRANSPORT.widthRegular ? 'regulier' : width <= TRANSPORT.widthPermit ? 'ontheffing' : 'begeleiding'
|
||
};
|
||
});
|
||
}
|
||
|
||
function computeTotals(parts) {
|
||
const pieces = allWallPieces(parts);
|
||
const kinds = new Map();
|
||
const sawList = new Map();
|
||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||
const saw = { lengthMM: 0, cuts: 0, angles: new Map() };
|
||
function addPiece(kind, width, depth, length, cutAngles) {
|
||
const ca = cutAngles || [0, 0];
|
||
saw.lengthMM += length;
|
||
saw.cuts += ca.length;
|
||
ca.forEach(a => saw.angles.set(a, (saw.angles.get(a) || 0) + 1));
|
||
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
|
||
k.count++; k.lengthMM += length;
|
||
kinds.set(kind, k);
|
||
const L = Math.round(length);
|
||
const key = `${width}|${depth}|${L}`;
|
||
const g = sawList.get(key) || { width, depth, length: L, count: 0 };
|
||
g.count++;
|
||
sawList.set(key, g);
|
||
}
|
||
parts.units.forEach(u => {
|
||
const fc = u.floorData.calcData;
|
||
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.joistLen));
|
||
['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
|
||
u.roof.planes.forEach(pl => {
|
||
const rc = pl.data.calcData;
|
||
const ang = Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10;
|
||
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
|
||
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
|
||
});
|
||
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
||
if (cap) cap.beams.forEach(bm => addPiece('dakbalk', cap.joistW, cap.joistH, bm.length, [bm.angle, bm.angle]));
|
||
});
|
||
const wallBom = [];
|
||
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); }));
|
||
const purchase = WallModel.planPurchase(wallBom);
|
||
|
||
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
|
||
const stonePiece = pieces.find(p => p.trasraam && WallModel.isStoneFinish(p.trasraam.finish));
|
||
const stoneCode = stonePiece ? stonePiece.trasraam.finish : null;
|
||
const stoneCodes = [...new Set(pieces.filter(p => p.stone).map(p => p.stone))].join(', ');
|
||
const sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0);
|
||
const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat);
|
||
|
||
const weights = parts.units.map(u => ({
|
||
label: `Unit ${u.id}`, element: u.id,
|
||
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
|
||
}));
|
||
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
|
||
|
||
const isolatie = parts.units.map(u => ({
|
||
label: `Unit ${u.id}`, element: u.id,
|
||
m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0)
|
||
}));
|
||
if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) });
|
||
|
||
const kindList = Array.from(kinds.values());
|
||
return {
|
||
kinds: kindList,
|
||
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
|
||
saw: {
|
||
meters: saw.lengthMM / 1000, cuts: saw.cuts,
|
||
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
|
||
},
|
||
beamCount: kindList.reduce((a, k) => a + k.count, 0),
|
||
purchase,
|
||
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
|
||
purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0),
|
||
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
|
||
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
|
||
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
|
||
isolatie,
|
||
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
|
||
plaatwerk: [
|
||
{
|
||
naam: 'Spaanplaat (wanden)',
|
||
m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2),
|
||
m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3),
|
||
kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||
},
|
||
{ naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) },
|
||
{ naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) },
|
||
{ naam: `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm (bovenop de sporen)`, m2: sumU(u => u.roof.specs.beschotM2 || 0), m3: sumU(u => u.roof.specs.beschotVolM3 || 0), kg: sumU(u => u.roof.specs.beschotKg || 0) },
|
||
{ naam: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 0) },
|
||
{ naam: `Felsplaten (staande naad, ${RoofModel.FELS.thickMM} mm)`, m2: sumU(u => u.roof.specs.felsM2 || 0), m3: null, kg: sumU(u => u.roof.specs.felsKg || 0) },
|
||
{
|
||
naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0),
|
||
m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||
},
|
||
{
|
||
naam: `Steenstrips gevel${stoneCodes ? ' (' + stoneCodes + ')' : ''}`,
|
||
m2: sumP(p => p.stats.stoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
|
||
},
|
||
{
|
||
naam: `Steenstrips trasraam${stoneCode ? ' (' + stoneCode + ')' : ''}`,
|
||
m2: sumP(p => p.stats.trasraamStoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
|
||
},
|
||
{
|
||
naam: `Isolatieblok steen (${WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM} mm)`,
|
||
m2: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)) / ((WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM) / 1000),
|
||
m3: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)), kg: 0
|
||
},
|
||
{
|
||
naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0),
|
||
m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0)
|
||
},
|
||
{
|
||
naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null,
|
||
kg: parts.sandwich ? parts.sandwich.kg : 0
|
||
},
|
||
{ naam: 'Vals plafond (ruimtes)', m2: parts.rooms.rooms.reduce((a, r) => a + r.ceilM2, 0), m3: null, kg: 0 },
|
||
{ naam: 'FIBO-wandpanelen (ruimtes)', m2: parts.rooms.rooms.reduce((a, r) => a + r.fiboM2, 0), m3: null, kg: 0 },
|
||
{
|
||
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
|
||
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||
},
|
||
{
|
||
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
|
||
m3: null, kg: 0 // gewicht volgt
|
||
}
|
||
],
|
||
weights,
|
||
totalKg: weights.reduce((a, r) => a + r.kg, 0),
|
||
nokbalken: parts.units.filter(u => u.roof.unitRoof.ridgeBeam).map(u => {
|
||
const rb = u.roof.unitRoof.ridgeBeam;
|
||
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
|
||
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
|
||
}),
|
||
roofEdge: parts.roofEdge ? {
|
||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||
fasciaHeight: parts.roofEdge.fasciaHeight,
|
||
fasciaColor: (RoofModel.FASCIA_COLORS[parts.roofEdge.fasciaColor] || {}).label || '',
|
||
overhang: parts.roofEdge.overhang,
|
||
soffitM2: parts.roofEdge.soffitM2,
|
||
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
|
||
trimHeight: parts.roofEdge.trimHeight,
|
||
spots: parts.roofEdge.spots.length
|
||
} : null,
|
||
lamps: {
|
||
total: parts.lighting.lamps.length,
|
||
indoor: (parts.lighting.indoor || []).length,
|
||
both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
|
||
down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
|
||
up: parts.lighting.lamps.filter(l => l.direction === 'up').length
|
||
}
|
||
};
|
||
}
|
||
|
||
function validateState(design) {
|
||
if (!Array.isArray(design.units) || !design.units.length) throw new Error('veld "units" ontbreekt of is leeg');
|
||
const ids = new Set();
|
||
design.units.forEach((u, i) => {
|
||
if (!u.id || ids.has(u.id)) throw new Error(`unit #${i + 1}: id ontbreekt of is dubbel`);
|
||
ids.add(u.id);
|
||
if (u.attach && !['voor', 'achter', 'links', 'rechts'].includes(u.attach.side)) throw new Error(`unit ${u.id}: ongeldige koppelzijde`);
|
||
try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); }
|
||
});
|
||
(design.interiorWalls || []).forEach((iw, i) => {
|
||
if (iw.seam) {
|
||
if (!ids.has(iw.seam.a) || !ids.has(iw.seam.b)) throw new Error(`naadwand #${i + 1}: unit ${iw.seam.a} of ${iw.seam.b} bestaat niet`);
|
||
if (iw.seam.in !== undefined && !['a', 'b'].includes(iw.seam.in)) throw new Error(`naadwand #${i + 1}: ongeldige plaats`);
|
||
} else {
|
||
['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); });
|
||
}
|
||
try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); }
|
||
});
|
||
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
|
||
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
|
||
}
|
||
|
||
// 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));
|
||
const def = createDefaultSettings();
|
||
state.settings = state.settings || {};
|
||
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
|
||
if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding;
|
||
// v0.4.15: gevelbekleding per woning; een wand met cementplaat wordt een
|
||
// steen-override (de rest volgt de woning, standaard rabat).
|
||
const hadFacade = !!state.settings.facade;
|
||
state.settings.facade = Object.assign(def.facade, state.settings.facade || {});
|
||
if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat';
|
||
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
||
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
|
||
if (!WallModel.isStucColor(state.settings.facade.stucColor)) state.settings.facade.stucColor = def.facade.stucColor;
|
||
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
|
||
state.settings.sandwich = state.settings.sandwich !== false;
|
||
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
|
||
if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
|
||
state.settings.interiorPaint = state.settings.interiorPaint !== false;
|
||
// v0.4.15: maaiveld standaard gelijk met de bovenkant vloer (was 350 mm eronder).
|
||
if (!(state.settings.groundLevel >= 0) || (!state.settings.floorFinish && state.settings.groundLevel === 350)) state.settings.groundLevel = def.groundLevel;
|
||
if (!FloorModel.FINISHES[state.settings.floorFinish]) state.settings.floorFinish = def.floorFinish;
|
||
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
|
||
state.settings.roof = RoofModel.normalizeSettings(oldRoof);
|
||
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
|
||
const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting;
|
||
const lightIn = Object.assign({}, state.settings.lighting || {});
|
||
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
|
||
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
|
||
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
|
||
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
|
||
state.units.forEach(u => {
|
||
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
||
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.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
|
||
u.facade = { walls: (u.facade && u.facade.walls) || {} };
|
||
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||
if ((ws.materials || {}).exterior === 'cementplaat' && !u.facade.walls[side]) u.facade.walls[side] = { type: 'steen' };
|
||
});
|
||
});
|
||
return state;
|
||
}
|
||
|
||
return {
|
||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions,
|
||
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||
};
|
||
})();
|