v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+253
View File
@@ -0,0 +1,253 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// wall/kozijn-model.js — Vulling van een opening (window.KozijnModel):
// kunststof kozijnen naar het voorbeeld van de Aluplast Ideal 7000
// (examples/Buiten kozijnen.pdf): kader 70 mm in het aanzicht, 120 mm
// diep, buitenzijde in een andere kleur dan de binnenzijde (folie).
//
// Een opening krijgt een (optionele) vulling, opgeslagen bij de opening:
// op.fill = { type: 'raam'|'deur', rooster, glas, out, in }
// raam vast glas in het kader; rooster = ventilatierooster bovenin
// deur deurblad (draait links) met dorpel; is de opening breder of
// hoger dan één deur, dan vult vast glas de rest (zijlicht,
// bovenlicht). glas = glasstrook in het deurblad
// out/in kleur buiten/binnen (COLORS)
// Zonder fill blijft de opening open (zoals voorheen). Het formaat volgt
// altijd de opening (ook een schuine bovenzijde).
//
// Plain script (geen ES-module), geen DOM. Gebruikt door WallModel.build3D.
// ============================================================
window.KozijnModel = (() => {
const COLORS = {
wit: { label: 'Wit', ral: 'RAL 9016', hex: 0xf1f0ea },
grijs: { label: 'Grijs', ral: 'RAL 7038', hex: 0xaeb0aa },
antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 },
zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 }
};
const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur' };
const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit';
// Maten (mm).
const P = {
gap: 5, // stelruimte rondom
kader: 70, // kader in het aanzicht
depth: 120, // kaderdiepte
dorpel: 30, // deurdorpel (hoogte)
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
rooster: 80, // ventilatierooster (hoogte)
glass: 24, // glasdikte
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
leafH: 2200, // deurblad als er een bovenlicht boven past
leafT: 68, // deurbladdikte
minLight: 250 // kleinste zijlicht/bovenlicht
};
const colorKey = (k, def) => COLORS[k] ? k : def;
// Volledige vulling (defaults ingevuld) of null (geen vulling).
function normalize(fill) {
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
return {
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN)
};
}
// Standaardvulling bij een openingtype (WallModel.OPENING_TYPES); kleuren
// van `like` (bijv. de vorige opening) overnemen.
function defaultFill(openingType, like) {
const l = normalize(like) || {};
const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam';
return { type, rooster: false, glas: openingType === 'voordeur', out: l.out || DEFAULT_OUT, in: l.in || DEFAULT_IN };
}
// ------------------------------------------------------------
// 2D-veelhoeken (mm, tegen de klok in, x vanaf de linkerkant van de
// opening, y vanaf de onderkant).
// ------------------------------------------------------------
// Convexe veelhoek naar binnen verschoven, per zijde (zijde i = punt i ->
// punt i+1) een eigen afstand d[i].
function insetPoly(pts, d) {
const n = pts.length;
const lines = pts.map((p, i) => {
const q = pts[(i + 1) % n];
const len = Math.hypot(q.x - p.x, q.y - p.y) || 1;
const dx = (q.x - p.x) / len, dy = (q.y - p.y) / len;
return { x: p.x - dy * d[i], y: p.y + dx * d[i], dx, dy };
});
return pts.map((p, i) => {
const a = lines[(i - 1 + n) % n], b = lines[i];
const den = a.dx * b.dy - a.dy * b.dx;
if (Math.abs(den) < 1e-9) return { x: b.x, y: b.y };
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
return { x: a.x + a.dx * t, y: a.y + a.dy * t };
});
}
// Veelhoek afgekapt op een halfvlak (Sutherland-Hodgman): f(p) >= 0
// blijft, f is lineair.
function clipPoly(pts, f) {
const out = [];
pts.forEach((p, i) => {
const q = pts[(i + 1) % pts.length];
const 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;
}
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
// ------------------------------------------------------------
// 3D
// ------------------------------------------------------------
const mats = {};
function colorMat(key) {
const k = 'c:' + colorKey(key, DEFAULT_OUT);
return mats[k] || (mats[k] = new THREE.MeshStandardMaterial({ color: COLORS[colorKey(key, DEFAULT_OUT)].hex, roughness: 0.45 }));
}
function mat(key) {
if (mats[key]) return mats[key];
if (key === 'glass') mats[key] = new THREE.MeshStandardMaterial({ color: 0x9fbfcc, roughness: 0.05, metalness: 0.2, transparent: true, opacity: 0.28, depthWrite: false, side: THREE.DoubleSide });
else if (key === 'rubber') mats[key] = new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.8 });
else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 }); // beslag
return mats[key];
}
const toM = 0.001;
const toShape = (pts, holes) => {
const s = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM)));
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM)))));
return s;
};
// Plaat van z0 tot z1 (mm); buitenvlak (z1) in matOut als dat afwijkt
// (folie aan de buitenzijde, de rest in de binnenkleur).
function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
if (pts.length < 3 || areaOf(pts) < 1) return;
const FOIL = matOut && matOut !== matIn ? 1 : 0;
const add = (a, b, m) => {
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(toShape(pts, holes), { depth: (b - a) * toM, bevelEnabled: false }), m);
mesh.position.z = a * toM;
mesh.castShadow = shadow !== false;
mesh.receiveShadow = true;
g.add(mesh);
};
add(z0, z1 - FOIL, matIn);
if (FOIL) add(z1 - FOIL, z1, matOut);
}
const box = (g, m, x, y, z, w, h, d) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), m);
mesh.position.set(x * toM, y * toM, z * toM);
mesh.castShadow = true;
g.add(mesh);
};
// Kozijn in een opening (opgelost: w, h links, hR rechts). Geeft een
// groep in mm-lokale coördinaten van de opening (x vanaf links, y vanaf
// de onderkant, +z = buiten) in meters, of null.
// place: { zOut (mm, buitenvlak kozijn), depth (mm), exterior (dorpel) }
function build3D(op, fill, place) {
const f = normalize(fill);
if (!f) return null;
const g = new THREE.Group();
const mIn = colorMat(f.in), mOut = colorMat(f.out);
const depth = Math.min(P.depth, place.depth || P.depth);
const z1 = place.zOut, z0 = z1 - depth;
const hR = op.hR !== undefined ? op.hR : op.h;
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
// Bovenkant (schuin) van een veelhoek binnen het kader: >= 0 onder de
// lijn door TL-TR van `q` (lineair, voor clipPoly).
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]);
slab(g, outer, [inner], z0, z1, mIn, mOut);
let pane = inner;
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
pane = insetPoly(inner, [0, 0, P.rooster, 0]);
const band = [pane[3], pane[2], inner[2], inner[3]];
slab(g, [band[0], band[1], band[2], band[3]], null, z1 - 105, z1 - 8, mIn, mOut);
// Lamellen: donkere sleuven op beide vlakken.
[0.3, 0.55, 0.8].forEach(t => {
const a = insetPoly(inner, [0, 20, P.rooster * t - 4, 20]), b = insetPoly(inner, [0, 20, P.rooster * t + 4, 20]);
const slot = [b[3], b[2], a[2], a[3]];
slab(g, slot, null, z1 - 8, z1 - 6, mat('rubber'));
slab(g, slot, null, z1 - 107, z1 - 105, mat('rubber'));
});
}
glass(pane);
return g;
}
// Deur: kader als U (zonder onderregel) op een dorpel (buitenwand) of
// tot de vloer (binnenwand).
const sill = place.exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
slab(g, u, null, z0, z1, mIn, mOut);
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
const top = below(inner);
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
// Zijlicht rechts als er naast een deurblad nog genoeg ruimte is.
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const lx1 = side ? ix0 + P.leafW : ix1;
// Bovenlicht als er boven een deurblad nog genoeg ruimte is (overal).
const lowTop = Math.min(topAt(ix0), topAt(ix1));
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
const ly1 = transom ? iy0 + P.leafH : Infinity;
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
const bar = (pts) => slab(g, pts, null, z0 + 10, z1 - 10, mIn, mOut);
if (side) {
bar(region(lx1, lx1 + P.tussen, iy0, ly1));
// Zijlicht: onderregel + glas.
bar(region(lx1 + P.tussen, ix1, iy0, iy0 + P.kader));
glass(region(lx1 + P.tussen, ix1, iy0 + P.kader, ly1));
}
if (transom) {
bar(region(ix0, ix1, ly1, ly1 + P.tussen));
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
const leaf = clipPoly(rect(ix0 + 3, lx1 - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
if (leaf.length < 3) return g;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
let holes = null;
const lw = lxR - lx0;
if (f.glas && lw >= 500 && lt - lb >= 1400) {
const gx = lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
const strip = rect(gx - gw / 2, gx + gw / 2, lb + 350, lt - 300);
holes = [strip];
slab(g, strip, null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
}
slab(g, leaf, holes, lzIn, lzOut, mIn, mOut);
// Kruk aan beide zijden (scharnieren links, slot rechts).
if (lw >= 400) {
const hx = lxR - 75, hy = Math.min(lb + 1050, (lb + lt) / 2);
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
box(g, mat('beslag'), hx, hy - 30, z + s * 5, 34, 210, 10); // schild
box(g, mat('beslag'), hx, hy, z + s * 30, 20, 20, 50); // nek
box(g, mat('beslag'), hx - 60, hy, z + s * 55, 140, 20, 20); // greep
});
}
return g;
}
return { COLORS, FILL_TYPES, PROFILE: P, normalize, defaultFill, build3D };
})();
+343 -109
View File
@@ -14,8 +14,9 @@
// voor zowel de losse wand als de unit gelden.
//
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] }
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, topPts?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset,fill?}] }
// fill = vulling van de opening (kozijn, zie wall/kozijn-model.js).
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
@@ -51,7 +52,64 @@ window.WallModel = (() => {
// 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
const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit)
// Rabat (gevelbekleding op de kruislat): planken van BOARD_MM werkende
// breedte in de richting van materials.cladding, in een kleur uit
// RABAT_COLORS (woning-instelling). In 3D één blok per wandstuk, van het
// doek tot de voorkant van de planken (latwerk + plank): zo zijn hoeken,
// dagkanten en onderrand dicht en vervallen doek en latten erachter. De
// planken zijn een textuur in gevel-coördinaten (lopen door over naden).
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;
// Eén materiaal per kleur + richting (gedeeld). Textuur: TILE_BOARDS
// planken, elk met een lichte bovenkant en een schaduwlijn onderaan
// (overlap); kleine kleurverschillen per plank.
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();
// rect(a, b) = band over de lengte van de plank, van a tot b (px dwars).
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;
}
// Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek
// de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de
@@ -198,9 +256,10 @@ window.WallModel = (() => {
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
// Bovenrand van de wand (daklijn): één recht stuk van links naar rechts,
// of met een middenpunt (nok, wallPeak) twee stukken die elkaar daar
// raken. De nok ligt nooit lager dan de goten (buildConfig), dus de
// bovenrand is overal het MINIMUM van de (doorgetrokken) stukken — dat
// of via tussenpunten (CONFIG.wallTop: nok, of de knikken van een
// mansarde-/wolfsdak) meerdere stukken. De daklijn is altijd bol
// (wallTopPoints), dus de bovenrand is overal het MINIMUM van de
// (doorgetrokken) stukken — dat
// houdt at/under/rangeAbove simpel en maakt "laagste punt boven een
// opening" gelijk aan het laagste van de twee uiteinden.
// x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters).
@@ -213,8 +272,8 @@ window.WallModel = (() => {
k = k || 1;
const halfW = CONFIG.wallWidth / 2 * k;
const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k };
const pk = CONFIG.wallPeak ? { x: (CONFIG.wallPeak.x - CONFIG.wallWidth / 2) * k, y: CONFIG.wallPeak.h * k } : null;
const pts = pk ? [L, pk, R] : [L, R];
const 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);
@@ -223,15 +282,15 @@ window.WallModel = (() => {
const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope;
const at = (x) => Math.min(...segs.map(sg => lineY(sg, x)));
const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo));
let knee = null;
if (segs.length === 2) {
const [s0, s1] = segs, ds = s0.slope - s1.slope;
// Knieën: snijpunt van de onderkanten van twee opeenvolgende bovenregels.
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
: pk.x;
knee = { x, y: under(x) };
}
: s0.b.x;
return { x, y: under(x) };
});
const rangeAbove = (y) => {
let x0 = -halfW, x1 = halfW;
segs.forEach(sg => {
@@ -246,30 +305,54 @@ window.WallModel = (() => {
.filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x);
const underPts = (xa, xb) => {
const out = [{ x: xa, y: under(xa) }];
if (knee && knee.x > xa && knee.x < xb) out.push({ x: knee.x, y: knee.y });
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;
};
// peak: hoogste tussenpunt (nok), voor maatvoering.
const peak = inner.length ? inner.reduce((m, q) => q.y > m.y ? q : m) : null;
return {
pts, segs, peak: pk, knee, at, under, rangeAbove, crossings, underPts,
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: pk ? [pk.x] : []
kinks: inner.map(q => q.x)
};
}
// Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één
// parallellogram, of met nok twee stukken in verstek (nok boven, knie onder).
// parallellogram, of per stuk van de daklijn een bovenregel; die sluiten
// in verstek op elkaar aan (knik boven, knie onder).
function topPlateOutlines(tl) {
const [L, R] = [tl.pts[0], tl.pts[tl.pts.length - 1]];
if (!tl.peak) {
const vo = tl.segs[0].vo;
return [[L, R, { x: R.x, y: R.y - vo }, { x: L.x, y: L.y - vo }]];
const 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 }
]);
}
// Tussenpunten van de daklijn (mm vanaf links) uit de wand-parameters:
// topPts [{ f (0..1 van de breedte), h }] (mansarde, wolfsdak), of het
// middenpunt heightMid op peakX (standaard midden). Alleen punten die
// de daklijn bol houden (boven de koorde tussen hun buren); de bovenrand
// is dan overal het minimum van de doorgetrokken stukken (topLine).
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)) }];
}
const P = tl.peak, K = tl.knee;
return [
[L, P, K, { x: L.x, y: L.y - tl.segs[0].vo }],
[P, R, { x: R.x, y: R.y - tl.segs[1].vo }, K]
];
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 volwaardige constraint-solver): elke opening wordt eerst
@@ -305,7 +388,7 @@ window.WallModel = (() => {
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, rawHR: hRightOf(raw), rawY: raw.y };
return { 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 };
});
resolved.sort((a, b) => a.x - b.x);
@@ -400,24 +483,23 @@ window.WallModel = (() => {
const hRight = Math.round(params.heightRight || 2900);
const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width);
const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width);
// Middenpunt (nok): standaard midden op de wand, of op peakX (mm vanaf
// links; een wandstuk uit een unit waar de nok niet in het midden
// valt). Nooit lager dan de hoogste goot; gelijk = geen nok.
let wallPeak = null;
if (params.heightMid > 0) {
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
const h = Math.round(Math.max(params.heightMid, hLeft, hRight));
if (px > 1 && px < wallWidth - 1 && h > hLeft + (px / wallWidth) * (hRight - hLeft) + 0.5) wallPeak = { x: px, h };
}
const roofAt = (x) => wallPeak
? (x <= wallPeak.x ? hLeft + x / wallPeak.x * (wallPeak.h - hLeft) : wallPeak.h + (x - wallPeak.x) / (wallWidth - wallPeak.x) * (hRight - wallPeak.h))
: hLeft + (x / wallWidth) * (hRight - hLeft);
// Tussenpunten van de daklijn (nok; knikken bij mansarde/wolfsdak),
// zie wallTopPoints. Een wandstuk uit een unit krijgt ze op zijn eigen
// positie (peakX / topPts).
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,
wallPeak,
wallTop,
studWidth,
studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth),
plateThick,
@@ -447,7 +529,7 @@ window.WallModel = (() => {
const top = topLine(CONFIG);
const slope = top.segs[0].slope;
// Met nok: de steilste helling (beide kanten lopen dan op naar de nok).
const angleDeg = top.peak
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));
@@ -623,9 +705,12 @@ window.WallModel = (() => {
// 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.
// linkerrand van dit stuk (gebruikt door de horizontale latten via gridY0).
// Verticale latten: per wandstuk (de modules worden los vervoerd, de
// bekleding eindigt op de rand van het stuk) een lat op beide randen en
// langs beide zijden van elke opening (de bekleding moet daar op een lat
// eindigen); daartussen gelijk verdeeld, h.o.h. max. BATTEN.spacing
// (krimpend als het vak kleiner is, minimaal de twee randlatten).
// facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het
// horizontale latten-raster van de hele gevel begint. Een wandstuk dat
// hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster.
@@ -637,8 +722,6 @@ window.WallModel = (() => {
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam
const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL;
const isEnd = facade ? facade.isEnd : true;
const halfW = CONFIG.wallWidth / 2;
const tl = topLine(CONFIG), topYAt = tl.at;
const holes = sheathingHoles(CONFIG).concat(extraHoles || []);
@@ -653,17 +736,35 @@ window.WallModel = (() => {
yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp;
}
for (let y = yStart; y < tl.maxY - w / 2; y += sp) {
// Schuine bovenrand: afkappen op de bovenkant van de lat (y + w/2),
// anders steekt het uiteinde tot w/2 boven de daklijn (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 });
});
}
// Verticale latten: vanaf de linkerrand elke 400 mm, plus een lat op de rechterrand.
// Verticale latten: vaste posities (randen, zijden van openingen),
// daartussen gelijk verdeeld met h.o.h. <= sp.
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; // valt buiten de bekleding
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (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); // niet dubbel op elkaar
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);
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));
@@ -757,14 +858,19 @@ window.WallModel = (() => {
// 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 }
// opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, stage, rabatColor }
// 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.
// stage: bouwfase (BuildStages, standaard afgewerkt). Lagen van een latere
// fase en lagen die in deze fase volledig ingesloten zijn worden niet
// gebouwd; de stats rekenen altijd met alle lagen.
function build3D(CONFIG, calcData, opts) {
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const spaanplaatVisible = opts.spaanplaatVisible !== false;
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';
// Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone).
@@ -778,6 +884,19 @@ window.WallModel = (() => {
// spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand
// geheel in één unit: onder het dak van de andere unit is het een binnenwand).
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
// Zichtbare buitenlagen in deze fase. Bij rabat (vochtdoek-gevel) dekt
// het rabatblok doek en latten volledig af (zie RABAT).
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
const doekOn = buitenVisible && show('doek') && !rabatOn;
const battensOn = buitenVisible && show('latwerk') && !rabatOn;
// Regelwerk ingesloten: binnen spaanplaat, buiten een plaat of het doek
// (naadzijde 'geen': het deel van de andere unit staat ertegen). Dan
// alleen de stukken die in de dagkant van een opening of aan de rand
// te zien zijn (hoek-, koningsstijlen, regels, dorpels).
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 (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;
@@ -805,7 +924,8 @@ window.WallModel = (() => {
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 battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0; // kruislat (alleen vochtdoek); count = inkoop, pieces = gezaagde stukken
let rabatAreaM2 = 0; // gevelbekleding rabat (gevelvlak, alleen vochtdoek)
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek)
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok
@@ -845,9 +965,16 @@ window.WallModel = (() => {
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
wallPeak: CONFIG.wallPeak ? { x: CONFIG.wallPeak.x, h: CONFIG.wallPeak.h + skinAbove } : null
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove }))
}) : CONFIG;
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
// Latwerk stopt onder het boeideel/de daktrim (opts.battenAbove, mm
// t.o.v. de wandbovenkant, kan negatief zijn); zonder rand tot skinAbove.
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove }))
}) : CONFIG;
// Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok).
const underPts = tl.underPts;
@@ -904,9 +1031,32 @@ window.WallModel = (() => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++;
});
// Schuine regel op de bovenregel (CONFIG.topWedge): vult de wig tot
// de onderkant van het dak, over de diepte van het regelwerk.
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);
}
// C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk
// stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame.
// 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);
@@ -916,6 +1066,7 @@ window.WallModel = (() => {
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));
});
@@ -1063,7 +1214,7 @@ window.WallModel = (() => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) });
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)))));
@@ -1071,10 +1222,9 @@ window.WallModel = (() => {
});
};
const add = (shapes, depth, z, mat) => {
if (!shapes.length) return;
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.visible = buitenVisible;
mesh.castShadow = true;
wallGroup3D.add(mesh);
};
@@ -1136,22 +1286,19 @@ window.WallModel = (() => {
return shape;
});
if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const boardGeo = new THREE.ExtrudeGeometry(boardShapes, boardExtrudeSettings);
const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
const board = new THREE.Mesh(boardGeo, material);
board.position.set(0, 0, z);
board.visible = visible;
wallGroup3D.add(board);
if (!seamless) {
const edgesGeo = new THREE.EdgesGeometry(boardGeo);
const edgeLines = new THREE.LineSegments(edgesGeo, edgeMaterial);
edgeLines.position.set(0, 0, z);
edgeLines.visible = visible;
wallGroup3D.add(edgeLines);
// Niet zichtbaar (fase/weergave): alleen meten, niet bouwen.
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;
@@ -1163,15 +1310,15 @@ window.WallModel = (() => {
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);
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;
@@ -1191,8 +1338,7 @@ window.WallModel = (() => {
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
cap.rotation.y = dir * Math.PI / 2;
cap.position.set(x + dir * 0.0003, h / 2, 0);
cap.visible = spaanplaatVisible;
wallGroup3D.add(cap);
if (spaanplaatVisible) wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
@@ -1200,19 +1346,19 @@ window.WallModel = (() => {
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
if (spaanplaatBelow) {
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible, { y0: 0, y1: skinFrom * 0.001 });
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'), { y0: 0, y1: skinFrom * 0.001 });
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
}
if (buitenMateriaal === 'spaanplaat') {
// Binnenwand: aan beide zijden spaanplaat.
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible);
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'));
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,
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('beplating'), null,
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
cementplaatAreaM2 = cementResult.areaM2;
cementplaatVolumeM3 = cementResult.volumeM3;
@@ -1227,7 +1373,7 @@ window.WallModel = (() => {
trasraamIsolatieM3 = r.isoM3;
y0 = trasraam.top * toM;
} else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
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;
@@ -1257,6 +1403,10 @@ window.WallModel = (() => {
// transparant zijn).
const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
// Doek tot de bovenrand van de gevel (achter de dakrand langs, ook
// zichtbaar in fasen zonder dakrand); latwerk en rabat stoppen onder
// het boeideel/de daktrim (tlBat).
const tlBat = CONFIG_BAT === CONFIG_OUT ? tlOut : topLine(CONFIG_BAT, toM);
const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM;
@@ -1279,17 +1429,15 @@ window.WallModel = (() => {
return shape;
});
if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
const doekRow = new THREE.Mesh(rowGeo, doekMaterial);
// 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op
// de wand), anders flikkert het dak door het doek heen waar de
// gevel tot onder de dakrand doorloopt (skinAbove).
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
doekRow.visible = buitenVisible;
wallGroup3D.add(doekRow);
if (doekOn) {
const doekRow = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(rowShapes, { steps: 1, depth: sThickM, bevelEnabled: false })), doekMaterial);
// 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op
// de wand), anders flikkert het dak door het doek heen waar de
// gevel tot onder de dakrand doorloopt (skinAbove).
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
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-
@@ -1306,10 +1454,11 @@ window.WallModel = (() => {
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);
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;
@@ -1325,7 +1474,7 @@ window.WallModel = (() => {
trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3;
} else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
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;
@@ -1334,7 +1483,7 @@ window.WallModel = (() => {
// G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op
// het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen.
const bat = computeBattens(CONFIG_OUT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM);
const 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) => {
@@ -1343,15 +1492,69 @@ window.WallModel = (() => {
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);
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;
// I. RABAT op de kruislat: één blok van het doek tot de voorkant van
// de planken (RABAT), boven het trasraam tot onder de dakrand (zoals
// het latwerk), met dezelfde uitsparingen en hoekdoorloop als de
// andere buitenlagen. Oppervlak (gevelvlak) altijd, het blok alleen
// vanaf fase "gevel".
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 = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x)));
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 });
// UV's in gevel-coördinaten: u langs de gevel, v vanaf het latten-
// raster (bovenkant trasraam), zodat de planknaden doorlopen.
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);
}
}
// Kozijnen (KozijnModel): vanaf de fase gevel. Buitenwand: buitenvlak
// gelijk met de buitenbeplating, 120 mm diep, met dorpel; binnen- en
// naadwand: over de volle wanddikte, zonder dorpel.
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 };
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);
});
}
return {
@@ -1359,11 +1562,18 @@ window.WallModel = (() => {
stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
battenLengthM, battenVolumeM3, battenCount, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3
}
};
}
// Hout, 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; wedgeMats.set(wood, m); }
return wedgeMats.get(wood);
}
// Specificaties (volumes -> gewichten), op basis van de stats uit build3D.
function computeSpecs(CONFIG, stats, buitenMateriaal) {
const toM = 0.001;
@@ -1440,6 +1650,27 @@ window.WallModel = (() => {
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] });
}
// Schuine regel (CONFIG.topWedge): één stuk over de wand, uit een balk
// van de grootste wighoogte gezaagd onder de dakhelling.
// Per aaneengesloten stuk waar een wig zit (bijv. alleen onder een wolfseind).
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] });
});
}
// Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
// omtrek (PDF-tekening).
topPlateOutlines(tl).forEach((outline, i) => {
@@ -1580,6 +1811,9 @@ window.WallModel = (() => {
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})`);
}
@@ -1587,8 +1821,8 @@ window.WallModel = (() => {
}
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+8 -5
View File
@@ -180,13 +180,16 @@ window.WallPdf = (() => {
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight));
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
if (tl.peak) {
// Nokhoogte: stippellijn van vloer tot nok.
// Tussenpunten van de daklijn: stippellijn van vloer tot nok/knik
// (nok = het enige hoogste punt; mansarde-/wolfsgevel: knikken).
const inner = tl.pts.slice(1, -1);
inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
doc.setLineDashPattern([1, 1], 0);
doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y));
doc.line(px(q.x), py(0), px(q.x), py(q.y));
doc.setLineDashPattern([], 0);
doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.y) + 4);
}
doc.text((isNok ? 'nok ' : 'knik ') + Math.round(q.y), px(q.x) + 1.5, py(q.y) + 4);
});
const studEdges = new Set([0, wallWidth]);
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
+79 -12
View File
@@ -255,6 +255,11 @@ function mountWallEditor(container, callbacks) {
const lockCladding = callbacks && callbacks.lockCladding;
// ... en de buitenzijde (gevelbekleding per woning, override per wand).
const lockExterior = callbacks && callbacks.lockExterior;
// Alles op het tabblad Materiaal ligt dan vast: tabblad weg, uitleg op Opbouw.
if (lockExterior && lockCladding) {
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
if (tabBtn) tabBtn.style.display = 'none';
}
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
if (callbacks && callbacks.lockSpacing) {
@@ -279,8 +284,12 @@ function mountWallEditor(container, callbacks) {
let calcData = { fullStuds: [], studs: [] };
let wallGroup3D = null;
let lastBaseHeight = 2400;
// Daklijn met knikken (mansarde/wolfsdak, uit de unit-dakvorm): blijft
// staan tot een hoogte hier met de hand wordt gewijzigd.
let wallTopPts = null;
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset }
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? }
// fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open.
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
// recht en is h de hoogte. De onderkant is altijd waterpas.
// 'offset' betekent afhankelijk van anchor:
@@ -288,7 +297,7 @@ function mountWallEditor(container, callbacks) {
// right -> afstand vanaf rechter wandrand tot rechterkant opening
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
let openings = [
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }
];
let openingIdCounter = 2;
@@ -323,7 +332,7 @@ function mountWallEditor(container, callbacks) {
const isSloped = (op) => op.hR !== undefined;
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
o.hR !== undefined ? { hR: o.hR } : {});
o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {});
function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
@@ -372,7 +381,8 @@ function mountWallEditor(container, callbacks) {
h: preset.h,
y: preset.y,
anchor: 'left',
offset: 300 + n * 1400
offset: 300 + n * 1400,
fill: KozijnModel.defaultFill('raam', lastFill())
});
renderOpeningsList();
updateAll();
@@ -439,6 +449,7 @@ function mountWallEditor(container, callbacks) {
const tmp = hLeftInput.value;
hLeftInput.value = hRightInput.value;
hRightInput.value = tmp;
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
openings = openings.map(o => {
// Schuine bovenzijde klapt mee om.
@@ -454,6 +465,40 @@ function mountWallEditor(container, callbacks) {
setIoStatus('Horizontaal gespiegeld.', 'ok');
}
// Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren.
function fillControls(op) {
const f = KozijnModel.normalize(op.fill);
const type = f ? f.type : 'geen';
const opts = (entries, cur) => entries.map(([k, label]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${label}</option>`).join('');
const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]);
const sw = (field, label, on, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
<label>${label}</label>
<div><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
</div>`;
return `<div class="controls-grid opening-fill">
<div>
<div class="control-group">
<label>Vulling</label>
<div><select data-field="fill" data-id="${op.id}">${opts(Object.entries(KozijnModel.FILL_TYPES), type)}</select></div>
</div>
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
</div>
<div>
${f ? `<div class="control-group">
<label>Kleur buiten</label>
<div><select data-field="fillOut" data-id="${op.id}">${opts(colors, f.out)}</select></div>
</div>
<div class="control-group">
<label>Kleur binnen</label>
<div><select data-field="fillIn" data-id="${op.id}">${opts(colors, f.in)}</select></div>
</div>` : ''}
</div>
</div>`;
}
// Kleuren van de laatste opening met een vulling (voor nieuwe openingen).
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
function renderOpeningsList() {
const list = document.getElementById('openings-list');
list.innerHTML = openings.map(op => `
@@ -511,6 +556,7 @@ function mountWallEditor(container, callbacks) {
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div>
</div>
${fillControls(op)}
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div>
`).join('');
@@ -538,12 +584,22 @@ function mountWallEditor(container, callbacks) {
const op = openings.find(o => o.id === id);
if (!op) return;
if (field === 'type') {
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
// Type kiezen = standaardmaat en -vulling van dat type overnemen (daarna vrij aan te passen).
const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill);
renderOpeningsList();
} else if (field === 'fill') {
const v = e.target.value;
if (v === 'geen') delete op.fill;
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
renderOpeningsList();
} else if (/^fill(Rooster|Glas|Out|In)$/.test(field)) {
if (!op.fill) return;
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillOut: 'out', fillIn: 'in' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
@@ -597,6 +653,7 @@ function mountWallEditor(container, callbacks) {
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
// Middenpunt (nok); null = rechte bovenrand.
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
@@ -610,7 +667,7 @@ function mountWallEditor(container, callbacks) {
function getPureState() {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor },
openings: openings.map(rawOpening)
};
}
@@ -631,11 +688,13 @@ function mountWallEditor(container, callbacks) {
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
lastBaseHeight = state.wall.heightAll;
wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null;
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat)
syncMaterialButtons();
openings = state.openings.map(rawOpening);
@@ -789,6 +848,7 @@ function mountWallEditor(container, callbacks) {
heightLeft: hLeft,
heightRight: hRight,
heightMid: peakOn ? hMid : null,
topPts: wallTopPts || undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
@@ -987,14 +1047,16 @@ function mountWallEditor(container, callbacks) {
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
if (tl.peak) {
const pxC = toCanvasX(tl.peak.x);
// Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik).
const inner = tl.pts.slice(1, -1);
inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
ctxFront.fillStyle = '#9ab';
ctxFront.font = '11px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6));
ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6));
ctxFront.textAlign = 'start';
}
});
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
const drawStudFront = (st) => {
@@ -1322,6 +1384,8 @@ function mountWallEditor(container, callbacks) {
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen
let rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen
let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder)
function syncMaterialButtons() {
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
@@ -1355,6 +1419,7 @@ function mountWallEditor(container, callbacks) {
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
};
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
// met de unit-editor).
@@ -1363,7 +1428,7 @@ function mountWallEditor(container, callbacks) {
function update3DScene() {
if (wallGroup3D) scene.remove(wallGroup3D);
const result = WallModel.build3D(CONFIG, calcData, {
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, spaanplaatVisible, buitenVisible
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, rabatColor, spaanplaatVisible, buitenVisible, stage
});
wallGroup3D = result.group;
lastWallStats = result.stats;
@@ -1425,6 +1490,8 @@ function mountWallEditor(container, callbacks) {
const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => {
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
input.addEventListener('input', updateAll);
});
@@ -1476,6 +1543,6 @@ window.WallEditor = {
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
},
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }]
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
})
};