Files
Tectum/js/wall/wall-model.js
T

1555 lines
82 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// wall/wall-model.js — ENIGE bron van waarheid voor de wand-logica:
// ankerresolutie van openingen, regelwerk (stijlen), 3D-opbouw en de
// specificatie-berekening. Geen DOM. Wordt gebruikt door:
// - wall/wall.js (de losse wand-editor)
// - unit/unit.js (unit = vloer + 4 wanden)
// Wijzigingen aan de wand-logica horen dus HIER, zodat ze automatisch
// voor zowel de losse wand als de unit gelden.
//
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
window.WallModel = (() => {
// Referentiedichtheden — hier hardcoded aanpasbaar (geen UI-velden meer,
// zoals afgesproken). Spaanplaat (binnenzijde) en cement-gebonden platen
// (optie voor de buitenzijde) gebruiken hetzelfde kg/m3 x volume-patroon
// als voorheen "OSB"; vochtdoek (andere buitenzijde-optie) blijft een
// kg/m2-membraan.
const DENSITY_WOOD_KGM3 = 450;
const DENSITY_SPAANPLAAT_KGM3 = 650;
const DENSITY_CEMENTPLAAT_KGM3 = 1250;
const DENSITY_DOEK_KGM2 = 1.5;
// Klein, bewust niet-nul luchtgaatje tussen plaat en houtwerk (zie
// build3D). Ook gebruikt voor de totale wanddikte (thicknessOf).
const SKIN_GAP_MM = 0.5;
// Standaard muurbalk: 38 x 235 mm (dikte x diepte), ingekocht in deze
// handelslengtes. Het zaagplan (planPurchase) kiest per staaf de lengte
// met zo min mogelijk snijverlies.
const STANDARD_BEAM = { width: 38, depth: 235 };
const STOCK_LENGTHS_MM = [3600, 4200, 4800, 5400];
// Hoeken: elke wandkop bestaat uit 2 stijlen tegen elkaar (38 + 38 mm).
const CORNER_STUDS = 2;
// Latwerk op het vochtdoek voor de gevelbekleding (WEO/Novalit): kruislat
// van 2 lagen, latten 20 (dik) x 30 (breed) mm, handelslengte 4000 mm,
// 400 mm h.o.h. in beide richtingen. De bovenste laag staat haaks op de
// richting van de gevelbekleding (materials.cladding). Alleen bij
// vochtdoek; cement-gebonden platen zijn voor steenstrips.
const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 };
const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' };
const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit), nog niet getekend
// Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek
// de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de
// positie van gevellampen.
// Bij cementplaat met steen (stone): de steenstrip.
function claddingOffset(buitenMateriaal, stone) {
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
return buitenMateriaal === 'cementplaat' && stone ? STONE.THICK_MM : 0;
}
// Trasraam: strook cement-gebonden plaat (montage-achterkant voor de
// plint-afwerking) onderaan de gevel, vóór het vochtdoek; het latwerk
// begint erboven.
// De hoogte telt vanaf Y=0 van de vloer = het beloopbare vlak (bovenkant
// vloer), en dat is ook de onderkant van de wand: in wand-coördinaten
// loopt de strook dus van 0 tot de hoogte.
// Geeft null als hij uit staat. Alleen van toepassing op vochtdoek-wanden
// (cement-gebonden buitenzijde is al plaat).
const TRASRAAM_DEFAULT_MM = 350;
// finish: 'cementplaat' of een steencode uit TRASRAAM_STONES (steenstrip
// op een isolatieblok, zie STONE).
function trasraamFor(heightMM, finish) {
if (!(heightMM > 0)) return null;
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' };
}
// Steen (trasraam en steengevel): isolatieblok met steenstrip ervoor,
// samen THICK_MM dik (vóór het doek resp. de cementplaat). Texturen: assets/textures/buitenmuur/
// "<code> - Witte voeg.jpg", 2062 x 1125 px op 2 mm/px (steen + voeg
// ~62 mm per laag), naadloos herhalend. Voorvertoning in thumb/<px>/ (.png).
// JOINT_MM: hart van de onderste voeg boven de onderrand van de textuur
// (gemeten: 1-2 px); COURSE_MM: steen + voeg (31 px). De bovenrand van de
// textuur snijdt door een steen (1125 px is geen veelvoud van 31).
const STONE = { THICK_MM: 110, STRIP_MM: 20, TILE_W_MM: 4124, TILE_H_MM: 2250, JOINT_MM: 3, COURSE_MM: 62, DIR: 'assets/textures/buitenmuur/' };
const TRASRAAM_STONES = ('K490 K518 K560 K662 K706 K739 K752 K764 K773 K911P K911 K914 K915 K919 K923P K923 K941P K941 K947 K948 K960 K991 ' +
'R100 R116 R140 R200 R206 R214 R216 R220 R227 R228 R240 R268 R286 R287 R303 R307 R332 R335 R343 R356 R381 R382 R384 R385 R386 R391 ' +
'R400 R401 R435 R436 R440 R480 R483 R484 R487 R488 R489 R500 R510 R518 R535 R540 R550 R555 R560 R561 R563 R564 R565 R572DF14_64 R572DF ' +
'R580 R581 R582 R658 R659 R663 R664 R665 R669 R680 R682 R684 R685 R686 R687 R688 R689 R690 R691 R692 R-693 R694 R695 R696 R697 R698 ' +
'R700 R714 R715 R717 R719 R720 R721 R725 R726 R727 R729 R730 R731 R732 R733 R734 R735 R736 R737 R738 R739 R740 R742 R743 R744 R745X737 ' +
'R746 R747 R748 R748X764X749 R749 R750 R751 R751X759 R752 R754 R756 R757 R758 R759 R760 R761 R762 R763 R764 R764X756 R766 R767 R768 ' +
'R769 R770 R771 R772 R773 R775 R777 R788 R800 R835 R840 R911P R911 R913 R916 R917 R918 R921 R923P R931 R932 R941P R941 R942 R943 R944 ' +
'R960DF14_70 R960DF R961 R970 R980 R985 R991').split(' ');
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
// Eén materiaal per steen (gedeeld, pas geladen als hij gebruikt wordt).
// UV's van de steenblokken zijn in meters, dus repeat = 1/tegel.
const stoneMaterials = {};
function stoneMaterial(code) {
if (stoneMaterials[code]) return stoneMaterials[code];
const mat = new THREE.MeshStandardMaterial({ color: 0xb0a090, roughness: 0.9 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(stoneTextureUrl(code), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / STONE.TILE_W_MM, 1000 / STONE.TILE_H_MM);
tex.anisotropy = 8;
mat.map = tex;
mat.color.set(0xffffff);
mat.needsUpdate = true;
});
}
stoneMaterials[code] = mat;
return mat;
}
// Hoekdoorloop van de buitenlagen. Een wand die tussen twee doorlopende
// wanden past (unit-hoek) moet met zijn buitenlagen ook de kopse kant
// (hoekstijlen) van die doorlopende wand bedekken: skinExt = { left,
// right } = dikte van die wand (mm, 0 = vrij uiteinde). Elke laag loopt
// daarbovenop nog door tot zijn eigen buitenvlak, zodat de hoek dicht is.
// depth = afstand van het buitenvlak van de laag tot het wandvlak (mm).
// Negatief (binnenhoek): het stuk loopt zoveel de naad in, binnen de wand
// van de buur-unit. Geen doorloop; de buitenlagen krijgen daar een
// uitsparing (build3D: trim, tot opts.trimTop).
function cornerExt(skinExt, depth) {
const e = skinExt || {};
const one = (v) => v > 0 ? v + depth : 0;
return { left: one(e.left), right: one(e.right) };
}
// Onderkant van vochtdoek en latwerk (wand-coördinaten, mm).
function skinStartY(trasraam) {
return trasraam ? Math.max(0, trasraam.top) : 0;
}
// Openingtypes met hun standaardmaat (mm). 'y' = hoogte onderkant.
const OPENING_TYPES = {
raam: { label: 'Raam', w: 500, h: 1900, y: 250 },
deur: { label: 'Deur', w: 900, h: 2150, y: 0 },
voordeur: { label: 'Voordeur', w: 1150, h: 2150, y: 0 }
};
// Deuren krijgen aan weerszijden een tweede stijl direct tegen de
// koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
// Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat).
// 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het
// deel van de andere unit aan staat (HouseModel, naadwanden).
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
// Bovenrand van de wand (daklijn): één recht stuk van links naar rechts,
// of met een middenpunt (nok, wallPeak) twee stukken die elkaar daar
// raken. De nok ligt nooit lager dan de goten (buildConfig), dus de
// bovenrand is overal het MINIMUM van de (doorgetrokken) stukken — dat
// houdt at/under/rangeAbove simpel en maakt "laagste punt boven een
// opening" gelijk aan het laagste van de twee uiteinden.
// x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters).
// at(x) bovenkant (daklijn)
// under(x) onderkant bovenregel(s); knee = hun snijpunt (verstek)
// rangeAbove(y) [x0, x1] waar de daklijn boven y ligt (of null)
// crossings(y) x-posities waar de daklijn y kruist (knikpunten)
// underPts(a, b) onderkant bovenregel van a naar b, incl. knie
function topLine(CONFIG, k) {
k = k || 1;
const halfW = CONFIG.wallWidth / 2 * k;
const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k };
const pk = CONFIG.wallPeak ? { x: (CONFIG.wallPeak.x - CONFIG.wallWidth / 2) * k, y: CONFIG.wallPeak.h * k } : null;
const pts = pk ? [L, pk, R] : [L, R];
const segs = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x);
segs.push({ a, b, slope, vo: CONFIG.plateThick * k * Math.sqrt(1 + slope * slope) });
}
const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope;
const at = (x) => Math.min(...segs.map(sg => lineY(sg, x)));
const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo));
let knee = null;
if (segs.length === 2) {
const [s0, s1] = segs, ds = s0.slope - s1.slope;
// s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1
const x = Math.abs(ds) > 1e-12
? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds
: pk.x;
knee = { x, y: under(x) };
}
const rangeAbove = (y) => {
let x0 = -halfW, x1 = halfW;
segs.forEach(sg => {
if (Math.abs(sg.slope) < 1e-12) { if (sg.a.y <= y) x1 = x0 - 1; return; }
const xc = sg.a.x + (y - sg.a.y) / sg.slope;
if (sg.slope > 0) x0 = Math.max(x0, xc); else x1 = Math.min(x1, xc);
});
return x1 > x0 ? [x0, x1] : null;
};
const crossings = (y) => segs.filter(sg => Math.abs(sg.slope) > 1e-12)
.map(sg => ({ sg, x: sg.a.x + (y - sg.a.y) / sg.slope }))
.filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x);
const underPts = (xa, xb) => {
const out = [{ x: xa, y: under(xa) }];
if (knee && knee.x > xa && knee.x < xb) out.push({ x: knee.x, y: knee.y });
out.push({ x: xb, y: under(xb) });
return out;
};
return {
pts, segs, peak: pk, knee, at, under, rangeAbove, crossings, underPts,
maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y),
kinks: pk ? [pk.x] : []
};
}
// Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één
// parallellogram, of met nok twee stukken in verstek (nok boven, knie onder).
function topPlateOutlines(tl) {
const [L, R] = [tl.pts[0], tl.pts[tl.pts.length - 1]];
if (!tl.peak) {
const vo = tl.segs[0].vo;
return [[L, R, { x: R.x, y: R.y - vo }, { x: L.x, y: L.y - vo }]];
}
const P = tl.peak, K = tl.knee;
return [
[L, P, K, { x: L.x, y: L.y - tl.segs[0].vo }],
[P, R, { x: R.x, y: R.y - tl.segs[1].vo }, K]
];
}
// Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst
// individueel gepositioneerd op basis van zijn anker, dan van links naar
// rechts gesorteerd en tegen elkaar aan geduwd als er overlap is, en tot
// slot terug binnen de wand getrokken als de laatste opening over de
// rechter rand heen zou steken. Als openingen simpelweg niet allemaal
// passen bij de huidige wandbreedte, wordt dat expliciet gemeld i.p.v.
// stil verkeerd te tekenen.
//
// Breedte/positie (x) worden EERST volledig opgelost; pas daarna wordt per
// opening de hoogte/Y geklemd tegen de daklijn — en wel de daklijn ZOALS
// DIE OP DE EIGEN (definitieve) POSITIE van die opening loopt, niet tegen
// het laagste punt van de hele wand. Bij een hellende wand mag een opening
// dicht bij de hoge kant dus hoger/groter zijn dan een opening dicht bij
// de lage kant.
// roofAt(xLocal): daklijn, xLocal vanaf de linker wandrand (0..wallWidth).
function resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) {
// 2850mm is de vaste referentie-bovengrens (bijv. max. sparingbreedte in
// dit constructiesysteem); daarnaast blijft de wand zelf ook een harde
// grens (er moet nog stijlruimte overblijven).
const maxOpW = Math.min(2850, Math.max(400, wallWidth - (studWidth * (2 * CORNER_STUDS + 2))));
// Openingen blijven vrij van de dubbele hoekstijl; de koningsstijl mag
// tegen de binnenste hoekstijl aan vallen.
const edgeMargin = studWidth * CORNER_STUDS;
// Fase 1: breedte + x-positie (ankers), zonder hoogte/Y.
let resolved = rawOpenings.map(raw => {
const w = Math.min(Math.max(400, raw.w), maxOpW);
let x;
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawY: raw.y };
});
resolved.sort((a, b) => a.x - b.x);
// Individuele randclamp (marge voor een stijl aan weerszijden van de wand)
resolved.forEach(o => {
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin));
});
// Openingen die elkaar in de hoogte raken (incl. boven-/onderdorpel en
// 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// (bijv. een raam boven een deur), dan mogen ze in x overlappen.
// Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd.
const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => {
const [a0, a1] = useFinal ? vBand(a, a.h, a.y) : vBand(a, Math.max(400, a.rawH), a.rawY);
const [b0, b1] = useFinal ? vBand(b, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.rawY);
return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
};
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing
// rond elke opening steekt aan weerszijden al 1x stijldikte uit (om de
// koningsstijl zichtbaar te laten), dus bij minder afstand zouden hun
// uitsparingen elkaar kunnen overlappen. Naast een deur 1x extra voor
// de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Links-naar-rechts: duw botsende openingen uit elkaar.
for (let i = 1; i < resolved.length; i++) {
const cur = resolved[i];
for (let j = 0; j < i; j++) {
const prev = resolved[j];
if (stacked(prev, cur)) continue;
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur));
}
}
// Rechts-naar-links: wat over de rechterrand steekt terugtrekken, en de
// botsende openingen links ervan mee.
const rightLimit = wallWidth - edgeMargin;
for (let i = resolved.length - 1; i >= 0; i--) {
const cur = resolved[i];
let maxX = rightLimit - cur.w;
for (let j = i + 1; j < resolved.length; j++) {
const next = resolved[j];
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w);
}
if (cur.x > maxX) cur.x = maxX;
}
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen
// de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van
// die opening — het punt waar een verticale lijn op die plek de
// (eventueel hellende) horizontale topbalk zou snijden. Bij een
// opening die zelf een breedte heeft, wordt het laagste van de twee
// uiteinden aangehouden (de daklijn is lineair of heeft een nok als
// hoogste punt, dus dat is voldoende).
const localRoofHeightAt = roofAt;
resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w));
const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2));
const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH);
const maxOpY = Math.max(0, localCeiling - h - plateThick);
const y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = h;
o.y = y;
});
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede
// openingen voor deze wandbreedte, of boven elkaar zonder ruimte
// ertussen) zodat de UI dit kan tonen.
resolved.forEach(o => {
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5);
});
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i], b = resolved[j];
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
}
}
return resolved;
}
// Zet ruwe (DOM- of JSON-)parameters om naar de opgeloste CONFIG waar de
// rest van de logica mee rekent (afronding + fallback-waarden, en de
// openingen al geresolved op hun anker).
function buildConfig(params, rawOpenings) {
const wallWidth = Math.round(params.width || 3000);
const hLeft = Math.round(params.heightLeft || 2400);
const hRight = Math.round(params.heightRight || 2900);
const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width);
const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width);
// Middenpunt (nok): standaard midden op de wand, of op peakX (mm vanaf
// links; een wandstuk uit een unit waar de nok niet in het midden
// valt). Nooit lager dan de hoogste goot; gelijk = geen nok.
let wallPeak = null;
if (params.heightMid > 0) {
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
const h = Math.round(Math.max(params.heightMid, hLeft, hRight));
if (px > 1 && px < wallWidth - 1 && h > hLeft + (px / wallWidth) * (hRight - hLeft) + 0.5) wallPeak = { x: px, h };
}
const roofAt = (x) => wallPeak
? (x <= wallPeak.x ? hLeft + x / wallPeak.x * (wallPeak.h - hLeft) : wallPeak.h + (x - wallPeak.x) / (wallWidth - wallPeak.x) * (hRight - wallPeak.h))
: hLeft + (x / wallWidth) * (hRight - hLeft);
return {
wallWidth,
wallHeightLeft: hLeft,
wallHeightRight: hRight,
wallPeak,
studWidth,
studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth),
plateThick,
plateDepth: Math.round(params.plateDepth || STANDARD_BEAM.depth),
sheathingThick: Math.round(params.sheathingThick || 10),
minSpacing: Math.round(params.minSpacing || 240),
maxSpacing: Math.round(params.maxSpacing || 480),
openings: resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick)
};
}
function configFromState(state) {
return buildConfig(state.wall, state.openings || []);
}
// Totale wanddikte (mm): regelwerk + beplating aan beide zijden (bij
// buitenMateriaal 'geen' alleen aan de binnenzijde). Gebruikt door de unit
// om wanden met hun buitenkant op de vloerrand te zetten.
function thicknessOf(config, buitenMateriaal) {
const sides = buitenMateriaal === 'geen' ? 1 : 2;
return Math.max(config.studDepth, config.plateDepth) + sides * (config.sheathingThick + SKIN_GAP_MM);
}
// Regelwerk: volledige stijlen (incl. koningsstijlen) + kreupele stijlen.
function computeFrame(CONFIG) {
const startX = -CONFIG.wallWidth / 2;
const top = topLine(CONFIG);
const slope = top.segs[0].slope;
// Met nok: de steilste helling (beide kanten lopen dan op naar de nok).
const angleDeg = top.peak
? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI))
: Math.round(Math.atan(slope) * (180 / Math.PI));
// Dubbele hoekstijlen: de buitenste staat op de wandrand, de tweede er
// direct tegenaan. De regelmatige verdeling begint bij de binnenste.
const cornerInset = (CORNER_STUDS - 1) * CONFIG.studWidth;
const outerLeftStudX = startX + CONFIG.studWidth / 2;
const outerRightStudX = startX + CONFIG.wallWidth - CONFIG.studWidth / 2;
const wallLeftStudX = outerLeftStudX + cornerInset;
const wallRightStudX = outerRightStudX - cornerInset;
const cornerStuds = [];
for (let i = 0; i < CORNER_STUDS; i++) {
cornerStuds.push(outerLeftStudX + i * CONFIG.studWidth, outerRightStudX - i * CONFIG.studWidth);
}
// Stijlen als stukken { x, y0, y1, kind } (mm; y1 null = tot de
// daklijn). Eerst kandidaat-posities, daarna wordt elke kandidaat
// opgeknipt door de openingen (incl. boven-/onderdorpel) die hij kruist.
// Zo kunnen openingen ook boven elkaar staan (raam boven een deur): de
// stijlen ertussen lopen van dorpel tot dorpel.
// hoek dubbele hoekstijl
// koning koningsstijl direct naast een opening
// dubbel tweede stijl direct tegen de koningsstijl van een deur
// regel regelmatige verdeling in de vrije zones
// kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({
op, x0: startX + op.x, x1: startX + op.x + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw
}));
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
ops.forEach(op => {
const x0 = startX + op.x, x1 = x0 + op.w;
cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' });
if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' });
});
// Kolommen: openingen (incl. hun stijlen) die elkaar in x overlappen.
// Tussen de kolommen en de hoekstijlen: regelmatige verdeling.
const cols = [];
ops.map(op => {
const extra = (isDoor(op) ? 2 : 1) * sw;
return [startX + op.x - extra + sw / 2, startX + op.x + op.w + extra - sw / 2]; // hartlijnen buitenste stijlen
}).sort((p, q) => p[0] - q[0]).forEach(r => {
const last = cols[cols.length - 1];
if (last && r[0] < last[1] + sw) last[1] = Math.max(last[1], r[1]);
else cols.push(r.slice());
});
const edges = [wallLeftStudX];
cols.forEach(c => edges.push(c[0], c[1]));
edges.push(wallRightStudX);
for (let i = 0; i < edges.length; i += 2) {
const a = edges[i], b = edges[i + 1];
if (b - a > sw) calculateSegmentPositions(b - a, CONFIG.minSpacing, CONFIG.maxSpacing).forEach(p => cands.push({ x: a + p, kind: 'regel' }));
}
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
// Knippen op de openingen.
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
const segs = [];
cands.forEach(c => {
const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = CONFIG.plateThick, below = null;
const push = (y0, y1, above) => {
if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de
// kreupele stijl van de onderste opening al).
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
}
segs.push({ x, y0, y1, kind: c.kind });
};
hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = bd; }
});
if (roofMin(x) > cur + 1) push(cur, null, null);
});
// Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = [];
segs.sort((p, q) => PRIO[p.kind] - PRIO[q.kind]).forEach(sg => {
const top1 = sg.y1 === null ? Infinity : sg.y1;
const clash = studs.some(o => Math.abs(o.x - sg.x) < sw - 0.5 &&
Math.min(top1, o.y1 === null ? Infinity : o.y1) - Math.max(sg.y0, o.y0) > 1);
if (!clash) studs.push(sg);
});
studs.sort((p, q) => p.x - q.x || p.y0 - q.y0);
// Volle stijlen (onderregel tot daklijn), o.a. voor de maatvoering.
const fullStuds = [...new Set(studs.filter(sg => sg.y1 === null && sg.y0 <= CONFIG.plateThick).map(sg => sg.x))].sort((a, b) => a - b);
return { studs, fullStuds, slope, angleDeg };
}
// Uitsparing in de beplating/het vochtdoek per opening (mm; x t.o.v. het
// wandmidden, y vanaf de onderkant): de opening + 1x stijldikte marge
// links/rechts/boven, zodat de koningsstijlen en bovendorpel zichtbaar
// blijven. De bovenkant wordt defensief tegen de lokale daklijn geklemd —
// bij een sterk hellende wand met een opening dicht bij de lage kant kan
// de marge anders lokaal boven het dak uitkomen (kapotte uitsparing).
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen).
function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10;
return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth;
const hx1 = -halfW + op.x + op.w + CONFIG.studWidth;
let hy1 = op.y + op.h + CONFIG.studWidth;
const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin;
if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling);
return { hx0, hx1, hy0: op.y, hy1 };
});
}
// Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
// uitsparingen. Geeft [[x0, x1], ...] terug (mm, t.o.v. het wandmidden).
function skinSegmentsAtY(CONFIG, y, extraHoles) {
if (y <= 0) return [];
// Deel van de breedte waar de daklijn boven y ligt.
const range = topLine(CONFIG).rangeAbove(y);
if (!range) return [];
let segments = [range];
sheathingHoles(CONFIG).concat(extraHoles || []).filter(h => y > h.hy0 && y < h.hy1).forEach(h => {
segments = segments.flatMap(([a, b]) => {
if (h.hx1 <= a || h.hx0 >= b) return [[a, b]];
const out = [];
if (h.hx0 > a) out.push([a, h.hx0]);
if (h.hx1 < b) out.push([h.hx1, b]);
return out;
});
});
return segments;
}
// Kruislat (pure berekening, mm, x t.o.v. het wandmidden). Latten worden
// uitgespaard rond de openingen (zelfde uitsparing als het vochtdoek,
// sheathingHoles) en afgekapt op de (schuine) bovenrand.
// Geeft { top, under, layers: { horizontaal: [...], verticaal: [...] },
// lengths: [...], stock: {count, wasteMM}, totalLengthMM }.
// facade (optioneel, woning): { u0, extStart, isEnd } — plaats van dit
// wandstuk in de doorlopende gevel (zie HouseModel.assignFacades): u0 =
// afstand (mm, wandvlak) van het gevelbegin (incl. hoekdoorloop) tot de
// linkerrand van dit stuk. Het verticale latten-raster volgt dan de
// hele gevel i.p.v. per stuk opnieuw te beginnen; een eindlat komt
// alleen aan het einde van de gevel.
// facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het
// horizontale latten-raster van de hele gevel begint. Een wandstuk dat
// hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster.
// skinFrom (optioneel, mm): buitenafwerking pas vanaf deze hoogte (het
// blootliggende deel van een naadwand boven het dak van de andere unit).
// extraHoles (optioneel, mm, zoals sheathingHoles): extra uitsparingen
// (binnenhoek-doorloop, zie build3D).
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom, extraHoles) {
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam
const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL;
const isEnd = facade ? facade.isEnd : true;
const halfW = CONFIG.wallWidth / 2;
const tl = topLine(CONFIG), topYAt = tl.at;
const holes = sheathingHoles(CONFIG).concat(extraHoles || []);
const w = BATTEN.width, sp = BATTEN.spacing;
const top = (cladding === 'verticaal') ? 'horizontaal' : 'verticaal';
// Horizontale latten: vanaf de onderkant elke 400 mm (hartmaat).
const horizontaal = [];
let yStart = y0 + w / 2;
if (facade && facade.gridY0 !== undefined) {
const g = facade.gridY0 + w / 2;
yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp;
}
for (let y = yStart; y < tl.maxY - w / 2; y += sp) {
skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => {
if (x0 <= -halfW + 0.5) x0 = xL;
if (x1 >= halfW - 0.5) x1 = xR;
if (x1 - x0 > w) horizontaal.push({ x0, x1, y });
});
}
// Verticale latten: vanaf de linkerrand elke 400 mm, plus een lat op de rechterrand.
const xs = [];
// Raster vanaf het gevelbegin; een lat hoort bij het stuk waar zijn hart in valt.
for (let x = gridX0 + w / 2 + Math.max(0, Math.ceil((xL - gridX0 - w / 2) / sp - 1e-9)) * sp; x <= xR - (isEnd ? w / 2 - 0.5 : 0) && x < xR; x += sp) xs.push(x);
if (isEnd && (!xs.length || xR - w / 2 - xs[xs.length - 1] > 50)) xs.push(xR - w / 2);
const verticaal = [];
xs.forEach(x => {
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
let segs = [[y0, yTop]];
holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => {
segs = segs.flatMap(([a, b]) => {
if (h.hy1 <= a || h.hy0 >= b) return [[a, b]];
const out = [];
if (h.hy0 > a) out.push([a, h.hy0]);
if (h.hy1 < b) out.push([h.hy1, b]);
return out;
});
});
segs.filter(([a, b]) => b - a > w).forEach(([y0, y1]) => verticaal.push({ x, y0, y1 }));
});
const lengths = horizontaal.map(b => b.x1 - b.x0).concat(verticaal.map(b => b.y1 - b.y0));
const bars = lengths.length ? planStockCutting(lengths, [BATTEN.length]) : [];
const sum = summarizeStockPlan(bars);
return {
top, under: top === 'verticaal' ? 'horizontaal' : 'verticaal',
layers: { horizontaal, verticaal }, lengths,
stock: { count: bars.length, wasteMM: sum.wasteMM },
totalLengthMM: lengths.reduce((a, b) => a + b, 0)
};
}
// Vochtdoek-bedrukking zoals op de echte doeken: het logo als medium-grijs
// watermerk (geen kleur) onder 45°, 1,5m breed. De doekbanen hebben
// ExtrudeGeometry-UV's in meters (vormcoördinaten), dus repeat = 1/tegel.
// Eén gedeelde textuur; zonder DOM (node-tests) valt hij weg.
const DOEK_COLOR = '#444444';
const WATERMARK = { color: '#8c8c8c', logoWidthM: 1.5, tileM: 2.0, px: 512 };
let doekTexture = null;
function getDoekTexture() {
if (doekTexture !== null) return doekTexture || null;
doekTexture = false;
if (typeof document === 'undefined' || !window.TECTUM_LOGO) return null;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = WATERMARK.px;
const ctx = canvas.getContext && canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = DOEK_COLOR;
ctx.fillRect(0, 0, WATERMARK.px, WATERMARK.px);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1 / WATERMARK.tileM, 1 / WATERMARK.tileM);
tex.anisotropy = 8;
const img = new Image();
img.onload = () => {
// Logo-silhouet egaal grijs maken (source-in), dan 45° gedraaid midden
// in de tegel tekenen.
const pxPerM = WATERMARK.px / WATERMARK.tileM;
const w = WATERMARK.logoWidthM * pxPerM, h = w / window.TECTUM_LOGO.aspect;
const tint = document.createElement('canvas');
tint.width = Math.ceil(w); tint.height = Math.ceil(h);
const tctx = tint.getContext('2d');
tctx.drawImage(img, 0, 0, tint.width, tint.height);
tctx.globalCompositeOperation = 'source-in';
tctx.fillStyle = WATERMARK.color;
tctx.fillRect(0, 0, tint.width, tint.height);
ctx.save();
ctx.translate(WATERMARK.px / 2, WATERMARK.px / 2);
ctx.rotate(-Math.PI / 4); // oplopend van links naar rechts
ctx.drawImage(tint, -w / 2, -h / 2, w, h);
ctx.restore();
tex.needsUpdate = true;
};
img.src = window.TECTUM_LOGO.png;
doekTexture = tex;
return tex;
}
function createMaterials() {
const doekMap = getDoekTexture();
return {
woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }),
spaanplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x1e4620, roughness: 0.6, side: THREE.DoubleSide }),
doekMaterial: doekMap
? new THREE.MeshStandardMaterial({ color: 0xffffff, map: doekMap, roughness: 0.7, side: THREE.DoubleSide })
: new THREE.MeshStandardMaterial({ color: DOEK_COLOR, roughness: 0.7, side: THREE.DoubleSide }),
cementplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x808285, roughness: 0.8, side: THREE.DoubleSide }),
isolatieMaterial: new THREE.MeshStandardMaterial({ color: 0xf4f4f2, roughness: 0.95 }),
battenMaterial: new THREE.MeshStandardMaterial({ color: 0xd9b77e, roughness: 0.7 })
};
}
// Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte
// gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid)
// en geeft daarnaast de volume-boekhouding terug voor de specificaties.
// opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible }
// trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null.
// skinExt: { left, right } in mm (zie cornerExt) of null.
// facade: { u0, extStart, isEnd } (zie computeBattens) of null.
function build3D(CONFIG, calcData, opts) {
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const spaanplaatVisible = opts.spaanplaatVisible !== false;
const buitenVisible = opts.buitenVisible !== false;
const cladding = opts.cladding || 'horizontaal';
// Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone).
const trasraam = buitenMateriaal === 'vochtdoek' || (buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone)) ? (opts.trasraam || null) : null;
const skinExt = opts.skinExt || null;
const facade = opts.facade || null;
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0; // buitenafwerking vanaf (mm), zie computeBattens
const painted = !!opts.painted; // spaanplaat geschilderd: zonder plaatnaden
// stone: steencode (TRASRAAM_STONES) voor steenstrips op een cementplaat-gevel.
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
// spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand
// geheel in één unit: onder het dak van de andere unit is het een binnenwand).
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
// Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over
// naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving).
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
// Verticaal: een hoger beginnend stuk (facade.yOff, mm boven het
// loopvlak van de gevel) schuift het watermerk mee omhoog.
const uvShiftYM = facade && facade.yOff ? facade.yOff * 0.001 : 0;
const shiftUV = (geo) => {
if (!uvShiftM && !uvShiftYM) return geo;
const uv = geo.attributes.uv;
for (let k = 0; k < uv.count; k++) { uv.setX(k, uv.getX(k) + uvShiftM); uv.setY(k, uv.getY(k) + uvShiftYM); }
return geo;
};
const wallGroup3D = new THREE.Group();
// ------------------------------------------------------------
// Volume-boekhouding voor de specificaties. Puur analytisch, los van de
// driehoeksmesh-data: elke balk/dorpel/plaat-vorm wordt via de
// schoenveter-formule (shoelace) opgemeten en opgeteld, in m3.
// ------------------------------------------------------------
let woodVolumeM3 = 0;
let spaanplaatVolumeM3 = 0;
let spaanplaatAreaM2 = 0; // enkelzijdig, altijd binnenzijde/noord
let cementplaatVolumeM3 = 0;
let cementplaatAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='cementplaat'
let doekAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='vochtdoek'
let spaanplaatBuitenAreaM2 = 0; // alleen als buitenMateriaal='spaanplaat' (binnenwanden)
let spaanplaatBuitenVolumeM3 = 0;
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0; // kruislat (alleen vochtdoek)
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek)
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok
let horizBeamCount = 0; // onderregel-segmenten + topbalk + dorpels per opening
function createExtrudedBeam(shape, depth, positionZ) {
const extrudeSettings = { steps: 1, depth: depth, bevelEnabled: false };
const geo = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(0, 0, positionZ);
mesh.castShadow = true;
woodVolumeM3 += polygonArea(shape.getPoints()) * depth;
return mesh;
}
const toM = 0.001;
const sWidthM = CONFIG.studWidth * toM;
const sDepthM = CONFIG.studDepth * toM;
const pThickM = CONFIG.plateThick * toM;
const pDepthM = CONFIG.plateDepth * toM;
const sThickM = CONFIG.sheathingThick * toM;
const wWidthM = CONFIG.wallWidth * toM;
const hLeftM = CONFIG.wallHeightLeft * toM;
const hRightM = CONFIG.wallHeightRight * toM;
const halfW = wWidthM / 2;
const tl = topLine(CONFIG, toM);
const startXM = -halfW;
const topYAt = tl.at;
// Gevel doorgetrokken tot onder de dakrand (opts.skinAbove, mm): de
// buitenlagen (doek, latwerk, steen, cementplaat) volgen een bovenrand
// die zoveel hoger ligt, met dezelfde helling; regelwerk en binnenzijde
// blijven op de wandhoogte.
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
wallPeak: CONFIG.wallPeak ? { x: CONFIG.wallPeak.x, h: CONFIG.wallPeak.h + skinAbove } : null
}) : CONFIG;
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
// Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok).
const underPts = tl.underPts;
// Openingen naar meter-coordinaten. LET OP: xMin/xMax hier zijn al naar
// wereld-coordinaten verschoven (dezelfde as als de rest van de 3D-opbouw,
// -halfW..+halfW), niet lokaal t.o.v. de linker wandrand (0..wallWidth) —
// dat onderscheid was eerder de bron van een echte bug (dorpels en de
// OSB-uitsparing op de verkeerde plek, deels buiten de plaat).
const opsM = CONFIG.openings.map(op => ({
id: op.id,
xMin: startXM + op.x * toM,
xMax: startXM + (op.x + op.w) * toM,
yMin: op.y * toM,
yMax: (op.y + op.h) * toM,
wM: op.w * toM
}));
// A. ONDERBALK WAND — alleen doorgesneden ter plaatse van openingen die
// tot de vloer reiken (deuren); vensters boven de plaat blijven gewoon
// op een doorlopende onderregel staan.
const floorCuts = opsM
.filter(op => op.yMin < pThickM)
.map(op => ({ min: op.xMin, max: op.xMax }))
.sort((a, b) => a.min - b.min);
let cursor = -halfW;
floorCuts.forEach(cut => {
if (cut.min > cursor) {
const segW = cut.min - cursor;
const geo = new THREE.BoxGeometry(segW, pThickM, pDepthM);
const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(cursor + segW / 2, pThickM / 2, 0);
wallGroup3D.add(mesh);
woodVolumeM3 += segW * pThickM * pDepthM;
horizBeamCount++;
}
cursor = Math.max(cursor, cut.max);
});
if (cursor < halfW) {
const segW = halfW - cursor;
const geo = new THREE.BoxGeometry(segW, pThickM, pDepthM);
const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(cursor + segW / 2, pThickM / 2, 0);
wallGroup3D.add(mesh);
woodVolumeM3 += segW * pThickM * pDepthM;
horizBeamCount++;
}
// B. TOPBALK WAND (ongewijzigd door openingen) — dit is zelf ook een
// "horizontale balk" (liggend, ook al loopt hij mee met de dakhelling).
// Met nok: twee bovenregels die in verstek op de nok samenkomen.
topPlateOutlines(tl).forEach(pts => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++;
});
// C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk
// stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame.
calcData.studs.forEach(st => {
const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const y0 = st.y0 * toM;
const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
});
// D. DORPELS, per opening
opsM.forEach(op => {
// Bovendorpel / linteel (horizontale balk)
const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax + sWidthM);
opTopShape.lineTo(op.xMin, op.yMax + sWidthM);
opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++;
// Onderdorpel (indien zwevend, dus niet op de onderregel) — zit
// ONDER de opening (niet erin), consistent met de BOM/PDF-logica.
if (op.yMin > pThickM) {
const opBotShape = new THREE.Shape();
opBotShape.moveTo(op.xMin, op.yMin - sWidthM);
opBotShape.lineTo(op.xMax, op.yMin - sWidthM);
opBotShape.lineTo(op.xMax, op.yMin);
opBotShape.lineTo(op.xMin, op.yMin);
opBotShape.closePath();
wallGroup3D.add(createExtrudedBeam(opBotShape, sDepthM, -sDepthM / 2));
horizBeamCount++;
}
});
// (Isolatie wordt niet getekend; de spouwinhoud zit in computeSpecs.)
// E. VERTICALE PLATEN — gedeelde opbouw voor spaanplaat (binnenzijde,
// altijd) EN cement-gebonden platen (buitenzijde, als die gekozen is):
// beide zijn plaatmateriaal van precies 1m breed, elk hoog genoeg om in
// een enkel stuk te zagen (geen horizontale naden, alleen verticale).
// De platen worden vanaf een ankerzijde gelegd; de laatste (smallere)
// reststrook valt aan de andere kant.
const maxDepth = Math.max(sDepthM, pDepthM);
// Klein, bewust niet-nul luchtgaatje tussen plaat en houtwerk: voorkomt
// dat vlakken exact samenvallen (co-planar), wat anders overlap-
// artefacten/flikkering (z-fighting) geeft, met name goed zichtbaar op
// scherende hoeken zoals de schuine bovenrand.
const skinGapM = SKIN_GAP_MM * 0.001; // 0.5 mm
const seamM = 0.0005; // 0.5 mm naad tussen twee aangrenzende platen/banen
const boardWidthM = 1.0; // platen zijn altijd exact 1m breed
// Ankerzijde: gelijke hoogte -> links; linkerkant het hoogst -> rechts;
// rechterkant het hoogst -> links. (De volle platen beginnen op de
// ankerzijde, de smalle reststrook valt aan de andere kant.)
let boardAnchorSide;
if (hLeftM === hRightM) boardAnchorSide = 'left';
else if (hLeftM > hRightM) boardAnchorSide = 'right';
else boardAnchorSide = 'left';
const numFullBoards = Math.floor(wWidthM / boardWidthM);
const remainderM = wWidthM - numFullBoards * boardWidthM;
let boardBoundaries;
if (boardAnchorSide === 'left') {
boardBoundaries = [];
for (let i = 0; i <= numFullBoards; i++) boardBoundaries.push(-halfW + i * boardWidthM);
if (remainderM > 1e-9) boardBoundaries.push(halfW);
} else {
const raw = [];
for (let i = 0; i <= numFullBoards; i++) raw.push(halfW - i * boardWidthM);
if (remainderM > 1e-9) raw.push(-halfW);
boardBoundaries = raw.slice().reverse();
}
// Uitgebreide uitsparing per opening (1x stijldikte marge, zie hierboven),
// eenmalig berekend zodat elke plaat/baan er alleen zijn eigen deel van
// hoeft te knippen. Extra: de bovenkant van elke uitsparing wordt hier
// ook nog defensief tegen de lokale daklijn geklemd — bij een sterk
// hellende wand met een opening dicht bij de lage kant kan de marge
// (+sWidthM t.b.v. de bovendorpel) anders lokaal boven het dak uitkomen,
// wat dezelfde soort kapotte-uitsparing-bug zou geven als eerder opgelost.
// (Berekening zelf: sheathingHoles, gedeeld met de 2D-tekening.)
const edgeEps = 1e-6;
// Hoekdoorloop per buitenlaag, in m (zie cornerExt).
const extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; };
const holeExtents = sheathingHoles(CONFIG).map(hole => ({
hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM
}));
// Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand
// en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop
// ({ left, right }, mm; bovenkant dak van de buur). Daarboven loopt de
// gevel gewoon door. Als uitsparing (zoals een opening) in alle
// buitenlagen.
const trimL = skinExt && skinExt.left < 0 ? -skinExt.left * toM : 0;
const trimR = skinExt && skinExt.right < 0 ? -skinExt.right * toM : 0;
const trimTop = opts.trimTop || {};
const topOf = (v) => (v > 0 ? v : 1e7) * toM;
const trimHoles = [];
if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: topOf(trimTop.left) });
if (trimR > 0) trimHoles.push({ hx0: halfW - trimR, hx1: halfW + 1, hy0: -1, hy1: topOf(trimTop.right) });
const outerHoles = holeExtents.concat(trimHoles);
const trimHolesMM = trimHoles.map(h => ({ hx0: h.hx0 / toM, hx1: h.hx1 / toM, hy0: h.hy0 / toM, hy1: h.hy1 / toM }));
// Steenblok (trasraam en steengevel): isolatieblok met een steenstrip
// ervoor, samen STONE.THICK_MM dik, vóór het wandvlak (doek resp.
// cementplaat). Van hoogte y0 tot yCap (m) of tot de daklijn, met de
// openingen uitgespaard. Hoeken: doorloop zoals de andere buitenlagen
// (cornerExt); het laatste stuk van die doorloop is de hoek zelf,
// waarvan de kopse kant zichtbaar is: dat stuk is over de volle dikte
// steen. UV's in meters in gevel-coördinaten, zodat het metselwerk
// over wandstukken en naden doorloopt; per vlakrichting gezet (ook de
// kopse kanten horizontaal, niet de standaard van ExtrudeGeometry).
// Verticaal: anchor = { y (m), down } — op hoogte y ligt een voeg;
// de gevel telt vanaf daar omhoog (hele stenen boven de naad), het
// trasraam vanaf zijn bovenkant omlaag (hele stenen onder de naad).
// Geeft { areaM2, isoM3 }.
function stoneBlock(code, y0, yCap, anchor) {
// v (m) op hoogte y: omhoog vanaf de onderste voeg van de textuur;
// omlaag vanaf een voeg een aantal hele lagen hoger in de textuur,
// zodat de strook eronder nog binnen de textuur valt.
const jointM = STONE.JOINT_MM * toM, courseM = STONE.COURSE_MM * toM;
const vOff = anchor.down
? jointM + Math.ceil(Math.max(0, anchor.y - y0) / courseM) * courseM - anchor.y
: jointM - anchor.y;
const setUV = (geo) => {
const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv;
for (let k = 0; k < pos.count; k++) {
const x = pos.getX(k) + uvShiftM, y = pos.getY(k) + vOff, z = pos.getZ(k);
const nx = Math.abs(nrm.getX(k)), ny = Math.abs(nrm.getY(k));
if (nx > 0.5) uv.setXY(k, z, y); else if (ny > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
}
uv.needsUpdate = true;
return geo;
};
const S = STONE;
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
const tIso = (S.THICK_MM - S.STRIP_MM) * toM, tAll = S.THICK_MM * toM;
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + S.THICK_MM);
const xL = -halfW - ext.left, xR = halfW + ext.right;
const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR;
const topClamped = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x)));
const rects = outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 }));
const shapesIn = (a, b) => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) });
return (pieces || []).map(piece => {
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
return shape;
});
};
const add = (shapes, depth, z, mat) => {
if (!shapes.length) return;
const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat);
mesh.position.set(0, 0, z);
mesh.visible = buitenVisible;
mesh.castShadow = true;
wallGroup3D.add(mesh);
};
const stoneMat = stoneMaterial(code);
const isoMat = opts.materials.isolatieMaterial || cementplaatMaterial;
const mid = shapesIn(capL, capR);
const caps = shapesIn(xL, capL).concat(shapesIn(capR, xR));
add(mid, tIso, zB, isoMat);
add(mid, tAll - tIso, zB + tIso, stoneMat);
add(caps, tAll, zB, stoneMat);
const area = (list) => list.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso };
}
// side: 'front' (buitenzijde/zuid) of 'back' (binnenzijde/noord).
// range (optioneel, m): { y0, y1, zExtra } = alleen de strook daartussen
// (trasraam), zExtra verder naar buiten. ext (m): { left, right, zOut } =
// doorloop voorbij de wandranden (hoekdoorloop buitenzijde). Zonder zOut
// lopen de randplaten door (laag ligt al vóór het wandvlak); met zOut
// komt er een losse strook zOut verder naar buiten (laag in de
// beplatingsruimte, anders zouden de hoekstijlen erdoor steken).
function buildVerticalBoardSkin(material, side, visible, range, ext) {
// Geschilderde spaanplaat (opts.painted): één egaal vlak, geen naden.
const seamless = painted && material === spaanplaatMaterial;
const bounds = seamless ? [boardBoundaries[0], boardBoundaries[boardBoundaries.length - 1]] : boardBoundaries.slice();
if (ext && !ext.zOut) { bounds[0] -= ext.left; bounds[bounds.length - 1] += ext.right; }
const yB = range ? range.y0 : 0;
const yCap = range ? range.y1 : Infinity;
const TL = side === 'front' && !range ? tlOut : tl; // buitenplaat: tot onder de dakrand
const HX = side === 'front' ? outerHoles : holeExtents;
const skinTopAt = (x) => Math.min(yCap, TL.at(x));
let areaM2 = 0, volumeM3 = 0;
const z = (side === 'front' ? (maxDepth / 2) + skinGapM : -(maxDepth / 2) - sThickM - skinGapM) + ((range && range.zExtra) || 0);
// Duidelijke naad tussen platen: dunne rand rond elke plaat, 50%
// donkerder dan de plaatkleur zelf (ook zichtbaar rond eventuele
// opening-uitsparingen, niet alleen tussen platen onderling).
const edgeMaterial = new THREE.LineBasicMaterial({ color: material.color.clone().multiplyScalar(0.5) });
for (let i = 0; i < bounds.length - 1; i++) {
const bx0 = bounds[i];
const bx1 = bounds[i + 1];
const isWallLeftEdge = i === 0;
const isWallRightEdge = i === bounds.length - 2;
// Alleen inspringen op interne naden; de buitenste plaatranden blijven
// gewoon vlak met de wandrand (geen naad met het kozijn/de hoekstijl).
const ex0 = bx0 + (isWallLeftEdge ? 0 : seamM / 2);
const ex1 = bx1 - (isWallRightEdge ? 0 : seamM / 2);
if (ex1 - ex0 <= edgeEps) continue;
// Plaat = strook min uitsparingen (columnMinusRects): een opening
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt,
HX.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })),
{ yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
let boardShapes;
if (pieces) {
boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude
// gat-methode, net binnen de plaatrand afgekapt.
const boardShape = new THREE.Shape();
boardShape.moveTo(ex0, yB);
boardShape.lineTo(ex1, yB);
boardShape.lineTo(ex1, skinTopAt(ex1));
TL.kinks.filter(x => x > ex0 && x < ex1).forEach(x => boardShape.lineTo(x, skinTopAt(x)));
boardShape.lineTo(ex0, skinTopAt(ex0));
boardShape.closePath();
holeExtents.forEach(h => {
const chx0 = Math.max(ex0 + edgeEps, h.hx0);
const chx1 = Math.min(ex1 - edgeEps, h.hx1);
if (chx1 > chx0) {
const holePath = new THREE.Path();
const chy0 = Math.max(yB + edgeEps, h.hy0), chy1 = Math.min(yCap - edgeEps, h.hy1);
if (chy1 <= chy0) return;
holePath.moveTo(chx0, chy0);
holePath.lineTo(chx0, chy1);
holePath.lineTo(chx1, chy1);
holePath.lineTo(chx1, chy0);
holePath.closePath();
boardShape.holes.push(holePath);
}
});
boardShapes = [boardShape];
}
if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const boardGeo = new THREE.ExtrudeGeometry(boardShapes, boardExtrudeSettings);
const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
const board = new THREE.Mesh(boardGeo, material);
board.position.set(0, 0, z);
board.visible = visible;
wallGroup3D.add(board);
if (!seamless) {
const edgesGeo = new THREE.EdgesGeometry(boardGeo);
const edgeLines = new THREE.LineSegments(edgesGeo, edgeMaterial);
edgeLines.position.set(0, 0, z);
edgeLines.visible = visible;
wallGroup3D.add(edgeLines);
}
areaM2 += boardAreaM2;
volumeM3 += boardAreaM2 * sThickM;
}
if (ext && ext.zOut) {
[[-halfW - ext.left, -halfW], [halfW, halfW + ext.right]].forEach(([a, b]) => {
if (b - a <= edgeEps || Math.min(skinTopAt(a), skinTopAt(b)) <= yB + edgeEps) return;
const shape = new THREE.Shape([
new THREE.Vector2(a, yB), new THREE.Vector2(b, yB), new THREE.Vector2(b, skinTopAt(b)), new THREE.Vector2(a, skinTopAt(a))
]);
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false });
const mesh = new THREE.Mesh(geo, material);
mesh.position.set(0, 0, z + ext.zOut);
mesh.visible = visible;
wallGroup3D.add(mesh);
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial);
edgeLines.position.set(0, 0, z + ext.zOut);
edgeLines.visible = visible;
wallGroup3D.add(edgeLines);
const a2 = shapeAreaWithHoles(shape);
areaM2 += a2;
volumeM3 += a2 * sThickM;
});
}
return { areaM2, volumeM3 };
}
// Binnenzijde (noord): altijd spaanplaat.
const spaanplaatResult = buildVerticalBoardSkin(spaanplaatMaterial, 'back', spaanplaatVisible);
// Kopse kant van een binnenhoek-doorloop (trimL/trimR): die staat open
// naar de kamer van de buur-unit; binnenplaat erop (over regelwerk +
// beplating), net buiten het stijlvlak tegen flikkeren.
[[trimL, -halfW, -1], [trimR, halfW, 1]].forEach(([trim, x, dir]) => {
if (!(trim > 0)) return;
const h = Math.min(topYAt(x), topOf(dir < 0 ? trimTop.left : trimTop.right)), depth = maxDepth + 2 * (skinGapM + sThickM);
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
cap.rotation.y = dir * Math.PI / 2;
cap.position.set(x + dir * 0.0003, h / 2, 0);
cap.visible = spaanplaatVisible;
wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
if (spaanplaatBelow) {
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible, { y0: 0, y1: skinFrom * 0.001 });
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
}
if (buitenMateriaal === 'spaanplaat') {
// Binnenwand: aan beide zijden spaanplaat.
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible);
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
} else if (buitenMateriaal === 'geen') {
// Naadzijde: geen beplating (het deel van de andere unit staat ertegen).
} else if (buitenMateriaal === 'cementplaat') {
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, null,
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
cementplaatAreaM2 = cementResult.areaM2;
cementplaatVolumeM3 = cementResult.volumeM3;
// Steen (opts.stone): steenblok (isolatie + strip) over de hele gevel,
// vóór de cement-gebonden plaat; onderaan het trasraam (eigen steen
// of cementplaat), de gevelsteen begint erboven.
if (stone) {
let y0 = 0;
if (trasraam && isStoneFinish(trasraam.finish)) {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3;
y0 = trasraam.top * toM;
} else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + skinGapM
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
trasraamAreaM2 = tras.areaM2;
trasraamVolumeM3 = tras.volumeM3;
y0 = trasraam.top * toM;
}
// Anker op de gevel-hoogte van de naad (facade.gridY0 = bovenkant
// trasraam t.o.v. dit stuk), zodat ook een opbouw aansluit.
const anchorY = facade && facade.gridY0 !== undefined ? facade.gridY0 * toM : y0;
const r = stoneBlock(stone, y0, Infinity, { y: anchorY, down: false });
stoneAreaM2 = r.areaM2;
stoneIsolatieM3 = r.isoM3;
}
} else {
// F. VOCHTDOEK BUITENZIJDE (zuid) — horizontale banen van 1500mm
// (rolbreedte), "oneindig" strekkend in de breedte (dus geen
// verticale naden nodig binnen één baan). Begint onderaan
// (vloerhoogte); de bovenste baan wordt afgeknipt op de schuine
// daklijn. Elke baan overlapt de vorige met 5cm.
const doekHeightM = 1.5;
const doekOverlapM = 0.05;
const doekStepM = doekHeightM - doekOverlapM;
// Kleine Z-stap per baan: zorgt dat een hogere baan (die de vorige met
// 5cm overlapt) in de overlapzone net iets vóór de onderliggende baan
// ligt — zoals bij een echte shingle-overlap — i.p.v. exact co-planair
// (wat flikkering/z-fighting zou geven nu de platen niet meer
// transparant zijn).
const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
const wallOutlinePts = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...tlOut.pts.slice().reverse()];
const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM;
let rowIndex = 0;
while (rowY0 < maxRoofY - 1e-9) {
const rowY1 = rowY0 + doekHeightM;
// Baan = strook [rowY0, min(rowY1, daklijn)] min uitsparingen
// (columnMinusRects, zelfde als de platen): een opening over de
// baanrand wordt een inkeping, geen reepje doek door de opening.
const roofAt = tlOut.at;
const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0];
const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks);
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)),
outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : [];
let rowShapes;
if (pieces) {
rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude methode.
let rowPts = clipPolygonYLE(wallOutlinePts, rowY1);
rowPts = clipPolygonYGE(rowPts, rowY0);
rowShapes = [];
if (rowPts.length >= 3) {
const rowShape = new THREE.Shape();
rowShape.moveTo(rowPts[0].x, rowPts[0].y);
for (let k = 1; k < rowPts.length; k++) rowShape.lineTo(rowPts[k].x, rowPts[k].y);
rowShape.closePath();
holeExtents.forEach(h => {
const chy0 = Math.max(rowY0 + edgeEps, h.hy0);
const chy1 = Math.min(rowY1 - edgeEps, h.hy1);
if (chy1 > chy0) {
const holePath = new THREE.Path();
holePath.moveTo(h.hx0, chy0);
holePath.lineTo(h.hx0, chy1);
holePath.lineTo(h.hx1, chy1);
holePath.lineTo(h.hx1, chy0);
holePath.closePath();
rowShape.holes.push(holePath);
}
});
rowShapes.push(rowShape);
}
}
if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
const doekRow = new THREE.Mesh(rowGeo, doekMaterial);
// 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op
// de wand), anders flikkert het dak door het doek heen waar de
// gevel tot onder de dakrand doorloopt (skinAbove).
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
doekRow.visible = buitenVisible;
wallGroup3D.add(doekRow);
// Elke baan telt met zijn volle oppervlak mee (incl. de overlapzone
// met de vorige baan) — dat is fysiek correct voor een materiaal-
// hoeveelheid: de overlap wordt ook echt van de rol verbruikt.
doekAreaM2 += rowAreaM2;
}
// Hoekdoorloop: strook over de kopse kant van de doorlopende wand,
// net vóór het wandvlak (het doek zelf ligt in de beplatingsruimte).
const dExt = extM(0);
// Dun (1 mm, membraan); het latwerk ligt eroverheen.
const zOut = (maxDepth / 2) + skinGapM + sThickM + skinGapM + rowIndex * doekZStepM;
const stripM = 0.001;
[[-halfW - dExt.left, -halfW], [halfW, halfW + dExt.right]].forEach(([a, b]) => {
if (b - a <= edgeEps || Math.min(roofAt(a), roofAt(b)) <= rowY0 + edgeEps) return;
const pts = [{ x: a, y: rowY0 }, { x: b, y: rowY0 }, { x: b, y: Math.min(rowY1, roofAt(b)) }, { x: a, y: Math.min(rowY1, roofAt(a)) }];
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y)));
const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial);
mesh.position.set(0, 0, zOut);
mesh.visible = buitenVisible;
wallGroup3D.add(mesh);
doekAreaM2 += shapeAreaWithHoles(shape);
});
rowY0 += doekStepM;
rowIndex++;
}
// H. TRASRAAM: onderaan cement-gebonden plaat (zelfde plaatlogica en
// uitsparingen als de buitenplaten), vóór het doek
// (zelfde vlak als de latten). Het doek loopt
// er ongewijzigd achter door; het latwerk begint erboven.
if (trasraam && isStoneFinish(trasraam.finish)) {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3;
} else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
trasraamAreaM2 = tras.areaM2;
trasraamVolumeM3 = tras.volumeM3;
}
// G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op
// het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen.
const bat = computeBattens(CONFIG_OUT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM);
const bT = BATTEN.thick * toM, bW = BATTEN.width * toM;
const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM + DOEK_OUT_M;
const addLayer = (orientation, z) => {
bat.layers[orientation].forEach(b => {
const horiz = orientation === 'horizontaal';
const len = horiz ? (b.x1 - b.x0) * toM : (b.y1 - b.y0) * toM;
const mesh = new THREE.Mesh(new THREE.BoxGeometry(horiz ? len : bW, horiz ? bW : len, bT), battenMaterial);
mesh.position.set(horiz ? (b.x0 + b.x1) / 2 * toM : b.x * toM, horiz ? b.y * toM : (b.y0 + b.y1) / 2 * toM, z + bT / 2);
mesh.visible = buitenVisible;
wallGroup3D.add(mesh);
});
};
addLayer(bat.under, zDoek);
addLayer(bat.top, zDoek + bT);
battenLengthM = bat.totalLengthMM * toM;
battenVolumeM3 = battenLengthM * bT * bW;
battenCount = bat.stock.count;
}
return {
group: wallGroup3D,
stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
battenLengthM, battenVolumeM3, battenCount, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3
}
};
}
// Specificaties (volumes -> gewichten), op basis van de stats uit build3D.
function computeSpecs(CONFIG, stats, buitenMateriaal) {
const toM = 0.001;
const wWidthM = CONFIG.wallWidth * toM;
const hLeftM = CONFIG.wallHeightLeft * toM;
const hRightM = CONFIG.wallHeightRight * toM;
const sDepthM = CONFIG.studDepth * toM;
const halfW = wWidthM / 2;
// Omhullende van de wand (trapezium, incl. schuine top) als referentie
// voor de leegte-berekening — spouwdiepte wordt gelijkgesteld aan de
// stijldiepte (vereenvoudiging, zie toelichting in de UI).
const envelopePoints = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...topLine(CONFIG, toM).pts.slice().reverse()];
const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => {
return sum + (op.w * toM) * (op.h * toM) * sDepthM;
}, 0);
let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3;
if (voidVolumeM3 < 0) voidVolumeM3 = 0;
const woodKg = stats.woodVolumeM3 * DENSITY_WOOD_KGM3;
const spaanplaatKg = stats.spaanplaatVolumeM3 * DENSITY_SPAANPLAAT_KGM3;
let buitenAreaM2, buitenKg, buitenVolumeM3;
if (buitenMateriaal === 'spaanplaat') {
buitenAreaM2 = stats.spaanplaatBuitenAreaM2;
buitenKg = stats.spaanplaatBuitenVolumeM3 * DENSITY_SPAANPLAAT_KGM3;
buitenVolumeM3 = stats.spaanplaatBuitenVolumeM3;
} else if (buitenMateriaal === 'geen') {
buitenAreaM2 = 0; buitenKg = 0; buitenVolumeM3 = 0;
} else if (buitenMateriaal === 'cementplaat') {
buitenAreaM2 = stats.cementplaatAreaM2;
buitenKg = stats.cementplaatVolumeM3 * DENSITY_CEMENTPLAAT_KGM3;
buitenVolumeM3 = stats.cementplaatVolumeM3;
} else {
// Vochtdoek wordt per m2 gewogen (rol/dun membraan), niet per m3.
buitenAreaM2 = stats.doekAreaM2;
buitenKg = stats.doekAreaM2 * DENSITY_DOEK_KGM2;
buitenVolumeM3 = null; // n.v.t. (rolmateriaal)
}
const battenKg = (stats.battenVolumeM3 || 0) * DENSITY_WOOD_KGM3;
const trasraamKg = (stats.trasraamVolumeM3 || 0) * DENSITY_CEMENTPLAAT_KGM3;
const totalKg = woodKg + spaanplaatKg + buitenKg + battenKg + trasraamKg;
return { woodKg, spaanplaatKg, buitenAreaM2, buitenKg, buitenVolumeM3, voidVolumeM3, battenKg, trasraamKg, totalKg };
}
// Materiaallijst (BOM): elk los stuk hout met zijn maat, soort en
// positie (voor de PDF-tekening en de unit-totalen).
function computeBOM(CONFIG, calcData) {
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const halfW = wallWidth / 2;
const tl = topLine(CONFIG);
const startX = -halfW;
const pieces = [];
const opsWorld = CONFIG.openings.map(op => ({ xMin: startX + op.x, xMax: startX + op.x + op.w, yMin: op.y }));
const floorCuts = opsWorld.filter(o => o.yMin < CONFIG.plateThick)
.map(o => ({ min: o.xMin, max: o.xMax })).sort((a, b) => a.min - b.min);
let cursor = -halfW;
floorCuts.forEach(cut => {
if (cut.min > cursor) {
pieces.push({ length: cut.min - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + cut.min) / 2, y: CONFIG.plateThick / 2 });
}
cursor = Math.max(cursor, cut.max);
});
if (cursor < halfW) {
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2 });
}
// Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
// omtrek (PDF-tekening).
topPlateOutlines(tl).forEach((outline, i) => {
const sg = tl.segs[i];
pieces.push({
length: Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y), width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel',
x: (sg.a.x + sg.b.x) / 2, y: (sg.a.y + sg.b.y) / 2 - CONFIG.plateThick / 2, outline
});
});
// Verticale stukken krijgen, naast de bounding-box velden (length/y —
// gebruikt voor de materiaallijst: de lengte van het rechte stuk hout
// waar de schuine kop uit gezaagd wordt), ook hun 4 werkelijke
// hoekpunten (yBottomLeft/Right, yTopLeft/Right). De PDF-tekening
// gebruikt die hoekpunten om het stuk als schuin viervlak te tekenen
// i.p.v. als platte rechthoek tot het hoogste punt — anders komt alleen
// de hoogste kant van de balk goed op de daklijn te staan, en blijft de
// laagste kant er (visueel) los van.
// (outline: ook de knik onder de nok, als de stijl daar staat.)
calcData.studs.forEach(st => {
const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = st.y0;
const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk';
if (st.y1 !== null) {
pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1
});
return;
}
const topPts = tl.underPts(xLeft, xRight);
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()]
});
});
CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x;
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2 });
if (op.y > CONFIG.plateThick) {
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2 });
}
});
return pieces;
}
function groupBOM(pieces) {
const map = new Map();
pieces.forEach(p => {
const length = Math.round(p.length);
const key = `${p.width}|${p.depth}|${length}`;
if (!map.has(key)) map.set(key, { width: p.width, depth: p.depth, length, count: 0 });
map.get(key).count++;
});
const groups = Array.from(map.values());
groups.sort((a, b) => b.length - a.length);
groups.forEach((g, i) => { g.posnum = i + 1; });
const keyToPosnum = new Map();
groups.forEach(g => keyToPosnum.set(`${g.width}|${g.depth}|${g.length}`, g.posnum));
pieces.forEach(p => { p.posnum = keyToPosnum.get(`${p.width}|${p.depth}|${Math.round(p.length)}`); });
return groups;
}
// Inkoop/zaagplan per balkdoorsnede (dikte x diepte) over de gegeven
// BOM-stukken — ook over meerdere wanden tegelijk (unit), zodat
// reststukken van de ene wand in de andere gebruikt kunnen worden.
function planPurchase(pieces) {
const bySection = new Map();
pieces.forEach(p => {
const key = `${p.width}x${p.depth}`;
if (!bySection.has(key)) bySection.set(key, { width: p.width, depth: p.depth, lengths: [] });
bySection.get(key).lengths.push(p.length);
});
return Array.from(bySection.values()).map(sec => {
const bars = planStockCutting(sec.lengths, STOCK_LENGTHS_MM);
return { width: sec.width, depth: sec.depth, bars, ...summarizeStockPlan(bars) };
});
}
// Korte tekst, bijv. "38x235: 2x 3600, 5x 4800".
function purchaseText(plan) {
return plan.map(sec => `${sec.width}x${sec.depth}: ` +
sec.perStock.map(s => `${s.count}x ${s.stock}`).join(', ')).join(' | ') || '-';
}
// Structuurcontrole van een wand-state (bijv. bij JSON-import). Gooit een
// Error met een leesbare melding als er iets ontbreekt/ongeldig is.
function validateState(design) {
if (!design.wall || typeof design.wall !== 'object') throw new Error('veld "wall" ontbreekt');
const requiredWallFields = ['width', 'heightAll', 'heightLeft', 'heightRight', 'studWidth', 'studDepth',
'plateThick', 'plateDepth', 'sheathingThick', 'minSpacing', 'maxSpacing'];
requiredWallFields.forEach(f => {
if (typeof design.wall[f] !== 'number' || !isFinite(design.wall[f])) {
throw new Error(`wall.${f} ontbreekt of is geen geldig getal`);
}
});
// materials is optioneel bij import (oudere bestanden hebben dit veld
// niet), maar als het aanwezig is moet de waarde wel geldig zijn.
if (design.materials !== undefined) {
if (typeof design.materials !== 'object' || design.materials === null) {
throw new Error('veld "materials" is aanwezig maar geen object');
}
if (design.materials.exterior !== undefined &&
!EXTERIOR_MATERIALS[design.materials.exterior]) {
throw new Error(`materials.exterior moet ${Object.keys(EXTERIOR_MATERIALS).join('/')} zijn (was: ${design.materials.exterior})`);
}
if (design.materials.cladding !== undefined && !CLADDING_DIRECTIONS[design.materials.cladding]) {
throw new Error(`materials.cladding moet ${Object.keys(CLADDING_DIRECTIONS).join('/')} zijn (was: ${design.materials.cladding})`);
}
}
if (!Array.isArray(design.openings)) throw new Error('veld "openings" ontbreekt of is geen lijst');
design.openings.forEach((o, i) => {
if (typeof o.w !== 'number' || typeof o.h !== 'number' || typeof o.y !== 'number' || typeof o.offset !== 'number') {
throw new Error(`opening #${i + 1}: w/h/y/offset moeten getallen zijn`);
}
if (o.type !== undefined && !OPENING_TYPES[o.type]) {
throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
}
if (!['left', 'middle', 'right'].includes(o.anchor)) {
throw new Error(`opening #${i + 1}: anchor moet 'left', 'middle' of 'right' zijn (was: ${o.anchor})`);
}
});
}
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
})();