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