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:
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,176 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// floor/floor-pdf.js — ENIGE bron van het vloer-tekenblad (A3 liggend):
|
||||
// frame- en plaatmateriaal, bovenaanzicht met balken/doorvoeren en
|
||||
// maatketens, doorsnede en titelblok. Gebruikt door de losse vloer-editor en
|
||||
// door de unit-PDF.
|
||||
// Plain script, geladen na floor-model.js en core/pdf-sheet.js.
|
||||
// ============================================================
|
||||
|
||||
window.FloorPdf = (() => {
|
||||
// ------------------------------------------------------------
|
||||
// Zelfde opzet als WallPdf (frame-materiaallijst, plaatmateriaallijst,
|
||||
// titelblok, hoofdaanzicht met maatketens). Layout/kolommen zijn
|
||||
// afgestemd op de bestaande "Vloer^V-x"-tekeningen (zie examples/) —
|
||||
// die voorbeelden splitsen een grote vloer in meerdere modules; hier
|
||||
// wordt bewust altijd maar één, doorlopende module getekend.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Tekent één volledig vloerblad op de HUIDIGE pagina van `doc`.
|
||||
// opts: { project, titel, element, blad, roof }
|
||||
function drawSheet(doc, calcData, resolvedOpenings, opts) {
|
||||
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
|
||||
|
||||
const PAGE_H = doc.internal.pageSize.getHeight();
|
||||
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// --- Frame materiaal-tabel (linksboven) — één groep: alle vloerbalken
|
||||
// hebben in dit model dezelfde doorsnede/lengte. ---
|
||||
const tblX = [8, 26, 42, 58, 76, 92];
|
||||
let ty = 14;
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(9);
|
||||
doc.text('Frame materiaal', tblX[0], ty);
|
||||
ty += 6;
|
||||
doc.setFontSize(6.5);
|
||||
['Posnum', 'Dikte', 'Hoogte', 'Lengte', 'Aantal'].forEach((h, i) => doc.text(h, tblX[i], ty));
|
||||
ty += 1.5;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(tblX[0], ty, tblX[4] + 12, ty);
|
||||
ty += 4;
|
||||
[String(1), String(joistW), String(joistH), String(Math.round(depth)), String(joists.length)]
|
||||
.forEach((v, i) => doc.text(v, tblX[i], ty));
|
||||
|
||||
// --- Plaatmateriaal + isolatie ---
|
||||
let my = 240;
|
||||
doc.setFontSize(8.5);
|
||||
doc.text('Plaat materiaal', tblX[0], my); my += 4.5;
|
||||
doc.setFontSize(6.5);
|
||||
['Dikte', 'Hoogte', 'Lengte', 'Materiaal', 'Aantal'].forEach((h, i) => doc.text(h, tblX[i], my));
|
||||
my += 4;
|
||||
[String(underlayThick), String(Math.round(depth)), String(Math.round(width)), opts.roof ? 'OSB (plafond)' : 'OSB', '1']
|
||||
.forEach((v, i) => doc.text(v, tblX[i], my));
|
||||
if (opts.roof) {
|
||||
my += 4;
|
||||
[String(RoofModel.DAKLEER_MM), String(Math.round(depth)), String(Math.round(width)), 'Dakleer', '1']
|
||||
.forEach((v, i) => doc.text(v, tblX[i], my));
|
||||
}
|
||||
my += 6;
|
||||
doc.text('Isolatie: leegte tussen de balken (spouwvullend)', tblX[0], my);
|
||||
|
||||
// --- Titelblok (rechtsonder) ---
|
||||
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
|
||||
|
||||
// --- Hoofdaanzicht (bovenaanzicht) ---
|
||||
const drawX0 = 95, drawX1 = 410, drawY0 = 15, drawY1 = 178;
|
||||
const marginSides = 25, marginTop = 15, marginBottom = 30;
|
||||
const availW = (drawX1 - drawX0) - marginSides * 2;
|
||||
const availH = (drawY1 - drawY0) - marginTop - marginBottom;
|
||||
const scale = Math.min(availW / width, availH / depth);
|
||||
|
||||
const originX = drawX0 + marginSides;
|
||||
const topY = drawY0 + marginTop;
|
||||
const px = (xmm) => originX + xmm * scale;
|
||||
const py = (ymm) => topY + ymm * scale; // y=0 (voor) boven, groeit naar beneden
|
||||
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(px(0), py(0), width * scale, depth * scale, 'S');
|
||||
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(5);
|
||||
joists.forEach(x => {
|
||||
const xLeft = x + width / 2 - joistW / 2;
|
||||
doc.rect(px(xLeft), py(0), joistW * scale, depth * scale, 'S');
|
||||
doc.text('1', px(xLeft + joistW / 2), py(depth / 2), { align: 'center' });
|
||||
});
|
||||
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
resolvedOpenings.filter(o => !o.removed).forEach(o => {
|
||||
const cx = px(o.x + width / 2), cy = py(o.y);
|
||||
doc.circle(cx, cy, o.r * scale, 'S');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(5);
|
||||
doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' });
|
||||
});
|
||||
|
||||
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
|
||||
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setFontSize(7);
|
||||
const dimTopY = py(0) - 7;
|
||||
drawDimLine(px(0), dimTopY, px(width), dimTopY);
|
||||
doc.text(String(Math.round(width)), (px(0) + px(width)) / 2, dimTopY - 1.5, { align: 'center' });
|
||||
|
||||
const dimLeftX = px(0) - 7;
|
||||
drawDimLine(dimLeftX, py(0), dimLeftX, py(depth));
|
||||
doc.text(String(Math.round(depth)), dimLeftX - 2, (py(0) + py(depth)) / 2, { align: 'center', angle: 90 });
|
||||
|
||||
// H.o.h.-segmenten + cumulatieve maten onder de tekening — zelfde
|
||||
// opbouw als de stijl-maatketens in wall.js, nu op de balkposities.
|
||||
const joistEdges = new Set([0, width]);
|
||||
joists.forEach(x => joistEdges.add(Math.round(x + width / 2 - joistW / 2)));
|
||||
const cumPoints = [...joistEdges].filter(v => v >= 0 && v <= width).sort((a, b) => a - b);
|
||||
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.setFontSize(5.5);
|
||||
const segY = py(depth) + 9, cumY = py(depth) + 17;
|
||||
for (let i = 0; i < cumPoints.length - 1; i++) {
|
||||
const mid = (cumPoints[i] + cumPoints[i + 1]) / 2;
|
||||
doc.text(String(cumPoints[i + 1] - cumPoints[i]), px(mid), segY, { align: 'center' });
|
||||
}
|
||||
cumPoints.forEach(v => {
|
||||
doc.text(String(v), px(v), cumY, { align: 'center' });
|
||||
doc.line(px(v), cumY - 2.5, px(v), py(depth) + 2);
|
||||
});
|
||||
|
||||
// --- Doorsnede (balkprofiel) — kleine profielweergave onderaan,
|
||||
// analoog aan de bestaande 2D-doorsnede-canvas. ---
|
||||
const secY0 = Math.min(PAGE_H - 20, py(depth) + 30);
|
||||
const secScale = Math.min((drawX1 - drawX0 - 40) / width, 18 / (joistH + underlayThick));
|
||||
const secOriginX = drawX0 + 20;
|
||||
const secFloorY = secY0 + (joistH + underlayThick) * secScale;
|
||||
const spx = (xmm) => secOriginX + xmm * secScale;
|
||||
const spy = (ymm) => secFloorY - ymm * secScale;
|
||||
|
||||
// Dak (opts.roof): 180° gespiegeld — OSB onder (plafond), balken
|
||||
// erboven, dakleer bovenop (RoofModel).
|
||||
const joistY0 = opts.roof ? underlayThick : 0;
|
||||
const osbY0 = opts.roof ? 0 : joistH;
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
joists.forEach(x => {
|
||||
doc.rect(spx(x + width / 2 - joistW / 2), spy(joistY0 + joistH), joistW * secScale, joistH * secScale, 'S');
|
||||
});
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.rect(spx(0), spy(osbY0 + underlayThick), width * secScale, underlayThick * secScale, 'S');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(6);
|
||||
if (opts.roof) {
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(spx(0), spy(joistH + underlayThick), spx(width), spy(joistH + underlayThick));
|
||||
doc.text('Dakleer', spx(width / 2), spy(joistH + underlayThick) - 3, { align: 'center' });
|
||||
doc.text(`${underlayThick}mm OSB (plafond) — isolatie tussen de balken`, spx(width / 2), spy(0) + 6, { align: 'center' });
|
||||
} else {
|
||||
doc.text(`${underlayThick}mm OSB`, spx(width / 2), spy(joistH + underlayThick) - 3, { align: 'center' });
|
||||
doc.text('Isolatie: leegte tussen de balken', spx(width / 2), spy(0) + 6, { align: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
return { drawSheet };
|
||||
})();
|
||||
@@ -0,0 +1,856 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// floor/floor.js — Vloer-editor. Hergebruikt calculateSegmentPositions uit
|
||||
// core/geometry.js (moet voor dit bestand geladen worden) voor exact
|
||||
// dezelfde h.o.h.-balkverdeling als de wand, nu toegepast op vloerbalken.
|
||||
// Vloeren krijgen UITSLUITEND cirkelvormige openingen (leidingdoorvoer,
|
||||
// bijv. riool/water) — en die mogen alleen TUSSEN twee balken vallen, nooit
|
||||
// een balk kruisen: een doorvoer wordt daarom automatisch verkleind tot een
|
||||
// maat die wél binnen zijn balkvak past, en pas verwijderd (uit de
|
||||
// weergave, niet uit de invoer) als zelfs een minimale diameter nergens past.
|
||||
//
|
||||
// Zelfde soorten logica als wall.js: een isolatielaag (leegte tussen de
|
||||
// balken, analytisch bepaald — geen gewicht toegekend, precies zoals bij de
|
||||
// wand), een 3D-perspectief (balken, OSB-onderlaag met doorvoer-gaten,
|
||||
// isolatie, chassis-wielen) en een specificaties-paneel met gewichten.
|
||||
// Onderlaag is standaard OSB-plaat van 22mm.
|
||||
//
|
||||
// De rekenlogica (balken, doorvoeren, chassis, specs, 3D-opbouw) staat in
|
||||
// floor/floor-model.js (FloorModel) — gedeeld met de unit-editor.
|
||||
//
|
||||
// Plain script (geen ES-module), zelfde mount()/unmount()-patroon als
|
||||
// wall.js.
|
||||
// ============================================================
|
||||
|
||||
const FLOOR_TEMPLATE = `
|
||||
<div id="floor-app">
|
||||
<div id="floor-left-column">
|
||||
<div id="floor-pane-2d-container">
|
||||
<div class="pane-2d-sub" id="floor-pane-top">
|
||||
<div class="pane-label">2D BOVENAANZICHT</div>
|
||||
<canvas id="floor-canvas-top"></canvas>
|
||||
</div>
|
||||
<div class="pane-2d-sub" id="floor-pane-section">
|
||||
<div class="pane-label">DOORSNEDE (BALKPROFIEL)</div>
|
||||
<canvas id="floor-canvas-section"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div id="floor-control-panel">
|
||||
<div class="io-toolbar">
|
||||
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
||||
<span id="f-io-status" class="io-status"></span>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="floor-tab-main">1. Vloer Opbouw</button>
|
||||
<button class="tab-btn" data-tab="floor-tab-openings">2. Doorvoeren</button>
|
||||
<button class="tab-btn" data-tab="floor-tab-chassis">3. Chassis</button>
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-main" class="tab-content active">
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<h1>Vloer Dimensies (mm)</h1>
|
||||
<div class="control-group"><label>Breedte:</label>
|
||||
<div><input type="number" id="f-inp-width" value="2000" step="50"><span class="unit">mm</span></div></div>
|
||||
<div class="control-group"><label>Diepte:</label>
|
||||
<div><input type="number" id="f-inp-depth" value="2000" step="50"><span class="unit">mm</span></div></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h1>Vloerbalken</h1>
|
||||
<div class="control-group"><label>Balk Breedte:</label>
|
||||
<div><input type="number" id="f-inp-joist-w" value="70" step="5"><span class="unit">mm</span></div></div>
|
||||
<div class="control-group"><label>Balk Hoogte:</label>
|
||||
<div><input type="number" id="f-inp-joist-h" value="220" step="5"><span class="unit">mm</span></div></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h1>Onderlaag (OSB-plaat)</h1>
|
||||
<div class="control-group"><label>Dikte:</label>
|
||||
<div><input type="number" id="f-inp-underlay-t" value="22" step="1" min="3" max="40"><span class="unit">mm</span></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Regelwerk Afstand (h.o.h.)</h1>
|
||||
<div class="control-group"><label>Min. h.o.h.:</label>
|
||||
<div><input type="number" id="f-inp-min-sp" value="300" step="10"><span class="unit">mm</span></div></div>
|
||||
<div class="control-group"><label>Max. h.o.h.:</label>
|
||||
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>Totaal vloerbalken:</span><span data-spec="joist-count" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Vloeroppervlak:</span><span data-spec="floor-area" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Volume balkhout:</span><span data-spec="joist-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Gewicht balkhout:</span><span data-spec="joist-kg" class="highlight">-</span></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>OSB-plaat oppervlak:</span><span data-spec="osb-m2" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>OSB-plaat volume:</span><span data-spec="osb-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>OSB-plaat gewicht:</span><span data-spec="osb-kg" class="highlight">-</span></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>Isolatie-inhoud (leegte):</span><span data-spec="void-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Totaal gewicht vloer:</span><span data-spec="total-kg" class="highlight">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-openings" class="tab-content">
|
||||
<button id="f-btn-add-opening" class="add-opening-btn">+ Doorvoer Toevoegen</button>
|
||||
<div id="f-openings-global-warn" class="global-warning" style="display:none;"></div>
|
||||
<div id="f-openings-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-chassis" class="tab-content">
|
||||
<div class="material-toggle-group">
|
||||
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
|
||||
</div>
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="control-group"><label>Wieldiameter:</label>
|
||||
<div><input type="number" id="f-inp-wheel-d" value="400" step="10" min="50"><span class="unit">mm</span></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="control-group"><label>Spoorbreedte (X):</label>
|
||||
<div><input type="number" id="f-inp-track" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
|
||||
<div class="control-group"><label>Wielbasis (Y):</label>
|
||||
<div><input type="number" id="f-inp-wheelbase" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="floor-right-column">
|
||||
<div id="floor-pane-3d">
|
||||
<div class="pane-label">3D PERSPECTIEF</div>
|
||||
<div class="toolbar-3d" id="floor-toolbar-3d"></div>
|
||||
</div>
|
||||
|
||||
<div id="floor-specs-panel-3d">
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1>Balken</h1>
|
||||
<div class="result-row"><span>Vloerbalken:</span><span data-spec="joist-count" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Vloeroppervlak:</span><span data-spec="floor-area" class="highlight">-</span></div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<h1>Houtwerk</h1>
|
||||
<div class="result-row"><span>Volume balkhout:</span><span data-spec="joist-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Gewicht balkhout:</span><span data-spec="joist-kg" class="highlight">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1>OSB-plaat (onderlaag, 22mm)</h1>
|
||||
<div class="result-row"><span>Oppervlak:</span><span data-spec="osb-m2" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Volume:</span><span data-spec="osb-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Gewicht:</span><span data-spec="osb-kg" class="highlight">-</span></div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<h1>Isolatie & totaal</h1>
|
||||
<div class="result-row"><span>Inhoud leegtes:</span><span data-spec="void-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Totaal gewicht vloer:</span><span data-spec="total-kg" class="highlight">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wall-project-overlay">
|
||||
<div id="wall-project-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Project eigenschappen</h2>
|
||||
<button type="button" id="wall-project-close-x" class="modal-close-x">×</button>
|
||||
</div>
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="control-group"><label for="inp-proj-naam">Projectnaam:</label>
|
||||
<div><input type="text" id="inp-proj-naam" value=""></div></div>
|
||||
<div class="control-group"><label for="inp-proj-plaats">Plaats:</label>
|
||||
<div><input type="text" id="inp-proj-plaats" value=""></div></div>
|
||||
<div class="control-group"><label for="inp-proj-straat">Straat:</label>
|
||||
<div><input type="text" id="inp-proj-straat" value=""></div></div>
|
||||
<div class="control-group"><label for="inp-proj-nummer">Projectnummer:</label>
|
||||
<div><input type="text" id="inp-proj-nummer" value=""></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar:</label>
|
||||
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
|
||||
<div class="control-group"><label for="inp-proj-titel">Titel:</label>
|
||||
<div><input type="text" id="inp-proj-titel" value="Vloer"></div></div>
|
||||
<div class="control-group"><label for="inp-proj-element">Element nummer:</label>
|
||||
<div><input type="text" id="inp-proj-element" value=""></div></div>
|
||||
<div class="control-group"><label for="inp-proj-opmerking">Opmerking:</label>
|
||||
<div><input type="text" id="inp-proj-opmerking" value=""></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Dakmodus: een plat dak is een vloer die 180° gespiegeld is (OSB-plaat aan
|
||||
// de onderzijde als plafond, balken erboven) met dakleer als buitenlaag
|
||||
// (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D.
|
||||
function floorTemplateForRoof() {
|
||||
const r = [
|
||||
['1. Vloer Opbouw', '1. Dak Opbouw'],
|
||||
['Vloer Dimensies (mm)', 'Dak Dimensies (mm)'],
|
||||
['<h1>Vloerbalken</h1>', '<h1>Dakbalken</h1>'],
|
||||
['Onderlaag (OSB-plaat)', 'Plafondplaat (OSB, onderzijde)'],
|
||||
['Totaal vloerbalken:', 'Totaal dakbalken:'],
|
||||
['OSB-plaat (onderlaag, 22mm)', 'OSB-plaat (plafond)'],
|
||||
['<button class="tab-btn" data-tab="floor-tab-chassis">3. Chassis</button>', ''],
|
||||
['<h1>Isolatie & totaal</h1>', '<h1>Dakleer, isolatie & totaal</h1>\n <div class="result-row"><span>Dakleer oppervlak:</span><span data-spec="dakleer-m2" class="highlight">-</span></div>\n <div class="result-row"><span>Dakleer gewicht:</span><span data-spec="dakleer-kg" class="highlight">-</span></div>']
|
||||
];
|
||||
let t = FLOOR_TEMPLATE;
|
||||
r.forEach(([a, b]) => { t = t.split(a).join(b); });
|
||||
return t.split('Vloeroppervlak:').join('Dakoppervlak:').split('Totaal gewicht vloer:').join('Totaal gewicht dak:');
|
||||
}
|
||||
|
||||
function mountFloorEditor(container, callbacks) {
|
||||
const onSaved = callbacks && callbacks.onSaved;
|
||||
const roofMode = !!(callbacks && callbacks.mode === 'roof');
|
||||
container.innerHTML = roofMode ? floorTemplateForRoof() : FLOOR_TEMPLATE;
|
||||
|
||||
// Binnen een woning kan de maat vastliggen (dak volgt de vloer);
|
||||
// callbacks.lockSize is dan de uitleg-tekst voor de tooltip.
|
||||
if (callbacks && callbacks.lockSize) {
|
||||
['f-inp-width', 'f-inp-depth'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.disabled = true;
|
||||
el.title = callbacks.lockSize;
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// State
|
||||
// ----------------------------------------------------------
|
||||
// Dichtheden, balkvak-marge en minimale doorvoer-diameter: zie FloorModel.
|
||||
|
||||
// Geen residuele demo-doorvoer meer — leeg beginnen, gebruiker voegt zelf toe.
|
||||
let openings = [];
|
||||
let openingIdCounter = 1;
|
||||
|
||||
// Chassis-laag: een losse, schematische onderconstructie (transportwielen)
|
||||
// die NIET met de balkpositie hoeft te overlappen — in tegenstelling tot de
|
||||
// doorvoeren hierboven wordt hier niets geklemd/verkleind, alleen buiten de
|
||||
// vloerrand gewaarschuwd (zie computeChassisWheels).
|
||||
let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
|
||||
|
||||
// Tab-navigatie (1. Vloer Opbouw / 2. Doorvoeren / 3. Chassis) — dezelfde
|
||||
// generieke .tab-btn/.tab-content-wiring als wall.js. Ontbrak hier eerder
|
||||
// (residu van vóór de tab-bar werd toegevoegd), waardoor de knoppen wel
|
||||
// getekend maar niet klikbaar waren.
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
|
||||
document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
}
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
function anchorLabel(a) {
|
||||
if (a === 'right') return 'Afstand vanaf oostzijde (X)';
|
||||
if (a === 'middle') return 'Verschuiving t.o.v. midden (X)';
|
||||
return 'Afstand vanaf westzijde (X)';
|
||||
}
|
||||
|
||||
function renderOpeningsList() {
|
||||
const el = document.getElementById('f-openings-list');
|
||||
el.innerHTML = openings.map(op => `
|
||||
<div class="opening-card" data-opening-id="${op.id}">
|
||||
<div class="opening-card-header">
|
||||
<span class="opening-card-title">Doorvoer #${op.id}</span>
|
||||
<button class="opening-remove-btn" data-action="remove" data-id="${op.id}">×</button>
|
||||
</div>
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="control-group"><label>Diameter:</label>
|
||||
<div><input type="number" data-field="diameter" data-id="${op.id}" value="${op.diameter}" step="5" min="10"><span class="unit">mm</span></div></div>
|
||||
<div class="control-group"><label>Afstand vanaf noordzijde (Y):</label>
|
||||
<div><input type="number" data-field="offsetY" data-id="${op.id}" value="${op.offsetY}" step="50"><span class="unit">mm</span></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="control-group"><label>Anker (X):</label>
|
||||
<div>
|
||||
<select data-field="anchorX" data-id="${op.id}">
|
||||
<option value="left" ${op.anchorX === 'left' ? 'selected' : ''}>West</option>
|
||||
<option value="middle" ${op.anchorX === 'middle' ? 'selected' : ''}>Midden</option>
|
||||
<option value="right" ${op.anchorX === 'right' ? 'selected' : ''}>Oost</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group"><label>${anchorLabel(op.anchorX)}:</label>
|
||||
<div><input type="number" data-field="offsetX" data-id="${op.id}" value="${op.offsetX}" step="50"><span class="unit">mm</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('f-openings-list').addEventListener('input', (e) => {
|
||||
const id = parseInt(e.target.dataset.id, 10);
|
||||
const field = e.target.dataset.field;
|
||||
if (!field) return;
|
||||
const op = openings.find(o => o.id === id);
|
||||
if (!op) return;
|
||||
if (field === 'anchorX') { op.anchorX = e.target.value; renderOpeningsList(); }
|
||||
else op[field] = parseFloat(e.target.value) || 0;
|
||||
updateAll();
|
||||
});
|
||||
document.getElementById('f-openings-list').addEventListener('click', (e) => {
|
||||
if (e.target.dataset.action === 'remove') {
|
||||
openings = openings.filter(o => o.id !== parseInt(e.target.dataset.id, 10));
|
||||
renderOpeningsList();
|
||||
updateAll();
|
||||
}
|
||||
});
|
||||
document.getElementById('f-btn-add-opening').addEventListener('click', () => {
|
||||
openings.push({ id: openingIdCounter++, diameter: 110, anchorX: 'left', offsetX: 500 + openings.length * 600, offsetY: 500 });
|
||||
renderOpeningsList();
|
||||
updateAll();
|
||||
});
|
||||
|
||||
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
||||
|
||||
function syncChassisToggleLabel() {
|
||||
const btn = document.getElementById('f-btn-chassis-toggle');
|
||||
btn.classList.toggle('active', chassis.enabled);
|
||||
btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit');
|
||||
}
|
||||
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
|
||||
chassis.enabled = !chassis.enabled;
|
||||
syncChassisToggleLabel();
|
||||
updateAll();
|
||||
});
|
||||
document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => {
|
||||
chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400;
|
||||
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
||||
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
||||
updateAll();
|
||||
}));
|
||||
syncChassisToggleLabel();
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Project eigenschappen — zelfde modal/velden als wall.js (bewust
|
||||
// dezelfde id's: er is nooit meer dan één editor gelijktijdig gemount,
|
||||
// dus hergebruik hiervan is veilig en voorkomt dubbele CSS/HTML).
|
||||
// ------------------------------------------------------------
|
||||
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
|
||||
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
||||
let projectPropsSnapshot = null;
|
||||
|
||||
function openProjectProperties() {
|
||||
projectPropsSnapshot = {};
|
||||
PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; });
|
||||
document.getElementById('wall-project-overlay').classList.add('open');
|
||||
}
|
||||
function closeProjectProperties() {
|
||||
document.getElementById('wall-project-overlay').classList.remove('open');
|
||||
}
|
||||
document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties);
|
||||
document.getElementById('wall-project-cancel').addEventListener('click', () => {
|
||||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||||
closeProjectProperties();
|
||||
});
|
||||
document.getElementById('wall-project-close-x').addEventListener('click', () => {
|
||||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||||
closeProjectProperties();
|
||||
});
|
||||
document.getElementById('wall-project-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'wall-project-overlay') {
|
||||
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
||||
closeProjectProperties();
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// JSON export/import — zelfde patroon als wall.js: sla het volledige
|
||||
// vloer-ontwerp op (parameters + chassis + ruwe doorvoeren) als JSON, en
|
||||
// laad het weer terug. Bij "Opslaan als" wordt ook automatisch de
|
||||
// bijbehorende PDF-tekening gegenereerd (zie generateFloorPdf).
|
||||
// ------------------------------------------------------------
|
||||
function setIoStatus(msg, cls) {
|
||||
const el = document.getElementById('f-io-status');
|
||||
el.textContent = msg;
|
||||
el.className = 'io-status ' + (cls || '');
|
||||
clearTimeout(setIoStatus._t);
|
||||
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
|
||||
}
|
||||
|
||||
function buildDesignObject() {
|
||||
return { type: 'floor-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() };
|
||||
}
|
||||
|
||||
function exportDesign() {
|
||||
const suggested = 'vloer-ontwerp-' + new Date().toISOString().slice(0, 10);
|
||||
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
|
||||
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
|
||||
filename = filename.trim();
|
||||
if (!filename) filename = suggested;
|
||||
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
|
||||
|
||||
const design = buildDesignObject();
|
||||
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const pdfBase = filename.replace(/\.json$/i, '');
|
||||
try {
|
||||
const pdfDoc = generateFloorPdf();
|
||||
pdfDoc.save(pdfBase + '.pdf');
|
||||
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
|
||||
} catch (err) {
|
||||
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
|
||||
}
|
||||
if (typeof onSaved === 'function') onSaved(filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
function validateDesignJson(design) {
|
||||
if (!design || design.type !== 'floor-design') throw new Error('geen geldig vloer-ontwerp (type ontbreekt of onjuist)');
|
||||
FloorModel.validateState(design);
|
||||
}
|
||||
|
||||
function importDesign(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const design = JSON.parse(e.target.result);
|
||||
validateDesignJson(design);
|
||||
applyPureState(design);
|
||||
setIoStatus(`Geïmporteerd (${openings.length} doorvoer(en)).`, 'ok');
|
||||
if (typeof onSaved === 'function') onSaved(file.name);
|
||||
} catch (err) {
|
||||
setIoStatus('Import mislukt: ' + err.message, 'err');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
document.getElementById('f-file-import-json').click();
|
||||
}
|
||||
|
||||
document.getElementById('f-file-import-json').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) importDesign(file);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Balkvakken + doorvoer-resolutie (nooit een balk kruisen): zie
|
||||
// FloorModel.resolveOpenings (floor/floor-model.js).
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Balkverdeling: FloorModel.computeFrame (zelfde algoritme als de wand).
|
||||
// ----------------------------------------------------------
|
||||
let calcData = { joists: [] };
|
||||
|
||||
function fmt(n, decimals = 1) {
|
||||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
function setSpecAll(key, text) {
|
||||
document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; });
|
||||
}
|
||||
|
||||
function recalculate() {
|
||||
calcData = FloorModel.computeFrame({
|
||||
width: parseFloat(document.getElementById('f-inp-width').value),
|
||||
depth: parseFloat(document.getElementById('f-inp-depth').value),
|
||||
joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value),
|
||||
joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value),
|
||||
minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value),
|
||||
maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value),
|
||||
underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value)
|
||||
});
|
||||
}
|
||||
|
||||
// Specificaties: FloorModel.computeSpecs (balkhout, OSB, isolatie-leegte,
|
||||
// gewichtstotaal).
|
||||
function updateSpecificaties(resolvedOpenings) {
|
||||
const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings);
|
||||
if (roofMode) {
|
||||
setSpecAll('dakleer-m2', fmt(specs.dakleerM2, 2) + ' m2');
|
||||
setSpecAll('dakleer-kg', fmt(specs.dakleerKg) + ' kg');
|
||||
}
|
||||
|
||||
setSpecAll('joist-count', calcData.joists.length + ' stuks');
|
||||
setSpecAll('floor-area', fmt((calcData.width * calcData.depth) / 1e6, 2) + ' m2');
|
||||
|
||||
setSpecAll('joist-m3', fmt(specs.joistWoodM3, 3) + ' m3');
|
||||
setSpecAll('joist-kg', fmt(specs.joistWoodKg) + ' kg');
|
||||
|
||||
setSpecAll('osb-m2', fmt(specs.osbAreaM2, 2) + ' m2');
|
||||
setSpecAll('osb-m3', fmt(specs.osbVolM3, 3) + ' m3');
|
||||
setSpecAll('osb-kg', fmt(specs.osbKg) + ' kg');
|
||||
|
||||
setSpecAll('void-m3', fmt(specs.voidM3, 3) + ' m3');
|
||||
setSpecAll('total-kg', fmt(specs.totalKg) + ' kg');
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Chassis-laag (4 wielen, "dobbelsteen 4"): FloorModel.computeChassisWheels.
|
||||
|
||||
function updateChassisWarning(outOfBounds) {
|
||||
const warnBox = document.getElementById('f-chassis-warning');
|
||||
warnBox.style.display = outOfBounds ? 'block' : 'none';
|
||||
warnBox.textContent = 'Let op: één of meer wielen steken buiten de vloerrand — verklein de spoorbreedte/wielbasis of vergroot de vloer.';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Canvas: bovenaanzicht
|
||||
// ----------------------------------------------------------
|
||||
const canvasTop = document.getElementById('floor-canvas-top');
|
||||
const ctxTop = canvasTop.getContext('2d');
|
||||
|
||||
function drawTop(resolvedOpenings, chassisData) {
|
||||
const { width, depth } = calcData;
|
||||
const rect = canvasTop.parentElement.getBoundingClientRect();
|
||||
canvasTop.width = rect.width;
|
||||
canvasTop.height = rect.height;
|
||||
const w = canvasTop.width, h = canvasTop.height;
|
||||
ctxTop.clearRect(0, 0, w, h);
|
||||
|
||||
const scale = Math.min(w / (width * 1.35), h / (depth * 1.35));
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const toX = (xmm) => cx + xmm * scale;
|
||||
const toY = (ymm) => cy - (depth / 2 - ymm) * scale; // y=0 (voor) boven, y=depth (achter) onder
|
||||
|
||||
ctxTop.strokeStyle = '#222'; ctxTop.lineWidth = 1;
|
||||
const gridSize = 200 * scale;
|
||||
for (let x = 0; x < w; x += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(x, 0); ctxTop.lineTo(x, h); ctxTop.stroke(); }
|
||||
for (let y = 0; y < h; y += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(0, y); ctxTop.lineTo(w, y); ctxTop.stroke(); }
|
||||
|
||||
ctxTop.strokeStyle = '#666'; ctxTop.lineWidth = 2;
|
||||
ctxTop.strokeRect(toX(-width / 2), toY(0), width * scale, depth * scale);
|
||||
|
||||
calcData.joists.forEach(x => {
|
||||
ctxTop.fillStyle = '#c29b62'; ctxTop.strokeStyle = '#8a6836';
|
||||
const jw = calcData.joistW * scale;
|
||||
ctxTop.fillRect(toX(x) - jw / 2, toY(0), jw, depth * scale);
|
||||
ctxTop.strokeRect(toX(x) - jw / 2, toY(0), jw, depth * scale);
|
||||
});
|
||||
|
||||
resolvedOpenings.filter(o => !o.removed).forEach(op => {
|
||||
ctxTop.beginPath();
|
||||
ctxTop.arc(toX(op.x), toY(op.y), op.r * scale, 0, Math.PI * 2);
|
||||
ctxTop.fillStyle = op.overflowing ? 'rgba(255,170,60,.4)' : 'rgba(102,170,255,.35)';
|
||||
ctxTop.strokeStyle = op.overflowing ? '#ffaa33' : '#4da6ff';
|
||||
ctxTop.lineWidth = 2;
|
||||
ctxTop.fill(); ctxTop.stroke();
|
||||
ctxTop.fillStyle = '#fff'; ctxTop.font = '10px sans-serif'; ctxTop.textAlign = 'center';
|
||||
ctxTop.fillText('#' + op.id, toX(op.x), toY(op.y) - op.r * scale - 6);
|
||||
});
|
||||
|
||||
(chassisData ? chassisData.wheels : []).forEach(wheel => {
|
||||
ctxTop.beginPath();
|
||||
ctxTop.arc(toX(wheel.x), toY(wheel.y), wheel.r * scale, 0, Math.PI * 2);
|
||||
ctxTop.fillStyle = chassisData.outOfBounds ? 'rgba(255,60,60,.35)' : 'rgba(30,30,30,.55)';
|
||||
ctxTop.strokeStyle = chassisData.outOfBounds ? '#ff3c3c' : '#000';
|
||||
ctxTop.lineWidth = 1.5;
|
||||
ctxTop.fill(); ctxTop.stroke();
|
||||
});
|
||||
|
||||
// Maatlabels (zelfde stijl als het wand-vooraanzicht, core/canvas-labels.js)
|
||||
const xAxisY = Math.max(14, toY(0) - 14);
|
||||
drawCanvasDim(ctxTop, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
|
||||
'X: ' + Math.round(width) + 'mm', cx, Math.max(10, xAxisY - 4));
|
||||
const yAxisX = Math.max(14, toX(-width / 2) - 14);
|
||||
drawCanvasDim(ctxTop, yAxisX, toY(0), yAxisX, toY(depth),
|
||||
'Y: ' + Math.round(depth) + 'mm', Math.max(10, yAxisX - 4), (toY(0) + toY(depth)) / 2);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Canvas: doorsnede (balkprofiel — breedte x hoogte, aanvullende kijkhoek
|
||||
// naast het bovenaanzicht, analoog aan de vooraanzicht-doorsnede bij wall.js)
|
||||
// ----------------------------------------------------------
|
||||
const canvasSection = document.getElementById('floor-canvas-section');
|
||||
const ctxSection = canvasSection.getContext('2d');
|
||||
|
||||
function drawSection() {
|
||||
const { width, joistW, joistH, underlayThick } = calcData;
|
||||
const rect = canvasSection.parentElement.getBoundingClientRect();
|
||||
canvasSection.width = rect.width;
|
||||
canvasSection.height = rect.height;
|
||||
const w = canvasSection.width, h = canvasSection.height;
|
||||
ctxSection.clearRect(0, 0, w, h);
|
||||
|
||||
// Dakmodus: 180° gespiegeld — OSB onder, balken erboven, dakleer bovenop.
|
||||
const leerT = roofMode ? RoofModel.DAKLEER_MM : 0;
|
||||
const totalH = joistH + underlayThick + leerT;
|
||||
const joistY0 = roofMode ? underlayThick : 0;
|
||||
const osbY0 = roofMode ? 0 : joistH;
|
||||
const scale = Math.min(w / (width * 1.3), h / (totalH * 3));
|
||||
const cx = w / 2, floorY = h - 20;
|
||||
const toX = (xmm) => cx + xmm * scale;
|
||||
const toY = (ymm) => floorY - ymm * scale;
|
||||
|
||||
ctxSection.strokeStyle = '#666'; ctxSection.lineWidth = 1;
|
||||
ctxSection.beginPath();
|
||||
ctxSection.moveTo(toX(-width / 2), toY(0));
|
||||
ctxSection.lineTo(toX(width / 2), toY(0));
|
||||
ctxSection.stroke();
|
||||
|
||||
// Isolatie (leegte tussen de balken) — geschematiseerd als een
|
||||
// doorlopende, licht transparante band ter hoogte van de balken, zodat
|
||||
// ook in de doorsnede duidelijk is wát er tussen de balken zit.
|
||||
ctxSection.fillStyle = 'rgba(255, 224, 140, 0.25)';
|
||||
ctxSection.fillRect(toX(-width / 2), toY(joistY0 + joistH), width * scale, toY(joistY0) - toY(joistY0 + joistH));
|
||||
|
||||
calcData.joists.forEach(x => {
|
||||
ctxSection.fillStyle = '#c29b62'; ctxSection.strokeStyle = '#8a6836';
|
||||
ctxSection.fillRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale);
|
||||
ctxSection.strokeRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale);
|
||||
});
|
||||
|
||||
// OSB-plaat: bij een vloer boven op de balken, bij een dak eronder (plafond).
|
||||
ctxSection.fillStyle = '#cda86a'; ctxSection.strokeStyle = '#8a6836';
|
||||
ctxSection.fillRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
|
||||
ctxSection.strokeRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
|
||||
|
||||
ctxSection.fillStyle = '#fff'; ctxSection.font = '9px sans-serif'; ctxSection.textAlign = 'center';
|
||||
if (roofMode) {
|
||||
const topY = toY(totalH);
|
||||
ctxSection.fillStyle = '#2a2a2a'; ctxSection.strokeStyle = '#888';
|
||||
ctxSection.fillRect(toX(-width / 2), topY - 2, width * scale, 3);
|
||||
ctxSection.strokeRect(toX(-width / 2), topY - 2, width * scale, 3);
|
||||
ctxSection.fillStyle = '#fff';
|
||||
ctxSection.fillText('Dakleer', cx, topY - 8);
|
||||
ctxSection.textAlign = 'right';
|
||||
ctxSection.fillText(underlayThick + 'mm OSB (plafond)', toX(-width / 2) - 6, toY(underlayThick / 2) + 3);
|
||||
ctxSection.textAlign = 'center';
|
||||
} else {
|
||||
ctxSection.fillText(underlayThick + 'mm OSB', cx, toY(joistH + underlayThick) - 6);
|
||||
}
|
||||
|
||||
// Maatlabels (zelfde stijl als het wand-vooraanzicht)
|
||||
const xAxisY = Math.min(h - 12, toY(0) + 8);
|
||||
drawCanvasDim(ctxSection, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
|
||||
'X: ' + Math.round(width) + 'mm', cx, Math.min(h - 2, xAxisY + 11));
|
||||
const zAxisX = Math.min(w - 14, toX(width / 2) + 14);
|
||||
drawCanvasDim(ctxSection, zAxisX, toY(0), zAxisX, toY(totalH),
|
||||
'Z: ' + Math.round(totalH) + 'mm', Math.min(w - 4, zAxisX + 12), (toY(0) + toY(totalH)) / 2);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Waarschuwingen naar de openingen-kaarten
|
||||
// ----------------------------------------------------------
|
||||
function updateWarnings(resolved) {
|
||||
const anyWarn = resolved.some(o => o.overflowing);
|
||||
const warnBox = document.getElementById('f-openings-global-warn');
|
||||
warnBox.style.display = anyWarn ? 'block' : 'none';
|
||||
warnBox.textContent = 'Let op: één of meer doorvoeren zijn verkleind/verwijderd zodat ze geen balk kruisen, of overlappen elkaar.';
|
||||
resolved.forEach(o => {
|
||||
const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`);
|
||||
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
|
||||
if (!card || !warnEl) return;
|
||||
card.classList.toggle('opening-warn', !!o.overflowing);
|
||||
warnEl.style.display = o.overflowing ? 'block' : 'none';
|
||||
if (o.removed) warnEl.textContent = 'Past nergens tussen twee balken (ook niet verkleind) — niet getekend.';
|
||||
else if (o.shrunk) warnEl.textContent = `Verkleind naar ${Math.round(o.diameter)}mm om niet over een balk heen te vallen (gevraagd: ${o.requestedDiameter}mm).`;
|
||||
else if (o.overlapping) warnEl.textContent = 'Overlapt een andere doorvoer in hetzelfde balkvak.';
|
||||
else warnEl.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// THREE.JS 3D RENDERER — zelfde opzet als wall.js (scene/camera/renderer/
|
||||
// OrbitControls/licht/grid), maar plat gelegd: X = breedte, Z = diepte,
|
||||
// Y = dikte (balkhoogte + OSB), in plaats van wall.js' X/Y/Z (breedte/
|
||||
// hoogte/diepte). Balken en isolatie zijn simpele dozen; de OSB-onderlaag
|
||||
// is — net als de platen bij wall.js — een geëxtrudeerd Shape met ronde
|
||||
// uitsparingen voor de doorvoeren.
|
||||
// ----------------------------------------------------------
|
||||
const pane3D = document.getElementById('floor-pane-3d');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 100);
|
||||
camera.position.set(2.6, 2.3, 3.4);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.shadowMap.enabled = true;
|
||||
pane3D.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
const cameraKey = callbacks && callbacks.cameraKey;
|
||||
if (!CameraMemory.restore(cameraKey, camera, controls)) {
|
||||
controls.target.set(0, 0.15, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight.position.set(5, 8, 5);
|
||||
dirLight.castShadow = true;
|
||||
scene.add(dirLight);
|
||||
|
||||
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222));
|
||||
|
||||
const floorMaterials = roofMode ? RoofModel.createMaterials() : FloorModel.createMaterials();
|
||||
|
||||
let osbVisible = true;
|
||||
let isolatieVisible = true;
|
||||
let floorGroup3D = null;
|
||||
let lastResolvedOpenings = [];
|
||||
let lastChassisData = { wheels: [], outOfBounds: false };
|
||||
|
||||
// Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een
|
||||
// dak: plafondplaat + dakleer).
|
||||
createViewFilter(document.getElementById('floor-toolbar-3d'), [
|
||||
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' },
|
||||
{ key: 'isolatie', label: 'Isolatie' }
|
||||
], (v) => {
|
||||
osbVisible = v.beplating;
|
||||
isolatieVisible = v.isolatie;
|
||||
update3DScene();
|
||||
});
|
||||
|
||||
function update3DScene() {
|
||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||
floorGroup3D = roofMode
|
||||
? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, isolatieVisible, dakleerVisible: osbVisible })
|
||||
: FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible, isolatieVisible });
|
||||
scene.add(floorGroup3D);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// PDF-export: het tekenblad zelf komt uit FloorPdf (floor/floor-pdf.js),
|
||||
// gedeeld met de unit-PDF.
|
||||
// ------------------------------------------------------------
|
||||
function generateFloorPdf() {
|
||||
recalculate();
|
||||
const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
|
||||
const doc = PdfSheet.createDoc();
|
||||
const project = PdfSheet.readProjectFromDom();
|
||||
FloorPdf.drawSheet(doc, calcData, resolvedOpenings, { project, titel: project.titel, element: project.element, roof: roofMode });
|
||||
return doc;
|
||||
}
|
||||
|
||||
function updateAll() {
|
||||
recalculate();
|
||||
lastResolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
|
||||
lastChassisData = FloorModel.computeChassisWheels(chassis, calcData.width, calcData.depth);
|
||||
drawTop(lastResolvedOpenings, lastChassisData);
|
||||
drawSection();
|
||||
updateWarnings(lastResolvedOpenings);
|
||||
updateChassisWarning(lastChassisData.outOfBounds);
|
||||
updateSpecificaties(lastResolvedOpenings);
|
||||
update3DScene();
|
||||
}
|
||||
|
||||
let __rafId = null;
|
||||
function animate() {
|
||||
__rafId = requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
||||
drawTop(lastResolvedOpenings, lastChassisData);
|
||||
drawSection();
|
||||
}
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
function toggleFullscreen3D() {
|
||||
const el = document.getElementById('floor-app');
|
||||
el.classList.remove('fullscreen-2d');
|
||||
el.classList.toggle('fullscreen-3d');
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
function toggleFullscreen2D() {
|
||||
const el = document.getElementById('floor-app');
|
||||
el.classList.remove('fullscreen-3d');
|
||||
el.classList.toggle('fullscreen-2d');
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
function getPureState() {
|
||||
return {
|
||||
floor: {
|
||||
width: parseFloat(document.getElementById('f-inp-width').value),
|
||||
depth: parseFloat(document.getElementById('f-inp-depth').value),
|
||||
joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value),
|
||||
joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value),
|
||||
minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value),
|
||||
maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value),
|
||||
underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value)
|
||||
},
|
||||
chassis: { ...chassis },
|
||||
openings: openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY }))
|
||||
};
|
||||
}
|
||||
|
||||
function applyPureState(state) {
|
||||
document.getElementById('f-inp-width').value = state.floor.width;
|
||||
document.getElementById('f-inp-depth').value = state.floor.depth;
|
||||
document.getElementById('f-inp-joist-w').value = state.floor.joistWidth;
|
||||
document.getElementById('f-inp-joist-h').value = state.floor.joistHeight;
|
||||
document.getElementById('f-inp-min-sp').value = state.floor.minSpacing;
|
||||
document.getElementById('f-inp-max-sp').value = state.floor.maxSpacing;
|
||||
document.getElementById('f-inp-underlay-t').value = state.floor.underlayThick ?? 22;
|
||||
chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200, ...(state.chassis || {}) };
|
||||
document.getElementById('f-inp-wheel-d').value = chassis.wheelDiameter;
|
||||
document.getElementById('f-inp-track').value = chassis.trackWidth;
|
||||
document.getElementById('f-inp-wheelbase').value = chassis.wheelbase;
|
||||
syncChassisToggleLabel();
|
||||
openings = state.openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY }));
|
||||
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
|
||||
renderOpeningsList();
|
||||
updateAll();
|
||||
}
|
||||
|
||||
renderOpeningsList();
|
||||
updateAll();
|
||||
animate();
|
||||
|
||||
return {
|
||||
unmount() {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
if (__rafId !== null) cancelAnimationFrame(__rafId);
|
||||
CameraMemory.save(cameraKey, camera, controls);
|
||||
renderer.dispose();
|
||||
},
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
};
|
||||
}
|
||||
|
||||
window.FloorEditor = {
|
||||
mount: mountFloorEditor,
|
||||
getDefaultState: () => ({
|
||||
floor: { width: 2000, depth: 2000, joistWidth: 70, joistHeight: 220, minSpacing: 300, maxSpacing: 600, underlayThick: 22 },
|
||||
chassis: { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 },
|
||||
openings: []
|
||||
})
|
||||
};
|
||||
Reference in New Issue
Block a user