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

1628 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.WallModel = (() => {
const DENSITY_WOOD_KGM3 = 450;
const DENSITY_SPAANPLAAT_KGM3 = 650;
const DENSITY_CEMENTPLAAT_KGM3 = 1250;
const DENSITY_DOEK_KGM2 = 1.5;
// Bewust niet-nul luchtgaatje tussen plaat en houtwerk (zie build3D).
const SKIN_GAP_MM = 0.5;
const STANDARD_BEAM = { width: 38, depth: 235 };
const STOCK_LENGTHS_MM = [3600, 4200, 4800, 5400];
const CORNER_STUDS = 2;
const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 };
const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' };
const CLADDING_THICK_MM = 12;
// In 3D één blok per wandstuk, van het doek tot de voorkant van de planken: zo zijn hoeken, dagkanten
// en onderrand dicht en vervallen doek en latten erachter.
const RABAT = { BOARD_MM: 150, TILE_BOARDS: 8 };
const RABAT_COLORS = {
naturel: { label: 'Naturel', hex: '#a0805f' },
antraciet: { label: 'Antraciet', hex: '#3c4045' },
zwart: { label: 'Zwart', hex: '#1f2022' },
grijs: { label: 'Grijs', hex: '#8b8f93' },
wit: { label: 'Gebroken wit', hex: '#e6e3dc' },
groen: { label: 'Mosgroen', hex: '#55604c' }
};
const rabatHex = (key) => (RABAT_COLORS[key] || RABAT_COLORS.naturel).hex;
const rabatMaterials = {};
function rabatMaterial(colorKey, direction) {
const key = (RABAT_COLORS[colorKey] ? colorKey : 'naturel') + '|' + (direction === 'verticaal' ? 'v' : 'h');
if (rabatMaterials[key]) return rabatMaterials[key];
const hex = rabatHex(colorKey);
let mat;
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
if (!ctx) {
mat = new THREE.MeshStandardMaterial({ color: hex, roughness: 0.8 });
} else {
const B = 32, N = RABAT.TILE_BOARDS, vert = direction === 'verticaal';
canvas.width = vert ? B * N : 16;
canvas.height = vert ? 16 : B * N;
const base = new THREE.Color(hex);
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
const rect = (a, b, color) => {
ctx.fillStyle = color;
if (vert) ctx.fillRect(a, 0, b - a, canvas.height); else ctx.fillRect(0, a, canvas.width, b - a);
};
for (let i = 0; i < N; i++) {
const a = i * B, f = 0.96 + ((i * 37) % 9) / 100; // 0,96 .. 1,04
rect(a, a + B, shade(f));
rect(a, a + 2, shade(f * 1.12)); // bovenkant (licht)
rect(a + B - 4, a + B, shade(f * 0.55)); // schaduw van de overlap
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
const tileM = RABAT.BOARD_MM * N / 1000;
if (vert) tex.repeat.set(1 / tileM, 1); else tex.repeat.set(1, 1 / tileM);
tex.anisotropy = 8;
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.8 });
}
rabatMaterials[key] = mat;
return mat;
}
function claddingOffset(buitenMateriaal, stone, stuc) {
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
if (buitenMateriaal !== 'cementplaat') return 0;
return stone ? STONE.THICK_MM : stuc ? STUC.THICK_MM : 0;
}
const STUC = { THICK_MM: 15 };
const STUC_COLORS = {
wit: { label: 'Wit', hex: '#f3f2ee' },
gebroken_wit: { label: 'Gebroken wit', hex: '#ebe5d8' },
zand: { label: 'Zand', hex: '#ddd0b8' },
lichtgrijs: { label: 'Lichtgrijs', hex: '#cfd0cd' },
grijs: { label: 'Grijs', hex: '#9c9e9c' },
antraciet: { label: 'Antraciet', hex: '#4b4e51' }
};
const isStucColor = (key) => !!STUC_COLORS[key];
const stucHex = (key) => (STUC_COLORS[key] || STUC_COLORS.wit).hex;
const stucMaterials = {};
function stucMaterial(key) {
const k = isStucColor(key) ? key : 'wit';
if (stucMaterials[k]) return stucMaterials[k];
let mat;
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: stucHex(k), roughness: 0.95 });
else {
const N = 128;
canvas.width = canvas.height = N;
const base = new THREE.Color(stucHex(k));
const img = ctx.createImageData(N, N);
let seed = 7;
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
for (let i = 0; i < N * N; i++) {
const f = 0.94 + rnd() * 0.09;
img.data[i * 4] = Math.min(255, base.r * 255 * f);
img.data[i * 4 + 1] = Math.min(255, base.g * 255 * f);
img.data[i * 4 + 2] = Math.min(255, base.b * 255 * f);
img.data[i * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(4, 4); // 250 mm per tegel
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.97 });
}
stucMaterials[k] = mat;
return mat;
}
// Hoogte vanaf het loopvlak (= onderkant van de wand).
const TRASRAAM_DEFAULT_MM = 350;
function trasraamFor(heightMM, finish) {
if (!(heightMM > 0)) return null;
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' };
}
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
// COURSE_MM: steen + voeg (31 px). De bovenrand snijdt door een steen (1125 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 STONE_COLORS = {
'K490': '#b67d60', 'K518': '#666562', 'K560': '#937464', 'K662': '#877770', 'K706': '#615d58', 'K739': '#796f65', 'K752': '#8e6d5f', 'K764': '#a1927d',
'K773': '#9f8c76', 'K911': '#e7ddc9', 'K911P': '#d6c8b6', 'K914': '#d4c0a5', 'K915': '#c5a37b', 'K919': '#cbb498', 'K923': '#c6b3a4', 'K923P': '#beaea1',
'K941': '#d2c5b2', 'K941P': '#dcd2c0', 'K947': '#c9bca8', 'K948': '#b9a896', 'K960': '#b4a495', 'K991': '#9e7b69', 'R-693': '#675e5a', 'R100': '#eee4c7',
'R116': '#e5ddc5', 'R140': '#eae3cc', 'R200': '#edc484', 'R206': '#e0b48b', 'R214': '#b88e74', 'R216': '#e0bc8c', 'R220': '#e29a72', 'R227': '#d49069',
'R228': '#c48869', 'R240': '#e5c587', 'R268': '#ca956e', 'R286': '#b98d66', 'R287': '#d1a477', 'R303': '#a47562', 'R307': '#966b59', 'R332': '#ae8171',
'R335': '#a97968', 'R343': '#a07662', 'R356': '#a47f77', 'R381': '#9c7c6f', 'R382': '#8e7769', 'R384': '#706260', 'R385': '#8a7972', 'R386': '#897f7e',
'R391': '#8b6a64', 'R400': '#b67c63', 'R401': '#a97360', 'R435': '#ad7463', 'R436': '#9f7565', 'R440': '#b57860', 'R480': '#d58a65', 'R483': '#c08973',
'R484': '#a78d7d', 'R487': '#c47f61', 'R488': '#bd8267', 'R489': '#a87967', 'R500': '#7d6c5e', 'R510': '#5b5855', 'R518': '#655d5a', 'R535': '#977365',
'R540': '#7e685a', 'R550': '#8b796d', 'R555': '#9a7668', 'R560': '#8c6f63', 'R561': '#7c6762', 'R563': '#876965', 'R564': '#7c7168', 'R565': '#736a66',
'R572DF': '#8b6657', 'R572DF14_64': '#886355', 'R580': '#907265', 'R581': '#837571', 'R582': '#96705e', 'R658': '#a47e65', 'R659': '#8a6d5a', 'R663': '#8b7163',
'R664': '#8a6a5c', 'R665': '#ca996c', 'R669': '#716257', 'R680': '#b7a28d', 'R682': '#c3af99', 'R684': '#c29263', 'R685': '#926e5d', 'R686': '#b57f5e',
'R687': '#b37f60', 'R688': '#d4ae82', 'R689': '#a67961', 'R690': '#b69477', 'R691': '#e1d3b6', 'R692': '#e1cda3', 'R694': '#aa6c56', 'R695': '#cfa77e',
'R696': '#dbb897', 'R697': '#75685e', 'R698': '#a47760', 'R700': '#66615d', 'R714': '#90695b', 'R715': '#a16f5b', 'R717': '#706e6e', 'R719': '#a58272',
'R720': '#998d8d', 'R721': '#a6948b', 'R725': '#a77963', 'R726': '#987c67', 'R727': '#8e6f5c', 'R729': '#a97260', 'R730': '#e7d3b5', 'R731': '#c68b66',
'R732': '#e4cca6', 'R733': '#eee9ce', 'R734': '#c39c7e', 'R735': '#8c8982', 'R736': '#5f5b54', 'R737': '#7c7973', 'R738': '#7d7771', 'R739': '#81756c',
'R740': '#5e595a', 'R742': '#e7d0ac', 'R743': '#a47f6e', 'R744': '#b38672', 'R745X737': '#9e8d78', 'R746': '#9a7d6f', 'R747': '#88776b', 'R748': '#8b7667',
'R748X764X749': '#a08a77', 'R749': '#92826f', 'R750': '#a88374', 'R751': '#b87a63', 'R751X759': '#766b63', 'R752': '#946c58', 'R754': '#a97e6f', 'R756': '#d2b48c',
'R757': '#eee8cc', 'R758': '#cb9978', 'R759': '#c78365', 'R760': '#baa595', 'R761': '#746f6b', 'R762': '#e9cfa9', 'R763': '#ebe1c6', 'R764': '#bea994',
'R764X756': '#cfb695', 'R766': '#ddb991', 'R767': '#b38469', 'R768': '#b58774', 'R769': '#966e5d', 'R770': '#836e60', 'R771': '#c1b2a3', 'R772': '#d7c8b5',
'R773': '#9d8b77', 'R775': '#917e6e', 'R777': '#6e6b6a', 'R788': '#a27c6c', 'R800': '#aba394', 'R835': '#c2b4a2', 'R840': '#c3b9a7', 'R911': '#e7deca',
'R911P': '#d6c8b6', 'R913': '#d3c0a7', 'R916': '#dfc9a6', 'R917': '#dcaf7e', 'R918': '#caa174', 'R921': '#cbad9a', 'R923P': '#beaea1', 'R931': '#9f8773',
'R932': '#a8917e', 'R941': '#c0b3a1', 'R941P': '#c2b6aa', 'R942': '#d3ccc1', 'R943': '#beb2a4', 'R944': '#afa295', 'R960DF': '#bab1a4', 'R960DF14_70': '#bbb2a5',
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
};
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');
// UV's 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;
}
// Negatief (binnenhoek): het stuk loopt zoveel de naad in; geen doorloop, de buitenlagen krijgen daar een uitsparing.
function cornerExt(skinExt, depth) {
const e = skinExt || {};
const one = (v) => v > 0 ? v + depth : 0;
return { left: one(e.left), right: one(e.right) };
}
function skinStartY(trasraam) {
return trasraam ? Math.max(0, trasraam.top) : 0;
}
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 }
};
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
// Ruwe openingen hebben hR alleen als ze bewust schuin zijn; opgeloste openingen altijd.
const hRightOf = (op) => op.hR !== undefined ? op.hR : op.h;
const openingTopAt = (op, x) => op.y + op.h + (x - op.x) / op.w * (hRightOf(op) - op.h);
const openingTopMax = (op) => op.y + Math.max(op.h, hRightOf(op));
const openingArea = (op) => op.w * (op.h + hRightOf(op)) / 2;
function slopedSpanAtY(x0, x1, y0, yl, yr, y) {
if (y <= y0 || y >= Math.max(yl, yr)) return null;
if (y <= Math.min(yl, yr)) return [x0, x1];
const xc = x0 + (y - yl) / (yr - yl) * (x1 - x0);
return yr > yl ? [xc, x1] : [x0, xc];
}
const openingSpanAtY = (op, y) => slopedSpanAtY(op.x, op.x + op.w, op.y, op.y + op.h, op.y + hRightOf(op), y);
// 'geen': naadwand-deel dat tegen het deel van de andere unit staat.
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
// De daklijn is altijd bol, dus de bovenrand is overal het minimum van de doorgetrokken stukken.
// x t.o.v. het wandmidden, mm × k (k = 0.001: meters).
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 inner = (CONFIG.wallTop || []).map(q => ({ x: (q.x - CONFIG.wallWidth / 2) * k, y: q.h * k }));
const pts = [L, ...inner, 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));
const knees = segs.slice(0, -1).map((s0, i) => {
const s1 = segs[i + 1], 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
: s0.b.x;
return { 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) }];
knees.filter(kn => kn.x > xa && kn.x < xb).forEach(kn => out.push({ x: kn.x, y: kn.y }));
out.push({ x: xb, y: under(xb) });
return out;
};
const peak = inner.length ? inner.reduce((m, q) => q.y > m.y ? q : m) : null;
return {
pts, segs, peak, knees, knee: knees[0] || null, at, under, rangeAbove, crossings, underPts,
maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y),
kinks: inner.map(q => q.x)
};
}
function raiseTop(CONFIG, d) {
return d ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d }))
}) : CONFIG;
}
// Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove.
function skinConfig(CONFIG, skinAbove, skinTop) {
return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove);
}
function topPlateOutlines(tl) {
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
return tl.segs.map((sg, i) => [
sg.a, sg.b,
i < n - 1 ? tl.knees[i] : { x: R.x, y: R.y - sg.vo },
i > 0 ? tl.knees[i - 1] : { x: L.x, y: L.y - sg.vo }
]);
}
// Alleen punten die de daklijn bol houden (boven de koorde tussen hun buren).
function wallTopPoints(params, wallWidth, hLeft, hRight) {
let raw = [];
if (Array.isArray(params.topPts) && params.topPts.length) {
raw = params.topPts.map(q => ({ x: q.f * wallWidth, h: Math.round(q.h) }));
} else if (params.heightMid > 0) {
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
raw = [{ x: px, h: Math.round(Math.max(params.heightMid, hLeft, hRight)) }];
}
let pts = [{ x: 0, h: hLeft }, ...raw.filter(q => q.x > 1 && q.x < wallWidth - 1).sort((a, b) => a.x - b.x), { x: wallWidth, h: hRight }];
for (let changed = true; changed;) {
changed = false;
for (let i = 1; i < pts.length - 1; i++) {
const a = pts[i - 1], b = pts[i + 1], q = pts[i];
const chord = a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h);
if (q.x - a.x < 1 || b.x - q.x < 1 || q.h <= chord + 0.5) { pts.splice(i, 1); changed = true; break; }
}
}
return pts.slice(1, -1);
}
// Heuristiek, geen constraint-solver: eerst x (ankers, tegen elkaar aan duwen, terug binnen de wand),
// dan per opening de hoogte tegen de daklijn op zijn eigen positie (niet het laagste punt van de wand).
// Past het niet, dan melden i.p.v. stil verkeerd tekenen.
function resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) {
// 2850 mm: max. sparingbreedte in dit constructiesysteem.
const maxOpW = Math.min(2850, Math.max(400, wallWidth - (studWidth * (2 * CORNER_STUDS + 2))));
// De koningsstijl mag tegen de binnenste hoekstijl aan vallen.
const edgeMargin = studWidth * CORNER_STUDS;
let resolved = rawOpenings.map(raw => {
// Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 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;
const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
if (raw.chimney) o.chimney = raw.chimney;
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
return o;
});
resolved.sort((a, b) => a.x - b.x);
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
resolved.forEach(o => {
o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
});
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => {
const [a0, a1] = useFinal ? vBand(a, Math.max(a.h, a.hR), a.y) : vBand(a, Math.max(400, a.rawH, a.rawHR), a.rawY);
const [b0, b1] = useFinal ? vBand(b, Math.max(b.h, b.hR), b.y) : vBand(b, Math.max(400, b.rawH, b.rawHR), b.rawY);
return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
};
// 2× stijldikte: de OSB-uitsparing steekt aan weerszijden 1× stijldikte uit, anders overlappen de
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
const FAR = 'staat niet tegen de opening ernaast aan';
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
resolved.forEach((a, i) => {
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
let why = null;
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
if (why) { blame(a, b, why); return; }
rightOf.set(a, b);
leftOf.set(b, a);
});
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
for (let i = 1; i < resolved.length; i++) {
const cur = resolved[i], partner = leftOf.get(cur);
if (partner) cur.x = partner.x + partner.w;
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 + gap(prev, cur));
}
}
for (let i = resolved.length - 1; i >= 0; i--) {
const cur = resolved[i];
let maxX = wallWidth - mR(cur) - 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 - gap(cur, next) - cur.w);
}
if (cur.x > maxX) cur.x = maxX;
}
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
resolved.forEach(o => {
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
// Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = hL;
o.hR = hR;
});
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
const swing = (o) => !!o.fill && o.fill.type === 'deur';
rightOf.forEach((b, a) => {
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
});
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
resolved.forEach(o => {
o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 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 + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
}
}
return resolved;
}
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);
const wallTop = wallTopPoints(params, wallWidth, hLeft, hRight);
const tp = [{ x: 0, h: hLeft }, ...wallTop, { x: wallWidth, h: hRight }];
const roofAt = (x) => {
let i = 0;
while (i < tp.length - 2 && x > tp[i + 1].x) i++;
const a = tp[i], b = tp[i + 1];
return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h);
};
return {
wallWidth,
wallHeightLeft: hLeft,
wallHeightRight: hRight,
wallTop,
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 || []);
}
function thicknessOf(config, buitenMateriaal) {
const sides = buitenMateriaal === 'geen' ? 1 : 2;
return Math.max(config.studDepth, config.plateDepth) + sides * (config.sheathingThick + SKIN_GAP_MM);
}
function computeFrame(CONFIG) {
const startX = -CONFIG.wallWidth / 2;
const top = topLine(CONFIG);
const slope = top.segs[0].slope;
const angleDeg = top.segs.length > 1
? 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));
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);
}
// kind: hoek (dubbele hoekstijl), koning (naast een opening), dubbel (tegen de koningsstijl van een deur),
// regel, kreupel (boven/onder een opening).
const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => {
const x0 = startX + op.x, s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
return {
op, x0, x1: x0 + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + Math.max(op.h, op.hR) + vo,
topAt: (x) => op.y + op.h + (x - x0) * s + vo
};
});
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
ops.forEach(op => {
const x0 = startX + op.x, x1 = x0 + op.w;
if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
else cands.push({ x: x1, kind: 'kreupel', seam: true });
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
});
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];
}).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 })));
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
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.filter(inWall).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, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => {
if (c.kind === 'kreupel' && !c.seam) {
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
}
const sg = { x, y0: Math.min(cur[0], cur[1]), y1, kind: c.kind };
if (Math.abs(cur[0] - cur[1]) > 0.05) { sg.y0l = cur[0]; sg.y0r = cur[1]; }
segs.push(sg);
};
hit.forEach(bd => {
if (bd.y0 > curMax() + 1) push(bd.y0, bd);
const t = [bd.topAt(x - sw / 2), bd.topAt(x + sw / 2)];
if (Math.max(t[0], t[1]) > curMax()) { cur = t; below = bd; }
});
if (roofMin(x) > curMax() + 1) push(null, null);
});
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);
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 };
}
// Bovenkant defensief tegen de lokale daklijn geklemd: bij een sterk hellende wand kan de marge anders
// boven het dak uitkomen (kapotte uitsparing).
function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => {
// Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
const m = op.type === 'schoorsteen' ? 0 : sw;
// Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
const hx0 = -halfW + op.x - mL;
const hx1 = -halfW + op.x + op.w + mR;
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
const clampTop = (y, x) => {
const ceil = topYAt(x) - roofSafetyMargin;
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
};
const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
});
}
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
const holeTopAt = (h, x) => h.hy1 + (x - h.hx0) / (h.hx1 - h.hx0) * (holeTopR(h) - h.hy1);
const holeRect = (h) => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1, y1r: holeTopR(h) });
function skinSegmentsAtY(CONFIG, y, extraHoles) {
if (y <= 0) return [];
const range = topLine(CONFIG).rangeAbove(y);
if (!range) return [];
let segments = [range];
sheathingHoles(CONFIG).concat(extraHoles || []).forEach(h => {
const span = slopedSpanAtY(h.hx0, h.hx1, h.hy0, h.hy1, holeTopR(h), y);
if (!span) return;
const [hx0, hx1] = span;
segments = segments.flatMap(([a, b]) => {
if (hx1 <= a || hx0 >= b) return [[a, b]];
const out = [];
if (hx0 > a) out.push([a, hx0]);
if (hx1 < b) out.push([hx1, b]);
return out;
});
});
return segments;
}
// Verticale latten per wandstuk (modules worden los vervoerd, de bekleding eindigt op de rand van het stuk),
// plus langs beide zijden van elke opening (de bekleding moet daar op een lat eindigen).
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom, extraHoles) {
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0);
const ext = cornerExt(skinExt, 2 * BATTEN.thick);
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
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';
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) {
// Afkappen op de bovenkant van de lat (y + w/2), anders steekt het uiteinde door het dak.
const cap = tl.rangeAbove(y + w / 2);
if (!cap) continue;
skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => {
x0 = Math.max(x0, cap[0]); x1 = Math.min(x1, cap[1]);
if (x0 <= -halfW + 0.5) x0 = xL;
if (x1 >= halfW - 0.5) x1 = xR;
if (x1 - x0 > w) horizontaal.push({ x0, x1, y });
});
}
const lo = xL + w / 2, hi = xR - w / 2;
const fixed = [lo, hi];
holes.forEach(h => {
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
[!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
});
fixed.sort((a, b) => a - b);
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
const xs = [];
anchors.forEach((x, i) => {
xs.push(x);
const next = anchors[i + 1];
if (next === undefined) return;
const n = Math.ceil((next - x) / sp - 1e-9);
for (let k = 1; k < n; k++) xs.push(x + (next - x) * k / n);
});
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 => {
const hy1 = Math.max(holeTopAt(h, Math.max(h.hx0, x - w / 2)), holeTopAt(h, Math.min(h.hx1, x + w / 2)));
segs = segs.flatMap(([a, b]) => {
if (hy1 <= a || h.hy0 >= b) return [[a, b]];
const out = [];
if (h.hy0 > a) out.push([a, h.hy0]);
if (hy1 < b) out.push([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)
};
}
// ExtrudeGeometry-UV's in meters, dus repeat = 1/tegel. Zonder DOM (node-tests) valt de textuur 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 = () => {
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);
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 })
};
}
// Lokaal (m): X = breedte gecentreerd, Y vanaf de onderkant onderregel, +Z = buitenzijde.
function build3D(CONFIG, calcData, opts) {
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const stage = BuildStages.clamp(opts.stage);
const show = (key) => BuildStages.shows(stage, key);
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
const buitenVisible = opts.buitenVisible !== false;
const cladding = opts.cladding || 'horizontaal';
const stucKey = buitenMateriaal === 'cementplaat' && !isStoneFinish(opts.stone) && isStucColor(opts.stuc) ? opts.stuc : null;
const trasraam = buitenMateriaal === 'vochtdoek' || (buitenMateriaal === 'cementplaat' && (isStoneFinish(opts.stone) || stucKey)) ? (opts.trasraam || null) : null;
const skinExt = opts.skinExt || null;
const facade = opts.facade || null;
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
const painted = !!opts.painted;
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
// spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand.
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
const doekOn = buitenVisible && show('doek') && !rabatOn;
const battensOn = buitenVisible && show('latwerk') && !rabatOn;
const outerCovered = buitenMateriaal === 'geen' ||
(buitenVisible && show(buitenMateriaal === 'vochtdoek' ? 'doek' : 'beplating'));
const frameEnclosed = opts.cull !== false && spaanplaatVisible && outerCovered;
const HIDDEN_STUDS = { regel: true, kreupel: true, dubbel: true };
// Watermerk-UV's in gevel-coördinaten, zodat het patroon over wandstukken doorloopt.
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
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();
let woodVolumeM3 = 0;
let spaanplaatVolumeM3 = 0;
let spaanplaatAreaM2 = 0;
let cementplaatVolumeM3 = 0;
let cementplaatAreaM2 = 0;
let doekAreaM2 = 0;
let spaanplaatBuitenAreaM2 = 0;
let spaanplaatBuitenVolumeM3 = 0;
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
let rabatAreaM2 = 0;
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
let stucAreaM2 = 0;
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0;
let horizBeamCount = 0;
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;
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop);
const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM);
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove);
const underPts = tl.underPts;
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
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,
yMaxR: (op.y + op.hR) * toM,
wM: op.w * toM,
chimney: op.type === 'schoorsteen'
}));
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++;
}
// Bovenregel onderbroken waar een schoorsteen doorloopt.
const clipX = (pts, x, keepBelow) => {
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
pts.forEach((p, k) => {
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
if (fp >= 0) out.push(p);
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
});
return out;
};
let plates = topPlateOutlines(tl);
opsM.filter(op => op.chimney).forEach(op => {
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
});
plates.forEach(pts => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++;
});
if (CONFIG.topWedge) {
const wg = CONFIG.topWedge.pts, zO = sDepthM / 2, zI = -sDepthM / 2, pos = [], tlMM = topLine(CONFIG);
const P = (q, z, h) => [q.x * toM, (tlMM.at(q.x) + h) * toM, z];
const quad = (a, b, c, d) => pos.push(...a, ...b, ...c, ...a, ...c, ...d);
for (let k = 0; k < wg.length - 1; k++) {
const a = wg[k], b = wg[k + 1];
quad(P(a, zO, a.o), P(b, zO, b.o), P(b, zI, b.i), P(a, zI, a.i)); // boven (dakkant)
quad(P(a, zO, 0), P(b, zO, 0), P(b, zO, b.o), P(a, zO, a.o)); // buiten
quad(P(a, zI, a.i), P(b, zI, b.i), P(b, zI, 0), P(a, zI, 0)); // binnen
woodVolumeM3 += ((a.o + a.i) / 2 + (b.o + b.i) / 2) / 2 * (b.x - a.x) * CONFIG.studDepth * 1e-9;
}
[wg[0], wg[wg.length - 1]].forEach(q => quad(P(q, zO, 0), P(q, zO, q.o), P(q, zI, q.i), P(q, zI, 0))); // kopse kanten
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.computeVertexNormals();
const wedge = new THREE.Mesh(geo, woodMaterial.side === THREE.DoubleSide ? woodMaterial : wedgeMaterial(woodMaterial));
wedge.castShadow = true;
wallGroup3D.add(wedge);
}
// Ingesloten stijlen (frameEnclosed) tellen mee in het volume, maar worden niet gebouwd.
calcData.studs.forEach(st => {
const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const y0l = (st.y0l !== undefined ? st.y0l : st.y0) * toM, y0r = (st.y0r !== undefined ? st.y0r : st.y0) * toM;
const pts = st.y1 === null
? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
if (frameEnclosed && HIDDEN_STUDS[st.kind]) { woodVolumeM3 += polygonArea(pts) * sDepthM; return; }
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
});
opsM.forEach(op => {
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax);
opTopShape.lineTo(op.xMax, op.yMaxR);
opTopShape.lineTo(op.xMax, op.yMaxR + vo);
opTopShape.lineTo(op.xMin, op.yMax + vo);
opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++;
// Onderdorpel zit onder de opening (niet erin), zoals in de BOM/PDF.
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.)
// Platen zijn 1 m breed en hoog genoeg voor één stuk: alleen verticale naden.
const maxDepth = Math.max(sDepthM, pDepthM);
// Bewust niet-nul luchtgaatje tussen plaat en houtwerk: samenvallende vlakken geven z-fighting,
// vooral op scherende hoeken zoals de schuine bovenrand.
const skinGapM = SKIN_GAP_MM * 0.001;
const seamM = 0.0005;
const boardWidthM = 1.0;
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();
}
const edgeEps = 1e-6;
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, hy1r: hole.hy1r * toM
}));
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;
// Nooit tot in de bovenrand (dan geeft columnMinusRects geen vorm en valt de hele laag weg):
// hooguit 1 mm eronder; dat restje zit in het dak van de buur.
const underTop = (x0, x1) => Math.min(tlOut.at(x0), tlOut.at(x1), ...tlOut.kinks.filter(k => k > x0 && k < x1).map(tlOut.at)) - 0.001;
const trimHoles = [];
if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: Math.min(topOf(trimTop.left), underTop(-halfW, -halfW + trimL)) });
if (trimR > 0) trimHoles.push({ hx0: halfW - trimR, hx1: halfW + 1, hy0: -1, hy1: Math.min(topOf(trimTop.right), underTop(halfW - trimR, halfW)) });
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 }));
// Verticaal: op anchor.y ligt een voeg; de gevel telt omhoog, het trasraam omlaag (hele stenen aan de naad).
const meterUV = (geo, vOff) => {
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;
};
// Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit.
const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x))));
const topClampedOut = beyondEnd(tlOut);
const layerShapes = (a, b, y0, yCap) => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClampedOut : (x) => Math.min(yCap, topClampedOut(x)), outerHoles.map(holeRect),
capped ? { extraXs: tlOut.kinks.concat([-halfW, halfW]) } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap), [-halfW, halfW]) });
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;
});
};
function stoneBlock(code, y0, yCap, anchor) {
// 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) => meterUV(geo, vOff);
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 shapesIn = (a, b) => layerShapes(a, b, y0, yCap);
const add = (shapes, depth, z, mat) => {
if (!shapes.length || !buitenVisible || !show('gevel')) return;
const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat);
mesh.position.set(0, 0, z);
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 };
}
function stucBlock(colorKey, y0) {
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
const t = STUC.THICK_MM * toM;
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STUC.THICK_MM);
const shapes = layerShapes(-halfW - ext.left, halfW + ext.right, y0, Infinity);
if (shapes.length && buitenVisible && show('gevel')) {
const mesh = new THREE.Mesh(meterUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth: t, bevelEnabled: false }), 0), stucMaterial(colorKey));
mesh.position.set(0, 0, zB);
mesh.castShadow = true;
mesh.receiveShadow = true;
wallGroup3D.add(mesh);
}
return shapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
}
// Met ext.zOut een losse strook verder naar buiten: de laag ligt in de beplatingsruimte, anders steken de hoekstijlen erdoor.
function buildVerticalBoardSkin(material, side, visible, range, ext) {
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;
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);
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;
const ex0 = bx0 + (isWallLeftEdge ? 0 : seamM / 2);
const ex1 = bx1 - (isWallRightEdge ? 0 : seamM / 2);
if (ex1 - ex0 <= edgeEps) continue;
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt,
HX.map(holeRect),
{ yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
const 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;
});
if (!boardShapes.length) continue;
const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
if (visible) {
const boardGeo = new THREE.ExtrudeGeometry(boardShapes, { steps: 1, depth: sThickM, bevelEnabled: false });
const board = new THREE.Mesh(boardGeo, material);
board.position.set(0, 0, z);
wallGroup3D.add(board);
if (!seamless) {
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(boardGeo), edgeMaterial);
edgeLines.position.set(0, 0, z);
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))
]);
if (visible) {
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);
wallGroup3D.add(mesh);
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial);
edgeLines.position.set(0, 0, z + ext.zOut);
wallGroup3D.add(edgeLines);
}
const a2 = shapeAreaWithHoles(shape);
areaM2 += a2;
volumeM3 += a2 * sThickM;
});
}
return { areaM2, volumeM3 };
}
const spaanplaatResult = buildVerticalBoardSkin(spaanplaatMaterial, 'back', spaanplaatVisible);
// 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);
if (spaanplaatVisible) wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
if (spaanplaatBelow) {
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'), { y0: 0, y1: skinFrom * 0.001 });
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
}
if (buitenMateriaal === 'spaanplaat') {
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'));
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
} else if (buitenMateriaal === 'geen') {
} else if (buitenMateriaal === 'cementplaat') {
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('beplating'), null,
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
cementplaatAreaM2 = cementResult.areaM2;
cementplaatVolumeM3 = cementResult.volumeM3;
if (stone || stucKey) {
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 && show('latwerk'), {
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;
}
if (stucKey) stucAreaM2 = stucBlock(stucKey, y0);
else {
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 {
const doekHeightM = 1.5;
const doekOverlapM = 0.05;
const doekStepM = doekHeightM - doekOverlapM;
// Kleine Z-stap per baan: de overlap ligt net vóór de vorige baan (zoals echte shingles), niet co-planair (z-fighting).
const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001;
const tlBat = CONFIG_BAT === CONFIG_OUT ? tlOut : topLine(CONFIG_BAT, toM);
const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM;
let rowIndex = 0;
while (rowY0 < maxRoofY - 1e-9) {
const rowY1 = rowY0 + doekHeightM;
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(holeRect), { yCap: rowY1, extraXs }) : [];
const 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;
});
if (rowShapes.length) {
const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
if (doekOn) {
const doekRow = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(rowShapes, { steps: 1, depth: sThickM, bevelEnabled: false })), doekMaterial);
// 1 mm naar buiten: anders flikkert het dak door het doek waar de gevel tot onder de dakrand doorloopt.
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
wallGroup3D.add(doekRow);
}
// Volle oppervlak incl. overlap: die wordt ook echt van de rol verbruikt.
doekAreaM2 += rowAreaM2;
}
const dExt = extM(0);
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)));
if (doekOn) {
const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial);
mesh.position.set(0, 0, zOut);
wallGroup3D.add(mesh);
}
doekAreaM2 += shapeAreaWithHoles(shape);
});
rowY0 += doekStepM;
rowIndex++;
}
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 && show('latwerk'), {
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;
}
const bat = computeBattens(CONFIG_BAT, 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);
wallGroup3D.add(mesh);
});
};
if (battensOn) {
addLayer(bat.under, zDoek);
addLayer(bat.top, zDoek + bT);
}
battenLengthM = bat.totalLengthMM * toM;
battenVolumeM3 = battenLengthM * bT * bW;
battenCount = bat.stock.count;
battenPieces = bat.lengths.length;
const rabatDepthMM = 2 * BATTEN.thick + CLADDING_THICK_MM;
const rExt = extM(rabatDepthMM);
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
const rTop = beyondEnd(tlBat);
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).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;
}) : [];
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
if (rabatOn && rabatShapes.length) {
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
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) - gridM, z = pos.getZ(k);
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
}
uv.needsUpdate = true;
const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding));
mesh.position.set(0, 0, zDoek);
mesh.castShadow = true;
mesh.receiveShadow = true;
wallGroup3D.add(mesh);
}
}
if (show('gevel') && window.KozijnModel) {
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
: buitenMateriaal === 'geen' ? { zOut: half, depth: CONFIG.studDepth + sh, exterior: false }
: { zOut: half + sh, depth: CONFIG.studDepth + 2 * sh, exterior: false };
place.faceIn = -(half + sh + SKIN_GAP_MM);
place.faceOut = buitenMateriaal === 'geen' ? half : half + sh + SKIN_GAP_MM + claddingOffset(buitenMateriaal, stone, stucKey);
CONFIG.openings.forEach(op => {
const k = op.fill ? KozijnModel.build3D(op, op.fill, place) : null;
if (!k) return;
k.position.set(startXM + op.x * toM, op.y * toM, 0);
wallGroup3D.add(k);
});
}
if (window.KozijnModel && CONFIG.openings.length) {
const sh = CONFIG.sheathingThick, halfD = Math.max(CONFIG.studDepth, CONFIG.plateDepth) / 2, G = SKIN_GAP_MM;
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
const zIn = -(halfD + G + sh), zSheetOut = halfD + G + sh;
const zFacade = zSheetOut + (exterior ? G + claddingOffset(buitenMateriaal, stone, stucKey) : 0);
const kozBack = CONFIG.studDepth / 2 + sh - KozijnModel.PROFILE.depth;
const T = 12, SILL = 25, SILL_OUT = 25, SLAG_OUT = 35;
const finishIn = show('afwerking') && spaanplaatVisible, finishOut = exterior && buitenVisible && show('gevel');
const prism = (pts, z0, z1, mat) => {
if (z1 - z0 < 0.5 || pts.length < 3) return;
const geo = new THREE.ExtrudeGeometry(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM))), { steps: 1, depth: (z1 - z0) * toM, bevelEnabled: false });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.z = z0 * toM;
mesh.receiveShadow = true;
wallGroup3D.add(mesh);
};
const area = (pts) => Math.abs(pts.reduce((a, p, k) => { const q = pts[(k + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
const holes = sheathingHoles(CONFIG);
CONFIG.openings.forEach((op, i) => {
const h = holes[i];
if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
const hTop = (x) => holeTopAt(h, x);
const ring = [
[{ x: h.hx0, y: h.hy0 }, { x: x0, y: h.hy0 }, { x: x0, y: topAt(x0) }, { x: h.hx0, y: topAt(h.hx0) }],
[{ x: x1, y: h.hy0 }, { x: h.hx1, y: h.hy0 }, { x: h.hx1, y: topAt(h.hx1) }, { x: x1, y: topAt(x1) }],
[{ x: h.hx0, y: topAt(h.hx0) }, { x: h.hx1, y: topAt(h.hx1) }, { x: h.hx1, y: Math.max(topAt(h.hx1), hTop(h.hx1)) }, { x: h.hx0, y: Math.max(topAt(h.hx0), hTop(h.hx0)) }]
].filter(pts => area(pts) > 1);
const fill = KozijnModel.normalize(op.fill);
const isWindow = op.y >= CONFIG.plateThick;
// Gekoppelde zijde: geen dagkant; vensterbank en waterslag lopen door tot de naad.
const over = (d) => [op.joinL ? x0 : x0 - d, op.joinR ? x1 : x1 + d];
if (finishIn) {
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
if (zB !== null) {
const boards = [
!op.joinL && [{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
!op.joinR && [{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
].filter(Boolean);
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
const [sa, sb] = over(30);
if (isWindow) prism([{ x: sa, y: y0 - SILL + 5 }, { x: sb, y: y0 - SILL + 5 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
}
}
if (finishOut) {
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
const [sa, sb] = over(20);
if (isWindow && fill) prism([{ x: sa, y: y0 - 25 }, { x: sb, y: y0 - 25 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }],
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
}
});
}
return {
group: wallGroup3D,
stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
}
};
}
// Dubbelzijdig: de schuine regel heeft geen vaste oriëntatie.
const wedgeMats = new WeakMap();
function wedgeMaterial(wood) {
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; m.userData.base = wood; wedgeMats.set(wood, m); }
return wedgeMats.get(wood);
}
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;
// Spouwdiepte = stijldiepte (vereenvoudiging).
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) => sum + openingArea(op) * toM * 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 {
buitenAreaM2 = stats.doekAreaM2;
buitenKg = stats.doekAreaM2 * DENSITY_DOEK_KGM2;
buitenVolumeM3 = null;
}
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 };
}
// cutAngles: hoek per zaagsnede t.o.v. haaks (graden).
const cutDeg = (dy, dx) => Math.round(Math.atan2(Math.abs(dy), Math.abs(dx)) * 180 / Math.PI * 10) / 10;
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, cutAngles: [0, 0] });
}
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, cutAngles: [0, 0] });
}
if (CONFIG.topWedge) {
const wg = CONFIG.topWedge.pts, has = (q) => Math.max(q.o, q.i) >= 5, runs = [];
for (let k = 0; k < wg.length - 1; k++) {
if (!has(wg[k]) && !has(wg[k + 1])) continue;
const last = runs[runs.length - 1];
if (last && last[last.length - 1] === wg[k]) last.push(wg[k + 1]); else runs.push([wg[k], wg[k + 1]]);
}
runs.forEach(run => {
const hMax = Math.max(...run.map(q => Math.max(q.o, q.i)));
const bevel = Math.round(Math.max(...run.map(q => Math.atan(Math.abs(q.o - q.i) / CONFIG.studDepth))) * 1800 / Math.PI) / 10;
let len = 0;
for (let k = 0; k < run.length - 1; k++) len += Math.hypot(run[k + 1].x - run[k].x, tl.at(run[k + 1].x) - tl.at(run[k].x));
const outline = run.map(q => ({ x: q.x, y: tl.at(q.x) })).concat(run.slice().reverse().map(q => ({ x: q.x, y: tl.at(q.x) + Math.max(q.o, q.i) })));
const xm = (run[0].x + run[run.length - 1].x) / 2;
pieces.push({ length: Math.round(len), width: Math.max(1, Math.round(hMax)), depth: CONFIG.studDepth, kind: 'schuine regel', x: xm, y: tl.at(xm) + hMax / 2, outline, cutAngles: [bevel, bevel] });
});
}
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,
cutAngles: [cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x), cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x)]
});
});
// length/y = omhullende (het rechte stuk hout voor de materiaallijst); de PDF tekent met de 4 werkelijke hoekpunten.
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';
const yBottomLeft = st.y0l !== undefined ? st.y0l : yBottom, yBottomRight = st.y0r !== undefined ? st.y0r : yBottom;
const botCut = cutDeg(yBottomRight - yBottomLeft, xRight - xLeft);
if (st.y1 !== null) {
pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2,
yBottomLeft, yBottomRight, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [botCut, 0]
});
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: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4,
yBottomLeft, yBottomRight, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()],
cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x)))
});
});
CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x;
const dh = op.hR - op.h;
const header = { length: Math.hypot(op.w, dh), width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + (op.h + op.hR) / 2 + CONFIG.studWidth / 2, cutAngles: [cutDeg(dh, op.w), cutDeg(dh, op.w)] };
if (Math.abs(dh) > 0.5) {
const vo = CONFIG.studWidth * Math.hypot(op.w, dh) / op.w;
header.outline = [{ x: opXWorld, y: op.y + op.h }, { x: opXWorld + op.w, y: op.y + op.hR }, { x: opXWorld + op.w, y: op.y + op.hR + vo }, { x: opXWorld, y: op.y + op.h + vo }];
}
pieces.push(header);
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, cutAngles: [0, 0] });
}
});
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;
}
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) };
});
}
function purchaseText(plan) {
return plan.map(sec => `${sec.width}x${sec.depth}: ` +
sec.perStock.map(s => `${s.count}x ${s.stock}`).join(', ')).join(' | ') || '-';
}
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.hR !== undefined && (typeof o.hR !== 'number' || !isFinite(o.hR))) {
throw new Error(`opening #${i + 1}: hR (hoogte rechts) moet een getal 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 (o.fill != null && (typeof o.fill !== 'object' || !KozijnModel.FILL_TYPES[o.fill.type])) {
throw new Error(`opening #${i + 1}: fill.type moet ${Object.keys(KozijnModel.FILL_TYPES).join('/')} zijn`);
}
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, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
})();