v0.4.12: dak volgt wandvorm (plat/lessenaar/zadel), dakrand per hoogte, naadwand midden of geheel in unit A/B

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 14:43:42 +02:00
co-authored by Claude Opus 5.5
parent 001206a108
commit 373c2c2fb7
10 changed files with 627 additions and 274 deletions
+126 -51
View File
@@ -303,15 +303,16 @@ window.HouseModel = (() => {
// { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand
// langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl.
// hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk.
function assignFacades(units) {
// pieces: buitenwandstukken + naadwanddelen met een blootliggend buitendeel.
function assignFacades(pieces) {
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
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';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
segs.push({ piece: p, 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;
@@ -333,10 +334,10 @@ 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) {
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade,
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow,
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
@@ -372,21 +373,33 @@ window.HouseModel = (() => {
return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 });
});
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste
// wandbovenkant van deze unit.
const roofData = FloorModel.computeFromState(u.roof.deck, rims);
const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings);
const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => UnitModel.wallTopOf(ws)));
const roofW = roofData.calcData.width, roofD = roofData.calcData.depth;
const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0);
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
// de wanden aangeven (plat, lessenaar of zadel; 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 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 roof = {
element: `${u.id}-D1`, data: roofData, specs: roofSpecs,
element: `${u.id}-D1`, shape, planes: ur.planes, specs: ur.specs,
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 + wallTop,
top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData),
base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
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,
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
// horen bij "Beplating".
group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, {
group: build ? RoofModel.buildUnit3D(ur, {
materials: o.roofMaterials,
osbVisible: o.beplatingVisible !== false,
dakleerVisible: o.beplatingVisible !== false
@@ -399,12 +412,6 @@ window.HouseModel = (() => {
};
});
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk).
assignFacades(units);
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
Object.assign(p, buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade));
})));
// Binnenwanden staan op de vloer van de unit waarin hun midden valt.
// Naadwanden (iw.seam) leveren twee delen op, één per unit (seamLeaves).
const interior = [];
@@ -418,23 +425,36 @@ window.HouseModel = (() => {
const config = WallModel.configFromState(iw.wall);
const frame = WallModel.computeFrame(config);
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
interior.push(Object.assign({
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 }
}, buildWall(config, frame, buitenMateriaal)));
});
});
// Twee delen van een naadwand. Elk deel staat met zijn regelwerk op de
// naad (buitenzijde 'geen' = naadzijde) in zijn eigen unit, en loopt van
// wand tot wand: waar een buitenwandstuk de hartlijn van het deel kruist,
// stopt het (voetafdrukken, zoals bij de gevellampen).
// naad (buitenzijde 'geen' = naadzijde) in zijn eigen unit. Een deel loopt van wand tot wand: waar een buitenwandstuk de hartlijn
// van het deel kruist, stopt het (voetafdrukken, zoals bij de
// gevellampen). De hoogte volgt de daklijn van de buitenwand van de unit
// aan de naadzijde (RoofModel.wallProfile), zodat het deel onder het dak
// van zijn eigen unit past. Steekt een deel boven het dak van de andere
// unit uit (bijv. hoge unit tegen een lage), dan krijgt dat stuk van de
// naadzijde buitenafwerking (vochtdoek + latwerk, skinFrom) die met de
// gevel van de woning meeloopt; 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 []; // units niet (meer) tegen elkaar: naadwand vervalt
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
// Buitenwand van de unit aan de naadzijde (side +1: unit a, lage 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');
// Plaats (iw.seam.in): leeg = midden op de naad (twee delen), 'a'/'b' =
// geheel in die unit: één binnenwand (beplating aan beide zijden) tegen
// de naad aan, met de hoogte van de daklijn van die unit.
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const T2 = WallModel.thicknessOf(baseConfig, 'spaanplaat');
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
const along = (q) => horiz ? q.x : q.z, perpOf = (q) => horiz ? q.z : q.x;
@@ -444,7 +464,9 @@ window.HouseModel = (() => {
const leaves = [];
[[sm.a, 1], [sm.b, -1]].forEach(([uid, side]) => {
// side +1: unit a ligt aan de lage kant, de naad aan zijn +kant.
const line = seamPerp - side * T1 / 2;
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));
@@ -461,18 +483,49 @@ window.HouseModel = (() => {
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: T1,
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)) }, // naadzijde
floorTop: units.find(u => u.id === uid).layout.floorTop
};
leaves.push({ uid, side, place });
});
if (!leaves.length) return [];
// Hoogtes van een deel: daklijn van de buitenwand aan de naadzijde, op
// de uiteinden van het deel (incl. nok als die erin valt).
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 peak = prof.length > 2 ? prof[1] : null;
const hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1;
return {
heightLeft: Math.round(RoofModel.profileHeight(prof, pL)), heightRight: Math.round(RoofModel.profileHeight(prof, pR)),
heightMid: hasPeak ? Math.round(peak.h) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : undefined
};
};
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
// Laagste bovenkant dak van de andere unit langs dit deel (mm vanaf het
// loopvlak van het deel), of null als het deel er niet bovenuit steekt.
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;
};
// Openingen: opgeslagen voor het eerste deel; het andere deel krijgt
// dezelfde openingen op dezelfde plek in de wereld (gespiegeld).
const first = leaves[0];
iw.wall.wall.width = first.place.width; // voor de wand-editor
const firstConfig = WallModel.buildConfig(Object.assign({}, iw.wall.wall, { width: first.place.width }), iw.wall.openings || []);
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 });
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;
@@ -486,32 +539,52 @@ window.HouseModel = (() => {
const u = Math.min(toLocalU(lf.place, wa), toLocalU(lf.place, wb));
return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: Math.round(u) };
});
config = WallModel.buildConfig(Object.assign({}, iw.wall.wall, { width: lf.place.width }), ops);
config = WallModel.buildConfig(paramsOf(lf), ops);
}
const frame = WallModel.computeFrame(config);
const built = buildWall(config, frame, 'geen');
if (built.group) {
// Het regelwerk staat gecentreerd op lokaal z = 0, de beplating
// alleen aan de achterzijde: schuif zodat het midden van de totale
// dikte op place.cx/cz ligt (regelwerk op de naad).
const outer = new THREE.Group();
built.group.position.z = (T1 - maxD) / 2 * 0.001;
outer.add(built.group);
built.group = outer;
}
return Object.assign({
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, buitenMateriaal: 'geen', place: lf.place
}, built);
config, frame, place: lf.place,
// Het regelwerk staat gecentreerd op lokaal z = 0, de beplating
// alleen aan de achterzijde: bij het bouwen schuiven zodat het
// midden van de totale dikte op place.cx/cz ligt (regelwerk op de naad).
// Geheel in één unit: beplating aan beide zijden, dus symmetrisch.
shiftZ: inUnit ? 0 : (T1 - maxD) / 2,
...(skinFrom === null
? { buitenMateriaal: inUnit ? 'spaanplaat' : 'geen' }
: { buitenMateriaal: 'vochtdoek', skinFrom, spaanplaatBelow: !!inUnit, cladding: state.settings.cladding || 'horizontaal', trasraam: 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 = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
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);
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.
// 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;
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 })),
Math.max(...units.map(u => u.roof.top))) : null;
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
}))) : null;
// 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".
@@ -561,9 +634,11 @@ window.HouseModel = (() => {
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));
const rc = u.roof.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
u.roof.planes.forEach(pl => {
const rc = pl.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
});
});
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); }));
+3 -3
View File
@@ -39,7 +39,7 @@ window.HousePdf = (() => {
parts.units.forEach(u => {
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u });
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
});
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
@@ -245,8 +245,8 @@ window.HousePdf = (() => {
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.unit.roof.data.calcData, sheet.unit.roof.data.resolvedOpenings, Object.assign({ roof: true }, opts));
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, lamps: sheet.piece.lamps || [] }, opts));
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
else drawTotalsSheet(doc, totals, opts);
});
return doc;
+19 -8
View File
@@ -759,23 +759,34 @@ function mountHouseEditor(container, callbacks) {
UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after);
}
// Naadwand: twee delen (één per unit), één set openingen en maten.
// Naadwand: midden op de naad (twee delen, één per unit) of geheel in
// unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per
// deel de daklijn van zijn unit.
function renderSeamProps(iw, warn) {
const leaves = parts.interior.filter(x => x.id === iw.id);
const title = `Naadwand ${iw.seam.a} | ${iw.seam.b}`;
propsEl.innerHTML = warn + head('layout-split', title, leaves.length ? `${leaves.length} delen` : 'vervalt') +
const exposed = leaves.filter(l => l.skinFrom !== undefined);
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const plaatsen = { midden: 'Midden op de naad (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
const sheet = iw.wall.wall.sheathingThick;
const dikte = inUnit
? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' })
: num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' });
const uitleg = inUnit
? `Binnenwand geheel in unit ${inUnit}, tegen de naad aan, met beplating aan beide zijden. De hoogte volgt de daklijn van unit ${inUnit}.`
: 'Tussenwand op de naad, per unit een eigen deel dat tegen het andere aan staat. Openingen gelden voor beide delen (de deur loopt door). De hoogte volgt per deel de daklijn van zijn unit.';
propsEl.innerHTML = warn + head('layout-split', title, leaves.length ? (leaves.length === 1 ? '1 deel' : `${leaves.length} delen`) : 'vervalt') +
(leaves.length ? '' : UI.warn(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`)) +
cols(
section('Opbouw', 'layers',
num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' }) +
num('hi-height', 'Hoogte', iw.wall.wall.heightAll)),
section('Opbouw', 'layers', sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') + dikte),
UI.specCard('Delen', 'layout-split', leaves.map(l => [`${l.element} (unit ${l.seamUnit})`, l.place.width, 'mm']))) +
info('Tussenwand op de naad, per unit een eigen deel dat tegen het andere aan staat. Openingen gelden voor beide delen (de deur loopt door).') +
info(uitleg +
exposed.map(l => ` ${l.element} steekt vanaf ${l.skinFrom} mm boven het dak van de andere unit uit; dat stuk krijgt buitenafwerking die met de gevel meeloopt.`).join('')) +
actions(
button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`),
button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger'));
UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after);
UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after);
UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender);
UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after);
}
// Woning-instellingen (niets geselecteerd): alles wat voor de hele woning