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:
co-authored by
Claude Opus 5.5
parent
12c2579471
commit
23bfe153a2
+133
-19
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
+290
-14
@@ -6,10 +6,12 @@
|
||||
*/
|
||||
// ============================================================
|
||||
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
|
||||
// Blad 1 plattegrond woning (units, wandstukken, naden, binnenwanden)
|
||||
// Blad 1 aanzichten noord/oost/zuid/west (vlakke "render", vectorvormen)
|
||||
// Blad 2 plattegrond woning (units, wandstukken, naden, binnenwanden)
|
||||
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
|
||||
// daarna elke binnenwand (WallPdf)
|
||||
// laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht)
|
||||
// daarna totalen (balken, inkoop, glaswol, plaatwerk, gewicht, dakrand)
|
||||
// laatste zaaglijst vuren + zaagmachine-overzicht (tijdelijk)
|
||||
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
|
||||
// Plain script, geladen na house-model.js.
|
||||
// ============================================================
|
||||
@@ -21,6 +23,7 @@ window.HousePdf = (() => {
|
||||
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
|
||||
};
|
||||
const PLAN_ELEMENT = 'PL-1';
|
||||
const VIEWS_ELEMENT = 'AZ-1';
|
||||
|
||||
function fmt(n, decimals = 1) {
|
||||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
@@ -35,7 +38,10 @@ window.HousePdf = (() => {
|
||||
// Bladvolgorde + elementnummers; gedeeld door generate() en de
|
||||
// onderdelenlijst op blad 1.
|
||||
function sheetList(parts) {
|
||||
const list = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }];
|
||||
const list = [
|
||||
{ kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
|
||||
{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }
|
||||
];
|
||||
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 })));
|
||||
@@ -43,6 +49,7 @@ window.HousePdf = (() => {
|
||||
});
|
||||
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
||||
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
||||
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -51,7 +58,7 @@ window.HousePdf = (() => {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
sheets.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]),
|
||||
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
|
||||
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
|
||||
@@ -172,6 +179,245 @@ window.HousePdf = (() => {
|
||||
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Aanzichten: per windrichting een vlakke "render" van de gevels. Alle
|
||||
// zichtbare vlakken (gevels incl. trasraam en openingen, boeideel/daktrim,
|
||||
// hellende dakvlakken) als polygonen in het aanzicht, van achter naar voren
|
||||
// getekend (schildersalgoritme), in egale kleuren uit het model.
|
||||
// ------------------------------------------------------------
|
||||
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
||||
const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
const VIEW_COLORS = {
|
||||
rabat: '#a0805f', cementplaat: '#808285', glas: '#a9c9dc', deur: '#5b4a3b', lijn: [60, 60, 60], grond: [120, 120, 120]
|
||||
};
|
||||
// d = richting van woning naar kijker (x, z); u = horizontale schermas.
|
||||
const VIEWS = [
|
||||
{ key: 'noord', title: 'Noordgevel', d: { x: 0, z: -1 }, u: (p) => -p.x },
|
||||
{ key: 'oost', title: 'Oostgevel', d: { x: 1, z: 0 }, u: (p) => -p.z },
|
||||
{ key: 'zuid', title: 'Zuidgevel', d: { x: 0, z: 1 }, u: (p) => p.x },
|
||||
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
|
||||
];
|
||||
|
||||
// Alle vlakken in wereldcoördinaten: { pts: [{x, y, z}], n: {x, z} | null (dakvlak: nH), color, bias }.
|
||||
function viewFaces(parts, state) {
|
||||
const faces = [];
|
||||
const wallFace = (p, y0, color, depthBias) => {
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
|
||||
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
|
||||
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
|
||||
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
|
||||
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
|
||||
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
|
||||
.concat([{ x: xl, y: top(xl) }]);
|
||||
return loc;
|
||||
};
|
||||
const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; };
|
||||
const addWall = (p, y0) => {
|
||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat;
|
||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
// Trasraam (strook onderaan, iets naar voren)
|
||||
const tr = p.trasraam;
|
||||
if (tr && tr.top > y0) {
|
||||
const W = p.place.width;
|
||||
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
|
||||
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
||||
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
||||
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||
}
|
||||
// Openingen (glas / deur), vóór de gevel
|
||||
(p.config.openings || []).forEach(op => {
|
||||
if (op.y + op.h <= y0) return;
|
||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y), yb = op.y + op.h;
|
||||
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
|
||||
faces.push({ pts: [[x0, ya], [x1, ya], [x1, yb], [x0, yb]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
|
||||
});
|
||||
};
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
|
||||
parts.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom));
|
||||
|
||||
// Hellende dakvlakken (bovenkant, egaal in de kleur van de bedekking).
|
||||
parts.units.forEach(u => {
|
||||
const r = u.roof, sh = r.shape;
|
||||
if (sh.type === 'plat') return;
|
||||
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
|
||||
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
|
||||
r.planes.forEach(pl => {
|
||||
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
|
||||
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
|
||||
const inward = (t) => t + Math.sign(mid - t) * 0.5;
|
||||
const corners = sh.axis === 'x'
|
||||
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
|
||||
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
|
||||
const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }));
|
||||
// Normaal (omhoog) uit het vlak.
|
||||
const [A, B, C] = pts;
|
||||
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
||||
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
||||
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||
const len = Math.hypot(nx, ny, nz) || 1;
|
||||
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// Dakrand: buitenvlak van elk boeideel / elke daktrim.
|
||||
const edge = parts.roofEdge;
|
||||
if (edge) {
|
||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
||||
edge.segments.forEach(sg => {
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B);
|
||||
const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }];
|
||||
faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
});
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Legenda "Afwerking": gevel(s) (incl. overrides), trasraam, dak per unit,
|
||||
// vloer, boeideel/daktrim en binnenwanden. Per item een voorvertoning
|
||||
// (thumb, via PdfSheet.preloadImage vooraf geladen) of een kleurvlak.
|
||||
function materialLegend(state, parts) {
|
||||
const items = [];
|
||||
const st = state.settings;
|
||||
const seen = new Set();
|
||||
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
|
||||
const fc = HouseModel.facadeFor(state, u, side);
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : 'rabat';
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(fc.type === 'steen'
|
||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||
: { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat });
|
||||
}));
|
||||
if (st.trasraam.enabled) {
|
||||
const tf = st.trasraam.finish;
|
||||
items.push(WallModel.isStoneFinish(tf)
|
||||
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
|
||||
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
|
||||
}
|
||||
(parts ? parts.units : state.units).forEach(u => {
|
||||
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
||||
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
||||
items.push({ label: `Dak unit ${u.id}`, value: c.label, thumb: RoofModel.coveringThumbUrl(cov, 64), color: c.color });
|
||||
});
|
||||
const ff = FloorModel.FINISHES[st.floorFinish] || FloorModel.FINISHES.geen;
|
||||
items.push({ label: 'Vloer', value: ff.label, thumb: FloorModel.finishThumbUrl(st.floorFinish, 64), color: ff.color });
|
||||
const fascia = state.units.some(u => u.roof.fascia !== false), trim = state.units.some(u => u.roof.fascia === false);
|
||||
if (fascia) {
|
||||
const fcol = RoofModel.FASCIA_COLORS[st.roof.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet;
|
||||
items.push({ label: 'Boeideel', value: fcol.label, color: fcol.hex });
|
||||
}
|
||||
if (trim) items.push({ label: 'Daktrim', value: 'Aluminium', color: 0xb8bcc0 });
|
||||
items.push(st.interiorPaint
|
||||
? { label: 'Binnenwanden', value: `Geschilderd ${st.interiorColor}`, color: st.interiorColor }
|
||||
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
|
||||
return items;
|
||||
}
|
||||
// Voorvertoningen die de legenda gebruikt (om vooraf te laden).
|
||||
function materialThumbUrls(state) {
|
||||
return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
|
||||
}
|
||||
|
||||
function drawMaterialLegend(doc, items, x0, y0, x1, y1) {
|
||||
const { BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('Afwerking', x0, y0 + 3);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const cellW = 45, cellH = 11, sw = 9, top = y0 + 6;
|
||||
const cols = Math.max(1, Math.floor((x1 - x0) / cellW)), rows = Math.max(1, Math.floor((y1 - top) / cellH));
|
||||
items.slice(0, cols * rows).forEach((it, i) => {
|
||||
const cx = x0 + Math.floor(i / rows) * cellW, cy = top + (i % rows) * cellH;
|
||||
const data = PdfSheet.imageData(it.thumb);
|
||||
if (data) doc.addImage(data, 'PNG', cx, cy, sw, sw);
|
||||
else {
|
||||
doc.setFillColor(...(typeof it.color === 'number' ? numRgb(it.color) : hexRgb(it.color || '#cccccc')));
|
||||
doc.rect(cx, cy, sw, sw, 'F');
|
||||
}
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.rect(cx, cy, sw, sw, 'S');
|
||||
doc.setFontSize(6);
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text(it.label, cx + sw + 1.5, cy + 3.2);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(doc.splitTextToSize(it.value, cellW - sw - 3)[0], cx + sw + 1.5, cy + 7.2);
|
||||
});
|
||||
}
|
||||
|
||||
// Afmetingen (zelfde rijen als het infopaneel in de app), in twee kolommen.
|
||||
function drawDimensions(doc, rows, x0, y0, x1) {
|
||||
const { BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('Afmetingen', x0, y0 + 3);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const half = Math.ceil(rows.length / 2), colW = (x1 - x0) / 2, rowH = 4.6;
|
||||
rows.forEach(([label, value, unit], i) => {
|
||||
const cx = x0 + Math.floor(i / half) * colW, cy = y0 + 8 + (i % half) * rowH;
|
||||
doc.setFontSize(5.5);
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text(label, cx, cy - 1.7);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(`${value}${unit ? ' ' + unit : ''}`, cx, cy + 0.9);
|
||||
});
|
||||
}
|
||||
|
||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
const faces = viewFaces(parts, state);
|
||||
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
||||
// Per aanzicht de zichtbare vlakken in (u, v) mm, met diepte.
|
||||
const views = VIEWS.map(v => {
|
||||
const dot = (p) => p.x * v.d.x + p.z * v.d.z;
|
||||
const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5)
|
||||
.map(f => ({ f, depth: f.pts.reduce((a, p) => a + dot(p), 0) / f.pts.length + f.bias, pts: f.pts.map(p => ({ u: v.u(p), v: p.y })) }))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
|
||||
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
|
||||
}).filter(x => x.list.length);
|
||||
|
||||
// Raster 2 x 2 boven het titelblok; één schaal voor alle aanzichten.
|
||||
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
|
||||
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
|
||||
const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
|
||||
doc.setFont('helvetica', 'normal');
|
||||
views.forEach((w, i) => {
|
||||
const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap);
|
||||
const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale;
|
||||
const base = cy + cellH - 6; // grondlijn
|
||||
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(11);
|
||||
doc.text(w.v.title, cx, cy + 5);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.setDrawColor(...VIEW_COLORS.lijn);
|
||||
w.list.forEach(l => {
|
||||
const pts = l.pts.map(P);
|
||||
const col = typeof l.f.color === 'number' ? numRgb(l.f.color) : hexRgb(l.f.color);
|
||||
doc.setFillColor(...col);
|
||||
doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true);
|
||||
});
|
||||
// Grondlijn
|
||||
doc.setDrawColor(...VIEW_COLORS.grond);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
|
||||
});
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(`Aanzichten (vlakke weergave, schaal ca. 1:${Math.round(1 / scale)}); kleuren: gemiddelde kleur van de gekozen steen / bedekking.`, X0, Y1 + 5);
|
||||
drawMaterialLegend(doc, materialLegend(state, parts), X0, Y1 + 7, 192, 289);
|
||||
drawDimensions(doc, HouseModel.dimensionRows(HouseModel.computeDimensions(parts, state.settings.groundLevel), fmt), 197, Y1 + 7, 285);
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function drawTotalsSheet(doc, totals, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
@@ -197,22 +443,18 @@ window.HousePdf = (() => {
|
||||
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
|
||||
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
|
||||
|
||||
PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'],
|
||||
totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]),
|
||||
Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 }));
|
||||
|
||||
y = 30;
|
||||
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
|
||||
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
|
||||
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
|
||||
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
|
||||
y += 4;
|
||||
y = PdfSheet.table(doc, 215, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
|
||||
y = PdfSheet.table(doc, 120, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
|
||||
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
|
||||
tableOpts('Plaatwerk'));
|
||||
y += 4;
|
||||
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
|
||||
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
|
||||
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
|
||||
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
|
||||
if (totals.roofEdge) {
|
||||
y += 4;
|
||||
const e = totals.roofEdge, L = totals.lamps;
|
||||
@@ -225,12 +467,44 @@ window.HousePdf = (() => {
|
||||
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
||||
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
||||
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
|
||||
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
|
||||
}
|
||||
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
|
||||
function drawSawSheet(doc, totals, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(14);
|
||||
doc.text('Zaaglijst', 15, 18);
|
||||
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
|
||||
const rows = totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]);
|
||||
const perCol = 52, cols = [15, 100, 185];
|
||||
for (let c = 0; c < cols.length && c * perCol < rows.length; c++) {
|
||||
PdfSheet.table(doc, cols[c], 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'], rows.slice(c * perCol, (c + 1) * perCol),
|
||||
tableOpts(c === 0 ? 'Zaaglijst vuren (totaal)' : 'Zaaglijst (vervolg)'));
|
||||
}
|
||||
if (rows.length > perCol * cols.length) {
|
||||
doc.setFontSize(7);
|
||||
doc.text(`+ ${rows.length - perCol * cols.length} regels (niet getoond)`, 15, 285);
|
||||
}
|
||||
|
||||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||||
const sw = totals.saw;
|
||||
const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
|
||||
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
|
||||
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
|
||||
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'],
|
||||
['Zaagsneden', sw.cuts + ' st.'],
|
||||
['Verschillende hoeken', String(sw.angles.length)]
|
||||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
|
||||
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
||||
@@ -243,14 +517,16 @@ window.HousePdf = (() => {
|
||||
sheets.forEach((sheet, i) => {
|
||||
if (i > 0) doc.addPage('a3', 'landscape');
|
||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
||||
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
||||
else 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.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 if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts);
|
||||
else drawTotalsSheet(doc, totals, opts);
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { generate, sheetList };
|
||||
return { generate, sheetList, materialThumbUrls };
|
||||
})();
|
||||
|
||||
+125
-13
@@ -738,9 +738,9 @@ function mountHouseEditor(container, callbacks) {
|
||||
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS)
|
||||
.filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label]));
|
||||
const covInfo = (k) => { const c = RoofModel.COVERINGS[k]; return `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²`; };
|
||||
const bedekking = sel('hp-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(u.roof.covering, shapeType), {
|
||||
hint: shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(RoofModel.coveringFor(u.roof.covering, shapeType)) + '; boeideel schuift mee omhoog'
|
||||
});
|
||||
const covNow = RoofModel.coveringFor(u.roof.covering, shapeType);
|
||||
const bedekking = thumbPicker('hp-roof-cover', 'Bedekking', covOpts, covNow, RoofModel.coveringThumbUrl(covNow, 128),
|
||||
shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(covNow) + '; boeideel schuift mee omhoog');
|
||||
const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) +
|
||||
(u.roof.followFloor ? '' :
|
||||
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) +
|
||||
@@ -750,12 +750,16 @@ function mountHouseEditor(container, callbacks) {
|
||||
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' }) +
|
||||
bedekking;
|
||||
|
||||
const missing = HouseModel.missingSeamWalls(state, u.id);
|
||||
propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} × ${f.depth} mm`) +
|
||||
UI.tabs('unit', [
|
||||
{ key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols(
|
||||
num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }),
|
||||
sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) },
|
||||
{ key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, '') },
|
||||
{ key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, missing.length
|
||||
? UI.warn(`Zonder naadwand: ${missing.map(m => `unit ${m.a} | ${m.b}`).join(', ')}. De naad staat daar open.`) +
|
||||
actions(button('Naadwand toevoegen', 'plus-lg', `data-action="add-seams" data-unit="${u.id}"`))
|
||||
: '') },
|
||||
{ key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') }
|
||||
]) +
|
||||
actions(
|
||||
@@ -767,9 +771,11 @@ function mountHouseEditor(container, callbacks) {
|
||||
UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after);
|
||||
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
|
||||
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
|
||||
UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender);
|
||||
UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender);
|
||||
UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender);
|
||||
// Andere koppeling: naadwanden van deze unit gaan mee (oude vervallen,
|
||||
// nieuwe naden krijgen er een).
|
||||
UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||||
UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||||
UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
|
||||
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
|
||||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||||
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
|
||||
@@ -869,9 +875,10 @@ function mountHouseEditor(container, callbacks) {
|
||||
// Keuzes voor de vormgeving van het trasraam: plaat of een steen.
|
||||
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
||||
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
|
||||
// Steenkeuze met voorvertoning.
|
||||
const stonePicker = (id, label, value, hint) => sel(id, label, stoneOptions, value, { hint }) +
|
||||
`<img class="hs-stone-preview" src="${WallModel.stoneThumbUrl(value, 128)}" alt="${value}">`;
|
||||
// Keuzelijst met voorvertoning (textuur-thumbnail), als die er is.
|
||||
const thumbPicker = (id, label, options, value, thumbUrl, hint) => sel(id, label, options, value, { hint }) +
|
||||
(thumbUrl ? `<img class="hs-stone-preview" src="${thumbUrl}" alt="${value}">` : '');
|
||||
const stonePicker = (id, label, value, hint) => thumbPicker(id, label, stoneOptions, value, WallModel.stoneThumbUrl(value, 128), hint);
|
||||
|
||||
function renderHouseProps(warn) {
|
||||
const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting;
|
||||
@@ -916,7 +923,8 @@ function mountHouseEditor(container, callbacks) {
|
||||
{ key: 'binnen', label: 'Binnen', icon: 'palette', html: cols(
|
||||
section('Wanden', 'palette', toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden' }) +
|
||||
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '')),
|
||||
section('Vloer', 'grid-3x3', sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish, { hint: 'Alle vloeren (voorlopig één keuze)' }) +
|
||||
section('Vloer', 'grid-3x3', thumbPicker('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish,
|
||||
FloorModel.finishThumbUrl(st.floorFinish, 128), 'Alle vloeren (voorlopig één keuze)') +
|
||||
info('Wanden uit: kale spaanplaat met plaatnaden.'))) },
|
||||
{ key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols(
|
||||
section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })),
|
||||
@@ -986,12 +994,15 @@ function mountHouseEditor(container, callbacks) {
|
||||
if (act.dataset.action === 'add-unit') addUnit();
|
||||
else if (act.dataset.action === 'draw-iw') setTool('iw');
|
||||
else if (act.dataset.action === 'project') openProjectProperties();
|
||||
else if (act.dataset.action === 'add-seams') { HouseModel.ensureSeamWalls(state, act.dataset.unit); refresh(); }
|
||||
});
|
||||
|
||||
// --- Specificaties/totalen (onder de 3D) ---
|
||||
function renderSpecs(totals) {
|
||||
const f = houseFmt;
|
||||
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
|
||||
const cards = [
|
||||
UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)),
|
||||
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
|
||||
UI.specCard('Hout & isolatie', 'tree', [
|
||||
['Balken', totals.beamCount, 'st.'],
|
||||
@@ -1010,11 +1021,22 @@ function mountHouseEditor(container, callbacks) {
|
||||
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
|
||||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||||
}
|
||||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||||
const sw = totals.saw, hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
|
||||
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
|
||||
['Zaagmeters', f(sw.meters, 1), 'm'],
|
||||
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours), 'u:mm'],
|
||||
['Zaagsneden', sw.cuts, 'st.'],
|
||||
['Verschillende hoeken', sw.angles.length, '']
|
||||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden']))));
|
||||
document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
|
||||
}
|
||||
|
||||
// --- Alles opnieuw doorrekenen ---
|
||||
function refresh(opts) {
|
||||
// Voorvertoningen van de gekozen materialen alvast laden voor de PDF
|
||||
// (legenda op het aanzichtenblad; HousePdf.materialThumbs).
|
||||
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
|
||||
// Binnenzijde geschilderd: de (gedeelde) spaanplaat krijgt de verfkleur.
|
||||
wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR);
|
||||
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible });
|
||||
@@ -1080,9 +1102,93 @@ function mountHouseEditor(container, callbacks) {
|
||||
renderNav();
|
||||
}
|
||||
|
||||
let rafId = null;
|
||||
// --- Lopen (first person), eigen sneltoets Shift+T ---
|
||||
// Start 3 m voor de voordeur, buiten; botsen tegen wanden (deuren zijn
|
||||
// doorgangen), ondergrond = vloer binnen een unit, anders het maaiveld.
|
||||
const WALK_RADIUS = 250; // mm, "lichaam"
|
||||
function wallBlocked(xm, zm) {
|
||||
if (!parts) return false;
|
||||
const x = xm * 1000, z = zm * 1000;
|
||||
return HouseModel.allWallPieces(parts).some(p => {
|
||||
if (p.place.baseY) return false; // opbouw boven een lager dak
|
||||
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
|
||||
const dx = x - p.place.cx, dz = z - p.place.cz;
|
||||
const lx = dx * c - dz * s, lz = dx * s + dz * c;
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone) : 0;
|
||||
if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false;
|
||||
// Door een deur(opening) mag je lopen.
|
||||
return !(p.config.openings || []).some(op => op.y < 100 && op.y + op.h > 1800 &&
|
||||
lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6);
|
||||
});
|
||||
}
|
||||
function groundAt(xm, zm) {
|
||||
const x = xm * 1000, z = zm * 1000;
|
||||
const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1);
|
||||
const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0;
|
||||
return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000;
|
||||
}
|
||||
const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt });
|
||||
let orbitSaved = null;
|
||||
// Startpositie: 3 m voor de voordeur (anders een buitendeur, anders midden
|
||||
// voor de noordgevel), kijkend naar de deur.
|
||||
function walkStartPose() {
|
||||
const ext = [];
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||||
let best = null;
|
||||
['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => {
|
||||
if (op.type !== type) return false;
|
||||
best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 };
|
||||
return true;
|
||||
})));
|
||||
if (!best) {
|
||||
const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0];
|
||||
best = { p, lx: 0 };
|
||||
}
|
||||
const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2);
|
||||
const n = { x: best.p.place.normal.x, z: best.p.place.normal.y };
|
||||
const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000;
|
||||
return { x, z, yaw: Math.atan2(-n.x, -n.z) };
|
||||
}
|
||||
const walkHint = document.createElement('div');
|
||||
walkHint.className = 'walk-hint';
|
||||
walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · Shift+T = terug';
|
||||
pane3D.appendChild(walkHint);
|
||||
function setWalk(on) {
|
||||
if (on === walk.active || !parts) return;
|
||||
const view = document.getElementById('house-overview');
|
||||
if (on) {
|
||||
// Lopen gebeurt in de volle 3D (alleen render); vorige stand onthouden.
|
||||
orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className };
|
||||
view.classList.remove('fullscreen-2d');
|
||||
view.classList.add('fullscreen-3d', 'render-only');
|
||||
requestAnimationFrame(onResize);
|
||||
controls.enableRotate = controls.enablePan = controls.enableZoom = false;
|
||||
camera.fov = 65;
|
||||
camera.updateProjectionMatrix();
|
||||
walk.start(walkStartPose());
|
||||
} else {
|
||||
walk.stop();
|
||||
controls.enableRotate = controls.enablePan = controls.enableZoom = true;
|
||||
if (orbitSaved) {
|
||||
view.className = orbitSaved.cls;
|
||||
requestAnimationFrame(onResize);
|
||||
camera.fov = orbitSaved.fov;
|
||||
camera.updateProjectionMatrix();
|
||||
camera.position.copy(orbitSaved.pos);
|
||||
controls.target.copy(orbitSaved.target);
|
||||
controls.update();
|
||||
}
|
||||
}
|
||||
walkHint.classList.toggle('show', on);
|
||||
}
|
||||
|
||||
let rafId = null, lastT = performance.now();
|
||||
function animate() {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
const now = performance.now();
|
||||
walk.update((now - lastT) / 1000);
|
||||
lastT = now;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
function onResize() {
|
||||
@@ -1094,7 +1200,9 @@ function mountHouseEditor(container, callbacks) {
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
function toggleView(fn) {
|
||||
fn(document.getElementById('house-overview'));
|
||||
const el = document.getElementById('house-overview');
|
||||
setWalk(false); // weergave wisselen beëindigt het lopen
|
||||
fn(el);
|
||||
requestAnimationFrame(onResize);
|
||||
}
|
||||
|
||||
@@ -1108,12 +1216,15 @@ function mountHouseEditor(container, callbacks) {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
setWalk(false); // orbitcamera terug vóór het opslaan
|
||||
walk.dispose();
|
||||
CameraMemory.save(overviewCamKey, camera, controls);
|
||||
renderer.dispose();
|
||||
},
|
||||
refresh,
|
||||
setTool,
|
||||
toggleFullscreen3D: () => toggleView(UI.cycle3D),
|
||||
toggleWalk: () => setWalk(!walk.active),
|
||||
toggleFullscreen2D: () => toggleView(UI.toggle2D)
|
||||
};
|
||||
}
|
||||
@@ -1229,6 +1340,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
triggerImport,
|
||||
mirrorHorizontal: forward('mirrorHorizontal'),
|
||||
toggleFullscreen3D: forward('toggleFullscreen3D'),
|
||||
toggleWalk: forward('toggleWalk'),
|
||||
toggleFullscreen2D: forward('toggleFullscreen2D'),
|
||||
openProjectProperties,
|
||||
getStateJson,
|
||||
|
||||
Reference in New Issue
Block a user