v0.4.15: gevelbekleding per woning (rabat/steen, override per wand), stenen trasraam (110 mm, 181 texturen), riet per unit, vloerafwerking, verfkleur binnen, boeideelkleur, gevel tot onder dakrand, binnenhoek-fixes; testontwerp als standaard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 19:47:49 +02:00
co-authored by Claude Opus 5.5
parent 7d91416004
commit 12c2579471
743 changed files with 675 additions and 99 deletions
+129 -19
View File
@@ -60,12 +60,23 @@ window.HouseModel = (() => {
function createDefaultSettings() {
return {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
cladding: 'horizontaal', // richting gevelbekleding, voorlopig voor alle buitenwanden
// Gevelbekleding van alle buitenwanden (override per wand: unit.facade):
// 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' },
cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM },
// finish: 'cementplaat' of een steencode (WallModel.TRASRAAM_STONES).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
// Binnenzijde van alle wanden (voorlopig globaal): geschilderd in
// interiorColor (egaal, zonder plaatnaden) of kale spaanplaat.
interiorPaint: true,
interiorColor: '#f2efe9',
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
groundLevel: 350, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
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)
};
@@ -73,10 +84,35 @@ window.HouseModel = (() => {
function createUnitEntry(id, attach, unitOpts) {
const unit = UnitModel.createDefaultState(unitOpts);
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting() };
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
}
// Gevelbekleding: keuze voor de woning, override per wand.
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen' };
// { type, stone } voor één wand van een unit (u = entry uit state.units).
function facadeFor(state, u, side) {
const f = state.settings.facade;
const o = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
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;
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).
const TEST_DEFAULT = true;
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;
}
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
if (template === 'L') {
@@ -282,11 +318,14 @@ window.HouseModel = (() => {
const sp = state.settings.spacing;
const tr = state.settings.trasraam;
state.units.forEach(u => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height) : null;
Object.values(u.unit.walls).forEach(ws => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
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, { cladding: state.settings.cladding, trasraam });
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 });
});
if (u.roof.followFloor) {
u.roof.deck.floor.width = u.unit.floor.floor.width;
@@ -310,14 +349,22 @@ window.HouseModel = (() => {
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]) });
// Binnenhoek-doorloop (negatieve skinExt, zie trimTop): 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, zodat de gevel één geheel blijft met de rest.
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 = (first.piece.skinExt && first.piece.skinExt.left) || 0;
const extLeft = Math.max(0, (first.piece.skinExt && first.piece.skinExt.left) || 0);
// Horizontaal latten-raster: vanaf het trasraam van een gewoon stuk
// (absolute hoogte), zodat opbouw/naadwand-stukken erop aansluiten.
const ref = run.segs.find(sg => !sg.piece.place.baseY && sg.piece.skinFrom === undefined);
@@ -326,7 +373,7 @@ window.HouseModel = (() => {
run.segs.forEach(sg => {
const p = sg.piece;
sg.piece.facade = {
u0: Math.abs(sg.sAt0 - sLeft) + extLeft, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5,
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
};
@@ -344,10 +391,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) {
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop) {
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,
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
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
@@ -360,7 +408,8 @@ window.HouseModel = (() => {
const ws = u.unit.walls[wl.side];
const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek';
const cladding = (ws.materials || {}).cladding || 'horizontaal';
const trasraam = buitenMateriaal === 'vochtdoek' ? ((ws.materials || {}).trasraam || null) : null;
const stone = buitenMateriaal === 'cementplaat' ? ((ws.materials || {}).stone || null) : null;
const trasraam = buitenMateriaal === 'vochtdoek' || stone ? ((ws.materials || {}).trasraam || null) : null;
const wp = UnitModel.wallPieces(wl, ws, raised);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
@@ -368,7 +417,7 @@ window.HouseModel = (() => {
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,
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone,
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)
});
@@ -394,7 +443,8 @@ 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
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false,
finish: { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 }
}) : null;
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
@@ -643,9 +693,35 @@ window.HouseModel = (() => {
// 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))));
// Gevel tot onder de dakrand: tussen wandbovenkant en onderkant boeideel
// resp. daktrim zit het dakpakket (dek + bedekking, verschilt per dak).
// De buitenlagen van de wand lopen daar door (skinAbove, mm), zodat er
// geen dakrand bloot ligt, bij elke daksoort.
const roofSt = RoofModel.normalizeSettings(state.settings.roof);
const flatTopsPre = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTopPre = flatTopsPre.length ? Math.max(...flatTopsPre) : 0;
units.forEach(u => {
const su = state.units.find(x => x.id === u.id);
const below = su.roof.fascia !== false ? roofSt.fasciaHeight - RoofModel.EDGE_ABOVE_MM : roofSt.trimHeight;
u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
const top = u.roof.shape.type === 'plat' ? flatTopPre : u.roof.topAt(m.x, m.z);
p.skinAbove = Math.max(0, Math.round(top - u.roof.ceilingAt(m.x, m.z) - below));
// 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;
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;
});
}));
});
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);
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;
@@ -725,6 +801,9 @@ window.HouseModel = (() => {
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);
@@ -762,10 +841,24 @@ window.HouseModel = (() => {
},
{ 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: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 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)
@@ -784,6 +877,7 @@ window.HouseModel = (() => {
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,
@@ -837,11 +931,22 @@ window.HouseModel = (() => {
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;
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 (!(state.settings.groundLevel >= 0)) state.settings.groundLevel = def.groundLevel;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
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.
@@ -854,13 +959,18 @@ window.HouseModel = (() => {
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,
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,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize