2 Commits
30 changed files with 1181 additions and 308 deletions
+4 -1
View File
@@ -90,11 +90,14 @@ assets/ logo, texturen, skybox
- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm,
snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal),
omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen.
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (plattegrond,
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
vloeren, wandstukken, binnenwanden, totalen).
## Bekende beperkingen
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
- Tijdelijk: een nieuwe woning is het testontwerp (L-vorm, lessenaar met
riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`.
- Units kunnen niet gedraaid worden.
+2 -2
View File
@@ -5,5 +5,5 @@ Assets voor de 3D-weergave en de PDF:
- tiles.jpg tegeltextuur (1024x1024) voor de ronde vloer onder het object
- textures/buitenmuur/ steen voor het trasraam: "<code> - Witte voeg.jpg" (2062x1125, 2 mm/px,
naadloos), voorvertoning in thumb/32|64|128/*.png (WallModel.TRASRAAM_STONES)
- textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m)
- textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs
- textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m); thumb/32|64|128/*.png
- textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+17
View File
@@ -644,3 +644,20 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
/* Voorvertoning steen trasraam (woning > Gevel) */
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
/* Lopen (first person, derde weergavestand): uitleg onderin de 3D. */
.walk-hint { display: none; position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 5; }
.walk-hint.show { display: block; }
/* Beeld > Camera: uitleg (geen actie) */
#app-bar .submenu .menu-info { padding: 6px 12px 2px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted, #8a8f96); }
#app-bar .submenu .menu-help { cursor: default; opacity: .85; }
#app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); }
/* Openingen: "Volg dakhelling" (schuine bovenzijde) */
.opening-roof-btn {
display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; padding: 4px 8px; font-size: 11.5px;
background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer;
}
.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
+18
View File
@@ -55,6 +55,23 @@
<div class="menu-item" id="menu-fullscreen"><span>Volledig scherm</span><span class="menu-hotkey">F</span></div>
<div class="menu-item" id="menu-3d-fullscreen"><span>3D groot / alleen render</span><span class="menu-hotkey">T</span></div>
<div class="menu-item" id="menu-2d-fullscreen"><span>2D groot</span><span class="menu-hotkey">R</span></div>
<div class="menu-item" id="menu-camera-item">
<span>Camera</span><span class="menu-hotkey">&rsaquo;</span>
<div class="submenu">
<div class="menu-item" id="menu-walk"><span>Lopen (first person)</span><span class="menu-hotkey">Shift+T</span></div>
<div class="menu-info">Rondom (standaard)</div>
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">Slepen</span></div>
<div class="menu-item menu-help"><span>Verschuiven</span><span class="menu-hotkey">Rechts slepen</span></div>
<div class="menu-item menu-help"><span>Zoomen</span><span class="menu-hotkey">Scrollwiel</span></div>
<div class="menu-info">Lopen</div>
<div class="menu-item menu-help"><span>Vooruit / achteruit</span><span class="menu-hotkey">W / S, &uarr; / &darr;</span></div>
<div class="menu-item menu-help"><span>Opzij stappen</span><span class="menu-hotkey">A / D</span></div>
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">&larr; / &rarr;</span></div>
<div class="menu-item menu-help"><span>Rondkijken</span><span class="menu-hotkey">Slepen</span></div>
<div class="menu-item menu-help"><span>Rennen</span><span class="menu-hotkey">Shift</span></div>
<div class="menu-item menu-help"><span>Terug</span><span class="menu-hotkey">Shift+T</span></div>
</div>
</div>
</div>
</div>
@@ -116,6 +133,7 @@
<script src="js/core/geometry.js"></script>
<script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.js"></script>
<script src="js/core/walk-controls.js"></script>
<script src="js/core/view-filter.js"></script>
<script src="js/core/ui-kit.js"></script>
<script src="js/core/skybox.js"></script>
+9 -1
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.4.15';
const APP_VERSION = '0.4.17';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
@@ -39,6 +39,7 @@ function updateEditMenuState() {
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-walk').classList.toggle('disabled', !has('toggleWalk'));
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
}
@@ -199,6 +200,11 @@ function wireMenu() {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
closeAllMenus();
});
document.getElementById('menu-camera-item').addEventListener('click', (e) => e.stopPropagation());
document.getElementById('menu-walk').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
closeAllMenus();
});
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
closeAllMenus();
@@ -274,6 +280,8 @@ function wireHotkeys() {
toggleBrowserFullscreen();
} else if (e.key.toLowerCase() === 'h') {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
} else if (e.key.toLowerCase() === 't' && e.shiftKey) {
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
} else if (e.key.toLowerCase() === 't') {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
} else if (e.key.toLowerCase() === 'r') {
+100 -58
View File
@@ -154,87 +154,126 @@ function summarizeStockPlan(bars) {
}
// 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.
// bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }):
// rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
// (x1, y1r); schuin bovenin mag, onderin niet). 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; een
// uitsparing tot in de (schuine) bovenrand wordt een inkeping in de bovenrand.
// 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).
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
// extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
// de bovenrand tussen twee hoekpunten steeds lineair is.
// Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
// en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
// onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
// kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
// tussen vol en leeg worden tot lussen aaneengeregen.
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }].
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 top = (x) => Math.min(yCap, topYAt(x));
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])]);
.filter(r => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps)
.map(r => {
const y1r = r.y1r !== undefined ? r.y1r : r.y1, s = (y1r - r.y1) / (r.x1 - r.x0);
return { x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: r.y0, topAt: (x) => r.y1 + (x - r.x0) * s };
});
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
// 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;
// Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
const raw = [() => yBottom, top];
rs.forEach(r => raw.push(() => r.y0, r.topAt));
const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x)));
const lines = raw.map(clamp);
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
let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
// Snijpunten van de lijnen (ook met de bovenrand/onderkant) als extra kolomranden.
const cuts = [];
for (let i = 0; i < xs.length - 1; i++) {
const a = xs[i], b = xs[i + 1];
for (let p = 0; p < raw.length; p++) {
for (let q = p + 1; q < raw.length; q++) {
const da = raw[p](a) - raw[q](a), db = raw[p](b) - raw[q](b);
if ((da > eps && db < -eps) || (da < -eps && db > eps)) cuts.push(a + (b - a) * da / (da - db));
}
}
}
xs = uniq(xs.concat(cuts));
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);
// Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
const cols = [];
for (let i = 0; i < xs.length - 1; i++) {
const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) }))
.sort((p, q) => p.m - q.m)
.filter((v, k, arr) => k === 0 || v.m - arr[k - 1].m > eps || Math.abs(v.a - arr[k - 1].a) > eps || Math.abs(v.b - arr[k - 1].b) > eps);
const cells = [];
for (let k = 0; k < vals.length - 1; k++) {
const lo = vals[k], hi = vals[k + 1];
if (hi.m - lo.m <= eps) continue;
cells.push({ lo, hi, filled: !inRect(m, (lo.m + hi.m) / 2) });
}
cols.push({ a, b, cells });
}
// Hoekpunten gedeeld via een register (bijna-gelijke punten = hetzelfde punt).
const verts = [];
const vid = (x, y) => {
let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7);
if (k < 0) { k = verts.length; verts.push({ x, y }); }
return k;
};
// 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);
const edges = new Map(); // van -> [naar, ...]
const addEdge = (p, q) => {
const a = vid(p.x, p.y), b = vid(q.x, q.y);
if (a === b) return;
if (!edges.has(a)) edges.set(a, []);
edges.get(a).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]);
// Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
cols.forEach(col => col.cells.forEach((c, k) => {
if (!c.filled) return;
if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b });
if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a });
}));
// Verticale randen op elke kolomgrens: waar links vol en rechts leeg is (of andersom).
for (let i = 0; i < xs.length; i++) {
const x = xs[i];
const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : [];
const right = i < cols.length ? cols[i].cells.filter(c => c.filled).map(c => [c.lo.a, c.hi.a]) : [];
const ys = uniq(left.concat(right).flat());
for (let k = 0; k < ys.length - 1; k++) {
const ya = ys[k], yb = ys[k + 1], ym = (ya + yb) / 2;
if (yb - ya <= eps) continue;
const lf = left.some(([p, q]) => ym > p && ym < q), rf = right.some(([p, q]) => ym > p && ym < q);
if (lf && !rf) addEdge({ x, y: ya }, { x, y: yb });
else if (rf && !lf) addEdge({ x, y: yb }, { x, y: ya });
}
}
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = [];
edges.forEach((list, startKey) => {
edges.forEach((list, start) => {
while (list.length) {
const loop = [];
let cur = startKey.split(',').map(Number);
let next = list.pop();
loop.push(cur);
while (next.join(',') !== startKey) {
const loop = [start];
let next = list.pop(), guard = 0;
while (next !== start && guard++ < 100000) {
loop.push(next);
cur = next;
const outs = edges.get(cur.join(','));
const outs = edges.get(next);
if (!outs || !outs.length) break;
next = outs.pop();
}
loops.push(loop);
if (next === start) 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; });
const pts = loop.map(k => verts[k]);
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;
@@ -254,8 +293,11 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
return c;
};
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
// Gat bij de kleinste buitenlus die het bevat.
outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer));
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0];
const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
const owner = outers.find(o => inside(mid, o.outer)) || outers[0];
if (owner) owner.holes.push(hole);
});
return outers;
+22 -1
View File
@@ -22,6 +22,27 @@ window.PdfSheet = (() => {
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
};
// Afbeeldingen (bijv. textuur-voorvertoningen) voor in de PDF. generate()
// is synchroon, dus vooraf laden (preloadImage, bijv. zodra een materiaal
// gekozen is); imageData geeft de data-URL, of null als hij (nog) niet
// geladen is (dan tekent de aanroeper een kleurvlak).
const imageCache = {};
function preloadImage(url) {
if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return;
imageCache[url] = null;
const img = new Image();
img.onload = () => {
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth; c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
imageCache[url] = c.toDataURL('image/png');
} catch (e) { /* laat null: kleurvlak */ }
};
img.src = url;
}
const imageData = (url) => (url && imageCache[url]) || null;
function createDoc() {
const { jsPDF } = window.jspdf;
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
@@ -130,5 +151,5 @@ window.PdfSheet = (() => {
return y;
}
return { COLORS, createDoc, readProjectFromDom, dimLine, titleBlock, table };
return { COLORS, preloadImage, imageData, createDoc, readProjectFromDom, dimLine, titleBlock, table };
})();
+102
View File
@@ -0,0 +1,102 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/walk-controls.js — lopen in de 3D-weergave (first person).
// W/S of pijl omhoog/omlaag vooruit/achteruit
// A/D opzij stappen
// pijl links/rechts draaien
// Shift rennen
// slepen met de muis rondkijken
// Botsen (optioneel) en vloerhoogte komen van de aanroeper:
// opts.blocked(x, z) -> true als je daar niet kunt staan (m); standaard nooit
// opts.floorAt(x, z) -> hoogte van de ondergrond (m)
// opts.eye -> ooghoogte boven de ondergrond (m, standaard 1,65)
// Plain script (geen ES-module); gebruikt THREE.
//
// const walk = createWalkControls(camera, domElement, opts);
// walk.start({ x, z, yaw }); walk.update(dt); walk.stop();
// ============================================================
function createWalkControls(camera, domElement, opts) {
const o = Object.assign({ eye: 1.65, speed: 1.68, run: 3.84, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
const keys = new Set();
let active = false;
let x = 0, z = 0, yaw = 0, pitch = 0;
let drag = null;
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
function onKeyDown(e) {
if (!active || inField() || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.toLowerCase();
if (!MOVE_KEYS.includes(k)) return;
keys.add(k);
e.preventDefault();
}
function onKeyUp(e) { keys.delete(e.key.toLowerCase()); }
function onDown(e) { if (active && e.button === 0) drag = { x: e.clientX, y: e.clientY }; }
function onMove(e) {
if (!active || !drag) return;
const w = domElement.clientWidth || 1;
yaw -= (e.clientX - drag.x) / w * Math.PI;
pitch = Math.max(-1.2, Math.min(1.2, pitch - (e.clientY - drag.y) / w * Math.PI));
drag = { x: e.clientX, y: e.clientY };
}
function onUp() { drag = null; }
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
domElement.addEventListener('pointerdown', onDown);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
function apply() {
camera.position.set(x, o.floorAt(x, z) + o.eye, z);
// yaw 0 = kijkrichting +Z; pitch > 0 = omhoog.
const dir = new THREE.Vector3(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch));
camera.lookAt(camera.position.clone().add(dir));
}
// Stap met botsen: eerst de hele stap, anders per as (langs een muur glijden).
function step(dx, dz) {
if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; }
if (!o.blocked(x + dx, z)) x += dx;
else if (!o.blocked(x, z + dz)) z += dz;
}
return {
get active() { return active; },
start(pose) {
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
keys.clear();
active = true;
apply();
},
stop() { active = false; keys.clear(); drag = null; },
update(dt) {
if (!active) return;
const t = Math.min(dt, 0.1);
if (keys.has('arrowleft')) yaw += o.turn * t;
if (keys.has('arrowright')) yaw -= o.turn * t;
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
if (f || r) {
const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
}
apply();
},
dispose() {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
domElement.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
}
};
}
+8 -3
View File
@@ -216,9 +216,14 @@ window.FloorModel = (() => {
// Vloerafwerking (bovenop de OSB). Texturen in assets/textures/vloer/,
// naadloos herhalend; tileMM = maat van één tegel van de textuur.
const FINISHES = {
geen: { label: 'Geen (OSB)' },
visgraat_001: { label: 'Visgraat (Hongaarse punt)', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 }
geen: { label: 'Geen (OSB)', color: '#cda86a' },
visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 }
};
// Voorvertoning (assets/textures/vloer/thumb/<px>/<naam>.png) of null.
function finishThumbUrl(key, px) {
const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
}
const finishMaterials = {};
function finishMaterial(key) {
const f = FINISHES[key];
@@ -393,7 +398,7 @@ window.FloorModel = (() => {
}
return {
FINISHES, finishMaterial, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D
};
})();
+137 -21
View File
@@ -296,6 +296,21 @@ window.HouseModel = (() => {
});
}
// Naadwanden van één unit bijwerken na een gewijzigde koppeling: naadwanden
// van die unit waarvan de naad niet meer bestaat vervallen, ontbrekende
// naden krijgen er een.
function syncSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
state.interiorWalls = state.interiorWalls.filter(iw => !iw.seam || (iw.seam.a !== unitId && iw.seam.b !== unitId) ||
seams.some(sm => sameSeam(iw.seam, sm.a, sm.b)));
ensureSeamWalls(state, unitId);
}
// Naden van een unit zonder naadwand (bijv. verwijderd): [{ a, b }].
function missingSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
return seams.filter(sm => (sm.a === unitId || sm.b === unitId) && !state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b)));
}
function interiorLayout(iw) {
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
const alongX = Math.abs(dx) >= Math.abs(dz);
@@ -465,7 +480,7 @@ window.HouseModel = (() => {
pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
});
const roof = {
element: `${u.id}-D1`, shape, planes: ur.planes, specs: ur.specs,
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs,
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, // 3D: group-Y = 0 op het loopvlak
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
@@ -666,8 +681,10 @@ window.HouseModel = (() => {
const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0);
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
const u = Math.min(toLocalU(lf.place, wa), toLocalU(lf.place, wb));
return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: Math.round(u) };
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
// Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om.
const flip = ua > ub;
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
@@ -693,20 +710,15 @@ window.HouseModel = (() => {
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
// Gevel tot onder de dakrand: tussen wandbovenkant en onderkant boeideel
// resp. daktrim zit het dakpakket (dek + bedekking, verschilt per dak).
// De buitenlagen van de wand lopen daar door (skinAbove, mm), zodat er
// geen dakrand bloot ligt, bij elke daksoort.
const roofSt = RoofModel.normalizeSettings(state.settings.roof);
const flatTopsPre = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTopPre = flatTopsPre.length ? Math.max(...flatTopsPre) : 0;
// Gevel tot de bovenkant van het dak: tussen wandbovenkant en bovenkant
// dak zit het dakpakket (dek + bedekking, verschilt per dak). De
// buitenlagen van de wand lopen daar door (skinAbove, mm). Boeideel en
// daktrim komen daar bovenop (computeEdge, facadeDepth); de houtconstructie
// en de gevel zijn gelijk met of zonder boeideel.
units.forEach(u => {
const su = state.units.find(x => x.id === u.id);
const below = su.roof.fascia !== false ? roofSt.fasciaHeight - RoofModel.EDGE_ABOVE_MM : roofSt.trimHeight;
u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
const top = u.roof.shape.type === 'plat' ? flatTopPre : u.roof.topAt(m.x, m.z);
p.skinAbove = Math.max(0, Math.round(top - u.roof.ceilingAt(m.x, m.z) - below));
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z)));
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
// dak van de buur-unit die daar staat geen buitenlagen (trimTop).
const e = p.skinExt || {}, W = p.place.width;
@@ -736,11 +748,31 @@ window.HouseModel = (() => {
// de ribbe bij hoort; platte daken delen één randhoogte (de hoogste).
const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTop = flatTops.length ? Math.max(...flatTops) : 0;
// Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt
// erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of
// een gevel van een ander deel die naar dit dak toe kijkt (-).
const facadeDepth = (a, b, nrm) => {
const horiz = Math.abs(nrm.z) > 0.5;
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
let best = 0, bestOv = 1;
extPieces.forEach(p => {
const pn = { x: p.place.normal.x, z: p.place.normal.y };
const dot = pn.x * nrm.x + pn.z * nrm.z;
if (Math.abs(dot) < 0.5) return;
const q0 = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2), q1 = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
if (Math.abs((horiz ? q0.z : q0.x) - line) > 2) return;
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
if (ov <= bestOv) return;
bestOv = ov;
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone);
});
return best;
};
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,
topAt: u.roof.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
}))) : null;
})), facadeDepth) : 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".
@@ -772,11 +804,88 @@ window.HouseModel = (() => {
// ------------------------------------------------------------
// Totalen over de hele woning (overzicht + laatste PDF-blad).
// ------------------------------------------------------------
// ------------------------------------------------------------
// Afmetingen (infopaneel 3D + blad 1 PDF). Oppervlakken in m2, maten in mm.
// bruto vloer = vloer-rechthoeken (tot buitenkant constructie)
// leefbaar = bruto vloer min de voetafdruk van alle wanden
// gevel = buitenvlak van alle gevelstukken (incl. doorloop tot
// onder de dakrand), bruto en netto (min openingen)
// binnenwanden = geplaatst oppervlak aan de kamerzijde (alle wanden)
// dak = hellend dakoppervlak (bedekking) en in plattegrond
// groundLevel: maaiveld onder het loopvlak (mm), voor de bouwhoogte.
// ------------------------------------------------------------
function computeDimensions(parts, groundLevel) {
const m2 = (mm2) => mm2 / 1e6;
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 floorMM2 = rects.reduce((a, r) => a + r.W * r.D, 0);
const ext = [], all = allWallPieces(parts);
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
// Voetafdruk: gewone wanden (geen opbouw boven een lager dak).
const footprintMM2 = all.filter(p => !p.place.baseY).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
const wallArea = (p, y0) => {
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
const poly = [{ x: -W / 2, y: y0 }, { x: W / 2, y: y0 }, ...tl.pts.slice().reverse()];
return Math.max(0, polygonArea(poly)) + (p.skinAbove || 0) * W;
};
const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + WallModel.openingArea(op), 0);
const facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined));
const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0);
const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0);
const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0);
const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0);
const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0), 0);
const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
// Inhoud (bruto): per unit gemiddelde hoogte bovenkant dak boven de
// onderkant vloer, bemonsterd op een raster.
const inhoudMM3 = parts.units.reduce((a, u) => {
const r = u.rect, n = 8;
let sum = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) sum += u.roof.topAt(r.x0 + (i + 0.5) * r.W / n, r.z0 + (j + 0.5) * r.D / n);
return a + r.W * r.D * (sum / (n * n));
}, 0);
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
const top = parts.roofEdge ? parts.roofEdge.top : Math.max(...parts.units.map(u => u.roof.top));
return {
lengthX: bx1 - bx0, lengthZ: bz1 - bz0, height: top - (floorTop - (groundLevel || 0)),
brutoM2: m2(floorMM2), leefbaarM2: m2(floorMM2 - footprintMM2),
gevelBrutoM2: m2(gevelBruto), gevelOpeningM2: m2(gevelOpen), gevelNettoM2: m2(gevelBruto - gevelOpen), glasM2: m2(glas),
binnenwandM2: binnen, dakM2, dakPlanM2: m2(dakPlanMM2), inhoudM3: inhoudMM3 / 1e9
};
}
// Als rijen [label, waarde, eenheid] (UI-kaart en PDF-tabel).
function dimensionRows(d, fmt) {
return [
['Buitenmaten (b × d)', `${Math.round(d.lengthX)} × ${Math.round(d.lengthZ)}`, 'mm'],
['Bouwhoogte (vanaf maaiveld)', Math.round(d.height), 'mm'],
['Bruto vloeroppervlak (buitenwerks)', fmt(d.brutoM2, 2), 'm²'],
['Leefbare ruimte (netto vloer)', fmt(d.leefbaarM2, 2), 'm²'],
['Gevel buitenkant (bruto)', fmt(d.gevelBrutoM2, 2), 'm²'],
['Openingen in gevel', fmt(d.gevelOpeningM2, 2), 'm²'],
['Gevel buitenkant (netto)', fmt(d.gevelNettoM2, 2), 'm²'],
['Glas (ramen)', fmt(d.glasM2, 2), 'm²'],
['Wanden binnenkant (alle)', fmt(d.binnenwandM2, 2), 'm²'],
['Dak (dakoppervlak)', fmt(d.dakM2, 2), 'm²'],
['Dak (plattegrond)', fmt(d.dakPlanM2, 2), 'm²'],
['Inhoud (bruto)', fmt(d.inhoudM3, 1), 'm³']
];
}
// TIJDELIJK (verwijderen voor productie): capaciteit zaagmachine.
const SAW_M_PER_H = 2400;
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
function addPiece(kind, width, depth, length) {
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const saw = { lengthMM: 0, cuts: 0, angles: new Map() };
function addPiece(kind, width, depth, length, cutAngles) {
const ca = cutAngles || [0, 0];
saw.lengthMM += length;
saw.cuts += ca.length;
ca.forEach(a => saw.angles.set(a, (saw.angles.get(a) || 0) + 1));
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
k.count++; k.lengthMM += length;
kinds.set(kind, k);
@@ -792,12 +901,14 @@ window.HouseModel = (() => {
['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
u.roof.planes.forEach(pl => {
const rc = pl.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
// Sporen in een hellend dakvlak: loodrechte snede aan beide koppen.
const ang = Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
});
});
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); }));
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); }));
const purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
@@ -823,6 +934,11 @@ window.HouseModel = (() => {
return {
kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
// TIJDELIJK (verwijderen voor productie): zaagmachine, SAW_M_PER_H meter per uur.
saw: {
meters: saw.lengthMM / 1000, hours: saw.lengthMM / 1000 / SAW_M_PER_H, cuts: saw.cuts,
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
},
beamCount: kindList.reduce((a, k) => a + k.count, 0),
purchase,
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
@@ -970,9 +1086,9 @@ window.HouseModel = (() => {
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor,
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, SAW_M_PER_H, // SAW_M_PER_H: tijdelijk
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, validateState, fromUnitDesign, normalize
};
})();
+292 -14
View File
@@ -6,10 +6,12 @@
*/
// ============================================================
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
// Blad 1 plattegrond woning (units, wandstukken, naden, binnenwanden)
// Blad 1 aanzichten noord/oost/zuid/west (vlakke "render", vectorvormen)
// Blad 2 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)
// daarna totalen (balken, inkoop, glaswol, plaatwerk, gewicht, dakrand)
// laatste zaaglijst vuren + zaagmachine-overzicht (tijdelijk)
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
// Plain script, geladen na house-model.js.
// ============================================================
@@ -21,6 +23,7 @@ window.HousePdf = (() => {
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
};
const PLAN_ELEMENT = 'PL-1';
const VIEWS_ELEMENT = 'AZ-1';
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
@@ -35,7 +38,10 @@ window.HousePdf = (() => {
// Bladvolgorde + elementnummers; gedeeld door generate() en de
// onderdelenlijst op blad 1.
function sheetList(parts) {
const list = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }];
const list = [
{ kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
{ 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 })));
@@ -43,6 +49,7 @@ window.HousePdf = (() => {
});
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
return list;
}
@@ -51,7 +58,7 @@ window.HousePdf = (() => {
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)]),
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
@@ -172,6 +179,247 @@ window.HousePdf = (() => {
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
}
// ------------------------------------------------------------
// Aanzichten: per windrichting een vlakke "render" van de gevels. Alle
// zichtbare vlakken (gevels incl. trasraam en openingen, boeideel/daktrim,
// hellende dakvlakken) als polygonen in het aanzicht, van achter naar voren
// getekend (schildersalgoritme), in egale kleuren uit het model.
// ------------------------------------------------------------
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255];
const VIEW_COLORS = {
rabat: '#a0805f', cementplaat: '#808285', glas: '#a9c9dc', deur: '#5b4a3b', lijn: [60, 60, 60], grond: [120, 120, 120]
};
// d = richting van woning naar kijker (x, z); u = horizontale schermas.
const VIEWS = [
{ key: 'noord', title: 'Noordgevel', d: { x: 0, z: -1 }, u: (p) => -p.x },
{ key: 'oost', title: 'Oostgevel', d: { x: 1, z: 0 }, u: (p) => -p.z },
{ key: 'zuid', title: 'Zuidgevel', d: { x: 0, z: 1 }, u: (p) => p.x },
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
];
// Alle vlakken in wereldcoördinaten: { pts: [{x, y, z}], n: {x, z} | null (dakvlak: nH), color, bias }.
function viewFaces(parts, state) {
const faces = [];
const wallFace = (p, y0, color, depthBias) => {
const W = p.place.width, T = p.place.thickness;
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
.concat([{ x: xl, y: top(xl) }]);
return loc;
};
const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; };
const addWall = (p, y0) => {
const n = { x: p.place.normal.x, z: p.place.normal.y };
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat;
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
// Trasraam (strook onderaan, iets naar voren)
const tr = p.trasraam;
if (tr && tr.top > y0) {
const W = p.place.width;
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
}
// Openingen (glas / deur), vóór de gevel
(p.config.openings || []).forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return;
// Schuine bovenzijde: bovenkant links/rechts.
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
});
};
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
parts.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom));
// Hellende dakvlakken (bovenkant, egaal in de kleur van de bedekking).
parts.units.forEach(u => {
const r = u.roof, sh = r.shape;
if (sh.type === 'plat') return;
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
r.planes.forEach(pl => {
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
const inward = (t) => t + Math.sign(mid - t) * 0.5;
const corners = sh.axis === 'x'
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }));
// Normaal (omhoog) uit het vlak.
const [A, B, C] = pts;
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
const len = Math.hypot(nx, ny, nz) || 1;
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
});
});
// Dakrand: buitenvlak van elk boeideel / elke daktrim.
const edge = parts.roofEdge;
if (edge) {
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
edge.segments.forEach(sg => {
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B);
const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }];
faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
});
}
return faces;
}
// Legenda "Afwerking": gevel(s) (incl. overrides), trasraam, dak per unit,
// vloer, boeideel/daktrim en binnenwanden. Per item een voorvertoning
// (thumb, via PdfSheet.preloadImage vooraf geladen) of een kleurvlak.
function materialLegend(state, parts) {
const items = [];
const st = state.settings;
const seen = new Set();
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
const fc = HouseModel.facadeFor(state, u, side);
const key = fc.type === 'steen' ? 'steen:' + fc.stone : 'rabat';
if (seen.has(key)) return;
seen.add(key);
items.push(fc.type === 'steen'
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
: { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat });
}));
if (st.trasraam.enabled) {
const tf = st.trasraam.finish;
items.push(WallModel.isStoneFinish(tf)
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
}
(parts ? parts.units : state.units).forEach(u => {
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
items.push({ label: `Dak unit ${u.id}`, value: c.label, thumb: RoofModel.coveringThumbUrl(cov, 64), color: c.color });
});
const ff = FloorModel.FINISHES[st.floorFinish] || FloorModel.FINISHES.geen;
items.push({ label: 'Vloer', value: ff.label, thumb: FloorModel.finishThumbUrl(st.floorFinish, 64), color: ff.color });
const fascia = state.units.some(u => u.roof.fascia !== false), trim = state.units.some(u => u.roof.fascia === false);
if (fascia) {
const fcol = RoofModel.FASCIA_COLORS[st.roof.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet;
items.push({ label: 'Boeideel', value: fcol.label, color: fcol.hex });
}
if (trim) items.push({ label: 'Daktrim', value: 'Aluminium', color: 0xb8bcc0 });
items.push(st.interiorPaint
? { label: 'Binnenwanden', value: `Geschilderd ${st.interiorColor}`, color: st.interiorColor }
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
return items;
}
// Voorvertoningen die de legenda gebruikt (om vooraf te laden).
function materialThumbUrls(state) {
return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
}
function drawMaterialLegend(doc, items, x0, y0, x1, y1) {
const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...BLACK);
doc.text('Afwerking', x0, y0 + 3);
doc.setFont('helvetica', 'normal');
const cellW = 45, cellH = 11, sw = 9, top = y0 + 6;
const cols = Math.max(1, Math.floor((x1 - x0) / cellW)), rows = Math.max(1, Math.floor((y1 - top) / cellH));
items.slice(0, cols * rows).forEach((it, i) => {
const cx = x0 + Math.floor(i / rows) * cellW, cy = top + (i % rows) * cellH;
const data = PdfSheet.imageData(it.thumb);
if (data) doc.addImage(data, 'PNG', cx, cy, sw, sw);
else {
doc.setFillColor(...(typeof it.color === 'number' ? numRgb(it.color) : hexRgb(it.color || '#cccccc')));
doc.rect(cx, cy, sw, sw, 'F');
}
doc.setDrawColor(...GREY);
doc.setLineWidth(0.1);
doc.rect(cx, cy, sw, sw, 'S');
doc.setFontSize(6);
doc.setTextColor(...GREY);
doc.text(it.label, cx + sw + 1.5, cy + 3.2);
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(doc.splitTextToSize(it.value, cellW - sw - 3)[0], cx + sw + 1.5, cy + 7.2);
});
}
// Afmetingen (zelfde rijen als het infopaneel in de app), in twee kolommen.
function drawDimensions(doc, rows, x0, y0, x1) {
const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...BLACK);
doc.text('Afmetingen', x0, y0 + 3);
doc.setFont('helvetica', 'normal');
const half = Math.ceil(rows.length / 2), colW = (x1 - x0) / 2, rowH = 4.6;
rows.forEach(([label, value, unit], i) => {
const cx = x0 + Math.floor(i / half) * colW, cy = y0 + 8 + (i % half) * rowH;
doc.setFontSize(5.5);
doc.setTextColor(...GREY);
doc.text(label, cx, cy - 1.7);
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(`${value}${unit ? ' ' + unit : ''}`, cx, cy + 0.9);
});
}
function drawViewsSheet(doc, parts, state, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
const faces = viewFaces(parts, state);
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
// Per aanzicht de zichtbare vlakken in (u, v) mm, met diepte.
const views = VIEWS.map(v => {
const dot = (p) => p.x * v.d.x + p.z * v.d.z;
const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5)
.map(f => ({ f, depth: f.pts.reduce((a, p) => a + dot(p), 0) / f.pts.length + f.bias, pts: f.pts.map(p => ({ u: v.u(p), v: p.y })) }))
.sort((a, b) => a.depth - b.depth);
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
}).filter(x => x.list.length);
// Raster 2 x 2 boven het titelblok; één schaal voor alle aanzichten.
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
doc.setFont('helvetica', 'normal');
views.forEach((w, i) => {
const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap);
const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale;
const base = cy + cellH - 6; // grondlijn
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
doc.setTextColor(...BLACK);
doc.setFontSize(11);
doc.text(w.v.title, cx, cy + 5);
doc.setLineWidth(0.1);
doc.setDrawColor(...VIEW_COLORS.lijn);
w.list.forEach(l => {
const pts = l.pts.map(P);
const col = typeof l.f.color === 'number' ? numRgb(l.f.color) : hexRgb(l.f.color);
doc.setFillColor(...col);
doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true);
});
// Grondlijn
doc.setDrawColor(...VIEW_COLORS.grond);
doc.setLineWidth(0.4);
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
});
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(`Aanzichten (vlakke weergave, schaal ca. 1:${Math.round(1 / scale)}); kleuren: gemiddelde kleur van de gekozen steen / bedekking.`, X0, Y1 + 5);
drawMaterialLegend(doc, materialLegend(state, parts), X0, Y1 + 7, 192, 289);
drawDimensions(doc, HouseModel.dimensionRows(HouseModel.computeDimensions(parts, state.settings.groundLevel), fmt), 197, Y1 + 7, 285);
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function drawTotalsSheet(doc, totals, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
@@ -197,22 +445,18 @@ window.HousePdf = (() => {
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 = PdfSheet.table(doc, 120, 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'],
y = PdfSheet.table(doc, 120, 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'));
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
if (totals.roofEdge) {
y += 4;
const e = totals.roofEdge, L = totals.lamps;
@@ -225,12 +469,44 @@ window.HousePdf = (() => {
rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
}
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
function drawSawSheet(doc, totals, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Zaaglijst', 15, 18);
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
const rows = totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]);
const perCol = 52, cols = [15, 100, 185];
for (let c = 0; c < cols.length && c * perCol < rows.length; c++) {
PdfSheet.table(doc, cols[c], 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'], rows.slice(c * perCol, (c + 1) * perCol),
tableOpts(c === 0 ? 'Zaaglijst vuren (totaal)' : 'Zaaglijst (vervolg)'));
}
if (rows.length > perCol * cols.length) {
doc.setFontSize(7);
doc.text(`+ ${rows.length - perCol * cols.length} regels (niet getoond)`, 15, 285);
}
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const sw = totals.saw;
const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'],
['Zaagsneden', sw.cuts + ' st.'],
['Verschillende hoeken', String(sw.angles.length)]
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function generate(state) {
const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
@@ -243,14 +519,16 @@ window.HousePdf = (() => {
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);
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
else 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.plane.data.calcData, sheet.plane.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, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts);
else drawTotalsSheet(doc, totals, opts);
});
return doc;
}
return { generate, sheetList };
return { generate, sheetList, materialThumbUrls };
})();
+127 -15
View File
@@ -350,8 +350,8 @@ function mountHouseEditor(container, callbacks) {
}
const e = p.skinExt || {};
const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0);
const gaps = p.config.openings.filter(op => op.y < y && op.y + op.h > y)
.map(op => [-W / 2 + op.x, -W / 2 + op.x + op.w]).sort((a, b) => a[0] - b[0]);
const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
const spans = [];
let cur = xL;
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); });
@@ -738,9 +738,9 @@ function mountHouseEditor(container, callbacks) {
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS)
.filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label]));
const covInfo = (k) => { const c = RoofModel.COVERINGS[k]; return `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²`; };
const bedekking = sel('hp-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(u.roof.covering, shapeType), {
hint: shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(RoofModel.coveringFor(u.roof.covering, shapeType)) + '; boeideel schuift mee omhoog'
});
const covNow = RoofModel.coveringFor(u.roof.covering, shapeType);
const bedekking = thumbPicker('hp-roof-cover', 'Bedekking', covOpts, covNow, RoofModel.coveringThumbUrl(covNow, 128),
shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(covNow) + '; boeideel schuift mee omhoog');
const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) +
(u.roof.followFloor ? '' :
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) +
@@ -750,12 +750,16 @@ function mountHouseEditor(container, callbacks) {
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' }) +
bedekking;
const missing = HouseModel.missingSeamWalls(state, u.id);
propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} &times; ${f.depth} mm`) +
UI.tabs('unit', [
{ key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols(
num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }),
sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) },
{ key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, '') },
{ key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, missing.length
? UI.warn(`Zonder naadwand: ${missing.map(m => `unit ${m.a} | ${m.b}`).join(', ')}. De naad staat daar open.`) +
actions(button('Naadwand toevoegen', 'plus-lg', `data-action="add-seams" data-unit="${u.id}"`))
: '') },
{ key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') }
]) +
actions(
@@ -767,9 +771,11 @@ function mountHouseEditor(container, callbacks) {
UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after);
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender);
UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender);
UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender);
// Andere koppeling: naadwanden van deze unit gaan mee (oude vervallen,
// nieuwe naden krijgen er een).
UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
@@ -869,9 +875,10 @@ function mountHouseEditor(container, callbacks) {
// Keuzes voor de vormgeving van het trasraam: plaat of een steen.
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
// Steenkeuze met voorvertoning.
const stonePicker = (id, label, value, hint) => sel(id, label, stoneOptions, value, { hint }) +
`<img class="hs-stone-preview" src="${WallModel.stoneThumbUrl(value, 128)}" alt="${value}">`;
// Keuzelijst met voorvertoning (textuur-thumbnail), als die er is.
const thumbPicker = (id, label, options, value, thumbUrl, hint) => sel(id, label, options, value, { hint }) +
(thumbUrl ? `<img class="hs-stone-preview" src="${thumbUrl}" alt="${value}">` : '');
const stonePicker = (id, label, value, hint) => thumbPicker(id, label, stoneOptions, value, WallModel.stoneThumbUrl(value, 128), hint);
function renderHouseProps(warn) {
const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting;
@@ -916,7 +923,8 @@ function mountHouseEditor(container, callbacks) {
{ key: 'binnen', label: 'Binnen', icon: 'palette', html: cols(
section('Wanden', 'palette', toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden' }) +
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '')),
section('Vloer', 'grid-3x3', sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish, { hint: 'Alle vloeren (voorlopig één keuze)' }) +
section('Vloer', 'grid-3x3', thumbPicker('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish,
FloorModel.finishThumbUrl(st.floorFinish, 128), 'Alle vloeren (voorlopig één keuze)') +
info('Wanden uit: kale spaanplaat met plaatnaden.'))) },
{ key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols(
section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })),
@@ -986,12 +994,15 @@ function mountHouseEditor(container, callbacks) {
if (act.dataset.action === 'add-unit') addUnit();
else if (act.dataset.action === 'draw-iw') setTool('iw');
else if (act.dataset.action === 'project') openProjectProperties();
else if (act.dataset.action === 'add-seams') { HouseModel.ensureSeamWalls(state, act.dataset.unit); refresh(); }
});
// --- Specificaties/totalen (onder de 3D) ---
function renderSpecs(totals) {
const f = houseFmt;
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
const cards = [
UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)),
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
UI.specCard('Hout & isolatie', 'tree', [
['Balken', totals.beamCount, 'st.'],
@@ -1010,11 +1021,22 @@ function mountHouseEditor(container, callbacks) {
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
}
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const sw = totals.saw, hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
['Zaagmeters', f(sw.meters, 1), 'm'],
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours), 'u:mm'],
['Zaagsneden', sw.cuts, 'st.'],
['Verschillende hoeken', sw.angles.length, '']
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden']))));
document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
}
// --- Alles opnieuw doorrekenen ---
function refresh(opts) {
// Voorvertoningen van de gekozen materialen alvast laden voor de PDF
// (legenda op het aanzichtenblad; HousePdf.materialThumbs).
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
// Binnenzijde geschilderd: de (gedeelde) spaanplaat krijgt de verfkleur.
wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR);
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible });
@@ -1080,9 +1102,93 @@ function mountHouseEditor(container, callbacks) {
renderNav();
}
let rafId = null;
// --- Lopen (first person), eigen sneltoets Shift+T ---
// Start 3 m voor de voordeur, buiten; botsen tegen wanden (deuren zijn
// doorgangen), ondergrond = vloer binnen een unit, anders het maaiveld.
const WALK_RADIUS = 250; // mm, "lichaam"
function wallBlocked(xm, zm) {
if (!parts) return false;
const x = xm * 1000, z = zm * 1000;
return HouseModel.allWallPieces(parts).some(p => {
if (p.place.baseY) return false; // opbouw boven een lager dak
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
const dx = x - p.place.cx, dz = z - p.place.cz;
const lx = dx * c - dz * s, lz = dx * s + dz * c;
const W = p.place.width, T = p.place.thickness;
const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone) : 0;
if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false;
// Door een deur(opening) mag je lopen.
return !(p.config.openings || []).some(op => op.y < 100 && op.y + Math.min(op.h, op.hR) > 1800 &&
lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6);
});
}
function groundAt(xm, zm) {
const x = xm * 1000, z = zm * 1000;
const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1);
const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0;
return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000;
}
const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt });
let orbitSaved = null;
// Startpositie: 3 m voor de voordeur (anders een buitendeur, anders midden
// voor de noordgevel), kijkend naar de deur.
function walkStartPose() {
const ext = [];
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
let best = null;
['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => {
if (op.type !== type) return false;
best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 };
return true;
})));
if (!best) {
const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0];
best = { p, lx: 0 };
}
const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2);
const n = { x: best.p.place.normal.x, z: best.p.place.normal.y };
const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000;
return { x, z, yaw: Math.atan2(-n.x, -n.z) };
}
const walkHint = document.createElement('div');
walkHint.className = 'walk-hint';
walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · Shift+T = terug';
pane3D.appendChild(walkHint);
function setWalk(on) {
if (on === walk.active || !parts) return;
const view = document.getElementById('house-overview');
if (on) {
// Lopen gebeurt in de volle 3D (alleen render); vorige stand onthouden.
orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className };
view.classList.remove('fullscreen-2d');
view.classList.add('fullscreen-3d', 'render-only');
requestAnimationFrame(onResize);
controls.enableRotate = controls.enablePan = controls.enableZoom = false;
camera.fov = 65;
camera.updateProjectionMatrix();
walk.start(walkStartPose());
} else {
walk.stop();
controls.enableRotate = controls.enablePan = controls.enableZoom = true;
if (orbitSaved) {
view.className = orbitSaved.cls;
requestAnimationFrame(onResize);
camera.fov = orbitSaved.fov;
camera.updateProjectionMatrix();
camera.position.copy(orbitSaved.pos);
controls.target.copy(orbitSaved.target);
controls.update();
}
}
walkHint.classList.toggle('show', on);
}
let rafId = null, lastT = performance.now();
function animate() {
rafId = requestAnimationFrame(animate);
const now = performance.now();
walk.update((now - lastT) / 1000);
lastT = now;
renderer.render(scene, camera);
}
function onResize() {
@@ -1094,7 +1200,9 @@ function mountHouseEditor(container, callbacks) {
window.addEventListener('resize', onResize);
function toggleView(fn) {
fn(document.getElementById('house-overview'));
const el = document.getElementById('house-overview');
setWalk(false); // weergave wisselen beëindigt het lopen
fn(el);
requestAnimationFrame(onResize);
}
@@ -1108,12 +1216,15 @@ function mountHouseEditor(container, callbacks) {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKey);
if (rafId !== null) cancelAnimationFrame(rafId);
setWalk(false); // orbitcamera terug vóór het opslaan
walk.dispose();
CameraMemory.save(overviewCamKey, camera, controls);
renderer.dispose();
},
refresh,
setTool,
toggleFullscreen3D: () => toggleView(UI.cycle3D),
toggleWalk: () => setWalk(!walk.active),
toggleFullscreen2D: () => toggleView(UI.toggle2D)
};
}
@@ -1229,6 +1340,7 @@ function mountHouseEditor(container, callbacks) {
triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'),
toggleFullscreen3D: forward('toggleFullscreen3D'),
toggleWalk: forward('toggleWalk'),
toggleFullscreen2D: forward('toggleFullscreen2D'),
openProjectProperties,
getStateJson,
+12 -9
View File
@@ -191,7 +191,7 @@ window.LightingModel = (() => {
.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 => {
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + 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)]);
});
@@ -339,16 +339,19 @@ window.LightingModel = (() => {
// spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY),
// indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten-
// lampen (overdag lager: dan vallen ze nauwelijks op).
const OUTDOOR = { lamp: 2.0, spot: 1.6, angle: 0.5 };
const INDOOR = { intensity: 4.0, angle: 1.0, reach: 1.12, decay: 0.6 };
// Zacht en breed i.p.v. fel en smal: lagere intensiteit, grotere bundel
// en een brede zachte rand (penumbra), zodat naast elkaar liggende spots
// niet optellen tot felle vlekken.
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
const DAY_OUTDOOR_FACTOR = 0.35;
function buildLights3D(lamps, spots, spotY, indoor, o) {
const toM = 0.001;
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
const group = new THREE.Group();
// Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt.
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, decay);
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
light.position.set(x, y, z);
light.target.position.set(x + tx, y + ty, z + tz);
group.add(light);
@@ -358,18 +361,18 @@ window.LightingModel = (() => {
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;
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5);
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5);
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra);
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra);
});
// Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een
// hoek diagonaal).
(spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5);
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra);
});
(indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay);
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra);
});
return group;
}
+37 -5
View File
@@ -69,12 +69,18 @@ window.RoofModel = (() => {
// de stengels lopen langs de helling.
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer
// hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'] };
// colors: gemiddelde kleur per textuur (vlakke weergave, PDF-aanzichten).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
const COVERINGS = Object.assign({
dakleer: { label: 'Dakleer (EPDM)', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
dakleer: { label: 'Dakleer (EPDM)', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
}, ...RIET.nrs.map(nr => ({
['riet_' + nr]: { label: 'Riet ' + nr, thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true }
['riet_' + nr]: { label: 'Riet ' + nr, color: RIET.colors[nr] || '#8a7560', thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true }
})));
// Voorvertoning (assets/textures/dak/thumb/<px>/<naam>.png) of null.
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
}
// Werkelijke bedekking voor een dakvorm (riet niet op een plat dak).
function coveringFor(key, shapeType) {
const c = COVERINGS[key];
@@ -379,7 +385,14 @@ window.RoofModel = (() => {
// dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per
// ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel
// gevuld (met y = onderkant boeideel ter plaatse).
function computeEdge(settings, roofs) {
// facadeDepth(a, b, normal) (optioneel, mm): dikte van de gevelafwerking
// (doek + latwerk, steen) buiten het wandvlak langs die ribbe. De gevel
// loopt achter het boeideel door tot de bovenkant van het dak; het
// boeideel / de daktrim valt er overheen en sluit aan de dakkant aan op
// het dak (binnenrand = dakrand, van boven dicht). De spots komen in de
// onderplaat vóór de gevel. Negatief = gevel van een ander (hoger) deel
// die naar dit dak toe kijkt: de rand stopt tegen die afgewerkte gevel.
function computeEdge(settings, roofs, facadeDepth) {
const s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect);
if (!roofs.length) return null;
@@ -441,6 +454,25 @@ window.RoofModel = (() => {
overRoof: !!nb && !dropped, dropped
};
});
// Binnenrand: alleen tegen de gevel van een hoger deel (negatieve
// diepte) inkorten; spotlijn vóór de gevel. Op een hoek van twee haakse
// ribben het snijpunt van beide verschoven lijnen, anders loodrecht.
const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0));
const dep = depRaw.map(d => Math.min(0, d));
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
const shifted = segs.map((sg, i) => {
const pi = (i - 1 + n) % n, ni = (i + 1) % n, pv = segs[pi], nx = segs[ni];
const at = (v, own, ownD, other, otherD) => perp(own, other)
? { x: v.x + own.x * ownD + other.x * otherD, z: v.z + own.z * ownD + other.z * otherD }
: { x: v.x + own.x * ownD, z: v.z + own.z * ownD };
return {
a: at(sg.a, sg.normal, dep[i], pv.normal, dep[pi]), b: at(sg.b, sg.normal, dep[i], nx.normal, dep[ni]),
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
};
});
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
// Naast een vervallen ribbe (tegen een hoger dak):
// - ligt het verlengde van de rand in het hoge deel, dan loopt hij
// recht door tot die gevel (spot op spotafstand van de gevel);
@@ -559,7 +591,7 @@ window.RoofModel = (() => {
}
return {
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor,
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
computeEdge, buildEdge3D, normalizeSettings
+2 -2
View File
@@ -204,7 +204,7 @@ window.UnitModel = (() => {
const pieces = wallLayout.pieces.map(([a, b, inL, inR], 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 }; });
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)),
@@ -233,7 +233,7 @@ window.UnitModel = (() => {
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y - base, anchor: 'left', offset: op.x - a }; });
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB),
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
+180 -139
View File
@@ -15,7 +15,7 @@
//
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] }
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
@@ -94,6 +94,33 @@ window.WallModel = (() => {
'R746 R747 R748 R748X764X749 R749 R750 R751 R751X759 R752 R754 R756 R757 R758 R759 R760 R761 R762 R763 R764 R764X756 R766 R767 R768 ' +
'R769 R770 R771 R772 R773 R775 R777 R788 R800 R835 R840 R911P R911 R913 R916 R917 R918 R921 R923P R931 R932 R941P R941 R942 R943 R944 ' +
'R960DF14_70 R960DF R961 R970 R980 R985 R991').split(' ');
// Gemiddelde kleur per steen (uit de textuur berekend), voor vlakke
// weergaven zoals de aanzichten in de PDF.
const STONE_COLORS = {
'K490': '#b67d60', 'K518': '#666562', 'K560': '#937464', 'K662': '#877770', 'K706': '#615d58', 'K739': '#796f65', 'K752': '#8e6d5f', 'K764': '#a1927d',
'K773': '#9f8c76', 'K911': '#e7ddc9', 'K911P': '#d6c8b6', 'K914': '#d4c0a5', 'K915': '#c5a37b', 'K919': '#cbb498', 'K923': '#c6b3a4', 'K923P': '#beaea1',
'K941': '#d2c5b2', 'K941P': '#dcd2c0', 'K947': '#c9bca8', 'K948': '#b9a896', 'K960': '#b4a495', 'K991': '#9e7b69', 'R-693': '#675e5a', 'R100': '#eee4c7',
'R116': '#e5ddc5', 'R140': '#eae3cc', 'R200': '#edc484', 'R206': '#e0b48b', 'R214': '#b88e74', 'R216': '#e0bc8c', 'R220': '#e29a72', 'R227': '#d49069',
'R228': '#c48869', 'R240': '#e5c587', 'R268': '#ca956e', 'R286': '#b98d66', 'R287': '#d1a477', 'R303': '#a47562', 'R307': '#966b59', 'R332': '#ae8171',
'R335': '#a97968', 'R343': '#a07662', 'R356': '#a47f77', 'R381': '#9c7c6f', 'R382': '#8e7769', 'R384': '#706260', 'R385': '#8a7972', 'R386': '#897f7e',
'R391': '#8b6a64', 'R400': '#b67c63', 'R401': '#a97360', 'R435': '#ad7463', 'R436': '#9f7565', 'R440': '#b57860', 'R480': '#d58a65', 'R483': '#c08973',
'R484': '#a78d7d', 'R487': '#c47f61', 'R488': '#bd8267', 'R489': '#a87967', 'R500': '#7d6c5e', 'R510': '#5b5855', 'R518': '#655d5a', 'R535': '#977365',
'R540': '#7e685a', 'R550': '#8b796d', 'R555': '#9a7668', 'R560': '#8c6f63', 'R561': '#7c6762', 'R563': '#876965', 'R564': '#7c7168', 'R565': '#736a66',
'R572DF': '#8b6657', 'R572DF14_64': '#886355', 'R580': '#907265', 'R581': '#837571', 'R582': '#96705e', 'R658': '#a47e65', 'R659': '#8a6d5a', 'R663': '#8b7163',
'R664': '#8a6a5c', 'R665': '#ca996c', 'R669': '#716257', 'R680': '#b7a28d', 'R682': '#c3af99', 'R684': '#c29263', 'R685': '#926e5d', 'R686': '#b57f5e',
'R687': '#b37f60', 'R688': '#d4ae82', 'R689': '#a67961', 'R690': '#b69477', 'R691': '#e1d3b6', 'R692': '#e1cda3', 'R694': '#aa6c56', 'R695': '#cfa77e',
'R696': '#dbb897', 'R697': '#75685e', 'R698': '#a47760', 'R700': '#66615d', 'R714': '#90695b', 'R715': '#a16f5b', 'R717': '#706e6e', 'R719': '#a58272',
'R720': '#998d8d', 'R721': '#a6948b', 'R725': '#a77963', 'R726': '#987c67', 'R727': '#8e6f5c', 'R729': '#a97260', 'R730': '#e7d3b5', 'R731': '#c68b66',
'R732': '#e4cca6', 'R733': '#eee9ce', 'R734': '#c39c7e', 'R735': '#8c8982', 'R736': '#5f5b54', 'R737': '#7c7973', 'R738': '#7d7771', 'R739': '#81756c',
'R740': '#5e595a', 'R742': '#e7d0ac', 'R743': '#a47f6e', 'R744': '#b38672', 'R745X737': '#9e8d78', 'R746': '#9a7d6f', 'R747': '#88776b', 'R748': '#8b7667',
'R748X764X749': '#a08a77', 'R749': '#92826f', 'R750': '#a88374', 'R751': '#b87a63', 'R751X759': '#766b63', 'R752': '#946c58', 'R754': '#a97e6f', 'R756': '#d2b48c',
'R757': '#eee8cc', 'R758': '#cb9978', 'R759': '#c78365', 'R760': '#baa595', 'R761': '#746f6b', 'R762': '#e9cfa9', 'R763': '#ebe1c6', 'R764': '#bea994',
'R764X756': '#cfb695', 'R766': '#ddb991', 'R767': '#b38469', 'R768': '#b58774', 'R769': '#966e5d', 'R770': '#836e60', 'R771': '#c1b2a3', 'R772': '#d7c8b5',
'R773': '#9d8b77', 'R775': '#917e6e', 'R777': '#6e6b6a', 'R788': '#a27c6c', 'R800': '#aba394', 'R835': '#c2b4a2', 'R840': '#c3b9a7', 'R911': '#e7deca',
'R911P': '#d6c8b6', 'R913': '#d3c0a7', 'R916': '#dfc9a6', 'R917': '#dcaf7e', 'R918': '#caa174', 'R921': '#cbad9a', 'R923P': '#beaea1', 'R931': '#9f8773',
'R932': '#a8917e', 'R941': '#c0b3a1', 'R941P': '#c2b6aa', 'R942': '#d3ccc1', 'R943': '#beb2a4', 'R944': '#afa295', 'R960DF': '#bab1a4', 'R960DF14_70': '#bbb2a5',
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
};
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
@@ -146,6 +173,24 @@ window.WallModel = (() => {
// koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
// Schuine bovenzijde (zoals de wand zelf: schuin bovenin mag, onderin
// niet): h = hoogte links, hR = hoogte rechts. Ruwe openingen hebben hR
// alleen als hij (bewust) schuin is; opgeloste openingen altijd.
// x is lokaal (0..wandbreedte), zoals op.x.
const hRightOf = (op) => op.hR !== undefined ? op.hR : op.h;
const openingTopAt = (op, x) => op.y + op.h + (x - op.x) / op.w * (hRightOf(op) - op.h);
const openingTopMax = (op) => op.y + Math.max(op.h, hRightOf(op));
const openingArea = (op) => op.w * (op.h + hRightOf(op)) / 2;
// Stuk [x0, x1] van een vlak met waterpas onderkant y0 en schuine
// bovenkant (yl links, yr rechts) op hoogte y, of null.
function slopedSpanAtY(x0, x1, y0, yl, yr, y) {
if (y <= y0 || y >= Math.max(yl, yr)) return null;
if (y <= Math.min(yl, yr)) return [x0, x1];
const xc = x0 + (y - yl) / (yr - yl) * (x1 - x0);
return yr > yl ? [xc, x1] : [x0, xc];
}
const openingSpanAtY = (op, y) => slopedSpanAtY(op.x, op.x + op.w, op.y, op.y + op.h, op.y + hRightOf(op), y);
// Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat).
// 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het
@@ -260,7 +305,7 @@ window.WallModel = (() => {
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawY: raw.y };
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
});
resolved.sort((a, b) => a.x - b.x);
@@ -274,10 +319,11 @@ window.WallModel = (() => {
// 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// (bijv. een raam boven een deur), dan mogen ze in x overlappen.
// Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd.
// (Schuine bovenzijde: de hoogste kant telt.)
const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => {
const [a0, a1] = useFinal ? vBand(a, a.h, a.y) : vBand(a, Math.max(400, a.rawH), a.rawY);
const [b0, b1] = useFinal ? vBand(b, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.rawY);
const [a0, a1] = useFinal ? vBand(a, Math.max(a.h, a.hR), a.y) : vBand(a, Math.max(400, a.rawH, a.rawHR), a.rawY);
const [b0, b1] = useFinal ? vBand(b, Math.max(b.h, b.hR), b.y) : vBand(b, Math.max(400, b.rawH, b.rawHR), b.rawY);
return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
};
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing
@@ -310,23 +356,22 @@ window.WallModel = (() => {
if (cur.x > maxX) cur.x = maxX;
}
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen
// de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van
// die opening — het punt waar een verticale lijn op die plek de
// (eventueel hellende) horizontale topbalk zou snijden. Bij een
// opening die zelf een breedte heeft, wordt het laagste van de twee
// uiteinden aangehouden (de daklijn is lineair of heeft een nok als
// hoogste punt, dus dat is voldoende).
const localRoofHeightAt = roofAt;
// Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per
// opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu
// definitieve) x-positie van die opening — per kant: de linkerhoogte
// tegen de daklijn boven de linkerrand, de rechterhoogte tegen die
// boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen.
// Daartussen blijft de bovenkant onder het dak: de daklijn is lineair
// of heeft een nok als hoogste punt (bol), dus de rechte lijn tussen
// twee punten eronder blijft eronder.
resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w));
const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2));
const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH);
const maxOpY = Math.max(0, localCeiling - h - plateThick);
const y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = h;
o.y = y;
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = hL;
o.hR = hR;
});
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede
@@ -430,10 +475,16 @@ window.WallModel = (() => {
// kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({
op, x0: startX + op.x, x1: startX + op.x + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw
}));
// Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw):
// topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan.
const bands = ops.map(op => {
const x0 = startX + op.x, s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
return {
op, x0, x1: x0 + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + Math.max(op.h, op.hR) + vo,
topAt: (x) => op.y + op.h + (x - x0) * s + vo
};
});
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
ops.forEach(op => {
@@ -470,8 +521,11 @@ window.WallModel = (() => {
cands.forEach(c => {
const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = CONFIG.plateThick, below = null;
const push = (y0, y1, above) => {
// cur = onderkant van het volgende stuk [links, rechts]: schuin als
// het op een schuine bovendorpel staat (dan ook y0l/y0r; y0 = laagste).
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => {
if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de
@@ -479,13 +533,16 @@ window.WallModel = (() => {
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
}
segs.push({ x, y0, y1, kind: c.kind });
const sg = { x, y0: Math.min(cur[0], cur[1]), y1, kind: c.kind };
if (Math.abs(cur[0] - cur[1]) > 0.05) { sg.y0l = cur[0]; sg.y0r = cur[1]; }
segs.push(sg);
};
hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = bd; }
if (bd.y0 > curMax() + 1) push(bd.y0, bd);
const t = [bd.topAt(x - sw / 2), bd.topAt(x + sw / 2)];
if (Math.max(t[0], t[1]) > curMax()) { cur = t; below = bd; }
});
if (roofMin(x) > cur + 1) push(cur, null, null);
if (roofMin(x) > curMax() + 1) push(null, null);
});
// Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = [];
@@ -509,19 +566,30 @@ window.WallModel = (() => {
// bij een sterk hellende wand met een opening dicht bij de lage kant kan
// de marge anders lokaal boven het dak uitkomen (kapotte uitsparing).
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen).
// Schuine bovenzijde: hy1 = bovenkant links (bij hx0), hy1r = rechts (bij
// hx1), langs de bovenkant van de schuine bovendorpel; per kant geklemd
// (de daklijn is bol, dus de lijn ertussen blijft eronder).
function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth;
const hx1 = -halfW + op.x + op.w + CONFIG.studWidth;
let hy1 = op.y + op.h + CONFIG.studWidth;
const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin;
if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling);
return { hx0, hx1, hy0: op.y, hy1 };
const hx0 = -halfW + op.x - sw;
const hx1 = -halfW + op.x + op.w + sw;
const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
const clampTop = (y, x) => {
const ceil = topYAt(x) - roofSafetyMargin;
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
};
const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r };
});
}
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
const holeTopAt = (h, x) => h.hy1 + (x - h.hx0) / (h.hx1 - h.hx0) * (holeTopR(h) - h.hy1);
const holeRect = (h) => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1, y1r: holeTopR(h) });
// Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
@@ -532,12 +600,15 @@ window.WallModel = (() => {
const range = topLine(CONFIG).rangeAbove(y);
if (!range) return [];
let segments = [range];
sheathingHoles(CONFIG).concat(extraHoles || []).filter(h => y > h.hy0 && y < h.hy1).forEach(h => {
sheathingHoles(CONFIG).concat(extraHoles || []).forEach(h => {
const span = slopedSpanAtY(h.hx0, h.hx1, h.hy0, h.hy1, holeTopR(h), y);
if (!span) return;
const [hx0, hx1] = span;
segments = segments.flatMap(([a, b]) => {
if (h.hx1 <= a || h.hx0 >= b) return [[a, b]];
if (hx1 <= a || hx0 >= b) return [[a, b]];
const out = [];
if (h.hx0 > a) out.push([a, h.hx0]);
if (h.hx1 < b) out.push([h.hx1, b]);
if (hx0 > a) out.push([a, hx0]);
if (hx1 < b) out.push([hx1, b]);
return out;
});
});
@@ -598,11 +669,13 @@ window.WallModel = (() => {
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
let segs = [[y0, yTop]];
holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => {
// Schuine bovenkant: de hoogste waarde over de latbreedte.
const hy1 = Math.max(holeTopAt(h, Math.max(h.hx0, x - w / 2)), holeTopAt(h, Math.min(h.hx1, x + w / 2)));
segs = segs.flatMap(([a, b]) => {
if (h.hy1 <= a || h.hy0 >= b) return [[a, b]];
if (hy1 <= a || h.hy0 >= b) return [[a, b]];
const out = [];
if (h.hy0 > a) out.push([a, h.hy0]);
if (h.hy1 < b) out.push([h.hy1, b]);
if (hy1 < b) out.push([hy1, b]);
return out;
});
});
@@ -788,7 +861,8 @@ window.WallModel = (() => {
xMin: startXM + op.x * toM,
xMax: startXM + (op.x + op.w) * toM,
yMin: op.y * toM,
yMax: (op.y + op.h) * toM,
yMax: (op.y + op.h) * toM, // bovenkant links
yMaxR: (op.y + op.hR) * toM, // bovenkant rechts (schuine bovenzijde)
wM: op.w * toM
}));
@@ -837,21 +911,24 @@ window.WallModel = (() => {
const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const y0 = st.y0 * toM;
// Onderkant schuin als het stuk op een schuine bovendorpel staat.
const y0l = (st.y0l !== undefined ? st.y0l : st.y0) * toM, y0r = (st.y0r !== undefined ? st.y0r : st.y0) * toM;
const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
});
// D. DORPELS, per opening
opsM.forEach(op => {
// Bovendorpel / linteel (horizontale balk)
// Bovendorpel / linteel (liggende balk, volgt een schuine bovenzijde;
// loodrechte dikte = stijldikte)
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax + sWidthM);
opTopShape.lineTo(op.xMin, op.yMax + sWidthM);
opTopShape.lineTo(op.xMax, op.yMaxR);
opTopShape.lineTo(op.xMax, op.yMaxR + vo);
opTopShape.lineTo(op.xMin, op.yMax + vo);
opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++;
@@ -922,7 +999,7 @@ window.WallModel = (() => {
// Hoekdoorloop per buitenlaag, in m (zie cornerExt).
const extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; };
const holeExtents = sheathingHoles(CONFIG).map(hole => ({
hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM
hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM, hy1r: hole.hy1r * toM
}));
// Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand
// en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop
@@ -933,9 +1010,13 @@ window.WallModel = (() => {
const trimR = skinExt && skinExt.right < 0 ? -skinExt.right * toM : 0;
const trimTop = opts.trimTop || {};
const topOf = (v) => (v > 0 ? v : 1e7) * toM;
// Nooit tot in de bovenrand (dan geeft columnMinusRects geen vorm en
// valt de hele laag weg): hooguit 1 mm eronder; dat restje zit in het
// dak van de buur.
const underTop = (x0, x1) => Math.min(tlOut.at(x0), tlOut.at(x1), ...tlOut.kinks.filter(k => k > x0 && k < x1).map(tlOut.at)) - 0.001;
const trimHoles = [];
if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: topOf(trimTop.left) });
if (trimR > 0) trimHoles.push({ hx0: halfW - trimR, hx1: halfW + 1, hy0: -1, hy1: topOf(trimTop.right) });
if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: Math.min(topOf(trimTop.left), underTop(-halfW, -halfW + trimL)) });
if (trimR > 0) trimHoles.push({ hx0: halfW - trimR, hx1: halfW + 1, hy0: -1, hy1: Math.min(topOf(trimTop.right), underTop(halfW - trimR, halfW)) });
const outerHoles = holeExtents.concat(trimHoles);
const trimHolesMM = trimHoles.map(h => ({ hx0: h.hx0 / toM, hx1: h.hx1 / toM, hy0: h.hy0 / toM, hy1: h.hy1 / toM }));
@@ -977,13 +1058,13 @@ window.WallModel = (() => {
const xL = -halfW - ext.left, xR = halfW + ext.right;
const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR;
const topClamped = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x)));
const rects = outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 }));
const rects = outerHoles.map(holeRect);
const shapesIn = (a, b) => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) });
return (pieces || []).map(piece => {
return pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
return shape;
@@ -1047,42 +1128,13 @@ window.WallModel = (() => {
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt,
HX.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })),
HX.map(holeRect),
{ yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
let boardShapes;
if (pieces) {
boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude
// gat-methode, net binnen de plaatrand afgekapt.
const boardShape = new THREE.Shape();
boardShape.moveTo(ex0, yB);
boardShape.lineTo(ex1, yB);
boardShape.lineTo(ex1, skinTopAt(ex1));
TL.kinks.filter(x => x > ex0 && x < ex1).forEach(x => boardShape.lineTo(x, skinTopAt(x)));
boardShape.lineTo(ex0, skinTopAt(ex0));
boardShape.closePath();
holeExtents.forEach(h => {
const chx0 = Math.max(ex0 + edgeEps, h.hx0);
const chx1 = Math.min(ex1 - edgeEps, h.hx1);
if (chx1 > chx0) {
const holePath = new THREE.Path();
const chy0 = Math.max(yB + edgeEps, h.hy0), chy1 = Math.min(yCap - edgeEps, h.hy1);
if (chy1 <= chy0) return;
holePath.moveTo(chx0, chy0);
holePath.lineTo(chx0, chy1);
holePath.lineTo(chx1, chy1);
holePath.lineTo(chx1, chy0);
holePath.closePath();
boardShape.holes.push(holePath);
}
});
boardShapes = [boardShape];
}
const boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
@@ -1205,7 +1257,6 @@ window.WallModel = (() => {
// transparant zijn).
const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
const wallOutlinePts = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...tlOut.pts.slice().reverse()];
const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM;
@@ -1220,41 +1271,13 @@ window.WallModel = (() => {
const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0];
const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks);
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)),
outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : [];
outerHoles.map(holeRect), { yCap: rowY1, extraXs }) : [];
let rowShapes;
if (pieces) {
rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude methode.
let rowPts = clipPolygonYLE(wallOutlinePts, rowY1);
rowPts = clipPolygonYGE(rowPts, rowY0);
rowShapes = [];
if (rowPts.length >= 3) {
const rowShape = new THREE.Shape();
rowShape.moveTo(rowPts[0].x, rowPts[0].y);
for (let k = 1; k < rowPts.length; k++) rowShape.lineTo(rowPts[k].x, rowPts[k].y);
rowShape.closePath();
holeExtents.forEach(h => {
const chy0 = Math.max(rowY0 + edgeEps, h.hy0);
const chy1 = Math.min(rowY1 - edgeEps, h.hy1);
if (chy1 > chy0) {
const holePath = new THREE.Path();
holePath.moveTo(h.hx0, chy0);
holePath.lineTo(h.hx0, chy1);
holePath.lineTo(h.hx1, chy1);
holePath.lineTo(h.hx1, chy0);
holePath.closePath();
rowShape.holes.push(holePath);
}
});
rowShapes.push(rowShape);
}
}
const rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
@@ -1357,9 +1380,7 @@ window.WallModel = (() => {
const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => {
return sum + (op.w * toM) * (op.h * toM) * sDepthM;
}, 0);
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0);
let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3;
if (voidVolumeM3 < 0) voidVolumeM3 = 0;
@@ -1393,6 +1414,10 @@ window.WallModel = (() => {
// Materiaallijst (BOM): elk los stuk hout met zijn maat, soort en
// positie (voor de PDF-tekening en de unit-totalen).
// Elk stuk krijgt ook cutAngles: per zaagsnede de hoek t.o.v. haaks (graden;
// 0 = haaks). Schuine kop onder de daklijn, bovenregel langs de helling
// (loodrechte snede), keep onder een nok = twee sneden bovenaan.
const cutDeg = (dy, dx) => Math.round(Math.atan2(Math.abs(dy), Math.abs(dx)) * 180 / Math.PI * 10) / 10;
function computeBOM(CONFIG, calcData) {
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const halfW = wallWidth / 2;
@@ -1407,12 +1432,12 @@ window.WallModel = (() => {
let cursor = -halfW;
floorCuts.forEach(cut => {
if (cut.min > cursor) {
pieces.push({ length: cut.min - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + cut.min) / 2, y: CONFIG.plateThick / 2 });
pieces.push({ length: cut.min - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + cut.min) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] });
}
cursor = Math.max(cursor, cut.max);
});
if (cursor < halfW) {
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2 });
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] });
}
// Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
@@ -1421,7 +1446,8 @@ window.WallModel = (() => {
const sg = tl.segs[i];
pieces.push({
length: Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y), width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel',
x: (sg.a.x + sg.b.x) / 2, y: (sg.a.y + sg.b.y) / 2 - CONFIG.plateThick / 2, outline
x: (sg.a.x + sg.b.x) / 2, y: (sg.a.y + sg.b.y) / 2 - CONFIG.plateThick / 2, outline,
cutAngles: [cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x), cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x)]
});
});
@@ -1438,11 +1464,14 @@ window.WallModel = (() => {
const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = st.y0;
const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk';
// Schuine onderkant: stuk op een schuine bovendorpel.
const yBottomLeft = st.y0l !== undefined ? st.y0l : yBottom, yBottomRight = st.y0r !== undefined ? st.y0r : yBottom;
const botCut = cutDeg(yBottomRight - yBottomLeft, xRight - xLeft);
if (st.y1 !== null) {
pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1
yBottomLeft, yBottomRight, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [botCut, 0]
});
return;
}
@@ -1450,17 +1479,26 @@ window.WallModel = (() => {
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()]
y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4,
yBottomLeft, yBottomRight, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()],
cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x)))
});
});
CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x;
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2 });
// Schuine bovenzijde: dorpel langs de helling (loodrechte sneden,
// zoals de bovenregel), met de werkelijke omtrek voor de tekening.
const dh = op.hR - op.h;
const header = { length: Math.hypot(op.w, dh), width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + (op.h + op.hR) / 2 + CONFIG.studWidth / 2, cutAngles: [cutDeg(dh, op.w), cutDeg(dh, op.w)] };
if (Math.abs(dh) > 0.5) {
const vo = CONFIG.studWidth * Math.hypot(op.w, dh) / op.w;
header.outline = [{ x: opXWorld, y: op.y + op.h }, { x: opXWorld + op.w, y: op.y + op.hR }, { x: opXWorld + op.w, y: op.y + op.hR + vo }, { x: opXWorld, y: op.y + op.h + vo }];
}
pieces.push(header);
if (op.y > CONFIG.plateThick) {
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2 });
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2, cutAngles: [0, 0] });
}
});
@@ -1536,6 +1574,9 @@ window.WallModel = (() => {
if (typeof o.w !== 'number' || typeof o.h !== 'number' || typeof o.y !== 'number' || typeof o.offset !== 'number') {
throw new Error(`opening #${i + 1}: w/h/y/offset moeten getallen zijn`);
}
if (o.hR !== undefined && (typeof o.hR !== 'number' || !isFinite(o.hR))) {
throw new Error(`opening #${i + 1}: hR (hoogte rechts) moet een getal zijn`);
}
if (o.type !== undefined && !OPENING_TYPES[o.type]) {
throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
}
@@ -1546,8 +1587,8 @@ window.WallModel = (() => {
}
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines,
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+5 -4
View File
@@ -154,16 +154,17 @@ window.WallPdf = (() => {
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');
// Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y);
[[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l));
doc.line(x0, y0, x1, y1);
doc.line(x0, y1, x1, y0);
doc.line(x0, y1, x1, y0r);
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' });
doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' });
});
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
+107 -33
View File
@@ -280,7 +280,9 @@ function mountWallEditor(container, callbacks) {
let wallGroup3D = null;
let lastBaseHeight = 2400;
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset }
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset }
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
// recht en is h de hoogte. De onderkant is altijd waterpas.
// 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant opening
@@ -293,11 +295,14 @@ function mountWallEditor(container, callbacks) {
// ------------------------------------------------------------
// Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles
// alleen de breedte — precies zoals bij de meeste 2D-tekentools.
// de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// (verplaatsen). N/Z wijzigen de hoogte, O/W de breedte. De bovenste
// hoeken (NW/NO) verslepen ook alleen hun eigen kant in de hoogte: zo
// ontstaat een schuine bovenzijde. Onderin blijft het altijd waterpas
// (ZW/ZO: breedte + onderkant).
// ------------------------------------------------------------
let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH }
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH, startHR }
let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
@@ -315,10 +320,17 @@ function mountWallEditor(container, callbacks) {
op.w = w;
}
const isSloped = (op) => op.hR !== undefined;
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
o.hR !== undefined ? { hR: o.hR } : {});
function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return;
['w', 'h', 'y', 'offset'].forEach(field => {
// Schuin aan/uit veranderd (hoek slepen): kaart opnieuw opbouwen.
if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
});
@@ -429,6 +441,8 @@ function mountWallEditor(container, callbacks) {
hRightInput.value = tmp;
openings = openings.map(o => {
// Schuine bovenzijde klapt mee om.
if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h };
if (o.anchor === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset };
@@ -463,9 +477,13 @@ function mountWallEditor(container, callbacks) {
<div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label>Hoogte</label>
<label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
</div>
${isSloped(op) ? `<div class="control-group">
<label>Hoogte rechts</label>
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
</div>` : ''}
<div class="control-group">
<label>Afstand tot vloer</label>
<div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
@@ -486,6 +504,11 @@ function mountWallEditor(container, callbacks) {
<label>${anchorOffsetLabel(op.anchor)}</label>
<div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div>
</div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
@@ -519,6 +542,10 @@ function mountWallEditor(container, callbacks) {
const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
renderOpeningsList();
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
} else if (field === 'anchor') {
op.anchor = e.target.value;
@@ -532,9 +559,24 @@ function mountWallEditor(container, callbacks) {
if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10));
}
const roofBtn = e.target.closest('[data-action="follow-roof"]');
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
});
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// Bovenkant evenwijdig aan de daklijn: hoogte rechts = hoogte links +
// het verschil in daklijn tussen de rechter- en linkerrand (op de
// opgeloste positie; met een nok ertussen het rechte stuk ertussen).
function followRoof(id) {
const op = openings.find(o => o.id === id);
const res = (CONFIG.openings || []).find(o => o.id === id);
if (!op || !res) return;
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
syncOpeningCardInputs(op);
updateAll();
}
// ============================================================
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
@@ -569,7 +611,7 @@ function mountWallEditor(container, callbacks) {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }))
openings: openings.map(rawOpening)
};
}
@@ -596,7 +638,7 @@ function mountWallEditor(container, callbacks) {
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
syncMaterialButtons();
openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }));
openings = state.openings.map(rawOpening);
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null;
@@ -980,39 +1022,53 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h);
const y1 = toCanvasY(op.y + op.h); // bovenkant links
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde)
const isSelected = op.id === selectedOpeningId;
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, localX: op.x, y: op.y, w: op.w, h: op.h });
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
const outline = () => {
ctxFront.beginPath();
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
ctxFront.closePath();
};
outline();
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.fill();
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.stroke();
ctxFront.setLineDash([]);
ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4);
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2;
const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [
{ handle: 'n', cx: midX, cy: y1 },
{ handle: 'nw', cx: x0, cy: y1 },
{ handle: 'n', cx: midX, cy: midTop },
{ handle: 'ne', cx: x1, cy: y1r },
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
{ handle: 'sw', cx: x0, cy: y0 },
{ handle: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY },
{ handle: 'w', cx: x0, cy: midY }
{ handle: 'se', cx: x1, cy: y0 }
];
handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = '#4da6ff';
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath();
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
// Midden (verplaatsen) rond, de rest vierkant.
if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2);
else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
ctxFront.fill();
ctxFront.stroke();
});
@@ -1062,9 +1118,12 @@ function mountWallEditor(container, callbacks) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
}
function frontOpeningAt(mx, my) {
return frontOpeningRects.find(r =>
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) &&
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1));
// Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
return frontOpeningRects.find(r => {
if (mx < r.x0 || mx > r.x1) return false;
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
return my <= r.y0 && my >= yTop;
});
}
canvas2DFront.classList.add('interactive');
@@ -1077,9 +1136,9 @@ function mountWallEditor(container, callbacks) {
if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = {
type: 'resize', handle: hit.handle, openingId: hit.id,
type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
};
e.preventDefault();
return;
@@ -1092,7 +1151,7 @@ function mountWallEditor(container, callbacks) {
dragInfo = {
type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
};
highlightSelectedCard();
draw2D();
@@ -1121,17 +1180,32 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') {
if (dragInfo.handle === 'n') {
op.h = Math.max(400, snap50(dragInfo.startH + dyMM));
} else if (dragInfo.handle === 's') {
const top = dragInfo.startY + dragInfo.startH;
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400);
// Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
const hd = dragInfo.handle;
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
if (hd === 'n') {
// Hele bovenkant omhoog/omlaag (helling blijft).
const dh = snap50(dyMM);
op.h = Math.max(400, dragInfo.startH + dh);
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
} else if (hd === 'nw' || hd === 'ne') {
// Eén bovenhoek: alleen die kant in de hoogte -> schuine bovenzijde.
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
if (op.h === op.hR) delete op.hR; // weer recht
}
if (hd === 's' || hd === 'sw' || hd === 'se') {
// Onderkant; de bovenkant blijft staan (onderin altijd waterpas).
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400);
op.y = Math.max(0, newY);
op.h = top - op.y;
} else if (dragInfo.handle === 'e') {
op.h = topL - op.y;
if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (dragInfo.handle === 'w') {
} else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);