Files
Tectum/js/wall/kozijn-model.js
T

331 lines
16 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Kunststof kozijnen naar de Aluplast Ideal 7000 (examples/Buiten kozijnen.pdf).
// links/rechts (leaf, hinge, dir) zijn van buitenaf gezien = vanaf +z.
// userData.door: open/target in radialen (draaien) of meters (schuiven); de loopmodus (house.js) zet target.
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', schuifdeur: 'Schuifdeur' };
const HINGES = { links: 'Links', rechts: 'Rechts' };
const SWINGS = { binnen: 'Naar binnen', buiten: 'Naar buiten' };
const SLIDE_MOUNTS = { inbouw: 'In de wand', opbouw: 'Op de wand (rail)' };
const SLIDE_SIDES = { binnen: 'Binnenzijde', buiten: 'Buitenzijde' };
const SLIDE_DIRS = { links: 'Naar links', rechts: 'Naar rechts' };
const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit';
const P = {
gap: 5,
kader: 70,
depth: 120,
dorpel: 30,
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
rooster: 80,
glass: 24,
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
leafH: 2200, // deurblad als er een bovenlicht boven past
leafT: 68,
minLight: 200
};
const colorKey = (k, def) => COLORS[k] ? k : def;
const pick = (v, opts, def) => opts[v] ? v : def;
function normalize(fill) {
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
const f = {
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN)
};
if (f.type === 'deur') {
f.hinge = pick(fill.hinge, HINGES, 'links');
f.leaf = pick(fill.leaf, HINGES, f.hinge);
f.swing = pick(fill.swing, SWINGS, 'binnen');
f.volglas = !!fill.volglas;
}
if (f.type === 'schuifdeur') Object.assign(f, {
leaves: Number(fill.leaves) === 2 ? 2 : 1, mount: pick(fill.mount, SLIDE_MOUNTS, 'inbouw'),
side: pick(fill.side, SLIDE_SIDES, 'binnen'), dir: pick(fill.dir, SLIDE_DIRS, 'links')
});
return f;
}
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 };
}
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 };
});
}
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;
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 });
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;
};
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);
};
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
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]);
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);
[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;
}
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);
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);
const link = `${Math.round(op.x || 0)}|${Math.round(op.y || 0)}|${op.w}`;
if (f.type === 'schuifdeur') {
buildSlideLeaves(g, f, { ix0, ix1, iy0, topAt, z0, z1, mIn, mOut, sill, place, link, op });
return g;
}
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
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;
if (side) {
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
bar(region(t0, t0 + P.tussen, iy0, ly1));
bar(region(g0, g1, iy0, iy0 + P.kader));
glass(region(g0, g1, 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(la + 3, lb - 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), lb0 = 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.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
}
const outward = f.swing === 'buiten';
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
const pivot = new THREE.Group(), lg = new THREE.Group();
pivot.position.set(hx * toM, 0, hz * toM);
lg.position.set(-hx * toM, 0, -hz * toM);
pivot.add(lg);
g.add(pivot);
if (holes) slab(lg, holes[0], null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
slab(lg, leaf, holes, lzIn, lzOut, mIn, mOut);
const hy = Math.min(lb0 + 1050, (lb0 + lt) / 2);
if (lw >= 400) {
const kx = hingeR ? lx0 + 75 : lxR - 75, gd = hingeR ? 60 : -60;
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
box(lg, mat('beslag'), kx, hy - 30, z + s * 5, 34, 210, 10); // schild
box(lg, mat('beslag'), kx, hy, z + s * 30, 20, 20, 50); // nek
box(lg, mat('beslag'), kx + gd, hy, z + s * 55, 140, 20, 20); // greep
});
}
const dir = (hingeR ? -1 : 1) * (outward ? -1 : 1);
pivot.userData.door = {
kind: 'swing', dir, max: Math.PI / 2, open: 0, target: 0, link,
base: pivot.position.x, center: new THREE.Vector3((lx0 + lxR) / 2 * toM, hy * toM, (lzIn + lzOut) / 2 * toM)
};
return g;
}
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
function buildSlideLeaves(g, f, o) {
const n = f.leaves, opbouw = f.mount === 'opbouw';
const out = f.side === 'buiten';
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
const ov = opbouw ? SLIDE.overlap : -3;
const xa = o.ix0 - ov, xb = o.ix1 + ov;
const yb = o.iy0 + (o.sill ? 3 : 10);
const yt = Math.min(o.topAt(o.ix0), o.topAt(o.ix1)) + (opbouw ? SLIDE.overlap : -3);
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [lz0, lz1] = opbouw
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
dirs.forEach((dir, i) => {
const x0 = xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1 = xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0);
const lg = new THREE.Group();
g.add(lg);
const pts = rect(x0, x1, yb, yt);
if (f.glas && x1 - x0 > 2 * SLIDE.frame + 100) {
const pane = rect(x0 + SLIDE.frame, x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame);
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
} else {
slab(lg, pts, null, lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
}
const hx = dir < 0 ? x1 - 60 : x0 + 60, hy = Math.min(yb + 1050, (yb + yt) / 2);
if (opbouw) [lz0 - 18, lz1 + 18].forEach(z => box(lg, mat('beslag'), hx, hy, z, 20, 400, 20));
else [lz0 - 1, lz1 + 1].forEach(z => box(lg, mat('beslag'), hx, hy, z, 24, 160, 2));
lg.userData.door = {
kind: 'slide', dir, max: (x1 - x0 - (opbouw ? 2 * SLIDE.overlap : 60)) * toM, open: 0, target: 0, link: o.link,
base: 0, center: new THREE.Vector3((x0 + x1) / 2 * toM, hy * toM, (lz0 + lz1) / 2 * toM)
};
});
if (opbouw) {
const ra = n === 2 ? xa - lw : (dirs[0] < 0 ? xa - lw : xa), rb = n === 2 ? xb + lw : (dirs[0] < 0 ? xb : xb + lw);
const rz = out ? faceOut + SLIDE.railD / 2 : faceIn - SLIDE.railD / 2;
box(g, mat('beslag'), (ra + rb) / 2, yt + SLIDE.railH / 2 + 5, rz, rb - ra, SLIDE.railH, SLIDE.railD);
}
}
function planSymbol(op, fill, faces) {
const f = normalize(fill);
if (!f || f.type === 'raam') return [];
const zIn = faces.zIn, zOut = faces.zOut;
const ix0 = P.gap + P.kader, ix1 = op.w - P.gap - P.kader;
const out = [];
if (f.type === 'deur') {
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 1 : -1;
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
const arc = [];
for (let k = 0; k <= 16; k++) { const t = k / 16 * Math.PI / 2; arc.push({ x: hx + s * L * Math.cos(t), z: hz + dz * L * Math.sin(t) }); }
out.push({ kind: 'arc', pts: arc });
return out;
}
const n = f.leaves, opbouw = f.mount === 'opbouw', sideOut = f.side === 'buiten';
const ov = opbouw ? SLIDE.overlap : -3;
const xa = ix0 - ov, xb = ix1 + ov, lw = (xb - xa) / n;
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [z0, z1] = opbouw
? (sideOut ? [zOut + SLIDE.gap, zOut + SLIDE.gap + lt] : [zIn - SLIDE.gap - lt, zIn - SLIDE.gap])
: [(zIn + zOut) / 2 - lt / 2, (zIn + zOut) / 2 + lt / 2];
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
const za = sideOut ? Math.max(zOut, z1) + 140 : Math.min(zIn, z0) - 140;
dirs.forEach((dir, i) => {
const x0 = xa + i * lw, x1 = x0 + lw;
out.push({ kind: 'leaf', closed: true, pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }] });
const c = (x0 + x1) / 2, len = Math.min(500, lw * 0.6);
const a = { x: c - dir * len / 2, z: za }, b = { x: c + dir * len / 2, z: za };
out.push({ kind: 'arrow', pts: [a, b] });
out.push({ kind: 'arrow', pts: [{ x: b.x - dir * 90, z: za - 45 }, b, { x: b.x - dir * 90, z: za + 45 }] });
out.push({ kind: 'rail', closed: true, pts: [{ x: x0 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z1 }, { x: x0 + dir * lw, z: z1 }] });
});
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, colorMaterial: colorMat };
})();