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:
co-authored by
Claude Opus 5.5
parent
8cdfa21f46
commit
e1a688728c
@@ -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
@@ -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
@@ -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
@@ -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') }]
|
||||
})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user