Files
Tectum/js/floor/floor-model.js
T
MachielandClaude Opus 5.5 9bd7faeaa6 v0.4.9: naadwanden (2 delen, deur), kopbalken op naden, isolatie niet meer getekend
- HouseModel: naadwand per naad (ensureSeamWalls bij nieuwe woning en unit toevoegen),
  twee delen met beplating alleen aan de kamerzijde, openingen gespiegeld
- WallModel: buitenzijde 'geen', thicknessOf eenzijdig
- FloorModel: kopbalken (rims) voor vloer en dak op naden, ook in PDF en totalen
- isolatie: 3D-lagen en filter verwijderd, berekening blijft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:13:07 +02:00

345 lines
16 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// floor/floor-model.js — ENIGE bron van waarheid voor de vloer-logica:
// balkverdeling, doorvoer-resolutie (tussen de balken), chassis-wielen,
// specificaties en de 3D-opbouw. Geen DOM. Wordt gebruikt door:
// - floor/floor.js (de losse vloer-editor)
// - unit/unit.js (unit = vloer + 4 wanden)
// Wijzigingen aan de vloer-logica horen dus HIER.
//
// Werkt met een "vloer-state" (zelfde formaat als getStateJson/JSON-export):
// { floor: {width, depth, joistWidth, joistHeight, minSpacing, maxSpacing, underlayThick},
// chassis: {enabled, wheelDiameter, trackWidth, wheelbase},
// openings: [{id, diameter, anchorX, offsetX, offsetY}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
window.FloorModel = (() => {
const DENSITY_WOOD_KGM3 = 450; // zelfde referentiewaarde als WallModel
const DENSITY_OSB_KGM3 = 650; // OSB-plaat, zelfde referentiewaarde als voorheen "onderlaag"
const BAY_MARGIN_MM = 15; // vrije marge tussen doorvoer-rand en balkflank
const MIN_DIAMETER_MM = 20; // kleinste zinnige doorvoer; past dit niet, dan vervalt de doorvoer
// ----------------------------------------------------------
// Balkvakken: de ruimte tussen twee opeenvolgende balken (in wereld-
// coordinaten, zelfde stelsel als calcData.joists). Een doorvoer mag
// nooit een balk kruisen, dus wordt altijd binnen zo'n vak geklemd.
// ----------------------------------------------------------
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;
}
// Resolutie: X via hetzelfde anker+offset-patroon als de wand-openingen,
// maar het resultaat wordt altijd teruggeklemd tot het balkvak waar het in
// valt (nooit een balk kruisend). Past de gevraagde diameter niet — zelfs
// niet na verkleinen tot het minimum — dan vervalt de doorvoer (blijft wel
// gewoon in de invoerlijst staan, verdwijnt alleen uit de tekening/telling,
// zodat een latere wijziging aan de balkafstand 'm weer terug kan brengen).
//
// Y is een simpele afstand vanaf de voorrand — balken lopen over de volle
// diepte door, dus Y heeft geen balk-interactie zoals X die wel heeft.
//
// Overlap tussen twee doorvoeren IN HETZELFDE vak is een apart, 2D-
// probleem (afstand tussen middelpunten) — dat wordt gedetecteerd en
// gemeld, niet automatisch opgelost. Bewuste, kleinere scope, geen bug.
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;
}
// ----------------------------------------------------------
// Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand.
// params = state.floor (of de ruwe invoerwaarden uit de editor).
// rims (optioneel): { front, back } — kopbalk langs de voor-/achterrand
// (haaks op de balken), bijv. op een naad tussen twee units waar de
// modules weer aan elkaar gemonteerd worden. De langsbalken worden daar
// met één balkbreedte ingekort. Langs de zijranden ligt altijd al een balk.
// ----------------------------------------------------------
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 };
}
// Bovenkant vloer (mm) = waar de wanden van een unit op staan.
function topHeight(calcData) {
return calcData.joistH + calcData.underlayThick;
}
// ----------------------------------------------------------
// Specificaties: balkhout, OSB-onderlaag, isolatie-leegte (zelfde soort
// "envelope minus houtwerk minus openingen"-logica als wall.js) en het
// gewichtstotaal. Isolatie krijgt — net als bij de wand — bewust geen
// eigen dichtheid/gewicht toegekend (materiaalkeuze is aan de gebruiker),
// alleen de resterende inhoud wordt gerapporteerd.
// ----------------------------------------------------------
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 };
}
// ----------------------------------------------------------
// Chassis-laag: 4 wielen in een "dobbelsteen 4"-indeling, exact
// gecentreerd op de vloer (X: midden van de breedte, Y: midden van de
// diepte). Losse laag t.o.v. het regelwerk — wordt niet tegen balken
// geklemd zoals de doorvoeren, alleen gewaarschuwd als een wiel buiten de
// vloerrand zou uitsteken.
// ----------------------------------------------------------
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 };
}
// Alles in één keer vanuit een vloer-state (handig voor de unit).
// rims: zie computeFrame.
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 };
}
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 })
};
}
// ----------------------------------------------------------
// 3D-opbouw (meters): X = breedte, Z = diepte (y=0 "voor" ligt op
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren.
// opts: { materials, osbVisible }
// ----------------------------------------------------------
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;
// A. VLOERBALKEN — dozen over de diepte (ingekort bij een kopbalk), plus
// de kopbalken (rims) over de volle breedte.
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 => {
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.)
// C. OSB-ONDERLAAG — één geëxtrudeerd vlak over de volle vloer, met
// ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in
// (worldX, worldZ)-vlak en vervolgens 90° gekanteld zodat de dikte
// (extrusierichting) verticaal (wereld-Y) komt te liggen, boven op de
// balken.
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);
// D. CHASSIS-WIELEN — schematische cilinders onder de vloer, alleen als
// de chassis-laag aan staat (zelfde brondata als de 2D-tekening).
(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);
});
return floorGroup3D;
}
// Structuurcontrole van een vloer-state (bijv. bij JSON-import).
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 {
validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D
};
})();