- HouseModel: naadwand per naad (ensureSeamWalls bij nieuwe woning en unit toevoegen), twee delen met beplating alleen aan de kamerzijde, openingen gespiegeld - WallModel: buitenzijde 'geen', thicknessOf eenzijdig - FloorModel: kopbalken (rims) voor vloer en dak op naden, ook in PDF en totalen - isolatie: 3D-lagen en filter verwijderd, berekening blijft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1256 lines
62 KiB
JavaScript
1256 lines
62 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, 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.
|
|
function claddingOffset(buitenMateriaal) {
|
|
return buitenMateriaal === 'vochtdoek' ? 2 * BATTEN.thick + CLADDING_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;
|
|
function trasraamFor(heightMM) {
|
|
if (!(heightMM > 0)) return null;
|
|
return { bottom: 0, top: heightMM };
|
|
}
|
|
// 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).
|
|
function cornerExt(skinExt, depth) {
|
|
const e = skinExt || {};
|
|
return { left: e.left > 0 ? e.left + depth : 0, right: e.right > 0 ? e.right + depth : 0 };
|
|
}
|
|
|
|
// 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 }
|
|
};
|
|
|
|
// 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)' };
|
|
|
|
// 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.
|
|
function resolveOpenings(rawOpenings, wallWidth, studWidth, wallHeightLeft, wallHeightRight, 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));
|
|
});
|
|
|
|
// Links-naar-rechts: duw overlappende openingen uit 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 tussen twee openingen zouden hun uitsparingen elkaar
|
|
// kunnen overlappen (gecontroleerd, zie eerdere test).
|
|
const minGap = studWidth * 2;
|
|
for (let i = 1; i < resolved.length; i++) {
|
|
const prev = resolved[i - 1];
|
|
const cur = resolved[i];
|
|
const minX = prev.x + prev.w + minGap;
|
|
if (cur.x < minX) cur.x = minX;
|
|
}
|
|
|
|
// Rechts-naar-links correctie als de laatste opening nu over de rand steekt
|
|
const rightLimit = wallWidth - edgeMargin;
|
|
let overflow = 0;
|
|
if (resolved.length) {
|
|
const last = resolved[resolved.length - 1];
|
|
overflow = Math.max(0, (last.x + last.w) - rightLimit);
|
|
}
|
|
if (overflow > 0) {
|
|
for (let i = resolved.length - 1; i >= 0; i--) {
|
|
resolved[i].x -= overflow;
|
|
if (i > 0) {
|
|
const maxX = resolved[i].x - minGap - resolved[i - 1].w;
|
|
if (resolved[i - 1].x > maxX) resolved[i - 1].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, dus dat is voldoende).
|
|
const localRoofHeightAt = (xLocal) => wallHeightLeft + (xLocal / wallWidth) * (wallHeightRight - wallHeightLeft);
|
|
|
|
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) 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 = 1; i < resolved.length; i++) {
|
|
if (resolved[i].x < resolved[i - 1].x + resolved[i - 1].w + minGap - 0.5) {
|
|
resolved[i].overflowing = true;
|
|
resolved[i - 1].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);
|
|
|
|
return {
|
|
wallWidth,
|
|
wallHeightLeft: hLeft,
|
|
wallHeightRight: hRight,
|
|
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, hLeft, hRight, 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 slope = (CONFIG.wallHeightRight - CONFIG.wallHeightLeft) / CONFIG.wallWidth;
|
|
const angleDeg = 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);
|
|
}
|
|
|
|
let fullStuds = [];
|
|
let jackStuds = [];
|
|
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
|
|
|
|
if (ops.length === 0) {
|
|
fullStuds.push(...cornerStuds);
|
|
const availWidth = wallRightStudX - wallLeftStudX;
|
|
calculateSegmentPositions(availWidth, CONFIG.minSpacing, CONFIG.maxSpacing)
|
|
.forEach(p => fullStuds.push(wallLeftStudX + p));
|
|
} else {
|
|
// Hoekstijlen van de wand horen er, met of zonder opening, altijd bij.
|
|
fullStuds.push(...cornerStuds);
|
|
|
|
// Randen van de "vrije zones" tussen wand en openingen, per paar verwerkt
|
|
const edges = [wallLeftStudX];
|
|
|
|
ops.forEach(op => {
|
|
const opStartX = startX + op.x;
|
|
const kingLeftX = opStartX - CONFIG.studWidth / 2;
|
|
const kingRightX = opStartX + op.w + CONFIG.studWidth / 2;
|
|
|
|
fullStuds.push(kingLeftX, kingRightX);
|
|
edges.push(kingLeftX, kingRightX);
|
|
|
|
// Kreupele stijlen binnen de opening-breedte zelf
|
|
calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
|
|
.forEach(p => jackStuds.push({ x: opStartX + p, opening: op }));
|
|
});
|
|
|
|
edges.push(wallRightStudX);
|
|
|
|
for (let i = 0; i < edges.length; i += 2) {
|
|
const a = edges[i];
|
|
const b = edges[i + 1];
|
|
const span = (b - CONFIG.studWidth / 2) - (a + CONFIG.studWidth / 2);
|
|
if (span > 0) {
|
|
calculateSegmentPositions(span + CONFIG.studWidth, CONFIG.minSpacing, CONFIG.maxSpacing)
|
|
.forEach(p => fullStuds.push(a + p));
|
|
}
|
|
}
|
|
}
|
|
|
|
fullStuds = [...new Set(fullStuds.map(x => Math.round(x)))].sort((a, b) => a - b);
|
|
jackStuds.sort((a, b) => a.x - b.x);
|
|
|
|
return { fullStuds, jackStuds, 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 slope = (CONFIG.wallHeightRight - CONFIG.wallHeightLeft) / CONFIG.wallWidth;
|
|
const topYAt = (x) => CONFIG.wallHeightLeft + (x + halfW) * slope;
|
|
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) {
|
|
const halfW = CONFIG.wallWidth / 2;
|
|
const hL = CONFIG.wallHeightLeft, hR = CONFIG.wallHeightRight;
|
|
if (y <= 0 || y >= Math.max(hL, hR)) return [];
|
|
// Deel van de breedte waar de daklijn boven y ligt.
|
|
let x0 = -halfW, x1 = halfW;
|
|
if (hL !== hR) {
|
|
const xAtY = -halfW + (y - hL) / (hR - hL) * CONFIG.wallWidth;
|
|
if (hR > hL) x0 = Math.max(x0, xAtY); else x1 = Math.min(x1, xAtY);
|
|
} else if (y >= hL) return [];
|
|
let segments = [[x0, x1]];
|
|
sheathingHoles(CONFIG).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.
|
|
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade) {
|
|
const y0 = skinStartY(trasraam); // 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 hL = CONFIG.wallHeightLeft, hR = CONFIG.wallHeightRight;
|
|
const topYAt = (x) => hL + (x + halfW) * (hR - hL) / CONFIG.wallWidth;
|
|
const holes = sheathingHoles(CONFIG);
|
|
const w = BATTEN.width, sp = BATTEN.spacing;
|
|
const top = (cladding === 'verticaal') ? 'horizontaal' : 'verticaal';
|
|
|
|
// Horizontale latten: vanaf de onderkant elke 400 mm (hartmaat).
|
|
const horizontaal = [];
|
|
for (let y = y0 + w / 2; y < Math.max(hL, hR) - w / 2; y += sp) {
|
|
skinSegmentsAtY(CONFIG, y).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 }),
|
|
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';
|
|
const trasraam = buitenMateriaal === 'vochtdoek' ? (opts.trasraam || null) : null;
|
|
const skinExt = opts.skinExt || null;
|
|
const facade = opts.facade || null;
|
|
// 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;
|
|
const shiftUV = (geo) => {
|
|
if (!uvShiftM) return geo;
|
|
const uv = geo.attributes.uv;
|
|
for (let k = 0; k < uv.count; k++) uv.setX(k, uv.getX(k) + uvShiftM);
|
|
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 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 slope = (hRightM - hLeftM) / wWidthM;
|
|
const vertOffset = pThickM * Math.sqrt(1 + slope * slope);
|
|
const startXM = -halfW;
|
|
const topYAt = (x) => hLeftM + (x + halfW) * slope;
|
|
|
|
// 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).
|
|
const topPlateShape = new THREE.Shape();
|
|
topPlateShape.moveTo(-halfW, hLeftM);
|
|
topPlateShape.lineTo(halfW, hRightM);
|
|
topPlateShape.lineTo(halfW, hRightM - vertOffset);
|
|
topPlateShape.lineTo(-halfW, hLeftM - vertOffset);
|
|
topPlateShape.closePath();
|
|
wallGroup3D.add(createExtrudedBeam(topPlateShape, pDepthM, -pDepthM / 2));
|
|
horizBeamCount++;
|
|
|
|
// C. VOLLEDIGE VERTICALE STIJLEN (incl. de "koningsstijlen" naast elke opening)
|
|
calcData.fullStuds.forEach((xPosMM) => {
|
|
const xM = xPosMM * toM;
|
|
const xLeft = xM - (sWidthM / 2);
|
|
const xRight = xM + (sWidthM / 2);
|
|
|
|
const yTopLeft = (hLeftM + (xLeft + halfW) * slope) - vertOffset;
|
|
const yTopRight = (hLeftM + (xRight + halfW) * slope) - vertOffset;
|
|
|
|
const studShape = new THREE.Shape();
|
|
studShape.moveTo(xLeft, pThickM);
|
|
studShape.lineTo(xRight, pThickM);
|
|
studShape.lineTo(xRight, yTopRight);
|
|
studShape.lineTo(xLeft, yTopLeft);
|
|
studShape.closePath();
|
|
wallGroup3D.add(createExtrudedBeam(studShape, sDepthM, -sDepthM / 2));
|
|
});
|
|
|
|
// D. KREUPELE STIJLEN & DORPELS, per opening
|
|
opsM.forEach(op => {
|
|
calcData.jackStuds
|
|
.filter(j => j.opening.id === op.id)
|
|
.forEach(j => {
|
|
const xM = j.x * toM;
|
|
const xLeft = xM - (sWidthM / 2);
|
|
const xRight = xM + (sWidthM / 2);
|
|
|
|
const yTopLeft = (hLeftM + (xLeft + halfW) * slope) - vertOffset;
|
|
const yTopRight = (hLeftM + (xRight + halfW) * slope) - vertOffset;
|
|
|
|
// Onderste kreupele stijl mag niet door de onderdorpel heen lopen
|
|
// (die neemt zelf de band net onder op.yMin in beslag).
|
|
const sillBottomM = op.yMin - sWidthM;
|
|
if (sillBottomM > pThickM) {
|
|
const studBotShape = new THREE.Shape();
|
|
studBotShape.moveTo(xLeft, pThickM);
|
|
studBotShape.lineTo(xRight, pThickM);
|
|
studBotShape.lineTo(xRight, sillBottomM);
|
|
studBotShape.lineTo(xLeft, sillBottomM);
|
|
studBotShape.closePath();
|
|
wallGroup3D.add(createExtrudedBeam(studBotShape, sDepthM, -sDepthM / 2));
|
|
}
|
|
|
|
const actualOpTop = op.yMax + sWidthM;
|
|
if (actualOpTop < yTopLeft || actualOpTop < yTopRight) {
|
|
const studTopShape = new THREE.Shape();
|
|
studTopShape.moveTo(xLeft, actualOpTop);
|
|
studTopShape.lineTo(xRight, actualOpTop);
|
|
studTopShape.lineTo(xRight, yTopRight);
|
|
studTopShape.lineTo(xLeft, yTopLeft);
|
|
studTopShape.closePath();
|
|
wallGroup3D.add(createExtrudedBeam(studTopShape, sDepthM, -sDepthM / 2));
|
|
}
|
|
});
|
|
|
|
// 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
|
|
}));
|
|
|
|
// 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) {
|
|
const bounds = 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 skinTopAt = (x) => Math.min(yCap, topYAt(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,
|
|
holeExtents.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), range ? { yCap } : undefined);
|
|
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));
|
|
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);
|
|
|
|
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);
|
|
spaanplaatAreaM2 = spaanplaatResult.areaM2;
|
|
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
|
|
|
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
|
|
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
|
|
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;
|
|
} 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; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
|
|
const wallOutlinePts = [
|
|
{ x: -halfW, y: 0 }, { x: halfW, y: 0 },
|
|
{ x: halfW, y: hRightM }, { x: -halfW, y: hLeftM }
|
|
];
|
|
const maxRoofY = Math.max(hLeftM, hRightM);
|
|
|
|
let rowY0 = 0;
|
|
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 = (x) => hLeftM + (x + halfW) * slope;
|
|
let rx0 = -halfW, rx1 = halfW;
|
|
const extraXs = [];
|
|
if (Math.abs(slope) > 1e-12) {
|
|
const xAtRowBottom = -halfW + (rowY0 - hLeftM) / slope;
|
|
const xAtRowTop = -halfW + (rowY1 - hLeftM) / slope;
|
|
if (slope > 0) rx0 = Math.max(rx0, xAtRowBottom); else rx1 = Math.min(rx1, xAtRowBottom);
|
|
extraXs.push(xAtRowTop);
|
|
}
|
|
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)),
|
|
holeExtents.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);
|
|
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM);
|
|
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) {
|
|
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, cladding, trasraam, skinExt, facade);
|
|
const bT = BATTEN.thick * toM, bW = BATTEN.width * toM;
|
|
const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM;
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
// 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 },
|
|
{ x: halfW, y: hRightM }, { x: -halfW, y: hLeftM }
|
|
];
|
|
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 slope = (hRight - hLeft) / wallWidth;
|
|
const topYAt = (xWorld) => hLeft + (xWorld + halfW) * slope;
|
|
const vertOffset = CONFIG.plateThick * Math.sqrt(1 + slope * slope);
|
|
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 });
|
|
}
|
|
|
|
const topLength = Math.sqrt(wallWidth * wallWidth + (hRight - hLeft) * (hRight - hLeft));
|
|
pieces.push({ length: topLength, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel', x: 0, y: (hLeft + hRight) / 2 });
|
|
|
|
// 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.
|
|
calcData.fullStuds.forEach(x => {
|
|
const xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
|
|
const yTopLeft = topYAt(xLeft) - vertOffset, yTopRight = topYAt(xRight) - vertOffset;
|
|
const yBottom = CONFIG.plateThick;
|
|
const length = Math.max(yTopLeft, yTopRight) - yBottom;
|
|
pieces.push({
|
|
length, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'verticale balk', x,
|
|
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4,
|
|
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight
|
|
});
|
|
});
|
|
|
|
calcData.jackStuds.forEach(j => {
|
|
const op = j.opening;
|
|
const opYMin = op.y, opYMax = op.y + op.h;
|
|
const xLeft = j.x - CONFIG.studWidth / 2, xRight = j.x + CONFIG.studWidth / 2;
|
|
const yTopLeft = topYAt(xLeft) - vertOffset, yTopRight = topYAt(xRight) - vertOffset;
|
|
// De kreupele stijl mag niet door de onderdorpel heen lopen — die
|
|
// neemt zelf al de onderste stijldikte in vanaf opYMin in beslag.
|
|
const sillBottom = opYMin - CONFIG.studWidth;
|
|
if (sillBottom > CONFIG.plateThick) {
|
|
pieces.push({
|
|
length: sillBottom - CONFIG.plateThick, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'kreupele stijl', x: j.x,
|
|
y: (CONFIG.plateThick + sillBottom) / 2,
|
|
yBottomLeft: CONFIG.plateThick, yBottomRight: CONFIG.plateThick, yTopLeft: sillBottom, yTopRight: sillBottom
|
|
});
|
|
}
|
|
const actualOpTop = opYMax + CONFIG.studWidth;
|
|
if (actualOpTop < yTopLeft || actualOpTop < yTopRight) {
|
|
pieces.push({
|
|
length: Math.max(yTopLeft, yTopRight) - actualOpTop, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'kreupele stijl', x: j.x,
|
|
y: (actualOpTop + actualOpTop + yTopLeft + yTopRight) / 4,
|
|
yBottomLeft: actualOpTop, yBottomRight: actualOpTop, yTopLeft, yTopRight
|
|
});
|
|
}
|
|
});
|
|
|
|
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, trasraamFor, computeBattens, planPurchase, purchaseText,
|
|
sheathingHoles, skinSegmentsAtY,
|
|
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
|
createMaterials, build3D, computeSpecs
|
|
};
|
|
})();
|