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,299 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.4.0';
|
||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||
|
||||
let tabs = [];
|
||||
let activeTabId = null;
|
||||
let tabIdCounter = 1;
|
||||
let kindCounters = {};
|
||||
let currentEditor = null;
|
||||
|
||||
function editorModuleFor(kind) {
|
||||
if (kind === 'muur') return window.WallEditor;
|
||||
if (kind === 'vloer') return window.FloorEditor;
|
||||
if (kind === 'woning') return window.HouseEditor;
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultNameFor(kind) {
|
||||
kindCounters[kind] = (kindCounters[kind] || 0) + 1;
|
||||
return `${kind} ${String(kindCounters[kind]).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setWorkspaceEmpty() {
|
||||
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
||||
<div>Geen ontwerp geopend.</div>
|
||||
<div style="font-size:11px;">Bestand → Nieuw → kies een objecttype</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateEditMenuState() {
|
||||
const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
||||
document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign'));
|
||||
document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport'));
|
||||
document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal'));
|
||||
document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D'));
|
||||
document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D'));
|
||||
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
|
||||
}
|
||||
|
||||
function renderTabBar() {
|
||||
const bar = document.getElementById('tab-bar');
|
||||
bar.innerHTML = tabs.map(tab => `
|
||||
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
|
||||
<span class="tab-name">${tab.name}</span>
|
||||
<span class="tab-close" data-close-id="${tab.id}">×</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function onTabSaved(tab, filenameWithExt) {
|
||||
const base = filenameWithExt.replace(/\.json$/i, '');
|
||||
tab.name = base;
|
||||
tab.savedName = base;
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
function mountEditorForTab(tab) {
|
||||
const ws = document.getElementById('workspace');
|
||||
ws.innerHTML = '';
|
||||
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
||||
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
||||
currentEditor.loadStateJson(tab.stateJson);
|
||||
}
|
||||
}
|
||||
|
||||
function switchToTab(tabId) {
|
||||
if (activeTabId === tabId) return;
|
||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
||||
oldTab.stateJson = currentEditor.getStateJson();
|
||||
}
|
||||
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
||||
currentEditor = null;
|
||||
|
||||
activeTabId = tabId;
|
||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
function openNew(kind, template) {
|
||||
if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar
|
||||
|
||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
||||
oldTab.stateJson = currentEditor.getStateJson();
|
||||
}
|
||||
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
||||
currentEditor = null;
|
||||
|
||||
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null };
|
||||
tabs.push(tab);
|
||||
activeTabId = tab.id;
|
||||
mountEditorForTab(tab);
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
closeAllMenus();
|
||||
}
|
||||
|
||||
function isTabDirty(tab) {
|
||||
const module = editorModuleFor(tab.kind);
|
||||
if (!module || typeof module.getDefaultState !== 'function') return false;
|
||||
const state = (tab.id === activeTabId && currentEditor && typeof currentEditor.getStateJson === 'function')
|
||||
? currentEditor.getStateJson()
|
||||
: tab.stateJson;
|
||||
if (!state) return false;
|
||||
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
||||
}
|
||||
|
||||
function closeTab(tabId) {
|
||||
const tab = tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
if (tab.id !== activeTabId) {
|
||||
if (!isTabDirty(tab)) {
|
||||
tabs = tabs.filter(t => t.id !== tabId);
|
||||
renderTabBar();
|
||||
return;
|
||||
}
|
||||
switchToTab(tabId);
|
||||
}
|
||||
|
||||
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
||||
const saved = currentEditor.exportDesign();
|
||||
if (!saved) return;
|
||||
}
|
||||
|
||||
tabs = tabs.filter(t => t.id !== tabId);
|
||||
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
||||
currentEditor = null;
|
||||
activeTabId = null;
|
||||
|
||||
if (tabs.length > 0) {
|
||||
switchToTab(tabs[tabs.length - 1].id);
|
||||
} else {
|
||||
setWorkspaceEmpty();
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
}
|
||||
|
||||
function wireTabBar() {
|
||||
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('.tab-close');
|
||||
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
||||
const item = e.target.closest('.tab-item');
|
||||
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
|
||||
});
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open'));
|
||||
document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open'));
|
||||
}
|
||||
|
||||
function wireTopLevelMenu(btnId, dropdownId) {
|
||||
const btn = document.getElementById(btnId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = !dropdown.classList.contains('open');
|
||||
closeAllMenus();
|
||||
if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); }
|
||||
});
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown');
|
||||
wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown');
|
||||
wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown');
|
||||
|
||||
document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation());
|
||||
document.querySelectorAll('.menu-nieuw-option').forEach(opt => {
|
||||
opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template));
|
||||
});
|
||||
|
||||
document.getElementById('menu-opslaan-als').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-importeren').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-mirror-h').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-fullscreen').addEventListener('click', () => {
|
||||
toggleBrowserFullscreen();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-3d-fullscreen').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-project-props').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties();
|
||||
closeAllMenus();
|
||||
});
|
||||
|
||||
document.addEventListener('click', closeAllMenus);
|
||||
}
|
||||
|
||||
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
|
||||
function renderLicenseHtml(md) {
|
||||
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return md.trim().split(/\n\s*\n/).map(block => {
|
||||
const b = block.trim();
|
||||
if (b.startsWith('## ')) return `<h4>${esc(b.slice(3))}</h4>`;
|
||||
if (b.startsWith('# ')) return `<h3>${esc(b.slice(2))}</h3>`;
|
||||
return `<p>${esc(b.replace(/\s*\n\s*/g, ' '))}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function wireAbout() {
|
||||
const overlay = document.getElementById('about-overlay');
|
||||
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
|
||||
document.getElementById('btn-about').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
overlay.classList.add('open');
|
||||
});
|
||||
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
||||
}
|
||||
|
||||
function wireNewModal() {
|
||||
const overlay = document.getElementById('new-overlay');
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
||||
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
|
||||
opt.addEventListener('click', () => {
|
||||
openNew(opt.dataset.kind, opt.dataset.template);
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openNewModal() {
|
||||
closeAllMenus();
|
||||
document.getElementById('new-overlay').classList.add('open');
|
||||
}
|
||||
|
||||
function toggleBrowserFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
function wireHotkeys() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
||||
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
openNewModal();
|
||||
return;
|
||||
}
|
||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (e.key.toLowerCase() === 'f') {
|
||||
toggleBrowserFullscreen();
|
||||
} else if (e.key.toLowerCase() === 'h') {
|
||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
||||
} else if (e.key.toLowerCase() === 't') {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
||||
} else if (e.key.toLowerCase() === 'r') {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||
wireMenu();
|
||||
wireAbout();
|
||||
wireNewModal();
|
||||
wireHotkeys();
|
||||
wireTabBar();
|
||||
openNew('woning', 'L');
|
||||
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
if (splash) splash.remove();
|
||||
}, 4300);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt)
|
||||
// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount
|
||||
// na een wijziging niet terugspringt naar de standaardcamera.
|
||||
// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het
|
||||
// geheugen van deze sessie.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
window.CameraMemory = (() => {
|
||||
const store = new Map();
|
||||
|
||||
function save(key, camera, controls) {
|
||||
if (!key) return;
|
||||
store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() });
|
||||
}
|
||||
|
||||
// true als er een opgeslagen stand was (en die is toegepast).
|
||||
function restore(key, camera, controls) {
|
||||
const m = key && store.get(key);
|
||||
if (!m) return false;
|
||||
camera.position.fromArray(m.position);
|
||||
controls.target.fromArray(m.target);
|
||||
controls.update();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, restore };
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen
|
||||
// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt
|
||||
// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
const CANVAS_LABEL_COLOR = '#4da6ff';
|
||||
|
||||
// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY).
|
||||
// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid.
|
||||
function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = CANVAS_LABEL_COLOR;
|
||||
ctx.fillStyle = CANVAS_LABEL_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5) {
|
||||
ctx.translate(textX, textY);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(text, 0, 0);
|
||||
} else {
|
||||
ctx.fillText(text, textX, textY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid
|
||||
// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes
|
||||
// heeft, dus ook met THREE.Shape). Plain <script> (geen module), zodat dit
|
||||
// ook werkt bij rechtstreeks openen van index.html (file://) — daar breken
|
||||
// ES-module-imports door browser-CORS.
|
||||
//
|
||||
// Wordt geladen VOOR wall.js en floor.js: de functies hieronder zijn dus
|
||||
// gewoon globaal beschikbaar in beide, zonder duplicatie.
|
||||
// ============================================================
|
||||
|
||||
// Regelwerk-verdeling (h.o.h.): bepaalt hoeveel balken/stijlen er in een
|
||||
// gegeven overspanning passen, gegeven een minimale/maximale hart-op-hart-
|
||||
// afstand. Gebruikt door wand-stijlen EN vloerbalken (zelfde algoritme).
|
||||
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
if (spanWidth <= 0) return [];
|
||||
let numBays = Math.ceil(spanWidth / maxSp);
|
||||
let spacing = spanWidth / numBays;
|
||||
|
||||
if (spacing < minSp && numBays > 1) {
|
||||
numBays = Math.floor(spanWidth / minSp);
|
||||
spacing = spanWidth / numBays;
|
||||
}
|
||||
|
||||
const positions = [];
|
||||
for (let i = 1; i < numBays; i++) {
|
||||
positions.push(i * spacing);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Oppervlakte van een simpele polygon (shoelace-formule).
|
||||
function polygonArea(points) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[(i + 1) % points.length];
|
||||
area += p1.x * p2.y - p2.x * p1.y;
|
||||
}
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
// Sutherland-Hodgman clip van een (convexe) polygon tegen een horizontale
|
||||
// halfvlak-grens. Gebruikt om bijv. de wand-omtrek (het schuine trapezium) te
|
||||
// knippen tot losse horizontale banen voor het vochtdoek.
|
||||
function clipPolygonYLE(points, yLimit) {
|
||||
// Houdt het deel met y <= yLimit
|
||||
const result = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const cur = points[i];
|
||||
const prev = points[(i - 1 + points.length) % points.length];
|
||||
const curIn = cur.y <= yLimit + 1e-12;
|
||||
const prevIn = prev.y <= yLimit + 1e-12;
|
||||
if (curIn) {
|
||||
if (!prevIn) {
|
||||
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
||||
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
||||
}
|
||||
result.push(cur);
|
||||
} else if (prevIn) {
|
||||
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
||||
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function clipPolygonYGE(points, yLimit) {
|
||||
// Houdt het deel met y >= yLimit
|
||||
const result = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const cur = points[i];
|
||||
const prev = points[(i - 1 + points.length) % points.length];
|
||||
const curIn = cur.y >= yLimit - 1e-12;
|
||||
const prevIn = prev.y >= yLimit - 1e-12;
|
||||
if (curIn) {
|
||||
if (!prevIn) {
|
||||
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
||||
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
||||
}
|
||||
result.push(cur);
|
||||
} else if (prevIn) {
|
||||
const t = (yLimit - prev.y) / (cur.y - prev.y);
|
||||
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Oppervlakte van een THREE.Shape inclusief aftrek van zijn holes.
|
||||
function shapeAreaWithHoles(shape) {
|
||||
let area = polygonArea(shape.getPoints());
|
||||
(shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); });
|
||||
return area;
|
||||
}
|
||||
|
||||
// Simpele euclidische afstand — gebruikt door floor.js voor de
|
||||
// cirkel-overlapcheck (2D, dus geen 1D-ankerlogica zoals bij wand-openingen).
|
||||
function dist2D(a, b) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
// Zaagplan: verdeelt stukken (lengtes in mm) over handelslengtes met zo min
|
||||
// mogelijk snijverlies. Stukken langer dan de langste handelslengte worden
|
||||
// eerst opgeknipt (las/stuik in het werk). Daarna best-fit-decreasing: elk
|
||||
// stuk gaat in de staaf waar het het krapst past; tot slot krijgt elke staaf
|
||||
// de kortste handelslengte waar zijn stukken nog in passen.
|
||||
// Resultaat: [{ stock, cuts: [lengtes], used, waste }]. Zaagsnede-breedte
|
||||
// wordt verwaarloosd.
|
||||
function planStockCutting(pieceLengths, stockLengths) {
|
||||
const stocks = stockLengths.slice().sort((a, b) => a - b);
|
||||
const maxStock = stocks[stocks.length - 1];
|
||||
|
||||
const parts = [];
|
||||
pieceLengths.forEach(len => {
|
||||
let rest = Math.round(len);
|
||||
while (rest > maxStock) { parts.push(maxStock); rest -= maxStock; }
|
||||
if (rest > 0) parts.push(rest);
|
||||
});
|
||||
parts.sort((a, b) => b - a);
|
||||
|
||||
const bars = [];
|
||||
parts.forEach(p => {
|
||||
let best = null, bestRest = Infinity;
|
||||
bars.forEach(bar => {
|
||||
const r = maxStock - bar.used - p;
|
||||
if (r >= 0 && r < bestRest) { best = bar; bestRest = r; }
|
||||
});
|
||||
if (!best) { best = { used: 0, cuts: [] }; bars.push(best); }
|
||||
best.cuts.push(p);
|
||||
best.used += p;
|
||||
});
|
||||
bars.forEach(bar => {
|
||||
bar.stock = stocks.find(s => s >= bar.used);
|
||||
bar.waste = bar.stock - bar.used;
|
||||
});
|
||||
return bars;
|
||||
}
|
||||
|
||||
// Samenvatting van een zaagplan: aantal staven per handelslengte + totalen.
|
||||
function summarizeStockPlan(bars) {
|
||||
const byStock = new Map();
|
||||
bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1));
|
||||
return {
|
||||
perStock: Array.from(byStock.entries()).sort((a, b) => a[0] - b[0]).map(([stock, count]) => ({ stock, count })),
|
||||
stockMM: bars.reduce((a, b) => a + b.stock, 0),
|
||||
wasteMM: bars.reduce((a, b) => a + b.waste, 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
|
||||
// bovenrand topYAt(x), MIN een set rechthoekige uitsparingen
|
||||
// ({ x0, x1, y0, y1 }). Uitsparingen die over de rand van de strook vallen
|
||||
// worden een echte inkeping in de omtrek (i.p.v. een gat dat net binnen de
|
||||
// rand wordt afgekapt — dat liet een haarfijn reepje materiaal met zichtbare
|
||||
// randlijnen door de opening lopen). Een uitsparing over de volle breedte
|
||||
// splitst de strook in losse delen.
|
||||
// opts (optioneel):
|
||||
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan);
|
||||
// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot
|
||||
// boven yCap reiken worden er netjes op afgekapt (inkeping in de
|
||||
// bovenrand).
|
||||
// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de
|
||||
// bovenrand tussen twee hoekpunten steeds lineair is.
|
||||
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null
|
||||
// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund).
|
||||
function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
const eps = 1e-9;
|
||||
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity;
|
||||
const extraXs = ((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps);
|
||||
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
|
||||
|
||||
const rs = rects
|
||||
.map(r => ({ x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: Math.max(yBottom, r.y0), y1: Math.min(yCap, r.y1) }))
|
||||
.filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps);
|
||||
const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]);
|
||||
|
||||
// Laagste bovenrand binnen [a, b] (topYAt is lineair tussen de knikpunten).
|
||||
const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt));
|
||||
const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand
|
||||
if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null;
|
||||
|
||||
const ys = uniq([yBottom, ...rs.flatMap(r => reachesTop(r) ? [r.y0] : [r.y0, r.y1]).filter(y => y > yBottom + eps)]);
|
||||
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn
|
||||
|
||||
const vertex = (i, j) => ({ x: xs[i], y: j < nRows ? ys[j] : topYAt(xs[i]) });
|
||||
const filled = (i, j) => {
|
||||
if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false;
|
||||
const xm = (xs[i] + xs[i + 1]) / 2;
|
||||
const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2;
|
||||
return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1);
|
||||
};
|
||||
|
||||
// Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is.
|
||||
const edges = new Map(); // "i,j" -> [[i2,j2], ...]
|
||||
const addEdge = (a, b) => {
|
||||
const key = a.join(',');
|
||||
if (!edges.has(key)) edges.set(key, []);
|
||||
edges.get(key).push(b);
|
||||
};
|
||||
for (let i = 0; i < nCols; i++) {
|
||||
for (let j = 0; j < nRows; j++) {
|
||||
if (!filled(i, j)) continue;
|
||||
if (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]);
|
||||
if (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]);
|
||||
if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]);
|
||||
if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]);
|
||||
}
|
||||
}
|
||||
|
||||
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
|
||||
const loops = [];
|
||||
edges.forEach((list, startKey) => {
|
||||
while (list.length) {
|
||||
const loop = [];
|
||||
let cur = startKey.split(',').map(Number);
|
||||
let next = list.pop();
|
||||
loop.push(cur);
|
||||
while (next.join(',') !== startKey) {
|
||||
loop.push(next);
|
||||
cur = next;
|
||||
const outs = edges.get(cur.join(','));
|
||||
next = outs.pop();
|
||||
}
|
||||
loops.push(loop);
|
||||
}
|
||||
});
|
||||
const polys = loops.map(loop => {
|
||||
// Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de
|
||||
// bovenrand de onderrand raakt), dan collineaire tussenpunten.
|
||||
const pts = loop.map(([i, j]) => vertex(i, j))
|
||||
.filter((p, k, arr) => { const q = arr[(k + 1) % arr.length]; return Math.hypot(p.x - q.x, p.y - q.y) > 1e-9; });
|
||||
return pts.filter((p, k) => {
|
||||
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length];
|
||||
return Math.abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) > 1e-12;
|
||||
});
|
||||
});
|
||||
|
||||
const signedArea = (pts) => pts.reduce((s, p, k) => {
|
||||
const q = pts[(k + 1) % pts.length];
|
||||
return s + (p.x * q.y - q.x * p.y);
|
||||
}, 0) / 2;
|
||||
const inside = (pt, poly) => {
|
||||
let c = false;
|
||||
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
||||
if ((poly[a].y > pt.y) !== (poly[b].y > pt.y) &&
|
||||
pt.x < (poly[b].x - poly[a].x) * (pt.y - poly[a].y) / (poly[b].y - poly[a].y) + poly[a].x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
|
||||
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
|
||||
const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0];
|
||||
if (owner) owner.holes.push(hole);
|
||||
});
|
||||
return outers;
|
||||
}
|
||||
|
||||
// Omtrek van de vereniging van as-evenwijdige rechthoeken ({x0, x1, z0, z1}),
|
||||
// als gesloten lussen van punten {x, z}. Buitenlussen zijn tegen de klok in
|
||||
// (positieve oppervlakte in het x/z-vlak), gaten met de klok mee. Collineaire
|
||||
// tussenpunten zijn verwijderd. Gesorteerd op oppervlakte (grootste eerst).
|
||||
// Gebruikt voor de dakrand (boeidelen/daktrim) over meerdere units heen.
|
||||
function rectUnionLoops(rects) {
|
||||
const eps = 1e-6;
|
||||
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
|
||||
const xs = uniq(rects.flatMap(r => [r.x0, r.x1]));
|
||||
const zs = uniq(rects.flatMap(r => [r.z0, r.z1]));
|
||||
const nx = xs.length - 1, nz = zs.length - 1;
|
||||
const filled = (i, j) => {
|
||||
if (i < 0 || j < 0 || i >= nx || j >= nz) return false;
|
||||
const xm = (xs[i] + xs[i + 1]) / 2, zm = (zs[j] + zs[j + 1]) / 2;
|
||||
return rects.some(r => xm > r.x0 && xm < r.x1 && zm > r.z0 && zm < r.z1);
|
||||
};
|
||||
const edges = new Map();
|
||||
const add = (a, b) => { const k = a.join(','); if (!edges.has(k)) edges.set(k, []); edges.get(k).push(b); };
|
||||
for (let i = 0; i < nx; i++) {
|
||||
for (let j = 0; j < nz; j++) {
|
||||
if (!filled(i, j)) continue;
|
||||
if (!filled(i, j - 1)) add([i, j], [i + 1, j]);
|
||||
if (!filled(i + 1, j)) add([i + 1, j], [i + 1, j + 1]);
|
||||
if (!filled(i, j + 1)) add([i + 1, j + 1], [i, j + 1]);
|
||||
if (!filled(i - 1, j)) add([i, j + 1], [i, j]);
|
||||
}
|
||||
}
|
||||
const loops = [];
|
||||
edges.forEach((list, startKey) => {
|
||||
while (list.length) {
|
||||
const loop = [startKey.split(',').map(Number)];
|
||||
let next = list.pop();
|
||||
while (next.join(',') !== startKey) {
|
||||
loop.push(next);
|
||||
next = edges.get(next.join(',')).pop();
|
||||
}
|
||||
const pts = loop.map(([i, j]) => ({ x: xs[i], z: zs[j] }));
|
||||
loops.push(pts.filter((p, k) => {
|
||||
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length];
|
||||
return Math.abs((p.x - a.x) * (b.z - a.z) - (p.z - a.z) * (b.x - a.x)) > eps;
|
||||
}));
|
||||
}
|
||||
});
|
||||
const area = (pts) => pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2;
|
||||
return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
|
||||
}
|
||||
|
||||
// Buitenwaartse normaal (as-evenwijdig, lengte 1) van ribbe a -> b van een
|
||||
// lus tegen de klok in (positieve oppervlakte in x/z).
|
||||
function rectilinearNormal(a, b) {
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
return { x: dz / len, z: -dx / len };
|
||||
}
|
||||
|
||||
// Verschuift een rechthoekige (as-evenwijdige) lus tegen de klok in over
|
||||
// afstand d naar buiten. Collineaire tussenpunten (bijv. een unit-hoek midden
|
||||
// in een rechte gevel) blijven behouden en schuiven loodrecht mee.
|
||||
function offsetRectilinearLoop(pts, d) {
|
||||
const n = pts.length;
|
||||
return pts.map((p, k) => {
|
||||
const prev = pts[(k - 1 + n) % n], next = pts[(k + 1) % n];
|
||||
const n1 = rectilinearNormal(prev, p), n2 = rectilinearNormal(p, next);
|
||||
const same = Math.abs(n1.x - n2.x) < 1e-9 && Math.abs(n1.z - n2.z) < 1e-9;
|
||||
return same ? { x: p.x + n1.x * d, z: p.z + n1.z * d } : { x: p.x + (n1.x + n2.x) * d, z: p.z + (n1.z + n2.z) * d };
|
||||
});
|
||||
}
|
||||
|
||||
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
|
||||
// (zelfde as + normaal + gevelvlak, aansluitend). segs: [{ axis ('x'|'z'),
|
||||
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
|
||||
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
|
||||
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
|
||||
function groupFacadeRuns(segs, opts) {
|
||||
const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5;
|
||||
const runs = [];
|
||||
segs.slice().sort((a, b) => a.s0 - b.s0).forEach(sg => {
|
||||
const run = runs.find(r => r.axis === sg.axis && Math.abs(r.normal.x - sg.normal.x) < 1e-6 && Math.abs(r.normal.z - sg.normal.z) < 1e-6 &&
|
||||
Math.abs(r.line - sg.line) < lineTol && Math.abs(r.s1 - sg.s0) < gapTol);
|
||||
if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); }
|
||||
else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] }));
|
||||
});
|
||||
return runs;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/license.js — de licentietekst voor "Over deze software". Kopie van
|
||||
// LICENTIE.md (dat bestand is leidend; bij een wijziging beide aanpassen).
|
||||
// Als JS-string opgenomen omdat fetch() van lokale bestanden via file://
|
||||
// niet betrouwbaar werkt.
|
||||
// ============================================================
|
||||
|
||||
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
|
||||
|
||||
Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden.
|
||||
|
||||
## 1. Rechthebbende
|
||||
|
||||
De software Tectum, inclusief broncode, objectcode, ontwerp, rekenlogica,
|
||||
documentatie, afbeeldingen en alle bijbehorende bestanden (hierna: "de Software"), is
|
||||
auteursrechtelijk beschermd. Alle auteursrechten en overige intellectuele-eigendomsrechten
|
||||
op de Software berusten uitsluitend bij Machiel Heinen, maker van de Software (hierna:
|
||||
"de Rechthebbende").
|
||||
|
||||
De Software is ontwikkeld in opdracht van Van Emous Mantelzorgwoningen, gevestigd te
|
||||
Putten (KvK 91908671). Deze opdracht houdt geen overdracht van rechten in; gebruik door
|
||||
Van Emous Mantelzorgwoningen vindt plaats op basis van toestemming van de Rechthebbende.
|
||||
|
||||
## 2. Gebruik uitsluitend met toestemming
|
||||
|
||||
Zonder voorafgaande, uitdrukkelijke en schriftelijke toestemming van de Rechthebbende
|
||||
is het niet toegestaan de Software geheel of gedeeltelijk te verveelvoudigen, openbaar te
|
||||
maken, te wijzigen, te bewerken, te vertalen, te decompileren, te verspreiden, te verhuren,
|
||||
in sublicentie te geven of op enige andere wijze te gebruiken, behoudens voor zover de wet
|
||||
dit dwingend toestaat.
|
||||
|
||||
## 3. Aard van de toestemming
|
||||
|
||||
Toestemming wordt uitsluitend schriftelijk verleend door de Rechthebbende. Tenzij
|
||||
schriftelijk anders overeengekomen, is toestemming niet-exclusief, niet-overdraagbaar,
|
||||
niet sublicentieerbaar, beperkt tot het daarin omschreven doel en de daarin omschreven
|
||||
duur, en kan zij bij overtreding van de voorwaarden met onmiddellijke ingang worden
|
||||
ingetrokken. Toestemming houdt nimmer een overdracht van rechten in.
|
||||
|
||||
## 4. Aanpassingen en afgeleide werken
|
||||
|
||||
Alle rechten op aanpassingen, uitbreidingen en afgeleide werken van de Software, ongeacht
|
||||
door wie deze zijn gemaakt of in wiens opdracht, berusten bij de Rechthebbende.
|
||||
|
||||
## 5. Geen garantie
|
||||
|
||||
De Software wordt ter beschikking gesteld "zoals deze is" (as is), zonder enige garantie,
|
||||
uitdrukkelijk of stilzwijgend, waaronder begrepen garanties omtrent werking,
|
||||
foutloosheid, geschiktheid voor een bepaald doel, nauwkeurigheid van berekeningen,
|
||||
maatvoering of prijzen, en het niet inbreuk maken op rechten van derden. Uitkomsten van
|
||||
de Software zijn indicatief en vervangen geen constructieve berekening of controle door
|
||||
een deskundige.
|
||||
|
||||
## 6. Aansprakelijkheid
|
||||
|
||||
Voor zover wettelijk toegestaan, is de Rechthebbende niet aansprakelijk voor enige
|
||||
directe of indirecte schade voortvloeiend uit of verband houdend met het gebruik van, of
|
||||
het niet kunnen gebruiken van, de Software, behoudens opzet of bewuste roekeloosheid van
|
||||
de Rechthebbende.
|
||||
|
||||
## 7. Vermeldingen
|
||||
|
||||
Het is niet toegestaan copyright-, licentie- of eigendomsvermeldingen in of op de
|
||||
Software te verwijderen, te wijzigen of onleesbaar te maken.
|
||||
|
||||
## 8. Toepasselijk recht en bevoegde rechter
|
||||
|
||||
Op deze licentie en op alle toestemmingen die op grond daarvan worden verleend is
|
||||
Nederlands recht van toepassing. Geschillen worden uitsluitend voorgelegd aan de
|
||||
bevoegde rechter van de Rechtbank Gelderland.
|
||||
|
||||
Contact en aanvragen voor toestemming: machiel@start-it.nl
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/pdf-sheet.js — gedeelde bouwstenen voor de A3-tekenbladen (jsPDF):
|
||||
// kleuren, nieuw document, maatlijn en titelblok. Gebruikt door WallPdf,
|
||||
// FloorPdf en de unit-PDF, zodat elk blad er identiek uitziet.
|
||||
// Plain script (geen ES-module), geladen na jspdf.umd.min.js.
|
||||
// ============================================================
|
||||
|
||||
window.PdfSheet = (() => {
|
||||
// Kleurlogica: rood = houtwerk-omtrekken, blauw = hoofdmaatvoering,
|
||||
// zwart = opening-kruis, posnum-labels en de onderste maatketens.
|
||||
const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] };
|
||||
|
||||
// Veldnamen van de "Project eigenschappen"-modal van de wand-/vloer-editor.
|
||||
const PROJECT_DOM_IDS = {
|
||||
naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer',
|
||||
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
|
||||
};
|
||||
|
||||
function createDoc() {
|
||||
const { jsPDF } = window.jspdf;
|
||||
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
|
||||
}
|
||||
|
||||
function readProjectFromDom() {
|
||||
const project = {};
|
||||
Object.entries(PROJECT_DOM_IDS).forEach(([key, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
project[key] = (el && el.value) || '';
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
// Maatlijn met kleine loodrechte eindstreepjes ("|") op beide
|
||||
// uiteinden, zodat duidelijk is waar de maat precies begint/eindigt
|
||||
// i.p.v. een lijn die zonder markering in de lucht lijkt te eindigen.
|
||||
const DIM_TICK = 1.5;
|
||||
function dimLine(doc, x1, y1, x2, y2) {
|
||||
doc.line(x1, y1, x2, y2);
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const tx = (-dy / len) * (DIM_TICK / 2), ty = (dx / len) * (DIM_TICK / 2);
|
||||
doc.line(x1 - tx, y1 - ty, x1 + tx, y1 + ty);
|
||||
doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty);
|
||||
}
|
||||
|
||||
// Titelblok (rechtsonder), in twee kolommen zodat het weinig hoogte
|
||||
// inneemt, met het logo (core/logo-data.js) rechtsonder.
|
||||
// project: { naam, plaats, straat, nummer, tekenaar, opmerking };
|
||||
// opts: { titel, element, blad } (blad bijv. "2 / 7").
|
||||
function titleBlock(doc, project, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const p = project || {};
|
||||
const o = opts || {};
|
||||
const tbW = 118, tbH = 36;
|
||||
const tbX0 = 290, tbY0 = 289 - tbH;
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(tbX0, tbY0, tbW, tbH, 'S');
|
||||
|
||||
const today = new Date().toLocaleDateString('nl-NL');
|
||||
const columns = [
|
||||
[['Projectnaam:', p.naam], ['Plaats:', p.plaats], ['Straat:', p.straat], ['Projectnummer:', p.nummer], ['Tekenaar:', p.tekenaar]],
|
||||
[['Titel:', o.titel], ['Element nummer:', o.element], ['Datum getekend:', today], ['Schaal:', '1:1'], ['Opmerking:', p.opmerking]]
|
||||
];
|
||||
const colW = tbW / 2, labelW = 19;
|
||||
doc.setFontSize(6.2);
|
||||
columns.forEach((rows, c) => {
|
||||
const x = tbX0 + 3 + c * colW;
|
||||
let rowY = tbY0 + 5;
|
||||
rows.forEach(([label, value]) => {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text(label, x, rowY);
|
||||
doc.setTextColor(...BLACK);
|
||||
const text = String(value || '-');
|
||||
doc.text(doc.splitTextToSize(text, colW - labelW - 5)[0], x + labelW, rowY);
|
||||
rowY += 4.2;
|
||||
});
|
||||
});
|
||||
|
||||
// Scheidingslijn + onderstrook: blad links, logo rechts.
|
||||
const stripY = tbY0 + 25;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(tbX0, stripY, tbX0 + tbW, stripY);
|
||||
if (o.blad) {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Blad ' + o.blad, tbX0 + 3, tbY0 + tbH - 3.5);
|
||||
}
|
||||
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
|
||||
if (window.TECTUM_LOGO) {
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd
|
||||
} else {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Van Emous Mantelzorgwoningen', tbX0 + tbW - 3, tbY0 + tbH - 3.5, { align: 'right' });
|
||||
}
|
||||
}
|
||||
|
||||
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
|
||||
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
|
||||
function table(doc, x, y, colX, headers, rows, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const o = opts || {};
|
||||
const rowH = o.rowH || 4.3;
|
||||
doc.setTextColor(...BLACK);
|
||||
if (o.title) {
|
||||
doc.setFontSize(o.titleSize || 9);
|
||||
doc.text(o.title, x, y);
|
||||
y += 6;
|
||||
}
|
||||
doc.setFontSize(o.fontSize || 6.5);
|
||||
headers.forEach((h, i) => doc.text(h, x + colX[i], y));
|
||||
y += 1.5;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(x, y, x + colX[colX.length - 1] + (o.lastColW || 14), y);
|
||||
y += 4;
|
||||
rows.forEach(r => {
|
||||
if (o.maxY && y > o.maxY) return;
|
||||
r.forEach((v, i) => doc.text(String(v), x + colX[i], y, i > 0 && o.alignRight ? { align: 'right' } : undefined));
|
||||
y += rowH;
|
||||
});
|
||||
return y;
|
||||
}
|
||||
|
||||
return { COLORS, createDoc, readProjectFromDom, dimLine, titleBlock, table };
|
||||
})();
|
||||
@@ -0,0 +1,163 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
|
||||
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
|
||||
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
|
||||
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
|
||||
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
|
||||
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
|
||||
//
|
||||
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
|
||||
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
|
||||
// zie savePdfBlob hieronder).
|
||||
// ============================================================
|
||||
|
||||
function createPdfDocument() {
|
||||
const objects = [];
|
||||
let nextObjId = 1;
|
||||
const pageDefs = [];
|
||||
let fontId, catalogId, pagesId;
|
||||
|
||||
function newObj(content) {
|
||||
const id = nextObjId++;
|
||||
objects.push({ id, content });
|
||||
return id;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function escapePdfText(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
function toLatin1(str) {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
out += code <= 255 ? str[i] : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
|
||||
function estimateTextWidth(str, size) {
|
||||
return str.length * size * 0.55;
|
||||
}
|
||||
|
||||
fontId = newObj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
|
||||
|
||||
function addPage(widthPt, heightPt) {
|
||||
const ops = [];
|
||||
let strokeKey = null, fillKey = null, widthKey = null;
|
||||
|
||||
function setStroke(r, g, b) {
|
||||
const key = r + ',' + g + ',' + b;
|
||||
if (key === strokeKey) return;
|
||||
strokeKey = key;
|
||||
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} RG`);
|
||||
}
|
||||
function setFill(r, g, b) {
|
||||
const key = r + ',' + g + ',' + b;
|
||||
if (key === fillKey) return;
|
||||
fillKey = key;
|
||||
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} rg`);
|
||||
}
|
||||
function setLineWidth(w) {
|
||||
if (w === widthKey) return;
|
||||
widthKey = w;
|
||||
ops.push(`${fmt(w)} w`);
|
||||
}
|
||||
function line(x1, y1, x2, y2) {
|
||||
ops.push(`${fmt(x1)} ${fmt(y1)} m ${fmt(x2)} ${fmt(y2)} l S`);
|
||||
}
|
||||
function rect(x, y, w, h, mode) {
|
||||
ops.push(`${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re ${mode || 'S'}`);
|
||||
}
|
||||
function polyline(points, close) {
|
||||
if (!points.length) return;
|
||||
ops.push(`${fmt(points[0][0])} ${fmt(points[0][1])} m`);
|
||||
for (let i = 1; i < points.length; i++) ops.push(`${fmt(points[i][0])} ${fmt(points[i][1])} l`);
|
||||
ops.push(close ? 's' : 'S');
|
||||
}
|
||||
function text(x, y, size, str, align) {
|
||||
const s = escapePdfText(toLatin1(str));
|
||||
let tx = x;
|
||||
if (align === 'center') tx = x - estimateTextWidth(str, size) / 2;
|
||||
else if (align === 'right') tx = x - estimateTextWidth(str, size);
|
||||
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(tx)} ${fmt(y)} Td (${s}) Tj ET`);
|
||||
}
|
||||
function textRotated(x, y, size, str, degrees) {
|
||||
const rad = (degrees * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const s = escapePdfText(toLatin1(str));
|
||||
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ${fmt(x)} ${fmt(y)} Tm (${s}) Tj ET`);
|
||||
}
|
||||
|
||||
const contentId = newObj('');
|
||||
const pageId = newObj('');
|
||||
pageDefs.push({ pageId, contentId, ops, widthPt, heightPt });
|
||||
|
||||
return { setStroke, setFill, setLineWidth, line, rect, polyline, text, textRotated, estimateTextWidth };
|
||||
}
|
||||
|
||||
function build() {
|
||||
pagesId = newObj('');
|
||||
catalogId = newObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
|
||||
|
||||
pageDefs.forEach(p => {
|
||||
const streamBody = p.ops.join('\n');
|
||||
objects.find(o => o.id === p.contentId).content =
|
||||
`<< /Length ${streamBody.length} >>\nstream\n${streamBody}\nendstream`;
|
||||
objects.find(o => o.id === p.pageId).content =
|
||||
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${fmt(p.widthPt)} ${fmt(p.heightPt)}] ` +
|
||||
`/Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${p.contentId} 0 R >>`;
|
||||
});
|
||||
|
||||
objects.find(o => o.id === pagesId).content =
|
||||
`<< /Type /Pages /Kids [${pageDefs.map(p => p.pageId + ' 0 R').join(' ')}] /Count ${pageDefs.length} >>`;
|
||||
|
||||
objects.sort((a, b) => a.id - b.id);
|
||||
|
||||
let out = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
|
||||
const offsets = [0];
|
||||
objects.forEach(obj => {
|
||||
offsets[obj.id] = out.length;
|
||||
out += `${obj.id} 0 obj\n${obj.content}\nendobj\n`;
|
||||
});
|
||||
|
||||
const xrefOffset = out.length;
|
||||
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
|
||||
}
|
||||
out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
return { addPage, build };
|
||||
}
|
||||
|
||||
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
|
||||
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
|
||||
// hierboven is dat niet.
|
||||
function savePdfBlob(latin1String, filename) {
|
||||
const bytes = new Uint8Array(latin1String.length);
|
||||
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/view-filter.js — gedeelde "Weergave"-dropdown met vinkjes voor de
|
||||
// 3D-weergaven (woning, wand, vloer/dak). Vervangt de losse Aan/Uit-knoppen.
|
||||
// Plain script (geen ES-module).
|
||||
//
|
||||
// const filter = createViewFilter(toolbarEl, [
|
||||
// { key: 'beplating', label: 'Beplating', checked: true }, ...
|
||||
// ], (state) => { ... }); // state = { beplating: true, ... }
|
||||
// ============================================================
|
||||
|
||||
function createViewFilter(toolbar, items, onChange) {
|
||||
const state = {};
|
||||
items.forEach(it => { state[it.key] = it.checked !== false; });
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'view-filter';
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn-3d-toggle vf-btn">Weergave ▾</button>
|
||||
<div class="vf-menu">
|
||||
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
|
||||
</div>`;
|
||||
toolbar.insertBefore(wrap, toolbar.firstChild);
|
||||
|
||||
const menu = wrap.querySelector('.vf-menu');
|
||||
const btn = wrap.querySelector('.vf-btn');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('open');
|
||||
btn.classList.toggle('active', menu.classList.contains('open'));
|
||||
});
|
||||
menu.addEventListener('click', (e) => e.stopPropagation());
|
||||
menu.addEventListener('change', (e) => {
|
||||
const key = e.target.dataset.key;
|
||||
if (!key) return;
|
||||
state[key] = e.target.checked;
|
||||
onChange(Object.assign({}, state));
|
||||
});
|
||||
// Klik buiten het menu sluit het (listener verdwijnt met het element).
|
||||
const closeOnOutside = () => {
|
||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
||||
menu.classList.remove('open');
|
||||
btn.classList.remove('active');
|
||||
};
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
|
||||
return { state: () => Object.assign({}, state) };
|
||||
}
|
||||
@@ -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: []
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,569 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// house/house-model.js — HouseModel: ENIGE bron van de woning-logica. Een
|
||||
// woning bestaat uit units (UnitModel) die aan elkaar gekoppeld zijn, plus
|
||||
// binnenwanden. Geen DOM. Alle wand-/vloerlogica komt uit WallModel/
|
||||
// FloorModel/UnitModel.
|
||||
//
|
||||
// Woning-state:
|
||||
// { project: {...},
|
||||
// units: [{ id: 'A', attach: null | { to, side, align, offset }, unit: <unit-state> }],
|
||||
// interiorWalls: [{ id, x1, z1, x2, z2, wall: <wand-state> }] }
|
||||
//
|
||||
// Assenstelsel (mm, woning): X = breedte (rechts), Z = diepte (naar achter),
|
||||
// de eerste unit heeft zijn linker-voorhoek op (0, 0).
|
||||
//
|
||||
// Koppelen werkt als de ankers van openingen: een unit hangt aan een zijde
|
||||
// van een andere unit, uitgelijnd op start/midden/eind + verschuiving. Wordt
|
||||
// een unit groter/kleiner, dan schuiven de gekoppelde units mee.
|
||||
// Waar twee units tegen elkaar staan (naad) vervalt de wand van BEIDE units
|
||||
// over dat stuk (UnitModel.computeLayout met cuts).
|
||||
//
|
||||
// Plain script, geladen na unit-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.HouseModel = (() => {
|
||||
const EPS = 0.5; // mm
|
||||
|
||||
const ALIGN_LABELS = {
|
||||
x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' },
|
||||
z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' }
|
||||
};
|
||||
// Windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord).
|
||||
const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' };
|
||||
|
||||
const DEFAULT_INTERIOR_THICKNESS = 100;
|
||||
|
||||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function createEmptyProject() {
|
||||
return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' };
|
||||
}
|
||||
|
||||
function nextUnitId(state) {
|
||||
const used = new Set(state.units.map(u => u.id));
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const id = String.fromCharCode(65 + i);
|
||||
if (!used.has(id)) return id;
|
||||
}
|
||||
return 'U' + (state.units.length + 1);
|
||||
}
|
||||
|
||||
// template: 'single' (1 unit) of 'L' (2 units in L-vorm).
|
||||
// Woning-brede instellingen: regelwerk-h.o.h. voor ALLE buiten- resp.
|
||||
// binnenwanden (niet per wand), en de dakrand (RoofModel).
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||||
cladding: 'horizontaal', // richting gevelbekleding, voorlopig voor alle buitenwanden
|
||||
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
|
||||
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
|
||||
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM },
|
||||
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
|
||||
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
|
||||
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
|
||||
};
|
||||
}
|
||||
|
||||
function createUnitEntry(id, attach, unitOpts) {
|
||||
const unit = UnitModel.createDefaultState(unitOpts);
|
||||
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting() };
|
||||
}
|
||||
|
||||
function createDefaultState(template) {
|
||||
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
|
||||
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
|
||||
if (template === 'L') {
|
||||
state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
|
||||
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
|
||||
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
|
||||
state.units[0].unit.walls.voor.openings = [
|
||||
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 }
|
||||
];
|
||||
state.units[1].unit.walls.achter.openings = [
|
||||
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 },
|
||||
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 },
|
||||
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
|
||||
];
|
||||
}
|
||||
computeParts(state, { skip3D: true });
|
||||
return state;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Posities (vloer-rechthoeken) uit de koppelingen.
|
||||
// ------------------------------------------------------------
|
||||
function unitRects(state) {
|
||||
const size = {};
|
||||
state.units.forEach(u => {
|
||||
const f = FloorModel.computeFrame(u.unit.floor.floor);
|
||||
size[u.id] = { W: f.width, D: f.depth };
|
||||
});
|
||||
const rects = {};
|
||||
const make = (id, x0, z0, error) => {
|
||||
const { W, D } = size[id];
|
||||
rects[id] = { id, x0, z0, x1: x0 + W, z1: z0 + D, cx: x0 + W / 2, cz: z0 + D / 2, W, D, error: error || null };
|
||||
};
|
||||
|
||||
let pending = state.units.slice();
|
||||
let progress = true;
|
||||
while (pending.length && progress) {
|
||||
progress = false;
|
||||
pending = pending.filter(u => {
|
||||
if (!u.attach) {
|
||||
if (Object.keys(rects).length === 0) { make(u.id, 0, 0); progress = true; return false; }
|
||||
return true; // tweede ongekoppelde unit: hieronder als fout geplaatst
|
||||
}
|
||||
const P = rects[u.attach.to];
|
||||
if (!P) return true;
|
||||
const { W, D } = size[u.id];
|
||||
const a = u.attach, off = a.offset || 0;
|
||||
const alongX = (align) => align === 'middle' ? P.cx - W / 2 + off : align === 'end' ? P.x1 - off - W : P.x0 + off;
|
||||
const alongZ = (align) => align === 'middle' ? P.cz - D / 2 + off : align === 'end' ? P.z1 - off - D : P.z0 + off;
|
||||
if (a.side === 'achter') make(u.id, alongX(a.align), P.z1);
|
||||
else if (a.side === 'voor') make(u.id, alongX(a.align), P.z0 - D);
|
||||
else if (a.side === 'rechts') make(u.id, P.x1, alongZ(a.align));
|
||||
else make(u.id, P.x0 - W, alongZ(a.align));
|
||||
progress = true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
// Onoplosbare koppelingen (kring/ontbrekende unit): rechts naast de rest.
|
||||
pending.forEach(u => {
|
||||
const maxX = Math.max(0, ...Object.values(rects).map(r => r.x1));
|
||||
make(u.id, maxX + 1000, 0, 'Koppeling ongeldig (verwijst naar zichzelf of een ontbrekende unit).');
|
||||
});
|
||||
return state.units.map(u => rects[u.id]);
|
||||
}
|
||||
|
||||
// Naden: zijden die precies tegen elkaar liggen en (deels) overlappen.
|
||||
// Elke cut is [a, b, extA, extB]: extA/extB = hoever het aangrenzende
|
||||
// wandstuk de naad in doorloopt. Eindigt de naad op de rand van de BUUR-unit
|
||||
// (binnenhoek, bijv. bij een L-vorm), dan staat daar een haakse wand van die
|
||||
// buur; het wandstuk loopt dan met diens dikte door, zodat de binnenhoek
|
||||
// dicht is i.p.v. een gat van wanddikte x wanddikte.
|
||||
// thickness(id, side) = dikte van die wand (mm).
|
||||
function computeSeams(rects, thickness) {
|
||||
const T = thickness || (() => 0);
|
||||
const cuts = {};
|
||||
rects.forEach(r => { cuts[r.id] = { voor: [], achter: [], links: [], rechts: [] }; });
|
||||
const seams = [];
|
||||
const overlap = (a0, a1, b0, b1) => [Math.max(a0, b0), Math.min(a1, b1)];
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = 0; j < rects.length; j++) {
|
||||
if (i === j) continue;
|
||||
const A = rects[i], B = rects[j];
|
||||
if (Math.abs(A.z1 - B.z0) < EPS) { // A achter | B voor
|
||||
const [o0, o1] = overlap(A.x0, A.x1, B.x0, B.x1);
|
||||
if (o1 - o0 > EPS) {
|
||||
cuts[A.id].achter.push([o0 - A.cx, o1 - A.cx, B.x0 > A.x0 + EPS ? T(B.id, 'links') : 0, B.x1 < A.x1 - EPS ? T(B.id, 'rechts') : 0]);
|
||||
cuts[B.id].voor.push([o0 - B.cx, o1 - B.cx, A.x0 > B.x0 + EPS ? T(A.id, 'links') : 0, A.x1 < B.x1 - EPS ? T(A.id, 'rechts') : 0]);
|
||||
seams.push({ a: A.id, b: B.id, x0: o0, z0: A.z1, x1: o1, z1: A.z1 });
|
||||
}
|
||||
}
|
||||
if (Math.abs(A.x1 - B.x0) < EPS) { // A rechts | B links
|
||||
const [o0, o1] = overlap(A.z0, A.z1, B.z0, B.z1);
|
||||
if (o1 - o0 > EPS) {
|
||||
cuts[A.id].rechts.push([o0 - A.cz, o1 - A.cz, B.z0 > A.z0 + EPS ? T(B.id, 'voor') : 0, B.z1 < A.z1 - EPS ? T(B.id, 'achter') : 0]);
|
||||
cuts[B.id].links.push([o0 - B.cz, o1 - B.cz, A.z0 > B.z0 + EPS ? T(A.id, 'voor') : 0, A.z1 < B.z1 - EPS ? T(A.id, 'achter') : 0]);
|
||||
seams.push({ a: A.id, b: B.id, x0: A.x1, z0: o0, x1: A.x1, z1: o1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cuts, seams };
|
||||
}
|
||||
|
||||
function overlappingPairs(rects) {
|
||||
const pairs = [];
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const A = rects[i], B = rects[j];
|
||||
if (Math.min(A.x1, B.x1) - Math.max(A.x0, B.x0) > EPS && Math.min(A.z1, B.z1) - Math.max(A.z0, B.z0) > EPS) {
|
||||
pairs.push([A.id, B.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Binnenwanden: een getekend lijnstuk (hartlijn, as-evenwijdig) met een
|
||||
// gewone wand-state. Standaard 100 mm dik, aan beide zijden spaanplaat.
|
||||
// ------------------------------------------------------------
|
||||
function interiorWallDefaults(thickness, height) {
|
||||
const wall = WallEditor.getDefaultState();
|
||||
const t = thickness || DEFAULT_INTERIOR_THICKNESS;
|
||||
const h = height || wall.wall.heightAll;
|
||||
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
|
||||
wall.materials = { interior: 'spaanplaat', exterior: 'spaanplaat' };
|
||||
wall.openings = [];
|
||||
setThickness(wall, t);
|
||||
return wall;
|
||||
}
|
||||
|
||||
// Dikte = regelwerk + 2x beplating (WallModel.thicknessOf); de diepte van
|
||||
// stijlen en regels wordt daarop afgestemd.
|
||||
function setThickness(wallState, thickness) {
|
||||
const depth = Math.max(20, Math.round(thickness - 2 * wallState.wall.sheathingThick));
|
||||
wallState.wall.studDepth = depth;
|
||||
wallState.wall.plateDepth = depth;
|
||||
}
|
||||
function thicknessOf(wallState) {
|
||||
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + 2 * wallState.wall.sheathingThick;
|
||||
}
|
||||
|
||||
function interiorLayout(iw) {
|
||||
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
|
||||
const alongX = Math.abs(dx) >= Math.abs(dz);
|
||||
const width = Math.max(300, Math.round(alongX ? Math.abs(dx) : Math.abs(dz)));
|
||||
iw.wall.wall.width = width;
|
||||
return {
|
||||
width, alongX,
|
||||
cx: (iw.x1 + iw.x2) / 2, cz: (iw.z1 + iw.z2) / 2,
|
||||
rotY: alongX ? 0 : -Math.PI / 2 // lokaal +X langs +X resp. +Z
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
|
||||
// opts: { wallMaterials, floorMaterials, beplatingVisible, isolatieVisible, skip3D }
|
||||
// ------------------------------------------------------------
|
||||
// Past de woning-brede instellingen toe op de onderdeel-states (muteert
|
||||
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
|
||||
function applyGlobalSettings(state) {
|
||||
const sp = state.settings.spacing;
|
||||
const tr = state.settings.trasraam;
|
||||
state.units.forEach(u => {
|
||||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height) : null;
|
||||
Object.values(u.unit.walls).forEach(ws => {
|
||||
ws.wall.minSpacing = sp.exterior.min;
|
||||
ws.wall.maxSpacing = sp.exterior.max;
|
||||
ws.materials = Object.assign({}, ws.materials, { cladding: state.settings.cladding, trasraam });
|
||||
});
|
||||
if (u.roof.followFloor) {
|
||||
u.roof.deck.floor.width = u.unit.floor.floor.width;
|
||||
u.roof.deck.floor.depth = u.unit.floor.floor.depth;
|
||||
u.roof.offsetX = 0;
|
||||
u.roof.offsetZ = 0;
|
||||
}
|
||||
});
|
||||
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
||||
}
|
||||
|
||||
// Doorlopende gevels over alle units: collineaire, aansluitende buiten-
|
||||
// wandstukken (groupFacadeRuns, core/geometry.js). Zet per stuk p.facade =
|
||||
// { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand
|
||||
// langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl.
|
||||
// hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk.
|
||||
function assignFacades(units) {
|
||||
const segs = [];
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
const normal = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x';
|
||||
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
|
||||
segs.push({ piece: p, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) });
|
||||
})));
|
||||
groupFacadeRuns(segs).forEach(run => {
|
||||
const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1;
|
||||
const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0;
|
||||
const first = run.segs.reduce((best, sg) => Math.abs(sg.sAt0 - sLeft) < Math.abs(best.sAt0 - sLeft) ? sg : best);
|
||||
const extLeft = (first.piece.skinExt && first.piece.skinExt.left) || 0;
|
||||
run.segs.forEach(sg => {
|
||||
sg.piece.facade = { u0: Math.abs(sg.sAt0 - sLeft) + extLeft, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5 };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computeParts(state, opts) {
|
||||
const o = opts || {};
|
||||
const build = !o.skip3D;
|
||||
applyGlobalSettings(state);
|
||||
const rects = unitRects(state);
|
||||
const rectById = {};
|
||||
rects.forEach(r => { rectById[r.id] = r; });
|
||||
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
|
||||
const { cuts, seams } = computeSeams(rects, wallThickness);
|
||||
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade) {
|
||||
if (!build) return { group: null, stats: null, specs: null };
|
||||
const built = WallModel.build3D(config, frame, {
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade,
|
||||
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false,
|
||||
isolatieVisible: o.isolatieVisible !== false
|
||||
});
|
||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||
}
|
||||
|
||||
const units = state.units.map(u => {
|
||||
const rect = rectById[u.id];
|
||||
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
|
||||
const floorData = FloorModel.computeFromState(u.unit.floor);
|
||||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||||
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
||||
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false, isolatieVisible: o.isolatieVisible !== false
|
||||
}) : null;
|
||||
|
||||
const walls = layout.walls.map(wl => {
|
||||
const ws = u.unit.walls[wl.side];
|
||||
const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek';
|
||||
const cladding = (ws.materials || {}).cladding || 'horizontaal';
|
||||
const trasraam = buitenMateriaal === 'vochtdoek' ? ((ws.materials || {}).trasraam || null) : null;
|
||||
const wp = UnitModel.wallPieces(wl, ws);
|
||||
const pieces = wp.pieces.map(p => {
|
||||
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
|
||||
return Object.assign(p, {
|
||||
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
||||
label: `Unit ${u.id} ${wl.label.toLowerCase()}${p.suffix ? ' (' + p.suffix + ')' : ''}`,
|
||||
buitenMateriaal, cladding, trasraam,
|
||||
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop }
|
||||
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
|
||||
});
|
||||
return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 });
|
||||
});
|
||||
|
||||
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste
|
||||
// wandbovenkant van deze unit.
|
||||
const roofData = FloorModel.computeFromState(u.roof.deck);
|
||||
const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings);
|
||||
const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => Math.max(ws.wall.heightLeft, ws.wall.heightRight)));
|
||||
const roofW = roofData.calcData.width, roofD = roofData.calcData.depth;
|
||||
const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0);
|
||||
const roof = {
|
||||
element: `${u.id}-D1`, data: roofData, specs: roofSpecs,
|
||||
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
|
||||
base: layout.floorTop + wallTop,
|
||||
top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData),
|
||||
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
|
||||
// horen bij "Beplating", de dakisolatie bij "Isolatie".
|
||||
group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, {
|
||||
materials: o.roofMaterials, isolatieVisible: o.isolatieVisible !== false,
|
||||
osbVisible: o.beplatingVisible !== false,
|
||||
dakleerVisible: o.beplatingVisible !== false
|
||||
}) : null
|
||||
};
|
||||
|
||||
return {
|
||||
id: u.id, rect, layout, floorData, floorSpecs, floorGroup, walls, roof,
|
||||
floorElement: `${u.id}-V1`
|
||||
};
|
||||
});
|
||||
|
||||
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk).
|
||||
assignFacades(units);
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
Object.assign(p, buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade));
|
||||
})));
|
||||
|
||||
// Binnenwanden staan op de vloer van de unit waarin hun midden valt.
|
||||
const interior = state.interiorWalls.map((iw, i) => {
|
||||
const lay = interiorLayout(iw);
|
||||
const host = units.find(u => lay.cx >= u.rect.x0 && lay.cx <= u.rect.x1 && lay.cz >= u.rect.z0 && lay.cz <= u.rect.z1);
|
||||
const floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop));
|
||||
const config = WallModel.configFromState(iw.wall);
|
||||
const frame = WallModel.computeFrame(config);
|
||||
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
|
||||
return Object.assign({
|
||||
id: iw.id, element: `BW-${i + 1}`, label: `Binnenwand ${i + 1}`, config, frame, buitenMateriaal,
|
||||
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
|
||||
}, buildWall(config, frame, buitenMateriaal));
|
||||
});
|
||||
|
||||
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
|
||||
// boeidelen aan/uit per unit.
|
||||
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||||
units.map(u => ({ unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false })),
|
||||
Math.max(...units.map(u => u.roof.top))) : null;
|
||||
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
|
||||
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
||||
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
|
||||
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
||||
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
|
||||
}) : null;
|
||||
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials()) : null;
|
||||
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
|
||||
|
||||
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
|
||||
const sandwich = state.settings.sandwich && units.length
|
||||
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
|
||||
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
|
||||
visible: o.beplatingVisible !== false
|
||||
}) : null;
|
||||
|
||||
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup };
|
||||
}
|
||||
|
||||
// Alle wandstukken (buiten + binnen) plat op een rij.
|
||||
function allWallPieces(parts) {
|
||||
const ext = [];
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
|
||||
return ext.concat(parts.interior);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Totalen over de hele woning (overzicht + laatste PDF-blad).
|
||||
// ------------------------------------------------------------
|
||||
function computeTotals(parts) {
|
||||
const pieces = allWallPieces(parts);
|
||||
const kinds = new Map();
|
||||
const sawList = new Map();
|
||||
function addPiece(kind, width, depth, length) {
|
||||
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
|
||||
k.count++; k.lengthMM += length;
|
||||
kinds.set(kind, k);
|
||||
const L = Math.round(length);
|
||||
const key = `${width}|${depth}|${L}`;
|
||||
const g = sawList.get(key) || { width, depth, length: L, count: 0 };
|
||||
g.count++;
|
||||
sawList.set(key, g);
|
||||
}
|
||||
parts.units.forEach(u => {
|
||||
const fc = u.floorData.calcData;
|
||||
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.depth));
|
||||
const rc = u.roof.data.calcData;
|
||||
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.depth));
|
||||
});
|
||||
const wallBom = [];
|
||||
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length); }));
|
||||
const purchase = WallModel.planPurchase(wallBom);
|
||||
|
||||
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
|
||||
const sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0);
|
||||
const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat);
|
||||
|
||||
const weights = parts.units.map(u => ({
|
||||
label: `Unit ${u.id}`, element: u.id,
|
||||
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
|
||||
}));
|
||||
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
|
||||
|
||||
const isolatie = parts.units.map(u => ({
|
||||
label: `Unit ${u.id}`, element: u.id,
|
||||
m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0)
|
||||
}));
|
||||
if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) });
|
||||
|
||||
const kindList = Array.from(kinds.values());
|
||||
return {
|
||||
kinds: kindList,
|
||||
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
|
||||
beamCount: kindList.reduce((a, k) => a + k.count, 0),
|
||||
purchase,
|
||||
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
|
||||
purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0),
|
||||
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
|
||||
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
|
||||
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
|
||||
isolatie,
|
||||
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
|
||||
plaatwerk: [
|
||||
{
|
||||
naam: 'Spaanplaat (wanden)',
|
||||
m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2),
|
||||
m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3),
|
||||
kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||||
},
|
||||
{ naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) },
|
||||
{ naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) },
|
||||
{
|
||||
naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0),
|
||||
m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||||
},
|
||||
{
|
||||
naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0),
|
||||
m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0)
|
||||
},
|
||||
{
|
||||
naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null,
|
||||
kg: parts.sandwich ? parts.sandwich.kg : 0
|
||||
},
|
||||
{
|
||||
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
|
||||
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||||
}
|
||||
],
|
||||
weights,
|
||||
totalKg: weights.reduce((a, r) => a + r.kg, 0),
|
||||
roofEdge: parts.roofEdge ? {
|
||||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||||
fasciaHeight: parts.roofEdge.fasciaHeight,
|
||||
overhang: parts.roofEdge.overhang,
|
||||
soffitM2: parts.roofEdge.soffitM2,
|
||||
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
|
||||
trimHeight: parts.roofEdge.trimHeight,
|
||||
spots: parts.roofEdge.spots.length
|
||||
} : null,
|
||||
lamps: {
|
||||
total: parts.lighting.lamps.length,
|
||||
both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
|
||||
down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
|
||||
up: parts.lighting.lamps.filter(l => l.direction === 'up').length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Validatie / conversie
|
||||
// ------------------------------------------------------------
|
||||
function validateState(design) {
|
||||
if (!Array.isArray(design.units) || !design.units.length) throw new Error('veld "units" ontbreekt of is leeg');
|
||||
const ids = new Set();
|
||||
design.units.forEach((u, i) => {
|
||||
if (!u.id || ids.has(u.id)) throw new Error(`unit #${i + 1}: id ontbreekt of is dubbel`);
|
||||
ids.add(u.id);
|
||||
if (u.attach && !['voor', 'achter', 'links', 'rechts'].includes(u.attach.side)) throw new Error(`unit ${u.id}: ongeldige koppelzijde`);
|
||||
try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); }
|
||||
});
|
||||
(design.interiorWalls || []).forEach((iw, i) => {
|
||||
['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); });
|
||||
try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
|
||||
function fromUnitDesign(design) {
|
||||
const { type, version, exportedAt, project, ...unit } = design;
|
||||
return normalize({ project: Object.assign(createEmptyProject(), project || {}), units: [{ id: 'A', attach: null, unit }], interiorWalls: [] });
|
||||
}
|
||||
|
||||
function normalize(state) {
|
||||
state.project = Object.assign(createEmptyProject(), state.project || {});
|
||||
state.interiorWalls = state.interiorWalls || [];
|
||||
const def = createDefaultSettings();
|
||||
state.settings = state.settings || {};
|
||||
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
|
||||
if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding;
|
||||
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||||
state.settings.sandwich = state.settings.sandwich !== false;
|
||||
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
|
||||
state.settings.roof = RoofModel.normalizeSettings(oldRoof);
|
||||
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
|
||||
const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting;
|
||||
const lightIn = Object.assign({}, state.settings.lighting || {});
|
||||
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
|
||||
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
|
||||
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
|
||||
state.units.forEach(u => {
|
||||
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
||||
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
|
||||
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
|
||||
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS,
|
||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout,
|
||||
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,255 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
|
||||
// Blad 1 plattegrond woning (units, wandstukken, naden, binnenwanden)
|
||||
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
|
||||
// daarna elke binnenwand (WallPdf)
|
||||
// laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht)
|
||||
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
|
||||
// Plain script, geladen na house-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.HousePdf = (() => {
|
||||
const KIND_LABELS = {
|
||||
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
|
||||
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
|
||||
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
|
||||
};
|
||||
const PLAN_ELEMENT = 'PL-1';
|
||||
|
||||
function fmt(n, decimals = 1) {
|
||||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
|
||||
// Rechthoek van een wandstuk (bovenaanzicht) als 4 hoekpunten.
|
||||
function pieceCorners(place, lx0, lx1) {
|
||||
const t = place.thickness / 2;
|
||||
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
|
||||
}
|
||||
|
||||
// Bladvolgorde + elementnummers; gedeeld door generate() en de
|
||||
// onderdelenlijst op blad 1.
|
||||
function sheetList(parts) {
|
||||
const list = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }];
|
||||
parts.units.forEach(u => {
|
||||
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
||||
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
||||
list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u });
|
||||
});
|
||||
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
||||
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
||||
return list;
|
||||
}
|
||||
|
||||
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
sheets.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]),
|
||||
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
|
||||
const rects = parts.rects;
|
||||
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
||||
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
|
||||
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
|
||||
const margin = 32;
|
||||
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
|
||||
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
|
||||
const oy = (drawY0 + drawY1) / 2 - ((bz0 + bz1) / 2) * scale;
|
||||
const px = (x) => ox + x * scale, py = (z) => oy + z * scale;
|
||||
const poly = (pts, style) => {
|
||||
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
|
||||
};
|
||||
|
||||
// Vloeren + unit-letter
|
||||
rects.forEach(r => {
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(px(r.x0), py(r.z0), r.W * scale, r.D * scale, 'S');
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(16);
|
||||
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
|
||||
});
|
||||
|
||||
// Wandstukken (buiten + binnen) met openingen
|
||||
const drawPiece = (p) => {
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.3);
|
||||
poly(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2), 'S');
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
p.config.openings.forEach(op => {
|
||||
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
|
||||
poly(c, 'FD');
|
||||
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
|
||||
doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z));
|
||||
});
|
||||
};
|
||||
HouseModel.allWallPieces(parts).forEach(drawPiece);
|
||||
|
||||
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
|
||||
doc.setLineDashPattern([1, 1.2], 0);
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
parts.units.forEach(u => doc.rect(px(u.roof.rect.x0), py(u.roof.rect.z0), u.roof.rect.W * scale, u.roof.rect.D * scale, 'S'));
|
||||
if (parts.roofEdge) {
|
||||
doc.setLineWidth(0.25);
|
||||
parts.roofEdge.segments.forEach(sg => {
|
||||
doc.setDrawColor(...(sg.fascia ? BLACK : GREY));
|
||||
doc.line(px(sg.outerA.x), py(sg.outerA.z), px(sg.outerB.x), py(sg.outerB.z));
|
||||
});
|
||||
doc.setLineDashPattern([], 0);
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
|
||||
}
|
||||
doc.setLineDashPattern([], 0);
|
||||
|
||||
// Gevellampen
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 211, 107);
|
||||
doc.setLineWidth(0.15);
|
||||
parts.lighting.lamps.forEach(l => {
|
||||
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
||||
});
|
||||
|
||||
// Naden: gestippeld oranje
|
||||
doc.setDrawColor(230, 130, 30);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.setLineDashPattern([1.5, 1], 0);
|
||||
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
|
||||
doc.setLineDashPattern([], 0);
|
||||
|
||||
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setFontSize(6);
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
const n = p.place.normal, off = 6;
|
||||
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2);
|
||||
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
|
||||
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
|
||||
PdfSheet.dimLine(doc, x0, y0, x1, y1);
|
||||
const text = `${p.element}: ${p.place.width}`;
|
||||
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
|
||||
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
|
||||
else doc.text(text, tx, ty, { align: 'center' });
|
||||
})));
|
||||
doc.setTextColor(...BLACK);
|
||||
parts.interior.forEach(p => {
|
||||
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: p.place.rotY === 0 ? 0 : 90 });
|
||||
});
|
||||
|
||||
// Buitenmaten woning
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(7);
|
||||
const topY = py(bz0) - 20, leftX = px(bx0) - 20;
|
||||
PdfSheet.dimLine(doc, px(bx0), topY, px(bx1), topY);
|
||||
doc.text('X: ' + Math.round(bx1 - bx0), (px(bx0) + px(bx1)) / 2, topY - 1.5, { align: 'center' });
|
||||
PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1));
|
||||
doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 });
|
||||
|
||||
// Noordpijl (boven = noord)
|
||||
doc.setFillColor(...BLUE);
|
||||
doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F');
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(8);
|
||||
doc.text('N', drawX1 - 8, drawY0 + 14, { align: 'center' });
|
||||
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
||||
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
|
||||
}
|
||||
|
||||
function drawTotalsSheet(doc, totals, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(14);
|
||||
doc.text('Totalen woning', 15, 18);
|
||||
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
|
||||
|
||||
let y = 30;
|
||||
const kindRows = totals.kinds.map(k => [KIND_LABELS[k.kind] || k.kind, String(k.count), fmt(k.lengthMM / 1000, 2)]);
|
||||
kindRows.push(['Totaal', String(totals.beamCount), fmt(totals.kinds.reduce((a, k) => a + k.lengthMM, 0) / 1000, 2)]);
|
||||
y = PdfSheet.table(doc, 15, y, [0, 45, 65], ['Soort', 'Aantal', 'Lengte (m)'], kindRows, tableOpts('Houtwerk (balken)'));
|
||||
y += 4;
|
||||
y = PdfSheet.table(doc, 15, y, [0, 45], ['', ''], [
|
||||
['Volume hout', fmt(totals.woodM3, 3) + ' m3'],
|
||||
['Gewicht hout', fmt(totals.woodKg) + ' kg'],
|
||||
['Latwerk 20x30 (kruislat)', `${totals.battens.count} st. \u00e0 ${WallModel.BATTEN.length} (${fmt(totals.battens.lengthM, 1)} m)`]
|
||||
], tableOpts('Houtwerk totaal'));
|
||||
y += 4;
|
||||
const purchaseRows = [];
|
||||
totals.purchase.forEach(sec => sec.perStock.forEach(st => purchaseRows.push([`${sec.width}x${sec.depth}`, String(st.stock), String(st.count)])));
|
||||
purchaseRows.push(['Snijverlies', fmt(totals.purchaseWasteMM / 1000, 2) + ' m',
|
||||
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
|
||||
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
|
||||
|
||||
PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'],
|
||||
totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]),
|
||||
Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 }));
|
||||
|
||||
y = 30;
|
||||
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
|
||||
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
|
||||
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
|
||||
y += 4;
|
||||
y = PdfSheet.table(doc, 215, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
|
||||
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
|
||||
tableOpts('Plaatwerk'));
|
||||
y += 4;
|
||||
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
|
||||
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
|
||||
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
|
||||
if (totals.roofEdge) {
|
||||
y += 4;
|
||||
const e = totals.roofEdge, L = totals.lamps;
|
||||
const rows = [];
|
||||
if (e.fasciaLengthM > 0) {
|
||||
rows.push(['Boeidelen', `${fmt(e.fasciaLengthM, 2)} m, ${e.fasciaHeight} mm hoog, overstek ${e.overhang} mm`]);
|
||||
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
|
||||
}
|
||||
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
|
||||
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
||||
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & buitenverlichting'));
|
||||
}
|
||||
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
||||
});
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const sheets = sheetList(parts);
|
||||
const project = state.project;
|
||||
const doc = PdfSheet.createDoc();
|
||||
|
||||
sheets.forEach((sheet, i) => {
|
||||
if (i > 0) doc.addPage('a3', 'landscape');
|
||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
||||
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
|
||||
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.unit.roof.data.calcData, sheet.unit.roof.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
||||
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, lamps: sheet.piece.lamps || [] }, opts));
|
||||
else drawTotalsSheet(doc, totals, opts);
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { generate, sheetList };
|
||||
})();
|
||||
+1103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// lighting/lighting-model.js — LightingModel: ENIGE bron van de
|
||||
// buitenverlichting van een woning. Geen DOM.
|
||||
//
|
||||
// Woning-breed (settings.lighting): type, lichtrichting en alle afstanden.
|
||||
// mode: 'geen' | 'spots' (boeideel-spots, alleen waar boeidelen zijn) |
|
||||
// 'gevel' (gevellampen), direction: 'both' | 'down' | 'up'.
|
||||
//
|
||||
// Gevellampen worden per GEVEL verdeeld, niet per wand: wandstukken in
|
||||
// hetzelfde gevelvlak die op elkaar aansluiten (ook over een naad tussen
|
||||
// units) vormen samen één gevel van hoek tot hoek. De balklogica per wand
|
||||
// blijft ongewijzigd; alleen de lampen lopen uniform door.
|
||||
// Op lamphoogte (standaard 1950 mm) worden openingen incl. raamwerk
|
||||
// (koningsstijl/dorpels) van de gevel afgetrokken. Op elk vrij stuk van
|
||||
// minstens minWallWidth (2000 mm) komen lampen: de eerste en laatste op
|
||||
// endOffset (300 mm) van het uiteinde (hoek of opening), daartussen
|
||||
// verdeeld met min/max h.o.h. (zelfde verdeling als stijlen). Smallere vrije
|
||||
// stukken krijgen geen lampen. Gevellampen en boeideel-spots sluiten elkaar
|
||||
// uit: bij 'gevel' komen er nooit spots.
|
||||
// Per wand(zijde) blijft een harde override mogelijk
|
||||
// (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op
|
||||
// openingOffset = 500 mm naast elke opening, bijv. slaapkamers) of 'geen'.
|
||||
//
|
||||
// Plain script, geladen na core/geometry.js en roof-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.LightingModel = (() => {
|
||||
const DEFAULT_SETTINGS = {
|
||||
mode: 'spots', // 'geen' | 'spots' | 'gevel'
|
||||
direction: 'both', // gevellampen: 'both' | 'down' | 'up'
|
||||
lampHeight: 1950, // mm boven vloer
|
||||
lampMin: 1500, // mm h.o.h. gevellampen
|
||||
lampMax: 3000,
|
||||
minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen
|
||||
endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde
|
||||
openingOffset: 500 // override: lampen zoveel naast elke opening
|
||||
};
|
||||
const MODES = { geen: 'Geen', spots: 'Boeideel-spots', gevel: 'Gevellampen' };
|
||||
const DIRECTIONS = { both: 'Boven én onder', down: 'Alleen onder', up: 'Alleen boven' };
|
||||
const WALL_OVERRIDES = { auto: 'Automatisch', openingen: 'Naast openingen (override)', geen: 'Geen lampen' };
|
||||
|
||||
// Per unit alleen nog de override per wandzijde.
|
||||
function createDefaultUnitLighting() {
|
||||
return { walls: {} };
|
||||
}
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
return Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
}
|
||||
|
||||
// Spots op een lijnstuk A -> B: op beide uiteinden + daartussen verdeeld.
|
||||
function spotsBetween(A, B, min, max, out) {
|
||||
const len = Math.hypot(B.x - A.x, B.z - A.z);
|
||||
out.push({ x: A.x, z: A.z }, { x: B.x, z: B.z });
|
||||
if (len < 1) return;
|
||||
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
|
||||
}
|
||||
|
||||
// Lampen die dichter dan `min` bij elkaar staan worden samengevoegd tot één
|
||||
// lamp in het midden, tot alle onderlinge afstanden >= min zijn.
|
||||
function enforceMinSpacing(positions, min) {
|
||||
let pts = positions.slice().sort((a, b) => a - b);
|
||||
let changed = true;
|
||||
while (changed && pts.length > 1) {
|
||||
changed = false;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
if (pts[i + 1] - pts[i] < min - 0.5) {
|
||||
pts.splice(i, 2, (pts[i] + pts[i + 1]) / 2);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// Aftrekken van intervallen: [lo, hi] min blocked -> vrije intervallen.
|
||||
function freeIntervals(lo, hi, blocked) {
|
||||
const free = [];
|
||||
let cursor = lo;
|
||||
blocked.slice().sort((p, q) => p[0] - q[0]).forEach(([a, b]) => {
|
||||
if (a > cursor) free.push([cursor, Math.min(a, hi)]);
|
||||
cursor = Math.max(cursor, b);
|
||||
});
|
||||
if (cursor < hi) free.push([cursor, hi]);
|
||||
return free.filter(([a, b]) => b - a > 1);
|
||||
}
|
||||
|
||||
// Alles voor de hele woning. units = HouseModel-parts.units (met
|
||||
// walls[].pieces[].config/place), stateUnits = state.units (overrides per
|
||||
// wand), edge = RoofModel.computeEdge-resultaat (edge.spots wordt hier
|
||||
// gevuld). Zet per wandstuk p.lamps = [{ u, y }] (voor editor/PDF).
|
||||
// Geeft { lamps: [{ x, y, z, normal, direction, element, u, yLocal }] }.
|
||||
function compute(settings, roofSettings, units, stateUnits, edge) {
|
||||
const s = normalizeSettings(settings);
|
||||
const overrides = {};
|
||||
stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; });
|
||||
|
||||
// 1. Buiten-wandstukken als lijnstukken in hun gevelvlak (wereld-mm).
|
||||
const segs = [];
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
p.lamps = [];
|
||||
const W = p.place.width;
|
||||
// Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding.
|
||||
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal);
|
||||
const normal = { x: p.place.normal.x, z: p.place.normal.y }; // place.normal is {x, y} (plattegrond)
|
||||
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z';
|
||||
const perp = axis === 'x' ? 'z' : 'x';
|
||||
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
|
||||
segs.push({
|
||||
piece: p, normal, axis, perp, line: q0[perp],
|
||||
sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]),
|
||||
override: overrides[u.id][w.side] || 'auto'
|
||||
});
|
||||
})));
|
||||
const uToS = (sg, u) => sg.sAt0 + (sg.sAtW - sg.sAt0) * (u / sg.piece.place.width);
|
||||
const sToU = (sg, v) => (v - sg.sAt0) / (sg.sAtW - sg.sAt0) * sg.piece.place.width;
|
||||
|
||||
// 2. Gevels: zelfde normaal + zelfde gevelvlak + aansluitend.
|
||||
const runs = groupFacadeRuns(segs); // core/geometry.js
|
||||
|
||||
// 3. Lampen per gevel.
|
||||
const lamps = [];
|
||||
const H = s.lampHeight;
|
||||
const addLamp = (run, v) => {
|
||||
const sg = run.segs.find(x => v >= x.s0 - 0.5 && v <= x.s1 + 0.5);
|
||||
if (!sg) return;
|
||||
const u = sToU(sg, v);
|
||||
const pt = run.axis === 'x' ? { x: v, z: run.line } : { x: run.line, z: v };
|
||||
sg.piece.lamps.push({ u, y: H });
|
||||
lamps.push({ x: pt.x, z: pt.z, y: sg.piece.place.floorTop + H, normal: run.normal, direction: s.direction, element: sg.piece.element, u, yLocal: H });
|
||||
};
|
||||
if (s.mode === 'gevel') {
|
||||
runs.forEach(run => {
|
||||
const blocked = [];
|
||||
const pos = []; // lampposities langs deze gevel (wereld-mm)
|
||||
run.segs.forEach(sg => {
|
||||
const cfg = sg.piece.config, sW = cfg.studWidth;
|
||||
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
|
||||
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
|
||||
if (sg.override === 'openingen') {
|
||||
// Harde override: lampen naast elke opening, dit stuk doet verder niet mee.
|
||||
blocked.push([sg.s0, sg.s1]);
|
||||
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
|
||||
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
|
||||
return;
|
||||
}
|
||||
cfg.openings.filter(op => op.y - sW <= H && op.y + op.h + sW >= H).forEach(op => {
|
||||
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
|
||||
blocked.push([Math.min(a, b), Math.max(a, b)]);
|
||||
});
|
||||
});
|
||||
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
|
||||
if (b - a < s.minWallWidth) return; // te smal: geen lampen
|
||||
const first = a + s.endOffset, last = b - s.endOffset;
|
||||
pos.push(first);
|
||||
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
|
||||
pos.push(last);
|
||||
});
|
||||
// Min. h.o.h. geldt over ALLE lampen van de gevel samen (ook bij
|
||||
// afstand hoek/opening, overrides en de grens tussen wanden).
|
||||
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
|
||||
});
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
|
||||
}
|
||||
|
||||
// 4. Boeideel-spots per dakrand-ribbe (alleen bij type 'spots').
|
||||
if (edge) {
|
||||
const spots = [];
|
||||
if (s.mode === 'spots') {
|
||||
const rs = RoofModel.normalizeSettings(roofSettings);
|
||||
edge.segments.filter(sg => sg.fascia).forEach(sg => spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots));
|
||||
}
|
||||
edge.spots = spots.filter((p, i) => spots.findIndex(q => Math.hypot(q.x - p.x, q.z - p.z) < 5) === i);
|
||||
}
|
||||
return { lamps };
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }),
|
||||
glowMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 1 }),
|
||||
beamMaterial: new THREE.MeshBasicMaterial({ color: 0xffe9a8, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide })
|
||||
};
|
||||
}
|
||||
|
||||
// 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag.
|
||||
function build3D(lamps, materials) {
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
const w = 0.08, h = 0.16, d = 0.07, beamH = 0.7, beamR = 0.22;
|
||||
lamps.forEach(l => {
|
||||
const cx = l.x * toM + l.normal.x * d / 2, cz = l.z * toM + l.normal.z * d / 2, cy = l.y * toM;
|
||||
const alongX = Math.abs(l.normal.z) > 0.5;
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w : d, h, alongX ? d : w), materials.housingMaterial);
|
||||
box.position.set(cx, cy, cz);
|
||||
group.add(box);
|
||||
const addGlow = (sign) => {
|
||||
const g = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w * 0.8 : d * 0.8, 0.006, alongX ? d * 0.8 : w * 0.8), materials.glowMaterial);
|
||||
g.position.set(cx, cy + sign * h / 2, cz);
|
||||
group.add(g);
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial);
|
||||
cone.userData.beam = true; // verborgen in de volle render (dan schijnt echt licht)
|
||||
if (sign > 0) cone.rotation.x = Math.PI; // punt bij de lamp, breed naar boven
|
||||
cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz);
|
||||
group.add(cone);
|
||||
};
|
||||
if (l.direction !== 'up') addGlow(-1);
|
||||
if (l.direction !== 'down') addGlow(1);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
// Volle render: elke gevellamp (per lichtrichting) en elke boeideel-spot
|
||||
// wordt een echte SpotLight. Gevellampen staan net buiten het gevelvlak en
|
||||
// schijnen langs de gevel omhoog/omlaag; spots schijnen recht naar beneden.
|
||||
// spots = [{ x, z }] (mm), spotY = hoogte onderkant boeideel (mm).
|
||||
function buildLights3D(lamps, spots, spotY) {
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
const add = (x, y, z, dirY, intensity, angle, distance) => {
|
||||
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, 1.5);
|
||||
light.position.set(x, y, z);
|
||||
light.target.position.set(x, y + dirY * 2, z);
|
||||
group.add(light);
|
||||
group.add(light.target);
|
||||
};
|
||||
lamps.forEach(l => {
|
||||
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
||||
if (l.direction !== 'up') add(x, y - 0.09, z, -1, 3, 0.55, 4.5);
|
||||
if (l.direction !== 'down') add(x, y + 0.09, z, 1, 3, 0.55, 4.5);
|
||||
});
|
||||
(spots || []).forEach(p => add(p.x * toM, spotY * toM - 0.02, p.z * toM, -1, 2.5, 0.6, 5.5));
|
||||
return group;
|
||||
}
|
||||
|
||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, normalizeSettings, compute, createMaterials, build3D, buildLights3D };
|
||||
})();
|
||||
@@ -0,0 +1,234 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica (voorlopig
|
||||
// alleen plat dak). Geen DOM.
|
||||
//
|
||||
// Een plat dak is opgebouwd als een vloer (FloorModel: balken, isolatie,
|
||||
// doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de
|
||||
// onderzijde (plafond), de balken erboven, met dakleer als buitenlaag
|
||||
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
|
||||
//
|
||||
// Dakrand (over de hele woning, doorlopend over naden tussen units): de
|
||||
// omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek).
|
||||
// Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of
|
||||
// een daktrim is (boeidelen aan/uit per unit). De ribben sluiten in verstek
|
||||
// op elkaar aan, dus op een naad tussen units is niets te zien.
|
||||
// - boeidelen: een doos van 200..400 mm overstek (gemeten vanaf de
|
||||
// dakrand = standaard de buitenkant gevel), standaard 300 mm hoog.
|
||||
// Spots in de onderplaat staan op 40% van de overstek (spotLine); welke
|
||||
// spots er komen bepaalt LightingModel.
|
||||
// - zonder boeidelen: een daktrim van 80 mm hoog.
|
||||
//
|
||||
// Plain script, geladen na floor-model.js en core/geometry.js.
|
||||
// ============================================================
|
||||
|
||||
window.RoofModel = (() => {
|
||||
const DAKLEER_MM = 2; // visuele dikte van het dakleer
|
||||
const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2
|
||||
const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand
|
||||
const TRIM_ABOVE_MM = 30; // daktrim steekt zoveel boven het dakleer uit
|
||||
const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel
|
||||
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
|
||||
|
||||
// Woning-brede maten van de dakrand (aan/uit per unit: roof.fascia).
|
||||
const DEFAULT_SETTINGS = {
|
||||
overhang: 300, // mm, 200..400
|
||||
fasciaHeight: 300, // mm
|
||||
spotMin: 800, // mm h.o.h. boeideel-spots
|
||||
spotMax: 1200,
|
||||
trimHeight: 80 // mm
|
||||
};
|
||||
|
||||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
// Dak-state per unit: volgt standaard de vloermaat (followFloor), maar mag
|
||||
// groter/kleiner en verschoven worden (override).
|
||||
function createDefaultRoof(floorState) {
|
||||
const deck = clone(floorState);
|
||||
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
|
||||
deck.openings = [];
|
||||
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, deck };
|
||||
}
|
||||
|
||||
function thickness(calcData) {
|
||||
return calcData.joistH + calcData.underlayThick + DAKLEER_MM;
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return Object.assign(FloorModel.createMaterials(), {
|
||||
dakleerMaterial: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, side: THREE.DoubleSide }),
|
||||
fasciaMaterial: new THREE.MeshStandardMaterial({ color: 0x3a3f44, roughness: 0.6, side: THREE.DoubleSide }),
|
||||
trimMaterial: new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide }),
|
||||
spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 })
|
||||
});
|
||||
}
|
||||
|
||||
// 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant
|
||||
// plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld
|
||||
// (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna
|
||||
// komt het dakleer er bovenop.
|
||||
function build3D(calcData, resolvedOpenings, opts) {
|
||||
const m = opts.materials;
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
|
||||
materials: m, osbVisible: opts.osbVisible !== false, isolatieVisible: opts.isolatieVisible !== false
|
||||
});
|
||||
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
|
||||
deck.scale.y = -1;
|
||||
deck.position.y = deckH;
|
||||
group.add(deck);
|
||||
|
||||
// Dakleer: vlak over het hele dak, met dezelfde ronde uitsparingen als
|
||||
// de doorvoeren (zelfde opbouw als de OSB-plaat in FloorModel).
|
||||
const wM = calcData.width * toM, dM = calcData.depth * toM;
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-wM / 2, -dM / 2);
|
||||
shape.lineTo(wM / 2, -dM / 2);
|
||||
shape.lineTo(wM / 2, dM / 2);
|
||||
shape.lineTo(-wM / 2, dM / 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 - dM / 2, o.r * toM, 0, Math.PI * 2, false);
|
||||
shape.holes.push(hole);
|
||||
});
|
||||
const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: DAKLEER_MM * toM, bevelEnabled: false }), m.dakleerMaterial);
|
||||
leer.rotation.x = Math.PI / 2;
|
||||
leer.position.y = deckH + DAKLEER_MM * toM;
|
||||
leer.visible = opts.dakleerVisible !== false;
|
||||
group.add(leer);
|
||||
return group;
|
||||
}
|
||||
|
||||
// Specificaties: die van het dek (FloorModel) + dakleer.
|
||||
function computeSpecs(calcData, resolvedOpenings) {
|
||||
const deck = FloorModel.computeSpecs(calcData, resolvedOpenings);
|
||||
const dakleerM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren
|
||||
const dakleerKg = dakleerM2 * DENSITY_DAKLEER_KGM2;
|
||||
return Object.assign({}, deck, { dakleerM2, dakleerKg, totalKg: deck.totalKg + dakleerKg });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Dakrand over de hele woning
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Splits de ribben van de omtrek op elke hoek van een dak-rechthoek die op
|
||||
// die ribbe ligt ("hoek van een unit"), zodat spots daar ook beginnen/eindigen.
|
||||
function splitAtCorners(pts, rects) {
|
||||
const corners = [];
|
||||
rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }));
|
||||
const out = [];
|
||||
pts.forEach((a, k) => {
|
||||
const b = pts[(k + 1) % pts.length];
|
||||
out.push(a);
|
||||
const len = Math.hypot(b.x - a.x, b.z - a.z);
|
||||
corners
|
||||
.map(c => ({ c, t: ((c.x - a.x) * (b.x - a.x) + (c.z - a.z) * (b.z - a.z)) / (len * len) }))
|
||||
.filter(({ c, t }) => t > 1e-6 && t < 1 - 1e-6 &&
|
||||
Math.abs((c.x - a.x) * (b.z - a.z) - (c.z - a.z) * (b.x - a.x)) / len < 0.5)
|
||||
.sort((p, q) => p.t - q.t)
|
||||
.forEach(({ c }) => { if (!out.some(o => Math.abs(o.x - c.x) < 0.5 && Math.abs(o.z - c.z) < 0.5)) out.push({ x: c.x, z: c.z }); });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function onRectEdge(r, p) {
|
||||
const e = 0.5;
|
||||
const inX = p.x >= r.x0 - e && p.x <= r.x1 + e, inZ = p.z >= r.z0 - e && p.z <= r.z1 + e;
|
||||
return ((Math.abs(p.x - r.x0) < e || Math.abs(p.x - r.x1) < e) && inZ) ||
|
||||
((Math.abs(p.z - r.z0) < e || Math.abs(p.z - r.z1) < e) && inX);
|
||||
}
|
||||
function quadArea(pts) {
|
||||
return Math.abs(pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2);
|
||||
}
|
||||
|
||||
// roofs: [{ unitId, rect: { x0, x1, z0, z1 }, fascia: bool }] (mm, woning-
|
||||
// assen); topY = bovenkant dakleer (mm). Geeft per ribbe (segments) de
|
||||
// geometrie terug; edge.spots wordt door LightingModel gevuld.
|
||||
function computeEdge(settings, roofs, topY) {
|
||||
const s = normalizeSettings(settings);
|
||||
const rects = roofs.map(r => r.rect);
|
||||
const loops = rectUnionLoops(rects);
|
||||
if (!loops.length) return null;
|
||||
const base = splitAtCorners(loops[0].pts, rects);
|
||||
const offBoei = offsetRectilinearLoop(base, s.overhang);
|
||||
const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM);
|
||||
const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO);
|
||||
const n = base.length;
|
||||
|
||||
const segments = base.map((a, i) => {
|
||||
const j = (i + 1) % n, b = base[j];
|
||||
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
|
||||
const owner = roofs.find(r => onRectEdge(r.rect, mid)) || roofs[0];
|
||||
const fascia = owner.fascia !== false;
|
||||
const off = fascia ? offBoei : offTrim;
|
||||
return {
|
||||
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
|
||||
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j]
|
||||
};
|
||||
});
|
||||
|
||||
const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
|
||||
return {
|
||||
base, segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
|
||||
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight,
|
||||
top: topY, trimTop: topY + TRIM_ABOVE_MM,
|
||||
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
|
||||
trimLengthMM: segments.filter(sg => !sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
|
||||
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
|
||||
spots: []
|
||||
};
|
||||
}
|
||||
|
||||
// 3D van de dakrand: per ribbe een geëxtrudeerd vierhoek (dakrand ->
|
||||
// buitenrand, in verstek), naar beneden over de hoogte van boeideel resp.
|
||||
// daktrim. Plus de boeideel-spots (edge.spots) in de onderplaat.
|
||||
// opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") }
|
||||
function buildEdge3D(edge, materials, opts) {
|
||||
const o = opts || {};
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
if (!edge) return group;
|
||||
edge.segments.forEach(sg => {
|
||||
const pts = [sg.a, sg.b, sg.outerB, sg.outerA];
|
||||
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM)));
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight;
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.ExtrudeGeometry(shape, { steps: 1, depth: h * toM, bevelEnabled: false }),
|
||||
sg.fascia ? materials.fasciaMaterial : materials.trimMaterial
|
||||
);
|
||||
mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden
|
||||
mesh.position.y = (sg.fascia ? edge.top : edge.trimTop) * toM;
|
||||
mesh.visible = o.edgeVisible !== false;
|
||||
group.add(mesh);
|
||||
});
|
||||
|
||||
const spotR = 0.035, spotH = 0.012;
|
||||
edge.spots.forEach(p => {
|
||||
const spot = new THREE.Mesh(new THREE.CylinderGeometry(spotR, spotR, spotH, 16), materials.spotOnMaterial);
|
||||
spot.position.set(p.x * toM, (edge.top - edge.fasciaHeight) * toM - spotH / 2 + 0.002, p.z * toM);
|
||||
spot.visible = o.spotsVisible !== false;
|
||||
group.add(spot);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
|
||||
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
|
||||
return s;
|
||||
}
|
||||
|
||||
return {
|
||||
DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO,
|
||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||
computeEdge, buildEdge3D, normalizeSettings
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// sandwich/sandwich-model.js — sandwichpanelen rondom de woning (SandwichModel).
|
||||
// De vloer staat gedeeltelijk in de grond; de panelen werken de vloerrand af.
|
||||
// Ze beginnen op Y=0 van de vloer (het beloopbare vlak = bovenkant vloer) en
|
||||
// lopen DEPTH_MM naar beneden, tegen de buitenkant van de vloerrand, in één
|
||||
// doorlopende rand (in verstek) om de vereniging van alle unit-vloeren.
|
||||
// Plain script (geen ES-module), geladen na core/geometry.js.
|
||||
// ============================================================
|
||||
|
||||
window.SandwichModel = (() => {
|
||||
const DEPTH_MM = 600; // vanaf het loopvlak naar beneden (de grond in)
|
||||
const THICK_MM = 40; // paneeldikte
|
||||
// Handelsmaat plaat: 495 breed x 1000 lang x 40 dik, 9 kg per plaat.
|
||||
// Verticaal geplaatst (495 = werkende breedte langs de gevel), lengte
|
||||
// 1000 op DEPTH_MM afgezaagd: één plaat per 495 mm gevel.
|
||||
const PANEL_WIDTH_MM = 495;
|
||||
const PANEL_LENGTH_MM = 1000;
|
||||
const PANEL_KG = 9.0;
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
panelMaterial: new THREE.MeshStandardMaterial({ color: 0x3b3e42, roughness: 0.55, metalness: 0.2 }),
|
||||
seamMaterial: new THREE.LineBasicMaterial({ color: 0x24262a })
|
||||
};
|
||||
}
|
||||
|
||||
// rects: unit-vloeren { x0, x1, z0, z1 } (mm, woning-assen); topY =
|
||||
// loopvlak (mm). Per ribbe van elke omtreklus een vierhoek (vloerrand ->
|
||||
// buitenkant paneel, in verstek, ook bij binnenhoeken).
|
||||
function compute(rects, topY) {
|
||||
if (!rects.length) return null;
|
||||
const segments = [];
|
||||
rectUnionLoops(rects).forEach(loop => {
|
||||
const base = loop.pts, off = offsetRectilinearLoop(base, THICK_MM), n = base.length;
|
||||
base.forEach((a, i) => {
|
||||
const j = (i + 1) % n;
|
||||
segments.push({ a, b: base[j], outerA: off[i], outerB: off[j] });
|
||||
});
|
||||
});
|
||||
// Lengte gemeten over de hartlijn van het paneel.
|
||||
const lengthMM = segments.reduce((t, sg) => t + (Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z) + Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z)) / 2, 0);
|
||||
const areaM2 = lengthMM * DEPTH_MM / 1e6;
|
||||
const panelCount = segments.reduce((t, sg) => t + Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / PANEL_WIDTH_MM - 1e-9), 0);
|
||||
return {
|
||||
segments, top: topY, bottom: topY - DEPTH_MM, depth: DEPTH_MM, thick: THICK_MM,
|
||||
lengthMM, areaM2, panelCount, kg: panelCount * PANEL_KG, // inkoop: hele platen
|
||||
wasteMM: PANEL_LENGTH_MM - DEPTH_MM // afzaagstuk per plaat
|
||||
};
|
||||
}
|
||||
|
||||
// opts: { visible (volgt "Beplating") }
|
||||
function build3D(data, materials, opts) {
|
||||
const o = opts || {};
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
if (!data) return group;
|
||||
data.segments.forEach(sg => {
|
||||
const pts = [sg.a, sg.b, sg.outerB, sg.outerA];
|
||||
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM)));
|
||||
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: data.depth * toM, bevelEnabled: false });
|
||||
const mesh = new THREE.Mesh(geo, materials.panelMaterial);
|
||||
mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden
|
||||
mesh.position.y = data.top * toM;
|
||||
group.add(mesh);
|
||||
|
||||
// Paneelnaden op de buitenkant, elke PANEL_WIDTH_MM vanaf ribbe-start.
|
||||
const len = Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
|
||||
const pts3 = [];
|
||||
for (let d = PANEL_WIDTH_MM; d < len - 1; d += PANEL_WIDTH_MM) {
|
||||
const t = d / len, eps = 0.5; // net buiten het paneelvlak
|
||||
const nx = sg.outerA.x - sg.a.x, nz = sg.outerA.z - sg.a.z, nl = Math.hypot(nx, nz) || 1;
|
||||
const x = (sg.outerA.x + (sg.outerB.x - sg.outerA.x) * t + nx / nl * eps) * toM;
|
||||
const z = (sg.outerA.z + (sg.outerB.z - sg.outerA.z) * t + nz / nl * eps) * toM;
|
||||
pts3.push(new THREE.Vector3(x, data.top * toM, z), new THREE.Vector3(x, data.bottom * toM, z));
|
||||
}
|
||||
if (pts3.length) group.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts3), materials.seamMaterial));
|
||||
});
|
||||
group.visible = o.visible !== false;
|
||||
return group;
|
||||
}
|
||||
|
||||
return { DEPTH_MM, THICK_MM, PANEL_WIDTH_MM, PANEL_LENGTH_MM, PANEL_KG, createMaterials, compute, build3D };
|
||||
})();
|
||||
@@ -0,0 +1,214 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// unit/unit-model.js — UnitModel: ENIGE bron van de unit-logica (1 vloer +
|
||||
// 4 wanden). Geen DOM. Bevat zelf geen wand-/vloerlogica: alles gaat via
|
||||
// WallModel/FloorModel. Gebruikt door de woning (house/*).
|
||||
//
|
||||
// Een unit-state is:
|
||||
// { layout: { corners }, floor: <vloer-state>, walls: { voor, achter, links, rechts: <wand-state> } }
|
||||
//
|
||||
// Assenstelsel (mm, unit-lokaal): X = breedte, Z = diepte, vloermidden op
|
||||
// (0, 0), "voor" op Z = -diepte/2 (zelfde als FloorModel.build3D).
|
||||
//
|
||||
// Naden: wanneer een andere unit tegen een zijde aan staat, vervalt de wand
|
||||
// over dat stuk (cuts: [a, b, extA, extB], zie HouseModel.computeSeams —
|
||||
// extA/extB laten het aangrenzende wandstuk de naad in doorlopen om een
|
||||
// binnenhoek te sluiten). Een wand kan daardoor in meerdere stukken uiteenvallen
|
||||
// (bijv. de achterwand bij een L-vorm); elk stuk is een volwaardige wand
|
||||
// (eigen regelwerk, hoekstijlen, PDF-blad). Een wand die haaks op een
|
||||
// vervallen hoek staat, loopt door tot de vloerrand, zodat de buitengevel
|
||||
// over de naad heen doorloopt.
|
||||
//
|
||||
// Plain script, geladen na wall-model.js en floor-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.UnitModel = (() => {
|
||||
// Elke wand staat met zijn buitenzijde (+Z lokaal, "zuid" in de wand-
|
||||
// editor) naar buiten. rotY draait lokaal +Z naar de buitenrichting; "links"
|
||||
// in de wand-editor = links gezien van buitenaf. axis = de as waarlangs de
|
||||
// wand loopt; dir = +1 als lokaal +X met die as meeloopt.
|
||||
// normal = buitenrichting in de plattegrond (x rechts, y omlaag; voor boven).
|
||||
// Weergave in windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord):
|
||||
// voor = noord, achter = zuid, links = west, rechts = oost. De sleutels
|
||||
// (voor/achter/links/rechts) blijven intern, voor compatibele JSON.
|
||||
const WALL_SIDES = [
|
||||
{ key: 'voor', label: 'Wand noord', nr: 1, rotY: Math.PI, axis: 'x', dir: -1, normal: { x: 0, y: -1 } },
|
||||
{ key: 'achter', label: 'Wand zuid', nr: 2, rotY: 0, axis: 'x', dir: 1, normal: { x: 0, y: 1 } },
|
||||
{ key: 'links', label: 'Wand west', nr: 3, rotY: -Math.PI / 2, axis: 'z', dir: 1, normal: { x: -1, y: 0 } },
|
||||
{ key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } }
|
||||
];
|
||||
|
||||
// Hoekaansluiting: welke wanden lopen door over de volle vloermaat, en welke
|
||||
// passen er tussenin.
|
||||
const CORNER_MODES = {
|
||||
'voor-achter': 'Noord/zuid doorlopend',
|
||||
'zijkanten': 'West/oost doorlopend'
|
||||
};
|
||||
|
||||
const DEFAULT_HEIGHT_MM = 2600;
|
||||
|
||||
// Kortere wandstukken (restje naast een naad) worden weggelaten.
|
||||
const MIN_PIECE_MM = 300;
|
||||
const EPS = 0.5; // mm
|
||||
|
||||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function createDefaultState(opts) {
|
||||
const o = opts || {};
|
||||
const floor = FloorEditor.getDefaultState();
|
||||
floor.floor.width = o.width || 6000;
|
||||
floor.floor.depth = o.depth || 3000;
|
||||
const walls = {};
|
||||
WALL_SIDES.forEach(s => {
|
||||
const wall = WallEditor.getDefaultState();
|
||||
// Standaard unithoogte (los van de standaard van een losse wand).
|
||||
const h = o.height || DEFAULT_HEIGHT_MM;
|
||||
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
|
||||
if (!(o.windowOn || ['voor']).includes(s.key)) wall.openings = [];
|
||||
walls[s.key] = wall;
|
||||
});
|
||||
const state = { layout: { corners: 'voor-achter' }, floor, walls };
|
||||
computeLayout(state, {});
|
||||
return state;
|
||||
}
|
||||
|
||||
// Lokaal wand-punt (mm, x langs de wand gecentreerd, z dwars) -> X/Z in het
|
||||
// omliggende stelsel. Zelfde rotatie als THREE's rotation.y.
|
||||
function localToWorld(w, lx, lz) {
|
||||
const c = Math.cos(w.rotY), s = Math.sin(w.rotY);
|
||||
return { x: w.cx + lx * c + lz * s, z: w.cz - lx * s + lz * c };
|
||||
}
|
||||
|
||||
// Maat + positie van elke wand t.o.v. het vloermidden, rekening houdend met
|
||||
// naden. cuts = { voor: [[a, b], ...], ... } in unit-lokale as-coördinaten
|
||||
// (X voor voor/achter, Z voor links/rechts). Zet ook de afgeleide
|
||||
// wandbreedte in de wand-states (muteert state).
|
||||
function computeLayout(state, cuts) {
|
||||
const c = cuts || {};
|
||||
const floorFrame = FloorModel.computeFrame(state.floor.floor);
|
||||
const W = floorFrame.width, D = floorFrame.depth;
|
||||
|
||||
const T = {};
|
||||
WALL_SIDES.forEach(s => { T[s.key] = WallModel.thicknessOf(WallModel.configFromState(state.walls[s.key])); });
|
||||
|
||||
// Vervalt de wand van `side` op as-positie p (bijv. een hoek)?
|
||||
const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS);
|
||||
// Effectieve dikte van de doorlopende wand op een hoek: 0 als die daar vervalt.
|
||||
const tEff = (side, p) => cutAt(side, p) ? 0 : T[side];
|
||||
|
||||
let span; // per zijde [s0, s1] langs de eigen as
|
||||
if (state.layout.corners === 'zijkanten') {
|
||||
span = {
|
||||
links: [-D / 2, D / 2],
|
||||
rechts: [-D / 2, D / 2],
|
||||
voor: [-W / 2 + tEff('links', -D / 2), W / 2 - tEff('rechts', -D / 2)],
|
||||
achter: [-W / 2 + tEff('links', D / 2), W / 2 - tEff('rechts', D / 2)]
|
||||
};
|
||||
} else {
|
||||
span = {
|
||||
voor: [-W / 2, W / 2],
|
||||
achter: [-W / 2, W / 2],
|
||||
links: [-D / 2 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)],
|
||||
rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)]
|
||||
};
|
||||
}
|
||||
|
||||
const walls = WALL_SIDES.map(s => {
|
||||
const [s0, s1] = span[s.key];
|
||||
const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0));
|
||||
const mid = (s0 + s1) / 2;
|
||||
const off = { voor: -D / 2 + T.voor / 2, achter: D / 2 - T.achter / 2, links: -W / 2 + T.links / 2, rechts: W / 2 - T.rechts / 2 }[s.key];
|
||||
const cx = s.axis === 'x' ? mid : off;
|
||||
const cz = s.axis === 'x' ? off : mid;
|
||||
|
||||
// Naden omgerekend naar "vanaf links gezien van buitenaf" (u, 0..width),
|
||||
// incl. doorloop (ext) van het wandstuk vóór resp. ná de naad.
|
||||
const toU = (p) => s.dir > 0 ? p - s0 : s1 - p;
|
||||
const rawU = (c[s.key] || [])
|
||||
.map(([a, b, ea, eb]) => {
|
||||
const ua = toU(a), ub = toU(b);
|
||||
const [u0, u1, e0, e1] = ua <= ub ? [ua, ub, ea || 0, eb || 0] : [ub, ua, eb || 0, ea || 0];
|
||||
return [Math.max(0, u0), Math.min(width, u1), e0, e1];
|
||||
})
|
||||
.filter(([a, b]) => b - a > EPS)
|
||||
.sort((p, q) => p[0] - q[0]);
|
||||
|
||||
// Resterende stukken wand; een stuk dat aan een naad grenst loopt de
|
||||
// naad in door met de doorloop (ext) van die kant.
|
||||
const pieces = [];
|
||||
let cursor = 0;
|
||||
rawU.forEach(([a, b, e0, e1]) => {
|
||||
const end = Math.min(b, a + e0);
|
||||
if (end - cursor >= MIN_PIECE_MM) pieces.push([cursor, end]);
|
||||
cursor = Math.max(cursor, b - e1);
|
||||
});
|
||||
if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width]);
|
||||
// Werkelijk vervallende zones (voor de weergave in de wand-editor).
|
||||
const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS);
|
||||
|
||||
state.walls[s.key].wall.width = width;
|
||||
// Hoekdoorloop buitenlagen (WallModel.cornerExt): een tussenpassende
|
||||
// wand bedekt ook de kopse kant van de doorlopende wand ernaast.
|
||||
const full = s.axis === 'x' ? W / 2 : D / 2;
|
||||
const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1));
|
||||
const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 };
|
||||
return {
|
||||
side: s.key, label: s.label, nr: s.nr, rotY: s.rotY, normal: s.normal, axis: s.axis,
|
||||
thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt
|
||||
};
|
||||
});
|
||||
|
||||
return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls };
|
||||
}
|
||||
|
||||
// Wandstukken van één zijde als losse wand-configs. Openingen worden eerst
|
||||
// op de volle zijde opgelost (ankers gelden voor de hele zijde) en daarna
|
||||
// aan het stuk toegewezen waar ze volledig in vallen; openingen die in een
|
||||
// naad vallen vervallen (dropped).
|
||||
function wallPieces(wallLayout, wallState) {
|
||||
const fullConfig = WallModel.configFromState(wallState);
|
||||
const W = wallLayout.width;
|
||||
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
|
||||
const heightAt = (u) => hL + (u / W) * (hR - hL);
|
||||
const multi = wallLayout.pieces.length > 1;
|
||||
const used = new Set();
|
||||
|
||||
const pieces = wallLayout.pieces.map(([a, b], i) => {
|
||||
const openings = fullConfig.openings
|
||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
|
||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: op.x - a }; });
|
||||
const params = Object.assign({}, wallState.wall, {
|
||||
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
|
||||
});
|
||||
const config = WallModel.buildConfig(params, openings);
|
||||
return {
|
||||
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
|
||||
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2,
|
||||
skinExt: { left: a < EPS ? wallLayout.skinExt.left : 0, right: b > W - EPS ? wallLayout.skinExt.right : 0 },
|
||||
config, frame: WallModel.computeFrame(config)
|
||||
};
|
||||
});
|
||||
const dropped = fullConfig.openings.filter(op => !used.has(op.id));
|
||||
return { pieces, dropped, fullConfig };
|
||||
}
|
||||
|
||||
function validateState(design) {
|
||||
if (!design.floor) throw new Error('veld "floor" ontbreekt');
|
||||
try { FloorModel.validateState(design.floor); } catch (err) { throw new Error('vloer: ' + err.message); }
|
||||
if (!design.walls || typeof design.walls !== 'object') throw new Error('veld "walls" ontbreekt');
|
||||
WALL_SIDES.forEach(s => {
|
||||
if (!design.walls[s.key]) throw new Error(`walls.${s.key} ontbreekt`);
|
||||
try { WallModel.validateState(design.walls[s.key]); } catch (err) { throw new Error(`${s.label.toLowerCase()}: ${err.message}`); }
|
||||
});
|
||||
if (design.layout && !CORNER_MODES[design.layout.corners]) {
|
||||
throw new Error(`layout.corners moet ${Object.keys(CORNER_MODES).join(' of ')} zijn`);
|
||||
}
|
||||
}
|
||||
|
||||
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, validateState };
|
||||
})();
|
||||
Vendored
+131
@@ -0,0 +1,131 @@
|
||||
// ============================================================
|
||||
// js/vendor/OrbitControls.js — eigen, compacte vervanging van de officiële
|
||||
// three.js OrbitControls-addon. Geschreven omdat we geen netwerktoegang
|
||||
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
||||
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
||||
// `controls.target.set(...)`, `controls.update()`), plus interactie:
|
||||
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen.
|
||||
//
|
||||
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
||||
// ============================================================
|
||||
(function () {
|
||||
if (typeof THREE === 'undefined') {
|
||||
console.error('OrbitControls.js (eigen versie): THREE is niet geladen — controleer de scriptvolgorde.');
|
||||
return;
|
||||
}
|
||||
|
||||
function OrbitControls(camera, domElement) {
|
||||
const scope = this;
|
||||
this.object = camera;
|
||||
this.domElement = domElement || document;
|
||||
this.target = new THREE.Vector3();
|
||||
|
||||
this.minDistance = 0.1;
|
||||
this.maxDistance = 1000;
|
||||
this.minPolarAngle = 0.001;
|
||||
this.maxPolarAngle = Math.PI - 0.001;
|
||||
this.rotateSpeed = 1.0;
|
||||
this.zoomSpeed = 1.0;
|
||||
this.panSpeed = 1.0;
|
||||
this.enableRotate = true;
|
||||
this.enableZoom = true;
|
||||
this.enablePan = true;
|
||||
|
||||
const offset = new THREE.Vector3();
|
||||
const spherical = new THREE.Spherical();
|
||||
let isRotating = false;
|
||||
let isPanning = false;
|
||||
let lastX = 0, lastY = 0;
|
||||
|
||||
function getZoomScale() { return Math.pow(0.95, scope.zoomSpeed); }
|
||||
|
||||
this.update = function () {
|
||||
offset.copy(scope.object.position).sub(scope.target);
|
||||
spherical.setFromVector3(offset);
|
||||
offset.setFromSpherical(spherical);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
scope.object.lookAt(scope.target);
|
||||
return true;
|
||||
};
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (event.button === 2) {
|
||||
if (!scope.enablePan) return;
|
||||
isPanning = true;
|
||||
} else {
|
||||
if (!scope.enableRotate) return;
|
||||
isRotating = true;
|
||||
}
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (isRotating) {
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
const w = scope.domElement.clientWidth || 1;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
|
||||
offset.copy(scope.object.position).sub(scope.target);
|
||||
spherical.setFromVector3(offset);
|
||||
spherical.theta -= (dx / w) * Math.PI * scope.rotateSpeed;
|
||||
spherical.phi -= (dy / h) * Math.PI * scope.rotateSpeed;
|
||||
spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi));
|
||||
spherical.makeSafe();
|
||||
offset.setFromSpherical(spherical);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
scope.object.lookAt(scope.target);
|
||||
} else if (isPanning) {
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
const distance = scope.object.position.distanceTo(scope.target);
|
||||
const fov = (scope.object.fov || 50) * Math.PI / 180;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
const factor = 2 * Math.tan(fov / 2) * distance / h;
|
||||
|
||||
const te = scope.object.matrix.elements;
|
||||
const xAxis = new THREE.Vector3(te[0], te[1], te[2]).multiplyScalar(-dx * factor * scope.panSpeed);
|
||||
const yAxis = new THREE.Vector3(te[4], te[5], te[6]).multiplyScalar(dy * factor * scope.panSpeed);
|
||||
scope.target.add(xAxis).add(yAxis);
|
||||
scope.object.position.add(xAxis).add(yAxis);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() { isRotating = false; isPanning = false; }
|
||||
|
||||
function onWheel(event) {
|
||||
if (!scope.enableZoom) return;
|
||||
event.preventDefault();
|
||||
const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale();
|
||||
offset.copy(scope.object.position).sub(scope.target);
|
||||
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
|
||||
offset.setLength(distance);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
}
|
||||
|
||||
function onContextMenu(event) { event.preventDefault(); }
|
||||
|
||||
this.domElement.addEventListener('mousedown', onPointerDown);
|
||||
window.addEventListener('mousemove', onPointerMove);
|
||||
window.addEventListener('mouseup', onPointerUp);
|
||||
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||
this.domElement.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
this.dispose = function () {
|
||||
scope.domElement.removeEventListener('mousedown', onPointerDown);
|
||||
window.removeEventListener('mousemove', onPointerMove);
|
||||
window.removeEventListener('mouseup', onPointerUp);
|
||||
scope.domElement.removeEventListener('wheel', onWheel);
|
||||
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
||||
};
|
||||
}
|
||||
|
||||
THREE.OrbitControls = OrbitControls;
|
||||
})();
|
||||
Vendored
+373
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// wall/wall-pdf.js — ENIGE bron van het wand-tekenblad (A3 liggend):
|
||||
// frame-materiaallijst, plaatmateriaal, vooraanzicht met posnums en
|
||||
// maatketens, bovenaanzicht (doorsnede regelwerk + beplating), zijaanzicht
|
||||
// (laagopbouw) en titelblok. Gebruikt door de losse wand-editor (1 blad) en
|
||||
// door de unit (1 blad per wand in de gezamenlijke PDF).
|
||||
// Plain script, geladen na wall-model.js en core/pdf-sheet.js.
|
||||
// ============================================================
|
||||
|
||||
window.WallPdf = (() => {
|
||||
// Tekent één volledig wandblad op de HUIDIGE pagina van `doc`.
|
||||
// opts: { buitenMateriaal, cladding, trasraam, skinExt, project, titel, element, blad, lamps: [{ u, y }] }
|
||||
function drawSheet(doc, CONFIG, calcData, opts) {
|
||||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||||
const pieces = WallModel.computeBOM(CONFIG, calcData);
|
||||
const groups = WallModel.groupBOM(pieces);
|
||||
|
||||
const PAGE_W = doc.internal.pageSize.getWidth(), PAGE_H = doc.internal.pageSize.getHeight();
|
||||
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// --- Frame materiaal-tabel (linksboven) ---
|
||||
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;
|
||||
groups.forEach(g => {
|
||||
if (ty > 232) return; // laat ruimte vrij voor materiaaltabellen eronder
|
||||
[String(g.posnum), String(g.width), String(g.depth), String(g.length), String(g.count)]
|
||||
.forEach((v, i) => doc.text(v, tblX[i], ty));
|
||||
ty += 4.3;
|
||||
});
|
||||
|
||||
// --- Inkoop balken (handelslengtes, zaagplan uit WallModel) ---
|
||||
const purchase = WallModel.planPurchase(pieces);
|
||||
ty += 3;
|
||||
doc.setFontSize(9);
|
||||
doc.text('Inkoop balken', tblX[0], ty);
|
||||
ty += 5;
|
||||
doc.setFontSize(6.5);
|
||||
['Doorsnede', 'Lengte', 'Aantal', 'Verlies'].forEach((h, i) => doc.text(h, tblX[i], ty));
|
||||
ty += 4;
|
||||
purchase.forEach(sec => {
|
||||
sec.perStock.forEach(st => {
|
||||
if (ty > 232) return;
|
||||
const waste = sec.bars.filter(b => b.stock === st.stock).reduce((a, b) => a + b.waste, 0);
|
||||
[`${sec.width}x${sec.depth}`, String(st.stock), String(st.count), String(waste)].forEach((v, i) => doc.text(v, tblX[i], ty));
|
||||
ty += 4.3;
|
||||
});
|
||||
});
|
||||
|
||||
// --- Materiaal binnen/buiten + isolatie ---
|
||||
let my = 240;
|
||||
const midH = Math.round((CONFIG.wallHeightLeft + CONFIG.wallHeightRight) / 2);
|
||||
doc.setFontSize(8.5);
|
||||
doc.text('Materiaal binnen', tblX[0], my); my += 4.5;
|
||||
doc.setFontSize(6.5);
|
||||
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} Spaanplaat 1`, tblX[0], my); my += 6;
|
||||
doc.setFontSize(8.5);
|
||||
doc.text('Materiaal buiten', tblX[0], my); my += 4.5;
|
||||
doc.setFontSize(6.5);
|
||||
const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek';
|
||||
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} ${buitenNaam} 1`, tblX[0], my); my += 6;
|
||||
doc.text('Isolatie: glaswol (spouwvullend)', tblX[0], my);
|
||||
if (buitenMateriaal === 'vochtdoek') {
|
||||
// Kruislat op het vochtdoek (zelfde berekening als de 3D: WallModel.computeBattens).
|
||||
const bat = WallModel.computeBattens(CONFIG, opts.cladding || 'horizontaal', opts.trasraam, opts.skinExt, opts.facade);
|
||||
const B = WallModel.BATTEN;
|
||||
my += 4.5;
|
||||
doc.text(`Latwerk ${B.thick}x${B.width}: ${bat.stock.count} st. \u00e0 ${B.length} mm, kruislat ${B.spacing} h.o.h.`, tblX[0], my);
|
||||
my += 4;
|
||||
doc.text(`Bovenste lat ${bat.top}, gevelbekleding ${opts.cladding || 'horizontaal'} (WEO/Novalit)`, tblX[0], my);
|
||||
if (opts.trasraam) {
|
||||
my += 4;
|
||||
doc.text(`Trasraam: cement-gebonden plaat tot ${opts.trasraam.top} mm boven bovenkant vloer`, tblX[0], my);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Titelblok (rechtsonder) ---
|
||||
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
|
||||
|
||||
// --- Hoofdaanzicht ---
|
||||
// drawX1/marginBottom laten ruimte vrij voor het zijaanzicht (rechts)
|
||||
// en het bovenaanzicht (onder de maatketens).
|
||||
const drawX0 = 95, drawX1 = 380, drawY0 = 15, drawY1 = 225;
|
||||
const marginSides = 25, marginTop = 15, marginBottom = 68;
|
||||
const availW = (drawX1 - drawX0) - marginSides * 2;
|
||||
const availH = (drawY1 - drawY0) - marginTop - marginBottom;
|
||||
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
|
||||
const maxH = Math.max(hLeft, hRight);
|
||||
const scale = Math.min(availW / wallWidth, availH / maxH);
|
||||
|
||||
const originX = drawX0 + marginSides;
|
||||
const floorY = drawY1 - marginBottom;
|
||||
const px = (xmm) => originX + (xmm + wallWidth / 2) * scale;
|
||||
const py = (ymm) => floorY - ymm * scale;
|
||||
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.line(px(-wallWidth / 2), py(0), px(wallWidth / 2), py(0));
|
||||
doc.line(px(wallWidth / 2), py(0), px(wallWidth / 2), py(hRight));
|
||||
doc.line(px(wallWidth / 2), py(hRight), px(-wallWidth / 2), py(hLeft));
|
||||
doc.line(px(-wallWidth / 2), py(hLeft), px(-wallWidth / 2), py(0));
|
||||
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.3);
|
||||
pieces.forEach(p => {
|
||||
if (p.kind === 'bovenregel') return; // apart getekend hieronder, volgt de helling
|
||||
const isVertical = p.kind === 'verticale balk' || p.kind === 'kreupele stijl';
|
||||
if (isVertical) {
|
||||
// Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de
|
||||
// daklijn (yTopLeft/yTopRight kunnen verschillen), zodat zowel de
|
||||
// hoogste als de laagste kant van de balk op de juiste plek komt.
|
||||
const wx0 = p.x - p.width / 2, wx1 = p.x + p.width / 2;
|
||||
const corners = [
|
||||
[px(wx0), py(p.yBottomLeft)], [px(wx1), py(p.yBottomRight)],
|
||||
[px(wx1), py(p.yTopRight)], [px(wx0), py(p.yTopLeft)]
|
||||
];
|
||||
for (let i = 0; i < 4; i++) doc.line(corners[i][0], corners[i][1], corners[(i + 1) % 4][0], corners[(i + 1) % 4][1]);
|
||||
} else {
|
||||
const wx0 = p.x - p.length / 2, wx1 = p.x + p.length / 2;
|
||||
const wy0 = p.y - p.width / 2, wy1 = p.y + p.width / 2;
|
||||
doc.rect(px(wx0), py(wy1), px(wx1) - px(wx0), py(wy0) - py(wy1), 'S');
|
||||
}
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(5);
|
||||
doc.text(String(p.posnum), px(p.x), py(p.y), { align: 'center' });
|
||||
});
|
||||
|
||||
// Bovenregel: volgt de dakhelling, dus als schuin parallellogram
|
||||
// getekend (niet als vlakke rechthoek zoals de rest hierboven).
|
||||
const topPlatePiece = pieces.find(p => p.kind === 'bovenregel');
|
||||
if (topPlatePiece) {
|
||||
const slope = (hRight - hLeft) / wallWidth;
|
||||
const vOff = CONFIG.plateThick * Math.sqrt(1 + slope * slope);
|
||||
const tp = [
|
||||
[px(-wallWidth / 2), py(hLeft)], [px(wallWidth / 2), py(hRight)],
|
||||
[px(wallWidth / 2), py(hRight - vOff)], [px(-wallWidth / 2), py(hLeft - vOff)]
|
||||
];
|
||||
for (let i = 0; i < 4; i++) doc.line(tp[i][0], tp[i][1], tp[(i + 1) % 4][0], tp[(i + 1) % 4][1]);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(5);
|
||||
doc.text(String(topPlatePiece.posnum), px(topPlatePiece.x), py(topPlatePiece.y), { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setLineWidth(0.2);
|
||||
CONFIG.openings.forEach(op => {
|
||||
const opXWorld = -wallWidth / 2 + op.x;
|
||||
const x0 = px(opXWorld), y0 = py(op.y + op.h), x1 = px(opXWorld + op.w), y1 = py(op.y);
|
||||
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S');
|
||||
doc.line(x0, y0, x1, y1);
|
||||
doc.line(x0, y1, x1, y0);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(6);
|
||||
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant),
|
||||
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek
|
||||
// erboven.
|
||||
doc.text(`${op.w}x${op.h}`, (x0 + x1) / 2, y0 + 5, { 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(maxH) - 7;
|
||||
drawDimLine(px(-wallWidth / 2), dimTopY, px(wallWidth / 2), dimTopY);
|
||||
doc.text(String(Math.round(wallWidth)), (px(-wallWidth / 2) + px(wallWidth / 2)) / 2, dimTopY - 1.5, { align: 'center' });
|
||||
drawDimLine(px(-wallWidth / 2) - 7, py(0), px(-wallWidth / 2) - 7, py(hLeft));
|
||||
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
|
||||
drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight));
|
||||
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
|
||||
|
||||
const studEdges = new Set([0, wallWidth]);
|
||||
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
|
||||
const cumPoints = [...studEdges].filter(v => v >= 0 && v <= wallWidth).sort((a, b) => a - b);
|
||||
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.setFontSize(5.5);
|
||||
const segY = floorY + 9, cumY = floorY + 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 - wallWidth / 2), segY, { align: 'center' });
|
||||
}
|
||||
cumPoints.forEach(v => {
|
||||
doc.text(String(v), px(v - wallWidth / 2), cumY, { align: 'center' });
|
||||
doc.line(px(v - wallWidth / 2), cumY - 2.5, px(v - wallWidth / 2), floorY + 2);
|
||||
});
|
||||
|
||||
const openingPoints = [0];
|
||||
CONFIG.openings.slice().sort((a, b) => a.x - b.x).forEach(op => { openingPoints.push(op.x, op.x + op.w); });
|
||||
openingPoints.push(wallWidth);
|
||||
const openY = floorY + 25;
|
||||
doc.text('Opening', drawX0, openY);
|
||||
for (let i = 0; i < openingPoints.length - 1; i++) {
|
||||
const mid = (openingPoints[i] + openingPoints[i + 1]) / 2;
|
||||
doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' });
|
||||
}
|
||||
|
||||
// --- Gevellampen (woning): rechthoekje met "L" op lamphoogte ---
|
||||
(opts.lamps || []).forEach(l => {
|
||||
const lx = px(-wallWidth / 2 + l.u), ly = py(l.y);
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 211, 107);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(lx - 1.2, ly - 2, 2.4, 4, 'FD');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(5);
|
||||
doc.text('L', lx + 2, ly + 1);
|
||||
});
|
||||
|
||||
// --- Bovenaanzicht (doorsnede op stijlhoogte, zoals de voorbeeld-
|
||||
// tekeningen): binnenzijde boven, buitenzijde onder (= de kant van
|
||||
// waaruit het vooraanzicht bekeken wordt). Zelfde horizontale schaal.
|
||||
const sT = CONFIG.sheathingThick;
|
||||
const frameD = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
|
||||
const tvY0 = floorY + 34;
|
||||
const tvFrame0 = tvY0 + sT * scale;
|
||||
const tvFrame1 = tvFrame0 + frameD * scale;
|
||||
const tvY1 = tvFrame1 + sT * scale;
|
||||
const halfW = wallWidth / 2;
|
||||
|
||||
// Beplating, onderbroken ter plaatse van de openingen.
|
||||
const skinCuts = CONFIG.openings.map(op => [op.x, op.x + op.w]).sort((a, b) => a[0] - b[0]);
|
||||
const skinSegments = [];
|
||||
let skinCursor = 0;
|
||||
skinCuts.forEach(([a, b]) => { if (a > skinCursor) skinSegments.push([skinCursor, a]); skinCursor = Math.max(skinCursor, b); });
|
||||
if (skinCursor < wallWidth) skinSegments.push([skinCursor, wallWidth]);
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
skinSegments.forEach(([a, b]) => {
|
||||
doc.rect(px(a - halfW), tvY0, (b - a) * scale, sT * scale, 'S');
|
||||
doc.rect(px(a - halfW), tvFrame1, (b - a) * scale, sT * scale, 'S');
|
||||
});
|
||||
|
||||
// Stijlen (vol + kreupel) als doorsneden: rechthoek met kruis.
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
const studXs = calcData.fullStuds.concat(calcData.jackStuds.map(j => j.x));
|
||||
studXs.forEach(x => {
|
||||
const x0 = px(x - CONFIG.studWidth / 2), x1 = px(x + CONFIG.studWidth / 2);
|
||||
doc.rect(x0, tvFrame0, x1 - x0, tvFrame1 - tvFrame0, 'S');
|
||||
doc.line(x0, tvFrame0, x1, tvFrame1);
|
||||
doc.line(x0, tvFrame1, x1, tvFrame0);
|
||||
});
|
||||
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Bovenaanzicht', drawX0, (tvY0 + tvY1) / 2 + 1);
|
||||
doc.text('binnen', px(-halfW) - 2, tvY0 + 1, { align: 'right' });
|
||||
doc.text('buiten', px(-halfW) - 2, tvY1 + 1, { align: 'right' });
|
||||
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setFontSize(6);
|
||||
const tvDimX = px(halfW) + 5;
|
||||
drawDimLine(tvDimX, tvY0, tvDimX, tvY1);
|
||||
doc.text(`${sT} / ${frameD} / ${sT}`, tvDimX + 2, (tvY0 + tvY1) / 2 + 1);
|
||||
const tvDimY = tvY1 + 6;
|
||||
drawDimLine(px(-halfW), tvDimY, px(halfW), tvDimY);
|
||||
doc.setFontSize(7);
|
||||
doc.text(String(Math.round(wallWidth)), px(0), tvDimY + 3.5, { align: 'center' });
|
||||
|
||||
// --- Zijaanzicht (rechterkopse kant): laagopbouw binnen | regelwerk |
|
||||
// buiten, met onder- en bovenregel, op dezelfde schaal.
|
||||
const svX0 = 392;
|
||||
const svFrame0 = svX0 + sT * scale;
|
||||
const svFrame1 = svFrame0 + frameD * scale;
|
||||
const svX1 = svFrame1 + sT * scale;
|
||||
const svSlope = (hRight - hLeft) / wallWidth;
|
||||
const svVOff = CONFIG.plateThick * Math.sqrt(1 + svSlope * svSlope);
|
||||
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.rect(svX0, py(hRight), sT * scale, hRight * scale, 'S');
|
||||
doc.rect(svFrame1, py(hRight), sT * scale, hRight * scale, 'S');
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(svFrame0, py(hRight), svFrame1 - svFrame0, hRight * scale, 'S');
|
||||
doc.rect(svFrame0, py(CONFIG.plateThick), svFrame1 - svFrame0, CONFIG.plateThick * scale, 'S');
|
||||
doc.rect(svFrame0, py(hRight), svFrame1 - svFrame0, svVOff * scale, 'S');
|
||||
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
doc.text('Zijaanzicht', (svX0 + svX1) / 2, py(maxH) - 4, { align: 'center' });
|
||||
doc.text('buiten', svX1 + 1.5, floorY - 1);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(5.5);
|
||||
doc.text(`${sT} ${frameD} ${sT}`, (svX0 + svX1) / 2, floorY + 5, { align: 'center' });
|
||||
}
|
||||
|
||||
return { drawSheet };
|
||||
})();
|
||||
+1373
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user