/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Schoorsteen tegen een buitenwand: hol, binnen vlak met de wand, buiten (iets) voor de gevel. Hij maakt in elke // buitenwand in hetzelfde vlak die hij raakt een sparing zoals een raam (ook over een naad tussen units heen) en // onderbreekt de dakrand; aan de voorkant steekt hij minstens tot voorbij de dakoverstek uit. // Lokaal assenstelsel per schoorsteen: t langs de wand (wereld-x of -z), s loodrecht erop, 0 = buitenkant regelwerk // van de unit (rand van de vloer), positief naar buiten. window.ChimneyModel = (() => { const EPS = 0.5; const SHELL_MM = 100; // wand rond het rookkanaal const CAP_MM = 60, CAP_OVER_MM = 30; const FLUE_SHOW_MM = 500; // zichtbare diepte van het rookkanaal onder de kap const INSET_MM = 2; // sparingen in wand, dak en dakrand net binnen de schoorsteen (geen z-fighting) const BEAR_MM = -4; // nokbalk stopt net vóór de binnenkant van de schoorsteen const MIN_CUT_MM = 100; const CORNER_CLEAR_MM = 200; // slepen: zoveel vrij van een buitenhoek (gevelbekleding van de haakse wand) const INSIDE_TYPES = { wand: 'Zoals de wanden', stuc: 'Stucwerk', steen: 'Steenstrip' }; const OUTSIDE_TYPES = { steen: 'Steenstrip', stuc: 'Stucwerk' }; const DEFAULTS = { w: 800, out: 180, above: 600, inside: { type: 'wand', stone: 'R777', stuc: 'wit' }, outside: { type: 'steen', stone: 'R777', stuc: 'wit' } }; const SIDES = { voor: { axis: 'x', n: { x: 0, z: -1 } }, achter: { axis: 'x', n: { x: 0, z: 1 } }, links: { axis: 'z', n: { x: -1, z: 0 } }, rechts: { axis: 'z', n: { x: 1, z: 0 } } }; const skinOf = (finish) => finish.type === 'steen' ? WallModel.STONE.STRIP_MM : finish.type === 'stuc' ? WallModel.STUC.THICK_MM : 0; function normalizeFinish(f, types, def) { const o = Object.assign({}, def, f || {}); if (!types[o.type]) o.type = def.type; if (!WallModel.isStoneFinish(o.stone)) o.stone = def.stone; if (!WallModel.isStucColor(o.stuc)) o.stuc = def.stuc; return { type: o.type, stone: o.stone, stuc: o.stuc }; } function normalize(list, unitIds) { const used = new Set(); const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def); return (Array.isArray(list) ? list : []).filter(c => c && unitIds.includes(c.unitId) && SIDES[c.side]).map(c => { let id = Number(c.id) > 0 ? Math.round(c.id) : 1; while (used.has(id)) id++; used.add(id); return { id, unitId: c.unitId, side: c.side, u: num(c.u, 0, -20000, 40000), w: num(c.w, DEFAULTS.w, 400, 2000), out: num(c.out, DEFAULTS.out, 0, 1000), above: num(c.above, DEFAULTS.above, 0, 3000), inside: normalizeFinish(c.inside, INSIDE_TYPES, DEFAULTS.inside), outside: normalizeFinish(c.outside, OUTSIDE_TYPES, DEFAULTS.outside) }; }); } function create(list, unitId, side, u) { const id = (list || []).reduce((m, c) => Math.max(m, c.id || 0), 0) + 1; return normalize([{ id, unitId, side, u }], [unitId])[0]; } // Vlak van een zijde van een unit; u = hart vanaf de linker vloerhoek, van buitenaf gezien. const planeOf = (r, side) => ({ voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 })[side]; // opts: { wallThickness(unitId, side), faceDepth(unitId, side) (gevelbekleding), overhang } voor de voetafdruk // (sIn..sFront), die het dak al nodig heeft voordat de gevels gebouwd zijn. function place(list, rects, opts) { const o = opts || {}; return list.map(c => { const r = rects.find(q => q.id === c.unitId); if (!r || r.error) return null; const S = SIDES[c.side]; const t = { voor: r.x1 - c.u, achter: r.x0 + c.u, links: r.z0 + c.u, rechts: r.z1 - c.u }[c.side]; const T = o.wallThickness ? o.wallThickness(c.unitId, c.side) : 260, face = o.faceDepth ? o.faceDepth(c.unitId, c.side) : 0; return Object.assign({}, c, { axis: S.axis, n: S.n, plane: planeOf(r, c.side), t0: t - c.w / 2, t1: t + c.w / 2, sIn: -T, sFront: Math.max(face + c.out, (o.overhang || 0) + 20) }); }).filter(Boolean); } // Sparingen in het dak van een unit (plattegrondmaten t.o.v. het unitmidden, zoals RoofModel.computeUnitRoof): // de hele voetafdruk binnen het dak; een nokbalk stopt ervoor (BEAR_MM). function roofCuts(placed, rect, roofRect) { const out = []; placed.forEach(c => { const S = SIDES[c.side]; const k = S.axis === 'z' ? S.n.x : S.n.z, sA = c.plane + k * (c.sIn + INSET_MM), sB = c.plane + k * c.sFront; const w = S.axis === 'z' ? { x0: Math.min(sA, sB), x1: Math.max(sA, sB), z0: c.t0 + INSET_MM, z1: c.t1 - INSET_MM } : { x0: c.t0 + INSET_MM, x1: c.t1 - INSET_MM, z0: Math.min(sA, sB), z1: Math.max(sA, sB) }; const x0 = Math.max(w.x0, roofRect.x0), x1 = Math.min(w.x1, roofRect.x1), z0 = Math.max(w.z0, roofRect.z0), z1 = Math.min(w.z1, roofRect.z1); if (x1 - x0 < 1 || z1 - z0 < 1) return; out.push({ x0: x0 - rect.cx, x1: x1 - rect.cx, z0: z0 - rect.cz, z1: z1 - rect.cz, bear: BEAR_MM, chimney: c.id }); }); return out; } // Ruwe sparingen voor één unitwand (wl uit UnitModel.computeLayout; rect en rects in wereld-mm). // Loopt de schoorsteen over het wandeinde heen en gaat de wand daar over in die van een andere unit in hetzelfde // vlak (naad), dan loopt de sparing tot het einde door: geen hoekstijlen in de schoorsteen. function wallCuts(placed, rect, wl, studWidth, rects) { const S = SIDES[wl.side], sw = studWidth, plane = planeOf(rect, wl.side); const dir = UnitModel.WALL_SIDES.find(x => x.key === wl.side).dir; const mid = S.axis === 'x' ? wl.cx : wl.cz, c = S.axis === 'x' ? rect.cx : rect.cz; const s0 = mid - wl.width / 2, s1 = mid + wl.width / 2; const toU = (t) => { const p = t - c; return dir > 0 ? p - s0 : s1 - p; }; const toT = (u) => c + (dir > 0 ? u + s0 : s1 - u); const range = (r) => S.axis === 'x' ? [r.x0, r.x1] : [r.z0, r.z1]; const seamEnd = (u) => { const t = toT(u); return (rects || []).some(r => r !== rect && r.id !== rect.id && !r.error && Math.abs(planeOf(r, wl.side) - plane) < EPS && range(r)[0] <= t + EPS && range(r)[1] >= t - EPS); }; const edge = sw * WallModel.CORNER_STUDS; const out = []; placed.filter(ch => ch.side === wl.side && Math.abs(ch.plane - plane) < EPS).forEach(ch => { const ua = toU(ch.t0 + INSET_MM), ub = toU(ch.t1 - INSET_MM); const atStart = Math.min(ua, ub) < edge && Math.max(toU(ch.t0), toU(ch.t1)) > 0 && seamEnd(0); const atEnd = Math.max(ua, ub) > wl.width - edge && Math.min(toU(ch.t0), toU(ch.t1)) < wl.width && seamEnd(wl.width); const a = atStart ? 0 : Math.max(edge, Math.min(ua, ub)), b = atEnd ? wl.width : Math.min(wl.width - edge, Math.max(ua, ub)); if (b - a < MIN_CUT_MM) return; out.push({ id: 1000 + ch.id, type: 'schoorsteen', w: Math.round(b - a), h: 99999, y: 0, anchor: 'left', offset: Math.round(a), fill: null, chimney: { id: ch.id, inside: ch.inside.type, atStart, atEnd } }); }); return out; } // units: parts.units (met walls/pieces en roof), roofEdge: RoofModel.computeEdge (krijgt gaps), groundY absoluut. function resolve(placed, units, roofEdge, groundY, overhang) { return placed.map(ch => { const S = SIDES[ch.side]; const toW = (t, s) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, z: t } : { x: t, z: ch.plane + S.n.z * s }; const sOf = (p) => S.axis === 'z' ? (p.x - ch.plane) * S.n.x : (p.z - ch.plane) * S.n.z; const tOf = (p) => S.axis === 'z' ? p.z : p.x; let T = 0, face = 0, floorTop = null; units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (p.baseY || !p.place.normal || p.place.normal.x !== S.n.x || p.place.normal.y !== S.n.z) return; const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2); if (Math.abs(sOf(m)) > 2) return; const a = UnitModel.localToWorld(p.place, -p.place.width / 2, 0), b = UnitModel.localToWorld(p.place, p.place.width / 2, 0); if (Math.min(ch.t1, Math.max(tOf(a), tOf(b))) - Math.max(ch.t0, Math.min(tOf(a), tOf(b))) < EPS) return; T = Math.max(T, p.place.thickness); face = Math.max(face, WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc)); if (floorTop === null) floorTop = p.place.floorTop; }))); if (floorTop === null) { const u0 = units.find(u => u.id === ch.unitId); floorTop = u0 ? u0.layout.floorTop : 0; T = T || (u0 && u0.walls.find(w => w.side === ch.side) || { thickness: 260 }).thickness; } // De dakrand onderbreken en de voorkant tot voorbij de overstek. let roofOut = 0; if (roofEdge) roofEdge.segments.forEach(sg => { if (Math.abs(sg.normal.x - S.n.x) > 0.01 || Math.abs(sg.normal.z - S.n.z) > 0.01) return; if (Math.abs(sOf(sg.a)) > Math.max(overhang, 50) + face + 5) return; const ta = tOf(sg.outerA), tb = tOf(sg.outerB); if (Math.min(ch.t1, Math.max(ta, tb)) - Math.max(ch.t0, Math.min(ta, tb)) < EPS) return; sg.gaps = (sg.gaps || []).concat([[ch.t0 + INSET_MM, ch.t1 - INSET_MM]]); roofOut = Math.max(roofOut, sOf(sg.outerA) + 20); }); const sIn = -T, sFace = face, sFront = Math.max(face + ch.out, roofOut); const roofs = units.map(u => u.roof); const roofAt = (p) => roofs.filter(r => p.x > r.rect.x0 - EPS && p.x < r.rect.x1 + EPS && p.z > r.rect.z0 - EPS && p.z < r.rect.z1 + EPS); const ridgeTs = roofs.flatMap(r => r.ridges.filter(rd => rd.axis === (S.axis === 'x' ? 'x' : 'z') && rd.value > ch.t0 && rd.value < ch.t1).map(rd => rd.value)); // Ook de randen van de daken: twee lessenaars die op een naad de nok vormen hebben geen eigen nok. const edgeTs = roofs.flatMap(r => (S.axis === 'x' ? [r.rect.x0, r.rect.x1] : [r.rect.z0, r.rect.z1])).filter(t => t > ch.t0 + 1 && t < ch.t1 - 1); const ts = [...new Set([ch.t0, ch.t1, ...ridgeTs, ...edgeTs].map(Math.round))].sort((a, b) => a - b); let roofTop = -Infinity; ts.forEach(t => [sIn, 0, sFront].forEach(s => roofAt(toW(t, s)).forEach(r => { roofTop = Math.max(roofTop, r.topAt(toW(t, s).x, toW(t, s).z)); }))); // Binnenkant tot de onderkant van het dak (vlak achter de wand gemeten). const insideTop = ts.map(t => { const p = toW(t, sIn - 1), r = roofAt(p); return { t, y: r.length ? Math.min(...r.map(q => q.ceilingAt(p.x, p.z))) : floorTop + 2600 }; }); const under = Math.max(...insideTop.map(q => q.y)); if (!isFinite(roofTop)) roofTop = under + 300; const yTop = Math.round(roofTop + ch.above); const flue = { t0: ch.t0 + SHELL_MM, t1: Math.max(ch.t0 + SHELL_MM + MIN_CUT_MM, ch.t1 - SHELL_MM), s0: sIn + SHELL_MM, s1: Math.max(sIn + SHELL_MM + MIN_CUT_MM, sFront - SHELL_MM) }; const corners = (q) => [toW(q.t0, q.s0), toW(q.t1, q.s0), toW(q.t1, q.s1), toW(q.t0, q.s1)]; return Object.assign({}, ch, { T, sIn, sFace, sFront, yGround: groundY, yFloor: floorTop, yTop, yUnder: under, insideTop, flue, footprint: corners({ t0: ch.t0, t1: ch.t1, s0: sIn, s1: sFront }), flueFootprint: corners(flue), protrudes: Math.round(sFront - sFace), height: yTop - groundY }); }); } // Ligt een punt (wereld-mm) in of vlak voor een schoorsteen? function covers(list, x, z, margin) { const m = margin || 0; return list.some(c => { const S = SIDES[c.side]; const t = S.axis === 'z' ? z : x, s = S.axis === 'z' ? (x - c.plane) * S.n.x : (z - c.plane) * S.n.z; return t > c.t0 - m && t < c.t1 + m && s > c.sIn - m && s < c.sFront + m; }); } function hit(list, x, z, margin) { return list.find(c => covers([c], x, z, margin)) || null; } // Slepen: dichtstbijzijnde stuk buitengevel (omtrek van de units samen), hart van de schoorsteen erlangs zodat hij // er helemaal tegen staat; ook over een naad tussen units heen. Geeft { unitId, side, u, t, plane, n } of null. function snapToFacade(rects, x, z, w) { const rs = rects.filter(r => !r.error); if (!rs.length) return null; const loop = rectUnionLoops(rs)[0]; if (!loop) return null; const pts = loop.pts, n = pts.length; let best = null; for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % n], nrm = rectilinearNormal(a, b); const side = Object.keys(SIDES).find(k => Math.abs(SIDES[k].n.x - nrm.x) < 1e-6 && Math.abs(SIDES[k].n.z - nrm.z) < 1e-6); if (!side) continue; const axis = SIDES[side].axis, lo = Math.min(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x), hi = Math.max(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x); if (hi - lo < w + 2 * CORNER_CLEAR_MM) continue; const plane = axis === 'z' ? a.x : a.z, along = axis === 'z' ? z : x, across = axis === 'z' ? x : z; const t = Math.max(lo + w / 2 + CORNER_CLEAR_MM, Math.min(hi - w / 2 - CORNER_CLEAR_MM, along)); const d = Math.hypot(along - t, across - plane); if (best && d >= best.d) continue; const owner = rs.find(r => Math.abs(planeOf(r, side) - plane) < EPS && (axis === 'z' ? r.z0 <= t + EPS && r.z1 >= t - EPS : r.x0 <= t + EPS && r.x1 >= t - EPS)); if (!owner) continue; const u = { voor: owner.x1 - t, achter: t - owner.x0, links: t - owner.z0, rechts: owner.z1 - t }[side]; best = { d, unitId: owner.id, side, u: Math.round(u), t, plane, n: SIDES[side].n }; } return best; } function createMaterials() { return { cap: new THREE.MeshStandardMaterial({ color: 0x77736d, roughness: 0.85 }), flue: new THREE.MeshStandardMaterial({ color: 0x1b1a19, roughness: 1, side: THREE.DoubleSide }), plain: new THREE.MeshStandardMaterial({ color: 0xf2efe9, roughness: 0.9 }) }; } const finishMaterial = (f, fallback) => f.type === 'steen' ? WallModel.stoneMaterial(f.stone) : f.type === 'stuc' ? WallModel.stucMaterial(f.stuc) : fallback; // opts: { materials, wallMaterial (binnenkant 'wand': hetzelfde materiaal als de wanden) } function build3D(list, opts) { const o = opts || {}, m = o.materials || createMaterials(), toM = 0.001; const group = new THREE.Group(); list.forEach(c => { const S = SIDES[c.side]; const W = (t, s) => S.axis === 'z' ? { x: c.plane + S.n.x * s, z: t } : { x: t, z: c.plane + S.n.z * s }; const buckets = new Map(); const add = (mat, tri) => { if (!buckets.has(mat)) buckets.set(mat, { pos: [], uv: [] }); const b = buckets.get(mat); tri.forEach(v => { b.pos.push(v.x * toM, v.y * toM, v.z * toM); b.uv.push(v.u, v.v); }); }; // Verticaal vlak tussen p0 en p1 (wereld), buitennormaal nrm; hoogtes per kant. UV in meters (steen/stuc). const quad = (mat, p0, p1, y0a, y1a, y0b, y1b, nrm) => { const right = { x: nrm.z, z: -nrm.x }; if ((p1.x - p0.x) * right.x + (p1.z - p0.z) * right.z < 0) { [p0, p1] = [p1, p0]; [y0a, y0b] = [y0b, y0a]; [y1a, y1b] = [y1b, y1a]; } const v = (p, y) => ({ x: p.x, y, z: p.z, u: (p.x * right.x + p.z * right.z) * toM, v: (y - c.yFloor) * toM }); const BL = v(p0, y0a), BR = v(p1, y0b), TR = v(p1, y1b), TL = v(p0, y1a); add(mat, [BL, BR, TR]); add(mat, [BL, TR, TL]); }; const nOut = S.n, nIn = { x: -S.n.x, z: -S.n.z }; const tDir = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 }, tNeg = { x: -tDir.x, z: -tDir.z }; const outMat = finishMaterial(c.outside, m.plain); const yG = c.yGround, yT = c.yTop; quad(outMat, W(c.t0, c.sFront), W(c.t1, c.sFront), yG, yT, yG, yT, nOut); quad(outMat, W(c.t0, c.sIn), W(c.t0, c.sFront), yG, yT, yG, yT, tNeg); quad(outMat, W(c.t1, c.sIn), W(c.t1, c.sFront), yG, yT, yG, yT, tDir); quad(outMat, W(c.t0, c.sIn), W(c.t1, c.sIn), c.yUnder, yT, c.yUnder, yT, nIn); // achterkant boven het dak // Binnenkant: van de vloer tot de onderkant van het dak. const k = skinOf(c.inside), inMat = finishMaterial(c.inside, o.wallMaterial || m.plain), sI = c.sIn - k; for (let i = 0; i < c.insideTop.length - 1; i++) { const a = c.insideTop[i], b = c.insideTop[i + 1]; quad(inMat, W(a.t, sI), W(b.t, sI), c.yFloor, a.y, c.yFloor, b.y, nIn); } if (k > 0) { const first = c.insideTop[0], last = c.insideTop[c.insideTop.length - 1]; quad(inMat, W(c.t0, sI), W(c.t0, c.sIn), c.yFloor, first.y, c.yFloor, first.y, tNeg); quad(inMat, W(c.t1, sI), W(c.t1, c.sIn), c.yFloor, last.y, c.yFloor, last.y, tDir); } // Rookkanaal: zwarte binnenwanden en bodem tot FLUE_SHOW_MM onder de kap. const f = c.flue, yF = yT - Math.min(FLUE_SHOW_MM, c.above + 100); quad(m.flue, W(f.t0, f.s0), W(f.t1, f.s0), yF, yT, yF, yT, nOut); quad(m.flue, W(f.t0, f.s1), W(f.t1, f.s1), yF, yT, yF, yT, nIn); quad(m.flue, W(f.t0, f.s0), W(f.t0, f.s1), yF, yT, yF, yT, tDir); quad(m.flue, W(f.t1, f.s0), W(f.t1, f.s1), yF, yT, yF, yT, tNeg); const fp = [W(f.t0, f.s0), W(f.t1, f.s0), W(f.t1, f.s1), W(f.t0, f.s1)].map(p => ({ x: p.x, y: yF, z: p.z, u: 0, v: 0 })); add(m.flue, [fp[0], fp[1], fp[2]]); add(m.flue, [fp[0], fp[2], fp[3]]); buckets.forEach((b, mat) => { const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3)); geo.setAttribute('uv', new THREE.Float32BufferAttribute(b.uv, 2)); geo.computeVertexNormals(); const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = mat !== m.flue; mesh.receiveShadow = true; group.add(mesh); }); // Afdekplaat rond het rookkanaal (vier stroken). const capY = (yT + CAP_MM / 2) * toM; const strips = [ [c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, c.sIn - CAP_OVER_MM, f.s0], [c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, f.s1, c.sFront + CAP_OVER_MM], [c.t0 - CAP_OVER_MM, f.t0, f.s0, f.s1], [f.t1, c.t1 + CAP_OVER_MM, f.s0, f.s1] ]; strips.forEach(([ta, tb, sa, sb]) => { if (tb - ta < 1 || sb - sa < 1) return; const p = W((ta + tb) / 2, (sa + sb) / 2); const [dx, dz] = S.axis === 'z' ? [sb - sa, tb - ta] : [tb - ta, sb - sa]; const box = new THREE.Mesh(new THREE.BoxGeometry(dx * toM, CAP_MM * toM, dz * toM), m.cap); box.position.set(p.x * toM, capY, p.z * toM); box.castShadow = box.receiveShadow = true; group.add(box); }); }); return group; } return { SHELL_MM, INSIDE_TYPES, OUTSIDE_TYPES, DEFAULTS, SIDES, normalize, create, place, wallCuts, roofCuts, resolve, covers, hit, snapToFacade, createMaterials, build3D }; })();