Tectum v0.4.0 — mijlpaal gevelopbouw

Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 11:38:54 +02:00
co-authored by Claude Opus 5.5
commit c690e72d13
36 changed files with 9954 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
/*!
* 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).
// ----------------------------------------------------------
function computeFrame(params) {
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);
return { joists, width, depth, joistW, joistH, underlayThick };
}
// 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 joistWoodM3 = joists.length * joistWM * joistHM * depthM;
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).
function computeFromState(state) {
const calcData = computeFrame(state.floor);
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 }),
isolatieMaterial: new THREE.MeshStandardMaterial({
color: 0xffe08c, roughness: 0.9, transparent: true, opacity: 0.5, 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, isolatieVisible }
// ----------------------------------------------------------
function build3D(calcData, resolvedOpenings, chassisData, opts) {
const { woodMaterial, osbMaterial, isolatieMaterial, wheelMaterial } = opts.materials;
const osbVisible = opts.osbVisible !== false;
const isolatieVisible = opts.isolatieVisible !== 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, lopen over de volle diepte door.
joists.forEach(xMM => {
const geo = new THREE.BoxGeometry(joistWM, joistHM, depthM);
const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(xMM * toM, joistHM / 2, 0);
mesh.castShadow = true;
floorGroup3D.add(mesh);
});
// B. ISOLATIE — vult elk balkvak (tussen twee opeenvolgende balken) over
// de volle balkhoogte/diepte. Zelfde "leegte tussen het regelwerk"-
// gedachte als bij wall.js, hier ook echt gevisualiseerd als laag.
const bays = computeBays(joists, joistW);
bays.forEach(bay => {
const bayWidthM = (bay.right - bay.left) * toM;
if (bayWidthM <= 0) return;
const geo = new THREE.BoxGeometry(bayWidthM, joistHM, depthM);
const mesh = new THREE.Mesh(geo, isolatieMaterial);
mesh.position.set(((bay.left + bay.right) / 2) * toM, joistHM / 2, 0);
mesh.visible = isolatieVisible;
floorGroup3D.add(mesh);
});
// 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
};
})();