v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+205 -67
View File
@@ -64,7 +64,7 @@ window.HouseModel = (() => {
// rabat = vochtdoek + kruislat + rabat (richting: cladding);
// steen = cement-gebonden plaat + steenblok (stone = steencode, WallModel.STONE).
// Het trasraam (eigen afwerking/steen) komt onder beide.
facade: { type: 'rabat', stone: 'K490' },
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel' }, // rabatColor: WallModel.RABAT_COLORS
cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
@@ -78,7 +78,8 @@ window.HouseModel = (() => {
groundLevel: 0, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
floorFinish: 'visgraat_001', // vloerafwerking (FloorModel.FINISHES), voorlopig globaal
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS),
elektra: ElektraModel.normalizeSettings() // meterkast, schakelaars, wcd, leidingen (ElektraModel)
};
}
@@ -98,21 +99,52 @@ window.HouseModel = (() => {
return { type, stone, override: !!o };
}
// TIJDELIJK (testen): een nieuwe woning is standaard het testontwerp:
// L-vorm, unit A lessenaar (noord 2600, zuid 3200) met riet. Op false
// zetten om terug te gaan naar de gewone standaard (één unit).
// TIJDELIJK (testen): een nieuwe woning is standaard het voorbeeldontwerp
// (ook als json/premade/vlinderwoning.json): 92 m², A (straat) en B (tuin)
// met een vlinderdak (lessenaars laag op de naad), C een lage tuinkamer
// met plat dak, veel glas. Op false zetten voor de gewone standaard (één unit).
const TEST_DEFAULT = true;
function createVlinderwoning() {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
st.project.naam = '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';
// Vulling (KozijnModel): deuren met glasstrook, ramen met een
// borstwering (vanaf 900 mm) met ventilatierooster.
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;
}
function createDefaultState(template) {
if (TEST_DEFAULT && (!template || template === 'single')) {
const st = createDefaultState('L');
const A = st.units[0], W = A.unit.walls;
const h = (k, l, r) => Object.assign(W[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
h('achter', 3200, 3200); h('links', 2600, 3200); h('rechts', 3200, 2600);
A.roof.covering = 'riet_001';
computeParts(st, { skip3D: true });
return st;
}
if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning();
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
if (template === 'L') {
@@ -120,12 +152,12 @@ window.HouseModel = (() => {
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
state.units[0].unit.walls.voor.openings = [
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 }
{ 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 },
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 },
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
{ 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') }
];
}
ensureSeamWalls(state);
@@ -276,7 +308,8 @@ window.HouseModel = (() => {
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 }];
// Binnendeur: wit, zonder glas.
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;
}
@@ -325,13 +358,27 @@ window.HouseModel = (() => {
// ------------------------------------------------------------
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
// opts: { wallMaterials, floorMaterials, beplatingVisible, skip3D }
// opts: { wallMaterials, floorMaterials, ..., stage, lampenVisible, skip3D }
// stage = bouwfase (BuildStages, standaard afgewerkt): bepaalt welke lagen
// gebouwd worden; de berekening (stats, totalen) is altijd volledig.
// ------------------------------------------------------------
// Past de woning-brede instellingen toe op de onderdeel-states (muteert
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
// Grenzen (UnitModel.LIMITS) afdwingen: vloermaat, goot/plat dak en nok.
// Muteert state.
function enforceLimits(state) {
const L = UnitModel.LIMITS;
state.units.forEach(u => {
UnitModel.clampSize(u.unit.floor.floor);
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;
const tr = state.settings.trasraam;
enforceLimits(state);
state.units.forEach(u => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
Object.entries(u.unit.walls).forEach(([side, ws]) => {
@@ -339,8 +386,8 @@ window.HouseModel = (() => {
ws.wall.maxSpacing = sp.exterior.max;
const fc = facadeFor(state, u, side);
ws.materials = Object.assign({}, ws.materials, fc.type === 'steen'
? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam }
: { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam });
? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam, rabatColor: null }
: { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam, rabatColor: state.settings.facade.rabatColor });
});
if (u.roof.followFloor) {
u.roof.deck.floor.width = u.unit.floor.floor.width;
@@ -399,6 +446,8 @@ window.HouseModel = (() => {
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 = {};
@@ -406,12 +455,11 @@ window.HouseModel = (() => {
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
const { cuts, seams } = computeSeams(rects, wallThickness);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop) {
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove) {
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,
painted: state.settings.interiorPaint,
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove,
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) };
}
@@ -458,21 +506,22 @@ window.HouseModel = (() => {
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: o.beplatingVisible !== false,
finish: { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 }
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));
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
// de wanden aangeven (plat, lessenaar of zadel; RoofModel.computeShape).
// de wanden aangeven (plat, lessenaar, zadel, mansarde of wolfsdak;
// RoofModel.computeShape).
const shape = 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' }[shape.type];
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}`;
@@ -481,6 +530,7 @@ window.HouseModel = (() => {
});
const roof = {
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs,
unitRoof: ur, // o.a. het bovenste deel van een wolfsdak (cap, RoofModel.capRegions)
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
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
@@ -488,12 +538,11 @@ window.HouseModel = (() => {
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,
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
// horen bij "Beplating".
// Bouwfase: plafondplaat bij "beplating", bedekking bij "gevel".
group: build ? RoofModel.buildUnit3D(ur, {
materials: o.roofMaterials,
osbVisible: o.beplatingVisible !== false,
dakleerVisible: o.beplatingVisible !== false
osbVisible: shows('beplating'),
dakleerVisible: shows('gevel')
}) : null
};
@@ -640,15 +689,17 @@ window.HouseModel = (() => {
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 peak = prof.length > 2 ? prof[1] : null;
// Opbouw erboven (raiseSeamWalls): het deel stopt op de voet daarvan.
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 hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1 && lim(peak.h) > Math.max(hL, hR) + 0.5;
// Tussenpunten (nok, knikken) die in het deel vallen, als fractie vanaf links.
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: hasPeak ? Math.round(lim(peak.h)) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : undefined
heightMid: null, peakX: undefined, topPts: inner.length ? inner : undefined
};
};
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
@@ -669,7 +720,7 @@ window.HouseModel = (() => {
const first = leaves[0];
const firstParams = paramsOf(first);
// Voor de wand-editor: breedte en daklijn van het eerste deel.
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: firstParams.heightMid });
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);
@@ -683,6 +734,7 @@ window.HouseModel = (() => {
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 (andere kijkrichting): 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)) };
});
@@ -706,6 +758,23 @@ window.HouseModel = (() => {
});
}
// Schuine regel op de bovenregel (WallModel, CONFIG.topWedge): helt het
// dak dwars over de wand (goot van een zadel-/lessenaardak, onder een
// wolfseind), dan zit er tussen de vlakke bovenregel en de onderkant van
// het dak een wig. Per positie langs de wand de hoogte van die wig aan
// de buiten- (o) en binnenkant (i) van het regelwerk (mm), of 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;
}
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
const extPieces = [];
@@ -718,7 +787,10 @@ window.HouseModel = (() => {
units.forEach(u => {
u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z)));
// (vanaf de bovenkant van de wand: daar kan nog een schuine regel op)
const wallTop = p.place.floorTop + WallModel.topLine(p.config).at(0);
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
p.config.topWedge = topWedge(p, u.roof);
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
// dak van de buur-unit die daar staat geen buitenlagen (trimTop).
const e = p.skinExt || {}, W = p.place.width;
@@ -731,23 +803,11 @@ window.HouseModel = (() => {
});
}));
});
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
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);
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);
});
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
// boeidelen aan/uit per unit. De rand volgt de dakvorm van de unit waar
// de ribbe bij hoort; platte daken delen één randhoogte (de hoogste).
const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTop = flatTops.length ? Math.max(...flatTops) : 0;
// boeidelen aan/uit per unit. De rand volgt de dakvorm (en hoogte) van
// de unit waar de ribbe bij hoort, ook bij platte daken: een hoogte-
// verschil verspringt, en vanaf de boeideelhoogte krijgt het hoge dak een
// eigen rand en loopt die van het lage door (RoofModel.computeEdge).
// Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt
// erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of
// een gevel van een ander deel die naar dit dak toe kijkt (-).
@@ -771,27 +831,66 @@ window.HouseModel = (() => {
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.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
topAt: u.roof.topAt, ridges: u.roof.ridges
})), facadeDepth) : null;
// Latwerk stopt onder het boeideel/de daktrim die boven het wandstuk
// zit (battenAbove, mm t.o.v. de wandbovenkant, kan negatief zijn): rand
// bovenkant = dak + EDGE_ABOVE, hoogte fasciaHeight resp. trimHeight + EDGE_ABOVE.
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);
// Een paar afstanden vóór de gevel: 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)));
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);
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);
});
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
// Zichtbaarheid: dakrand en lampen in de laatste bouwfase; lampen ook via "Lampen".
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
}) : null;
// Binnenspots in het plafond, per unit (LightingModel.computeIndoor).
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking');
// Elektra: meterkast, schakelaars (letter = ook op hun lampen), wcd's,
// groepen en leidingen naar de meterkast (ElektraModel).
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge);
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
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: o.beplatingVisible !== false
visible: shows('gevel')
}) : null;
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup };
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup };
}
// Alle wandstukken (buiten + binnen) plat op een rij.
@@ -872,8 +971,38 @@ window.HouseModel = (() => {
];
}
// TIJDELIJK (verwijderen voor productie): capaciteit zaagmachine.
const SAW_M_PER_H = 2400;
// ------------------------------------------------------------
// Transport: elke unit gaat als losse module op de wagen (vloer, wanden
// incl. zijn naadwanddelen, dak; zonder dakrand). Buitenmaat incl. de
// gevelafwerking (latwerk + rabat, steen). Breedteklassen indicatief
// (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing,
// daarboven met begeleiding. Controleer altijd bij de vervoerder.
// ------------------------------------------------------------
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
};
// Afwerking buiten het wandvlak per zijde (0 als de wand vervalt).
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.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);
@@ -906,6 +1035,9 @@ window.HouseModel = (() => {
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));
});
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
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); }));
@@ -934,9 +1066,9 @@ window.HouseModel = (() => {
return {
kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
// TIJDELIJK (verwijderen voor productie): zaagmachine, SAW_M_PER_H meter per uur.
// Zaagmachine: totale zaaglengte, sneden en hoeken (tijd: CostModel).
saw: {
meters: saw.lengthMM / 1000, hours: saw.lengthMM / 1000 / SAW_M_PER_H, cuts: saw.cuts,
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),
@@ -986,6 +1118,10 @@ window.HouseModel = (() => {
{
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,
@@ -1053,6 +1189,7 @@ window.HouseModel = (() => {
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;
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;
@@ -1071,6 +1208,7 @@ window.HouseModel = (() => {
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; }
@@ -1086,9 +1224,9 @@ window.HouseModel = (() => {
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, SAW_M_PER_H, // SAW_M_PER_H: tijdelijk
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, validateState, fromUnitDesign, normalize
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();