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 };
})();