v0.4.16: PDF-aanzichten (vlakke render) met legenda afwerking en afmetingen, zaaglijst/zaagmachine eigen blad, infopaneel afmetingen, lopen (Shift+T) met camera-menu, zachter licht, naadwanden volgen koppeling, boeideel over de gevel (gevel tot bovenkant dak), thumbnails riet/vloer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 22:29:57 +02:00
co-authored by Claude Opus 5.5
parent 12c2579471
commit 23bfe153a2
26 changed files with 818 additions and 77 deletions
+133 -19
View File
@@ -296,6 +296,21 @@ window.HouseModel = (() => {
});
}
// Naadwanden van één unit bijwerken na een gewijzigde koppeling: naadwanden
// van die unit waarvan de naad niet meer bestaat vervallen, ontbrekende
// naden krijgen er een.
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);
}
// Naden van een unit zonder naadwand (bijv. verwijderd): [{ a, b }].
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);
@@ -465,7 +480,7 @@ window.HouseModel = (() => {
pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
});
const roof = {
element: `${u.id}-D1`, shape, planes: ur.planes, specs: ur.specs,
element: `${u.id}-D1`, shape, covering: ur.covering, 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, // 3D: group-Y = 0 op het loopvlak
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
@@ -693,20 +708,15 @@ 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;
// Gevel tot de bovenkant van het dak: tussen wandbovenkant en bovenkant
// dak zit het dakpakket (dek + bedekking, verschilt per dak). De
// buitenlagen van de wand lopen daar door (skinAbove, mm). Boeideel en
// daktrim komen daar bovenop (computeEdge, facadeDepth); de houtconstructie
// en de gevel zijn gelijk met of zonder boeideel.
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));
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z)));
// 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;
@@ -736,11 +746,31 @@ window.HouseModel = (() => {
// 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;
// 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 (-).
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);
});
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.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
}))) : null;
})), facadeDepth) : 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".
@@ -772,11 +802,88 @@ window.HouseModel = (() => {
// ------------------------------------------------------------
// Totalen over de hele woning (overzicht + laatste PDF-blad).
// ------------------------------------------------------------
// ------------------------------------------------------------
// Afmetingen (infopaneel 3D + blad 1 PDF). Oppervlakken in m2, maten in mm.
// bruto vloer = vloer-rechthoeken (tot buitenkant constructie)
// leefbaar = bruto vloer min de voetafdruk van alle wanden
// gevel = buitenvlak van alle gevelstukken (incl. doorloop tot
// onder de dakrand), bruto en netto (min openingen)
// binnenwanden = geplaatst oppervlak aan de kamerzijde (alle wanden)
// dak = hellend dakoppervlak (bedekking) en in plattegrond
// groundLevel: maaiveld onder het loopvlak (mm), voor de bouwhoogte.
// ------------------------------------------------------------
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))));
// Voetafdruk: gewone wanden (geen opbouw boven een lager dak).
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 + op.w * op.h, 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 + op.w * op.h, 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), 0);
const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
// Inhoud (bruto): per unit gemiddelde hoogte bovenkant dak boven de
// onderkant vloer, bemonsterd op een raster.
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
};
}
// Als rijen [label, waarde, eenheid] (UI-kaart en PDF-tabel).
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³']
];
}
// TIJDELIJK (verwijderen voor productie): capaciteit zaagmachine.
const SAW_M_PER_H = 2400;
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
function addPiece(kind, width, depth, length) {
// 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);
@@ -792,12 +899,14 @@ window.HouseModel = (() => {
['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;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
// Sporen in een hellend dakvlak: loodrechte snede aan beide koppen.
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 wallBom = [];
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length); }));
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);
@@ -823,6 +932,11 @@ 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.
saw: {
meters: saw.lengthMM / 1000, hours: saw.lengthMM / 1000 / SAW_M_PER_H, 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),
@@ -970,9 +1084,9 @@ window.HouseModel = (() => {
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor,
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, SAW_M_PER_H, // SAW_M_PER_H: tijdelijk
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, validateState, fromUnitDesign, normalize
};
})();