v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+44 -93
View File
@@ -4,33 +4,13 @@
* 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
const DENSITY_WOOD_KGM3 = 450;
const DENSITY_OSB_KGM3 = 650;
const BAY_MARGIN_MM = 15;
const MIN_DIAMETER_MM = 20;
// ----------------------------------------------------------
// 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++) {
@@ -52,19 +32,9 @@ window.FloorModel = (() => {
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.
// 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);
@@ -111,14 +81,6 @@ window.FloorModel = (() => {
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;
@@ -140,18 +102,11 @@ window.FloorModel = (() => {
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.
// ----------------------------------------------------------
// 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;
@@ -183,13 +138,6 @@ window.FloorModel = (() => {
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;
@@ -204,8 +152,6 @@ window.FloorModel = (() => {
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);
@@ -213,13 +159,36 @@ window.FloorModel = (() => {
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 }
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' }
};
// Voorvertoning (assets/textures/vloer/thumb/<px>/<naam>.png) of null.
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;
@@ -227,8 +196,13 @@ window.FloorModel = (() => {
const finishMaterials = {};
function finishMaterial(key) {
const f = FINISHES[key];
if (!f || !f.file) return null;
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) => {
@@ -252,18 +226,7 @@ window.FloorModel = (() => {
};
}
// ----------------------------------------------------------
// 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.
// ----------------------------------------------------------
// 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;
@@ -274,8 +237,6 @@ window.FloorModel = (() => {
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;
@@ -294,13 +255,8 @@ window.FloorModel = (() => {
floorGroup3D.add(mesh);
});
// (Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.)
// 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);
@@ -335,8 +291,6 @@ window.FloorModel = (() => {
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;
@@ -348,8 +302,6 @@ window.FloorModel = (() => {
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();
@@ -377,7 +329,6 @@ window.FloorModel = (() => {
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'];
@@ -401,7 +352,7 @@ window.FloorModel = (() => {
}
return {
FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D
};
})();