Files

218 lines
13 KiB
JavaScript

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