Files
Tectum/js/floor/floor-model.js
T

359 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 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 };
}
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 joistWoodM3 = joists.length * joistWM * joistHM * joistLenM + rimCount * joistWM * joistHM * widthM;
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;
});
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);
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 ? 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(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;
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);
});
[['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 osbShape = new THREE.Shape();
osbShape.moveTo(-widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, depthM / 2);
osbShape.lineTo(-widthM / 2, depthM / 2);
osbShape.closePath();
resolvedOpenings.filter(o => !o.removed).forEach(o => {
const cx = o.x * toM;
const cz = o.y * toM - depthM / 2;
const rM = o.r * toM;
if (rM <= 0) return;
const hole = new THREE.Path();
hole.absarc(cx, cz, rM, 0, Math.PI * 2, false);
osbShape.holes.push(hole);
});
const osbGeo = new THREE.ExtrudeGeometry(osbShape, { 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 shape = new THREE.Shape();
shape.moveTo(-widthM / 2, -depthM / 2);
shape.lineTo(widthM / 2, -depthM / 2);
shape.lineTo(widthM / 2, depthM / 2);
shape.lineTo(-widthM / 2, depthM / 2);
shape.closePath();
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * toM, o.y * toM - depthM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
const geo = new THREE.ShapeGeometry(shape);
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
};
})();