/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Palen onder een overkapping: steekt een dak minstens minExt buiten de vloeren uit (veranda), dan komen er // palen onder de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel // uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft. // sideWall: een vrije hoek krijgt geen paal maar een dichte zijwand (gestuukt) van de dakhoek terug tot de gevel, // met de buitenkant gelijk met de zijkant van het dak. Ligt die buitenkant in het vlak van een gevel, dan loopt de // bekleding van die gevel erover door (outsideOf, bijv. felsplaten) en staat de wand gelijk met de bekleding. window.PostModel = (() => { const EPS = 0.5; const SHOE_MM = 100; // verzinkte paalvoet tussen maaiveld en paal const WOODS = { eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 }, douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 } }; const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000, sideWall: false, sideColor: 'wit' }; const SIDE_MM = 200; // dikte zijwand (regelwerk, plaat en stuc aan beide kanten) const SIDE_TOP_BELOW_MM = 40; // doorlopende gevelbekleding: tot zo ver onder de bovenkant van het dak function normalizeSettings(settings) { const s = Object.assign({}, DEFAULT_SETTINGS, settings || {}); s.enabled = s.enabled !== false; s.sideWall = !!s.sideWall; if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood; if (!WallModel.isStucColor(s.sideColor)) s.sideColor = DEFAULT_SETTINGS.sideColor; const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def); s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400); s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000); s.minExt = num(s.minExt, DEFAULT_SETTINGS.minExt, 400, 5000); return s; } // units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut). // outsideOf(wall, out): { finish, kind, depth } van de gevel in het vlak van de buitenkant, of null. function compute(settings, units, groundY, outsideOf) { const s = normalizeSettings(settings); const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0, walls: [], wallM2: 0, sideColor: s.sideColor }; if (!s.enabled || !units.length) return out; const floors = units.map(u => u.rect); const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m); const underRoof = (x, z) => units.some(u => { const r = u.roof.rect; return x > r.x0 && x < r.x1 && z > r.z0 && z < r.z1; }); // Vrije stukken dakrand: buitenrand van de daken samen, recht tegenover een gevel op minstens minExt (loodrecht // op de rand gemeten); de zijkanten van een veranda krijgen alleen de hoekpalen. In een binnenhoek (veranda // tussen twee units) loopt het stuk door tot de andere gevel en rust daar op. const runs = []; units.forEach(u => { const R = u.roof.rect; [ { axis: 'z', at: R.x1, n: 1, s0: R.z0, s1: R.z1 }, { axis: 'z', at: R.x0, n: -1, s0: R.z0, s1: R.z1 }, { axis: 'x', at: R.z1, n: 1, s0: R.x0, s1: R.x1 }, { axis: 'x', at: R.z0, n: -1, s0: R.x0, s1: R.x1 } ].forEach(e => { const pt = (t, d) => (e.axis === 'z' ? { x: e.at - e.n * d, z: t } : { x: t, z: e.at - e.n * d }); const facing = (p) => Math.min(...floors.map(f => { const [lo, hi, c0, c1, v, c] = e.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1, p.z, p.x] : [f.x0, f.x1, f.z0, f.z1, p.x, p.z]; if (!(v > lo + EPS && v < hi - EPS)) return Infinity; const d = e.n > 0 ? c - c1 : c0 - c; return d > -EPS ? d : Infinity; })); const free = (t) => { const p = pt(t, 0), o = pt(t, -1), d = facing(p); return !underRoof(o.x, o.z) && d >= s.minExt - EPS && d < Infinity; }; const cuts = [e.s0, e.s1].concat(floors.flatMap(f => (e.axis === 'z' ? [f.z0, f.z1] : [f.x0, f.x1])) .filter(t => t > e.s0 + EPS && t < e.s1 - EPS)).sort((a, b) => a - b); for (let i = 0; i < cuts.length - 1; i++) { const a = cuts[i], b = cuts[i + 1]; if (b - a < EPS || !free((a + b) / 2)) continue; const last = runs[runs.length - 1]; if (last && last.axis === e.axis && last.n === e.n && Math.abs(last.at - e.at) < EPS && Math.abs(last.b - a) < EPS) last.b = b; else runs.push({ axis: e.axis, at: e.at, n: e.n, a, b }); } }); }); // Aansluitende stukken van verschillende units (bijv. twee units met dezelfde veranda) samenvoegen. runs.sort((p, q) => p.axis.localeCompare(q.axis) || p.n - q.n || Math.round(p.at) - Math.round(q.at) || p.a - q.a); const merged = []; runs.forEach(r => { const m = merged.find(o => o.axis === r.axis && o.n === r.n && Math.abs(o.at - r.at) < EPS && Math.abs(o.b - r.a) < EPS); if (m) m.b = r.b; else merged.push(Object.assign({}, r)); }); const half = s.size / 2; const pts = []; const roofAt = (x, z) => units.map(u => u.roof).find(rf => x > rf.rect.x0 - EPS && x < rf.rect.x1 + EPS && z > rf.rect.z0 - EPS && z < rf.rect.z1 + EPS); const endFreeOf = (r, t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); }; // Zijwand op het vrije eind t van run r (dir +1: wand van t de run in, -1: andersom), terug tot de gevel waar de // run naar kijkt. Delen twee runs een vrije hoek (veranda in een binnenhoek), dan is de run met de kortste weg // naar de gevel de voorkant en komt de wand langs de zijkant; het andere eind rust op die wand. const sideCands = []; if (s.sideWall) merged.forEach(r => [[r.a, 1], [r.b, -1]].forEach(([t, dir]) => { if (!endFreeOf(r, t)) return; const lo = Math.min(t, t + dir * SIDE_MM), hi = Math.max(t, t + dir * SIDE_MM); let reach = Infinity; floors.forEach(f => { const [s0, s1, c0, c1] = r.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1] : [f.x0, f.x1, f.z0, f.z1]; if (s0 > hi - EPS || s1 < lo + EPS) return; const d = r.n > 0 ? r.at - c1 : c0 - r.at; if (d > -EPS && d < reach) reach = d; }); if (!isFinite(reach) || reach < s.size) return; const a = Math.min(r.at, r.at - r.n * reach), b = Math.max(r.at, r.at - r.n * reach); const corner = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; const outward = r.axis === 'z' ? { x: 0, z: -dir } : { x: -dir, z: 0 }; sideCands.push({ r, t, corner, reach, out: outward, w: r.axis === 'z' ? { x0: a, x1: b, z0: lo, z1: hi } : { x0: lo, x1: hi, z0: a, z1: b } }); })); const sameCorner = (p, q) => Math.abs(p.x - q.x) < EPS && Math.abs(p.z - q.z) < EPS; const sideAt = (r, t) => { const c = sideCands.find(d => d.r === r && d.t === t); return !!c && sideCands.some(d => d.built && sameCorner(d.corner, c.corner)); }; sideCands.forEach(c => { if (sideCands.some(d => d !== c && sameCorner(d.corner, c.corner) && (d.reach < c.reach - EPS || (Math.abs(d.reach - c.reach) < EPS && sideCands.indexOf(d) < sideCands.indexOf(c))))) return; const w = c.w; const tops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]] .map(([x, z]) => { const rf = roofAt(x, z); return rf ? rf.ceilingAt(x, z) : null; }).filter(v => v !== null); if (!tops.length || !(Math.max(...tops) > groundY)) return; const wall = Object.assign({}, w, { y0: groundY, y1: Math.round(Math.max(...tops)), color: s.sideColor, out: c.out, outside: null }); const o = outsideOf ? outsideOf(wall, c.out) : null; if (o) { if (c.out.x > 0) wall.x1 += o.depth; else if (c.out.x < 0) wall.x0 -= o.depth; if (c.out.z > 0) wall.z1 += o.depth; else if (c.out.z < 0) wall.z0 -= o.depth; Object.assign(wall, { outside: o.finish, outsideKind: o.kind }); // Zoals de gevel: de bekleding loopt tot onder de dakrand door (over de kant van het dakpakket). const roofTops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]] .map(([x, z]) => { const rf = roofAt(x, z); return rf && rf.topAt ? rf.topAt(x, z) : null; }).filter(v => v !== null); if (roofTops.length) wall.y1 = Math.max(wall.y1, Math.round(Math.min(...roofTops) - SIDE_TOP_BELOW_MM)); } out.walls.push(wall); c.built = true; }); merged.forEach(r => { const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half }); // Een eind dat op of tegen een vloer uitkomt rust op die gevel. const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); }; // Een dichte zijwand neemt de plaats van de hoekpaal in; het eind rust dan op die wand. const fa = endFree(r.a) && !sideAt(r, r.a), fb = endFree(r.b) && !sideAt(r, r.b); const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b; if (pb - pa < EPS) return; const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9)); for (let k = 0; k <= n; k++) { if ((k === 0 && !fa) || (k === n && !fb)) continue; pts.push(pos(pa + (pb - pa) * k / n)); } }); pts.forEach(p => { if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return; if (out.walls.some(w => p.x > w.x0 - half && p.x < w.x1 + half && p.z > w.z0 - half && p.z < w.z1 + half)) return; const roof = roofAt(p.x, p.z); if (!roof) return; // Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier. const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1)))); if (!(top > groundY + SHOE_MM)) return; out.posts.push({ x: Math.round(p.x), z: Math.round(p.z), y0: groundY, y1: Math.round(top), size: s.size }); }); out.count = out.posts.length; out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0); out.m3 = out.lengthMM * s.size * s.size / 1e9; out.kg = out.m3 * WOODS[s.wood].kgM3; const wallArea = (w) => Math.max(w.x1 - w.x0, w.z1 - w.z0) * (w.y1 - w.y0) / 1e6; out.wallM2 = out.walls.reduce((t, w) => t + wallArea(w), 0); out.outsideM2 = {}; out.walls.filter(w => w.outsideKind).forEach(w => { out.outsideM2[w.outsideKind] = (out.outsideM2[w.outsideKind] || 0) + wallArea(w); }); return out; } function createMaterials() { return { wood: Object.fromEntries(Object.entries(WOODS).map(([k, w]) => [k, new THREE.MeshStandardMaterial({ color: w.hex, roughness: 0.78 })])), shoe: new THREE.MeshStandardMaterial({ color: 0x9ba0a4, roughness: 0.45, metalness: 0.6 }) }; } function build3D(data, materials) { const toM = 0.001, group = new THREE.Group(); if (!data || !(data.posts.length || (data.walls || []).length)) return group; const m = materials || createMaterials(); const woodMat = m.wood[data.wood] || m.wood.eiken; data.posts.forEach(p => { const h = p.y1 - p.y0 - SHOE_MM; const post = new THREE.Mesh(new THREE.BoxGeometry(p.size * toM, h * toM, p.size * toM), woodMat); post.position.set(p.x * toM, (p.y0 + SHOE_MM + h / 2) * toM, p.z * toM); group.add(post); const plate = new THREE.Mesh(new THREE.BoxGeometry((p.size + 40) * toM, 10 * toM, (p.size + 40) * toM), m.shoe); plate.position.set(p.x * toM, (p.y0 + 5) * toM, p.z * toM); group.add(plate); const stem = new THREE.Mesh(new THREE.BoxGeometry(p.size * 0.35 * toM, (SHOE_MM - 10) * toM, p.size * 0.35 * toM), m.shoe); stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM); group.add(stem); }); (data.walls || []).forEach(w => { const c = [(w.x0 + w.x1) / 2 * toM, (w.y0 + w.y1) / 2 * toM, (w.z0 + w.z1) / 2 * toM]; const geo = new THREE.BoxGeometry((w.x1 - w.x0) * toM, (w.y1 - w.y0) * toM, (w.z1 - w.z0) * toM); // UV in wereldmeters, zoals de gevels: texturen op dezelfde schaal. 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) + c[0], y = pos.getY(k) + c[1], z = pos.getZ(k) + c[2]; 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; // Volgorde van de vlakken in BoxGeometry: +x, -x, +y, -y, +z, -z. const stuc = WallModel.stucMaterial(w.color), mats = [stuc, stuc, stuc, stuc, stuc, stuc]; const outMat = w.outside && w.out ? WallModel.claddingMaterial(w.outside) : null; if (outMat) mats[w.out.x > 0 ? 0 : w.out.x < 0 ? 1 : w.out.z > 0 ? 4 : 5] = outMat; const box = new THREE.Mesh(geo, mats); box.position.set(...c); box.castShadow = true; box.receiveShadow = true; group.add(box); }); return group; } return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D }; })();