/*! * 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 }; } // Vloerafwerking (bovenop de OSB). Texturen in assets/textures/vloer/, // naadloos herhalend; tileMM = maat van één tegel van de textuur. 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 } }; // Voorvertoning (assets/textures/vloer/thumb//.png) of null. 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) return null; if (finishMaterials[key]) 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 }) }; } // ---------------------------------------------------------- // 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, finish, outerJoistsOnly } // outerJoistsOnly: alleen de buitenste balken en kopbalken (de rest is // ingesloten, bijv. dakbalken tussen plafondplaat en dakbedekking). // finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als // dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten // van het vloermidden), 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; // 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, 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.) // 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); }); // Vloerafwerking: vlak net boven de OSB, over de hele vloer (onder de // wanden verdwijnt het), met de doorvoeren uitgespaard. 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; } // 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 { FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels, computeSpecs, computeFromState, createMaterials, build3D }; })();