455 lines
21 KiB
JavaScript
455 lines
21 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
|
||
window.FloorModel = (() => {
|
||
const DENSITY_WOOD_KGM3 = 450;
|
||
const DENSITY_OSB_KGM3 = 650;
|
||
const BAY_MARGIN_MM = 15;
|
||
const MIN_DIAMETER_MM = 20;
|
||
|
||
function computeBays(joists, joistW) {
|
||
const bays = [];
|
||
for (let i = 0; i < joists.length - 1; i++) {
|
||
bays.push({ left: joists[i] + joistW / 2, right: joists[i + 1] - joistW / 2 });
|
||
}
|
||
return bays;
|
||
}
|
||
|
||
function findNearestBay(bays, x) {
|
||
for (const bay of bays) {
|
||
if (x >= bay.left && x <= bay.right) return bay;
|
||
}
|
||
let nearest = bays[0], nearestDist = Infinity;
|
||
bays.forEach(b => {
|
||
const center = (b.left + b.right) / 2;
|
||
const d = Math.abs(x - center);
|
||
if (d < nearestDist) { nearestDist = d; nearest = b; }
|
||
});
|
||
return nearest;
|
||
}
|
||
|
||
// Past de diameter niet (ook niet verkleind tot MIN_DIAMETER_MM), dan vervalt de doorvoer alleen uit
|
||
// tekening en telling: hij blijft in de invoer, zodat een andere balkafstand hem terugbrengt.
|
||
// Overlap van twee doorvoeren in hetzelfde vak wordt gemeld, niet opgelost (bewust).
|
||
function resolveFloorOpenings(rawOpenings, width, depth, joists, joistW) {
|
||
const bays = computeBays(joists, joistW);
|
||
|
||
const resolved = rawOpenings.map(raw => {
|
||
let requestedX;
|
||
if (raw.anchorX === 'right') requestedX = width - raw.offsetX;
|
||
else if (raw.anchorX === 'middle') requestedX = width / 2 + raw.offsetX;
|
||
else requestedX = raw.offsetX;
|
||
const worldX = requestedX - width / 2;
|
||
|
||
const bay = bays.length ? findNearestBay(bays, worldX) : null;
|
||
const bayClearWidth = bay ? (bay.right - bay.left) - BAY_MARGIN_MM * 2 : 0;
|
||
|
||
let removed = !bay || bayClearWidth < MIN_DIAMETER_MM;
|
||
let diameter = raw.diameter, r = 0, x = worldX;
|
||
let shrunk = false;
|
||
|
||
if (!removed) {
|
||
r = diameter / 2;
|
||
if (diameter > bayClearWidth) { diameter = bayClearWidth; shrunk = true; }
|
||
r = diameter / 2;
|
||
const xMin = bay.left + BAY_MARGIN_MM + r;
|
||
const xMax = bay.right - BAY_MARGIN_MM - r;
|
||
x = Math.max(xMin, Math.min(worldX, xMax));
|
||
}
|
||
|
||
const requestedY = raw.offsetY;
|
||
const y = Math.max(10, Math.min(requestedY, depth - 10));
|
||
|
||
return {
|
||
id: raw.id, requestedDiameter: raw.diameter, diameter, r, x, y,
|
||
removed, shrunk, repositioned: !removed && x !== worldX, overlapping: false
|
||
};
|
||
});
|
||
|
||
const active = resolved.filter(o => !o.removed);
|
||
for (let i = 0; i < active.length; i++) {
|
||
for (let j = i + 1; j < active.length; j++) {
|
||
const a = active[i], b = active[j];
|
||
if (dist2D(a, b) < a.r + b.r + 30) { a.overlapping = true; b.overlapping = true; }
|
||
}
|
||
}
|
||
resolved.forEach(o => { o.overflowing = o.removed || o.shrunk || o.overlapping; });
|
||
return resolved;
|
||
}
|
||
|
||
function computeFrame(params, rims) {
|
||
const width = params.width || 2000;
|
||
const depth = params.depth || 2000;
|
||
const joistW = params.joistWidth || 70;
|
||
const joistH = params.joistHeight || 220;
|
||
const minSp = params.minSpacing || 300;
|
||
const maxSp = params.maxSpacing || 600;
|
||
const underlayThick = params.underlayThick || 22;
|
||
|
||
const startX = -width / 2;
|
||
const leftJoistX = startX + joistW / 2;
|
||
const rightJoistX = startX + width - joistW / 2;
|
||
let joists = [leftJoistX, rightJoistX];
|
||
calculateSegmentPositions(width - joistW, minSp, maxSp).forEach(p => joists.push(leftJoistX + p));
|
||
joists = [...new Set(joists.map(x => Math.round(x)))].sort((a, b) => a - b);
|
||
|
||
const r = { front: !!(rims && rims.front), back: !!(rims && rims.back) };
|
||
const joistLen = depth - (r.front ? joistW : 0) - (r.back ? joistW : 0);
|
||
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
|
||
}
|
||
|
||
// Rechthoekige sparingen (schoorsteen door het dak): x t.o.v. het midden (-width/2..width/2), y 0..depth.
|
||
// Raveelwerk zodat de plaat overal op hout eindigt: raveelstijlen over de volle lengte langs de zijkanten (tenzij de
|
||
// sparing tot de rand loopt), raveelbalken dwars ervoor en erna tussen die stijlen, kreupele balken daartussen tot
|
||
// de raveelbalk. Een gewone balk die een raveelstijl zou raken vervalt; de randbalk is onderbroken.
|
||
function normalizeCuts(cuts, width, depth) {
|
||
return (cuts || []).map(c => ({
|
||
x0: Math.max(-width / 2, Math.min(c.x0, c.x1)), x1: Math.min(width / 2, Math.max(c.x0, c.x1)),
|
||
y0: Math.max(0, Math.min(c.y0, c.y1)), y1: Math.min(depth, Math.max(c.y0, c.y1))
|
||
})).filter(c => c.x1 - c.x0 > 1 && c.y1 - c.y0 > 1);
|
||
}
|
||
function joistLayout(calcData) {
|
||
const { joists, joistW, depth, width } = calcData, rims = calcData.rims || {}, cuts = calcData.cuts || [];
|
||
const half = width / 2, ya = rims.front ? joistW : 0, yb = depth - (rims.back ? joistW : 0);
|
||
const frames = cuts.map(c => ({
|
||
c,
|
||
sides: [c.x0 > -half + joistW ? c.x0 - joistW / 2 : null, c.x1 < half - joistW ? c.x1 + joistW / 2 : null].filter(x => x !== null),
|
||
head: c.y0 > ya + joistW, tail: c.y1 < yb - joistW
|
||
}));
|
||
const overlaps = (x, a, b) => x + joistW / 2 > a + 0.5 && x - joistW / 2 < b - 0.5;
|
||
const pieces = [];
|
||
joists.forEach(x => {
|
||
if (frames.some(f => f.sides.some(sx => overlaps(x, sx - joistW / 2, sx + joistW / 2)))) return;
|
||
let segs = [[ya, yb]];
|
||
frames.filter(f => overlaps(x, f.c.x0, f.c.x1)).forEach(f => {
|
||
const a0 = f.head ? f.c.y0 - joistW : f.c.y0, b0 = f.tail ? f.c.y1 + joistW : f.c.y1;
|
||
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, a0)], [Math.max(a, b0), b]]).filter(([a, b]) => b - a > 1);
|
||
});
|
||
pieces.push({ x, segs });
|
||
});
|
||
const trimmers = [];
|
||
frames.forEach(f => {
|
||
f.sides.forEach(sx => { if (!pieces.some(p => overlaps(p.x, sx - joistW / 2, sx + joistW / 2))) pieces.push({ x: sx, segs: [[ya, yb]], frame: true }); });
|
||
const x0 = f.c.x0 > -half + joistW ? f.c.x0 : -half, x1 = f.c.x1 < half - joistW ? f.c.x1 : half;
|
||
if (f.head) trimmers.push({ x0, x1, y0: f.c.y0 - joistW, y1: f.c.y0 });
|
||
if (f.tail) trimmers.push({ x0, x1, y0: f.c.y1, y1: f.c.y1 + joistW });
|
||
});
|
||
pieces.sort((a, b) => a.x - b.x);
|
||
// Randbalken (dwars, voor en achter): onderbroken waar een sparing tot in de rand loopt.
|
||
const rimRuns = [['front', 0, joistW], ['back', depth - joistW, depth]].filter(([k]) => rims[k]).map(([k, y0, y1]) => {
|
||
let segs = [[-half, half]];
|
||
cuts.filter(c => c.y1 > y0 + 0.5 && c.y0 < y1 - 0.5).forEach(c => {
|
||
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, c.x0)], [Math.max(a, c.x1), b]]).filter(([a, b]) => b - a > 1);
|
||
});
|
||
return { key: k, y0, y1, segs };
|
||
});
|
||
return { pieces, trimmers, rimRuns };
|
||
}
|
||
// Bovenvlak (plaat, afwerking) als vormen in x/(y - depth/2), met de doorvoeren als gaten.
|
||
function surfaceShapes(calcData, resolvedOpenings, k) {
|
||
const s = k || 1, W = calcData.width, D = calcData.depth, cuts = calcData.cuts || [];
|
||
const pts = (loop) => loop.map(p => new THREE.Vector2(p.x * s, (p.z - D / 2) * s));
|
||
let regions;
|
||
if (!cuts.length) regions = [{ outer: [{ x: -W / 2, z: 0 }, { x: W / 2, z: 0 }, { x: W / 2, z: D }, { x: -W / 2, z: D }], holes: [] }];
|
||
else {
|
||
const xs = [...new Set([-W / 2, W / 2, ...cuts.flatMap(c => [c.x0, c.x1])])].sort((a, b) => a - b);
|
||
const ys = [...new Set([0, D, ...cuts.flatMap(c => [c.y0, c.y1])])].sort((a, b) => a - b);
|
||
const cells = [];
|
||
for (let i = 0; i < xs.length - 1; i++) for (let j = 0; j < ys.length - 1; j++) {
|
||
const xm = (xs[i] + xs[i + 1]) / 2, ym = (ys[j] + ys[j + 1]) / 2;
|
||
if (!cuts.some(c => xm > c.x0 && xm < c.x1 && ym > c.y0 && ym < c.y1)) cells.push({ x0: xs[i], x1: xs[i + 1], z0: ys[j], z1: ys[j + 1] });
|
||
}
|
||
const loops = rectUnionLoops(cells);
|
||
const inside = (pt, poly) => {
|
||
let c = false;
|
||
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
||
if ((poly[a].z > pt.z) !== (poly[b].z > pt.z) && pt.x < (poly[b].x - poly[a].x) * (pt.z - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
|
||
}
|
||
return c;
|
||
};
|
||
const sign = Math.sign(loops[0].area);
|
||
regions = loops.filter(l => Math.sign(l.area) === sign).map(l => ({ outer: l.pts, holes: [] }));
|
||
loops.filter(l => Math.sign(l.area) !== sign).forEach(l => {
|
||
const owner = regions.find(r => inside(l.pts[0], r.outer));
|
||
if (owner) owner.holes.push(l.pts);
|
||
});
|
||
}
|
||
return regions.map(rg => {
|
||
const shape = new THREE.Shape(pts(rg.outer));
|
||
rg.holes.forEach(h => shape.holes.push(new THREE.Path(pts(h))));
|
||
resolvedOpenings.filter(o => !o.removed && o.r > 0 && inside2(o, rg.outer)).forEach(o => {
|
||
const hole = new THREE.Path();
|
||
hole.absarc(o.x * s, (o.y - D / 2) * s, o.r * s, 0, Math.PI * 2, false);
|
||
shape.holes.push(hole);
|
||
});
|
||
return shape;
|
||
});
|
||
function inside2(o, poly) {
|
||
let c = false;
|
||
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
||
if ((poly[a].z > o.y) !== (poly[b].z > o.y) && o.x < (poly[b].x - poly[a].x) * (o.y - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
|
||
}
|
||
return c;
|
||
}
|
||
}
|
||
|
||
function topHeight(calcData) {
|
||
return calcData.joistH + calcData.underlayThick;
|
||
}
|
||
|
||
// Isolatie krijgt bewust geen gewicht: de materiaalkeuze is aan de gebruiker.
|
||
function computeSpecs(calcData, resolvedOpenings) {
|
||
const toM = 0.001;
|
||
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
|
||
const widthM = width * toM, depthM = depth * toM;
|
||
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
|
||
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
|
||
const joistLenM = (calcData.joistLen || depth) * toM;
|
||
|
||
const cuts = calcData.cuts || [];
|
||
const lay = cuts.length ? joistLayout(calcData) : null;
|
||
const joistMM = lay ? lay.pieces.reduce((t, p) => t + p.segs.reduce((u, [a, b]) => u + b - a, 0), 0) + lay.trimmers.reduce((t, tr) => t + tr.x1 - tr.x0, 0)
|
||
: joists.length * (calcData.joistLen || depth);
|
||
const rimMM = lay ? lay.rimRuns.reduce((t, r) => t + r.segs.reduce((u, [a, b]) => u + b - a, 0), 0) : rimCount * width;
|
||
const joistWoodM3 = joistMM * toM * joistWM * joistHM + rimMM * toM * joistWM * joistHM;
|
||
const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
|
||
|
||
const grossAreaM2 = widthM * depthM;
|
||
let holesAreaM2 = 0, holesVoidM3 = 0;
|
||
resolvedOpenings.filter(o => !o.removed).forEach(o => {
|
||
const rM = o.r * toM;
|
||
holesAreaM2 += Math.PI * rM * rM;
|
||
holesVoidM3 += Math.PI * rM * rM * joistHM;
|
||
});
|
||
cuts.forEach(c => { holesAreaM2 += (c.x1 - c.x0) * (c.y1 - c.y0) * toM * toM; });
|
||
const osbAreaM2 = Math.max(0, grossAreaM2 - holesAreaM2);
|
||
const osbVolM3 = osbAreaM2 * underlayM;
|
||
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
|
||
|
||
const envelopeM3 = widthM * depthM * joistHM;
|
||
let voidM3 = envelopeM3 - joistWoodM3 - holesVoidM3;
|
||
if (voidM3 < 0) voidM3 = 0;
|
||
|
||
const totalKg = joistWoodKg + osbKg;
|
||
|
||
return { joistWoodM3, joistWoodKg, osbAreaM2, osbVolM3, osbKg, voidM3, totalKg };
|
||
}
|
||
|
||
function computeChassisWheels(chassis, width, depth) {
|
||
if (!chassis || !chassis.enabled) return { wheels: [], outOfBounds: false };
|
||
const r = chassis.wheelDiameter / 2;
|
||
const dx = chassis.trackWidth / 2, dy = chassis.wheelbase / 2;
|
||
const cy = depth / 2;
|
||
const wheels = [
|
||
{ x: -dx, y: cy - dy }, { x: dx, y: cy - dy },
|
||
{ x: -dx, y: cy + dy }, { x: dx, y: cy + dy }
|
||
].map(p => ({ ...p, r }));
|
||
const outOfBounds = wheels.some(w =>
|
||
(w.x - w.r) < -width / 2 || (w.x + w.r) > width / 2 || (w.y - w.r) < 0 || (w.y + w.r) > depth);
|
||
return { wheels, outOfBounds };
|
||
}
|
||
|
||
function computeFromState(state, rims) {
|
||
const calcData = computeFrame(state.floor, rims);
|
||
calcData.cuts = normalizeCuts(state.cuts, calcData.width, calcData.depth);
|
||
const resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW);
|
||
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
|
||
return { calcData, resolvedOpenings, chassisData };
|
||
}
|
||
|
||
const FINISHES = {
|
||
geen: { label: 'Geen (OSB)', color: '#cda86a' },
|
||
visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 },
|
||
tegel_wit: { label: 'Tegel wit 300×300', color: '#ecebe6', tile: { w: 300, h: 300, joint: 3 }, jointColor: '#b9b6ad' },
|
||
tegel_grijs: { label: 'Tegel grijs 600×600', color: '#a9a8a3', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#7d7c78' },
|
||
tegel_antraciet: { label: 'Tegel antraciet 600×600', color: '#4a4b4d', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#2c2d2e' }
|
||
};
|
||
function tileTexture(tile, color, jointColor) {
|
||
if (typeof document === 'undefined') return null;
|
||
const k = 512 / Math.max(tile.w, tile.h);
|
||
const cv = document.createElement('canvas');
|
||
cv.width = Math.max(8, Math.round(tile.w * k));
|
||
cv.height = Math.max(8, Math.round(tile.h * k));
|
||
const c = cv.getContext('2d');
|
||
c.fillStyle = color;
|
||
c.fillRect(0, 0, cv.width, cv.height);
|
||
for (let i = 0; i < 400; i++) {
|
||
c.fillStyle = `rgba(${Math.random() < 0.5 ? '255,255,255' : '0,0,0'},0.025)`;
|
||
c.fillRect(Math.random() * cv.width, Math.random() * cv.height, 6 + Math.random() * 20, 6 + Math.random() * 20);
|
||
}
|
||
const j = Math.max(1, Math.round(tile.joint * k));
|
||
c.fillStyle = jointColor;
|
||
c.fillRect(0, 0, cv.width, j);
|
||
c.fillRect(0, 0, j, cv.height);
|
||
const tex = new THREE.CanvasTexture(cv);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(1000 / tile.w, 1000 / tile.h);
|
||
tex.anisotropy = 8;
|
||
return tex;
|
||
}
|
||
function finishThumbUrl(key, px) {
|
||
const f = FINISHES[key];
|
||
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
|
||
}
|
||
const finishMaterials = {};
|
||
function finishMaterial(key) {
|
||
const f = FINISHES[key];
|
||
if (!f || !(f.file || f.tile)) return null;
|
||
if (finishMaterials[key]) return finishMaterials[key];
|
||
if (f.tile) {
|
||
const tex = tileTexture(f.tile, f.color, f.jointColor);
|
||
finishMaterials[key] = new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.45 } : { color: f.color, roughness: 0.45 });
|
||
return finishMaterials[key];
|
||
}
|
||
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
|
||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
||
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
|
||
tex.anisotropy = 8;
|
||
mat.map = tex;
|
||
mat.color.set(0xffffff);
|
||
mat.needsUpdate = true;
|
||
});
|
||
}
|
||
finishMaterials[key] = mat;
|
||
return mat;
|
||
}
|
||
|
||
function createMaterials() {
|
||
return {
|
||
woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }),
|
||
osbMaterial: new THREE.MeshStandardMaterial({ color: 0xcda86a, roughness: 0.75, side: THREE.DoubleSide }),
|
||
wheelMaterial: new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.5 })
|
||
};
|
||
}
|
||
|
||
// finish.originX/Z: UV's in woning-meters, zodat het patroon over units doorloopt.
|
||
function build3D(calcData, resolvedOpenings, chassisData, opts) {
|
||
const { woodMaterial, osbMaterial, wheelMaterial } = opts.materials;
|
||
const osbVisible = opts.osbVisible !== false;
|
||
const floorGroup3D = new THREE.Group();
|
||
|
||
const toM = 0.001;
|
||
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
|
||
const widthM = width * toM, depthM = depth * toM;
|
||
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
|
||
|
||
const rims = calcData.rims || {};
|
||
const joistLenM = (calcData.joistLen || depth) * toM;
|
||
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
||
if ((calcData.cuts || []).length) {
|
||
const lay = joistLayout(calcData);
|
||
const box = (x0, x1, y0, y1) => {
|
||
const mesh = new THREE.Mesh(new THREE.BoxGeometry((x1 - x0) * toM, joistHM, (y1 - y0) * toM), woodMaterial);
|
||
mesh.position.set((x0 + x1) / 2 * toM, joistHM / 2, ((y0 + y1) / 2 - depth / 2) * toM);
|
||
mesh.castShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
};
|
||
const outer = [joists[0], joists[joists.length - 1]];
|
||
lay.pieces.forEach(p => {
|
||
// Plaat zichtbaar: alleen de buitenste balken en het raveelwerk (zoals zonder sparing).
|
||
if (opts.outerJoistsOnly && !outer.includes(p.x) && !p.frame && p.segs.length === 1 && p.segs[0][1] - p.segs[0][0] > depth - 3 * joistW) return;
|
||
p.segs.forEach(([a, b]) => box(p.x - joistW / 2, p.x + joistW / 2, a, b));
|
||
});
|
||
lay.trimmers.forEach(t => box(t.x0, t.x1, t.y0, t.y1));
|
||
} else joists.forEach((xMM, i) => {
|
||
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
|
||
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
||
const mesh = new THREE.Mesh(geo, woodMaterial);
|
||
mesh.position.set(xMM * toM, joistHM / 2, joistZ);
|
||
mesh.castShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
});
|
||
if ((calcData.cuts || []).length) {
|
||
joistLayout(calcData).rimRuns.forEach(r => r.segs.forEach(([a, b]) => {
|
||
const mesh = new THREE.Mesh(new THREE.BoxGeometry((b - a) * toM, joistHM, joistWM), woodMaterial);
|
||
mesh.position.set((a + b) / 2 * toM, joistHM / 2, ((r.y0 + r.y1) / 2 - depth / 2) * toM);
|
||
mesh.castShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
}));
|
||
} else [['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
|
||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(widthM, joistHM, joistWM), woodMaterial);
|
||
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
|
||
mesh.castShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
});
|
||
|
||
// Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
|
||
|
||
const osbGeo = new THREE.ExtrudeGeometry(surfaceShapes(calcData, resolvedOpenings, toM), { steps: 1, depth: underlayM, bevelEnabled: false });
|
||
const osbMesh = new THREE.Mesh(osbGeo, osbMaterial);
|
||
osbMesh.rotation.x = Math.PI / 2;
|
||
osbMesh.position.set(0, joistHM + underlayM, 0);
|
||
osbMesh.visible = osbVisible;
|
||
osbMesh.castShadow = true;
|
||
floorGroup3D.add(osbMesh);
|
||
|
||
const osbEdges = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(osbGeo),
|
||
new THREE.LineBasicMaterial({ color: 0x6b5636 })
|
||
);
|
||
osbEdges.rotation.x = Math.PI / 2;
|
||
osbEdges.position.copy(osbMesh.position);
|
||
osbEdges.visible = osbVisible;
|
||
floorGroup3D.add(osbEdges);
|
||
|
||
(chassisData ? chassisData.wheels : []).forEach(wheel => {
|
||
const rM = wheel.r * toM;
|
||
const thicknessM = 0.08;
|
||
const geo = new THREE.CylinderGeometry(rM, rM, thicknessM, 24);
|
||
const mesh = new THREE.Mesh(geo, wheelMaterial);
|
||
mesh.rotation.z = Math.PI / 2;
|
||
mesh.position.set(wheel.x * toM, -rM, wheel.y * toM - depthM / 2);
|
||
mesh.castShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
});
|
||
|
||
const fin = opts.finish && finishMaterial(opts.finish.key);
|
||
if (fin) {
|
||
const geo = new THREE.ShapeGeometry(surfaceShapes(calcData, resolvedOpenings, toM));
|
||
const ox = opts.finish.originX || 0, oz = opts.finish.originZ || 0;
|
||
const pos = geo.attributes.position, uv = geo.attributes.uv;
|
||
// Na het kantelen (-90° om X) wordt vorm-y wereld -z.
|
||
for (let k = 0; k < pos.count; k++) uv.setXY(k, pos.getX(k) + ox, -pos.getY(k) + oz);
|
||
const mesh = new THREE.Mesh(geo, fin);
|
||
mesh.rotation.x = -Math.PI / 2;
|
||
mesh.position.y = joistHM + underlayM + 0.0005;
|
||
mesh.receiveShadow = true;
|
||
floorGroup3D.add(mesh);
|
||
}
|
||
return floorGroup3D;
|
||
}
|
||
|
||
function validateState(design) {
|
||
if (!design.floor || typeof design.floor !== 'object') throw new Error('veld "floor" ontbreekt');
|
||
const requiredFloorFields = ['width', 'depth', 'joistWidth', 'joistHeight', 'minSpacing', 'maxSpacing', 'underlayThick'];
|
||
requiredFloorFields.forEach(f => {
|
||
if (typeof design.floor[f] !== 'number' || !isFinite(design.floor[f])) {
|
||
throw new Error(`floor.${f} ontbreekt of is geen geldig getal`);
|
||
}
|
||
});
|
||
if (design.chassis !== undefined && (typeof design.chassis !== 'object' || design.chassis === null)) {
|
||
throw new Error('veld "chassis" is aanwezig maar geen object');
|
||
}
|
||
if (!Array.isArray(design.openings)) throw new Error('veld "openings" ontbreekt of is geen lijst');
|
||
design.openings.forEach((o, i) => {
|
||
if (typeof o.diameter !== 'number' || typeof o.offsetX !== 'number' || typeof o.offsetY !== 'number') {
|
||
throw new Error(`doorvoer #${i + 1}: diameter/offsetX/offsetY moeten getallen zijn`);
|
||
}
|
||
if (!['left', 'middle', 'right'].includes(o.anchorX)) {
|
||
throw new Error(`doorvoer #${i + 1}: anchorX moet 'left', 'middle' of 'right' zijn (was: ${o.anchorX})`);
|
||
}
|
||
});
|
||
}
|
||
|
||
return {
|
||
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
|
||
computeSpecs, computeFromState, createMaterials, build3D, surfaceShapes, joistLayout
|
||
};
|
||
})();
|