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, - **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm,
snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal), snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal),
omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen. 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). vloeren, wandstukken, binnenwanden, totalen).
## Bekende beperkingen ## 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 - Tijdelijk: een nieuwe woning is het testontwerp (L-vorm, lessenaar met
riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`. riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`.
- Units kunnen niet gedraaid worden. - 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 - 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, - 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) 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/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 - 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) */ /* 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; } .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-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-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-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>
</div> </div>
@@ -116,6 +133,7 @@
<script src="js/core/geometry.js"></script> <script src="js/core/geometry.js"></script>
<script src="js/core/canvas-labels.js"></script> <script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.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/view-filter.js"></script>
<script src="js/core/ui-kit.js"></script> <script src="js/core/ui-kit.js"></script>
<script src="js/core/skybox.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. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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. // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = []; let tabs = [];
@@ -39,6 +39,7 @@ function updateEditMenuState() {
document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal')); document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal'));
document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D')); document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D'));
document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D')); 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')); document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
} }
@@ -199,6 +200,11 @@ function wireMenu() {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
closeAllMenus(); 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', () => { document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D(); if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
closeAllMenus(); closeAllMenus();
@@ -274,6 +280,8 @@ function wireHotkeys() {
toggleBrowserFullscreen(); toggleBrowserFullscreen();
} else if (e.key.toLowerCase() === 'h') { } else if (e.key.toLowerCase() === 'h') {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); 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') { } else if (e.key.toLowerCase() === 't') {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
} else if (e.key.toLowerCase() === 'r') { } 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) // Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
// bovenrand topYAt(x), MIN een set rechthoekige uitsparingen // bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }):
// ({ x0, x1, y0, y1 }). Uitsparingen die over de rand van de strook vallen // rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
// worden een echte inkeping in de omtrek (i.p.v. een gat dat net binnen de // (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de
// rand wordt afgekapt — dat liet een haarfijn reepje materiaal met zichtbare // rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v.
// randlijnen door de opening lopen). Een uitsparing over de volle breedte // een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn
// splitst de strook in losse delen. // 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): // opts (optioneel):
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan); // yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot // extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
// boven yCap reiken worden er netjes op afgekapt (inkeping in de // de bovenrand tussen twee hoekpunten steeds lineair is.
// bovenrand). // Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de // en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
// bovenrand tussen twee hoekpunten steeds lineair is. // onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null // kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund). // 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) { function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
const eps = 1e-9; const eps = 1e-9;
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity; 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 uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
const rs = rects 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 => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps)
.filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps); .map(r => {
const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]); 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). // Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt)); const raw = [() => yBottom, top];
const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand rs.forEach(r => raw.push(() => r.y0, r.topAt));
if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null; 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)]); let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn // 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]) }); // Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
const filled = (i, j) => { const cols = [];
if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false; for (let i = 0; i < xs.length - 1; i++) {
const xm = (xs[i] + xs[i + 1]) / 2; const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2; const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) }))
return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1); .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;
}; };
const edges = new Map(); // van -> [naar, ...]
// Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is. const addEdge = (p, q) => {
const edges = new Map(); // "i,j" -> [[i2,j2], ...] const a = vid(p.x, p.y), b = vid(q.x, q.y);
const addEdge = (a, b) => { if (a === b) return;
const key = a.join(','); if (!edges.has(a)) edges.set(a, []);
if (!edges.has(key)) edges.set(key, []); edges.get(a).push(b);
edges.get(key).push(b);
}; };
for (let i = 0; i < nCols; i++) { // Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
for (let j = 0; j < nRows; j++) { cols.forEach(col => col.cells.forEach((c, k) => {
if (!filled(i, j)) continue; if (!c.filled) return;
if (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]); 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 (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]); 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 });
if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]); }));
if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]); // 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). // Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = []; const loops = [];
edges.forEach((list, startKey) => { edges.forEach((list, start) => {
while (list.length) { while (list.length) {
const loop = []; const loop = [start];
let cur = startKey.split(',').map(Number); let next = list.pop(), guard = 0;
let next = list.pop(); while (next !== start && guard++ < 100000) {
loop.push(cur);
while (next.join(',') !== startKey) {
loop.push(next); loop.push(next);
cur = next; const outs = edges.get(next);
const outs = edges.get(cur.join(',')); if (!outs || !outs.length) break;
next = outs.pop(); next = outs.pop();
} }
loops.push(loop); if (next === start) loops.push(loop);
} }
}); });
const polys = loops.map(loop => { const polys = loops.map(loop => {
// Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de const pts = loop.map(k => verts[k]);
// bovenrand de onderrand raakt), dan collineaire tussenpunten.
const pts = loop.map(([i, j]) => vertex(i, j))
.filter((p, k, arr) => { const q = arr[(k + 1) % arr.length]; return Math.hypot(p.x - q.x, p.y - q.y) > 1e-9; });
return pts.filter((p, k) => { return pts.filter((p, k) => {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length]; 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; 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; return c;
}; };
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] })); 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 => { 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); if (owner) owner.holes.push(hole);
}); });
return outers; 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' 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() { function createDoc() {
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' }); return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
@@ -130,5 +151,5 @@ window.PdfSheet = (() => {
return y; 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/, // Vloerafwerking (bovenop de OSB). Texturen in assets/textures/vloer/,
// naadloos herhalend; tileMM = maat van één tegel van de textuur. // naadloos herhalend; tileMM = maat van één tegel van de textuur.
const FINISHES = { const FINISHES = {
geen: { label: 'Geen (OSB)' }, geen: { label: 'Geen (OSB)', color: '#cda86a' },
visgraat_001: { label: 'Visgraat (Hongaarse punt)', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 } visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 }
}; };
// 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 = {}; const finishMaterials = {};
function finishMaterial(key) { function finishMaterial(key) {
const f = FINISHES[key]; const f = FINISHES[key];
@@ -393,7 +398,7 @@ window.FloorModel = (() => {
} }
return { return {
FINISHES, finishMaterial, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels, FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D 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) { function interiorLayout(iw) {
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1; const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
const alongX = Math.abs(dx) >= Math.abs(dz); 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('.', ',') + '°' : ''})`; pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
}); });
const roof = { 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 }, 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 base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut). // Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
@@ -666,8 +681,10 @@ window.HouseModel = (() => {
const ops = firstConfig.openings.map(op => { const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0); 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 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)); const ua = toLocalU(lf.place, wa), ub = 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) }; // 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); config = WallModel.buildConfig(paramsOf(lf), ops);
} }
@@ -693,20 +710,15 @@ window.HouseModel = (() => {
// over het blootliggende deel van een naadwand. Daarna pas bouwen. // over het blootliggende deel van een naadwand. Daarna pas bouwen.
const extPieces = []; const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p)))); units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
// Gevel tot onder de dakrand: tussen wandbovenkant en onderkant boeideel // Gevel tot de bovenkant van het dak: tussen wandbovenkant en bovenkant
// resp. daktrim zit het dakpakket (dek + bedekking, verschilt per dak). // dak zit het dakpakket (dek + bedekking, verschilt per dak). De
// De buitenlagen van de wand lopen daar door (skinAbove, mm), zodat er // buitenlagen van de wand lopen daar door (skinAbove, mm). Boeideel en
// geen dakrand bloot ligt, bij elke daksoort. // daktrim komen daar bovenop (computeEdge, facadeDepth); de houtconstructie
const roofSt = RoofModel.normalizeSettings(state.settings.roof); // en de gevel zijn gelijk met of zonder boeideel.
const flatTopsPre = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTopPre = flatTopsPre.length ? Math.max(...flatTopsPre) : 0;
units.forEach(u => { 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 => { u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2); 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(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z)));
p.skinAbove = Math.max(0, Math.round(top - u.roof.ceilingAt(m.x, m.z) - below));
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het // Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
// dak van de buur-unit die daar staat geen buitenlagen (trimTop). // dak van de buur-unit die daar staat geen buitenlagen (trimTop).
const e = p.skinExt || {}, W = p.place.width; 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). // 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 flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTop = flatTops.length ? Math.max(...flatTops) : 0; 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, const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
units.map(u => ({ units.map(u => ({
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false, 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 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). // Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge); const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen". // 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). // 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) { function computeTotals(parts) {
const pieces = allWallPieces(parts); const pieces = allWallPieces(parts);
const kinds = new Map(); const kinds = new Map();
const sawList = 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 }; const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
k.count++; k.lengthMM += length; k.count++; k.lengthMM += length;
kinds.set(kind, k); 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)); ['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
u.roof.planes.forEach(pl => { u.roof.planes.forEach(pl => {
const rc = pl.data.calcData; 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)); ['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
}); });
}); });
const wallBom = []; 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 purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0); const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
@@ -823,6 +934,11 @@ window.HouseModel = (() => {
return { return {
kinds: kindList, kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width), 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), beamCount: kindList.reduce((a, k) => a + k.count, 0),
purchase, purchase,
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0), purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
@@ -970,9 +1086,9 @@ window.HouseModel = (() => {
} }
return { 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, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize 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. // 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 per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
// daarna elke binnenwand (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. // De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
// Plain script, geladen na house-model.js. // Plain script, geladen na house-model.js.
// ============================================================ // ============================================================
@@ -21,6 +23,7 @@ window.HousePdf = (() => {
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel' 'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
}; };
const PLAN_ELEMENT = 'PL-1'; const PLAN_ELEMENT = 'PL-1';
const VIEWS_ELEMENT = 'AZ-1';
function fmt(n, decimals = 1) { function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
@@ -35,7 +38,10 @@ window.HousePdf = (() => {
// Bladvolgorde + elementnummers; gedeeld door generate() en de // Bladvolgorde + elementnummers; gedeeld door generate() en de
// onderdelenlijst op blad 1. // onderdelenlijst op blad 1.
function sheetList(parts) { 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 => { parts.units.forEach(u => {
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: 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 }))); 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 })); 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: 'totals', element: 'TOT', label: 'Totalen' });
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
return list; return list;
} }
@@ -51,7 +58,7 @@ window.HousePdf = (() => {
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'], 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 }); { title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
@@ -172,6 +179,247 @@ window.HousePdf = (() => {
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4); '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) { function drawTotalsSheet(doc, totals, sheetOpts) {
const { BLACK } = PdfSheet.COLORS; const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
@@ -197,22 +445,18 @@ window.HousePdf = (() => {
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']); 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, 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; y = 30;
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']); const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 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 += 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']), 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')); tableOpts('Plaatwerk'));
y += 4; y += 4;
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']); const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' 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) { if (totals.roofEdge) {
y += 4; y += 4;
const e = totals.roofEdge, L = totals.lamps; const e = totals.roofEdge, L = totals.lamps;
@@ -225,12 +469,44 @@ window.HousePdf = (() => {
rows.push(['Boeideel-spots', e.spots + ' stuks']); 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(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]); 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); 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) { function generate(state) {
const parts = HouseModel.computeParts(state, { const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials() wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
@@ -243,14 +519,16 @@ window.HousePdf = (() => {
sheets.forEach((sheet, i) => { sheets.forEach((sheet, i) => {
if (i > 0) doc.addPage('a3', 'landscape'); if (i > 0) doc.addPage('a3', 'landscape');
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; 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 === '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 === '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 === '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); else drawTotalsSheet(doc, totals, opts);
}); });
return doc; 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 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 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) const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
.map(op => [-W / 2 + op.x, -W / 2 + op.x + op.w]).sort((a, b) => a[0] - b[0]); .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
const spans = []; const spans = [];
let cur = xL; let cur = xL;
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); }); 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) const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS)
.filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label])); .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 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), { const covNow = 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 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) + const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) +
(u.roof.followFloor ? '' : (u.roof.followFloor ? '' :
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + 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' }) + toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' }) +
bedekking; bedekking;
const missing = HouseModel.missingSeamWalls(state, u.id);
propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} &times; ${f.depth} mm`) + propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} &times; ${f.depth} mm`) +
UI.tabs('unit', [ UI.tabs('unit', [
{ key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols( { 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' }), 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)) }, 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.') : '') } { 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( actions(
@@ -767,9 +771,11 @@ function mountHouseEditor(container, callbacks) {
UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after); UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after);
UI.onNum('hp-att-offset', v => { u.attach.offset = 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-corners', v => { u.unit.layout.corners = v; }, rerender);
UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender); // Andere koppeling: naadwanden van deze unit gaan mee (oude vervallen,
UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender); // nieuwe naden krijgen er een).
UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender); 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-roof-follow', v => { u.roof.followFloor = v; }, rerender);
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onSel('hp-roof-cover', v => { u.roof.covering = 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. // Keuzes voor de vormgeving van het trasraam: plaat of een steen.
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
// Steenkeuze met voorvertoning. // Keuzelijst met voorvertoning (textuur-thumbnail), als die er is.
const stonePicker = (id, label, value, hint) => sel(id, label, stoneOptions, value, { hint }) + const thumbPicker = (id, label, options, value, thumbUrl, hint) => sel(id, label, options, value, { hint }) +
`<img class="hs-stone-preview" src="${WallModel.stoneThumbUrl(value, 128)}" alt="${value}">`; (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) { function renderHouseProps(warn) {
const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting; 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( { 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' }) + 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' }) : '')), (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.'))) }, info('Wanden uit: kale spaanplaat met plaatnaden.'))) },
{ key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols( { 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' })), 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(); if (act.dataset.action === 'add-unit') addUnit();
else if (act.dataset.action === 'draw-iw') setTool('iw'); else if (act.dataset.action === 'draw-iw') setTool('iw');
else if (act.dataset.action === 'project') openProjectProperties(); 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) --- // --- Specificaties/totalen (onder de 3D) ---
function renderSpecs(totals) { function renderSpecs(totals) {
const f = houseFmt; const f = houseFmt;
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
const cards = [ 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('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
UI.specCard('Hout & isolatie', 'tree', [ UI.specCard('Hout & isolatie', 'tree', [
['Balken', totals.beamCount, 'st.'], ['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.']); 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)); 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>`; document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
} }
// --- Alles opnieuw doorrekenen --- // --- Alles opnieuw doorrekenen ---
function refresh(opts) { 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. // Binnenzijde geschilderd: de (gedeelde) spaanplaat krijgt de verfkleur.
wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR); wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR);
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible }); parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible });
@@ -1080,9 +1102,93 @@ function mountHouseEditor(container, callbacks) {
renderNav(); 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() { function animate() {
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
const now = performance.now();
walk.update((now - lastT) / 1000);
lastT = now;
renderer.render(scene, camera); renderer.render(scene, camera);
} }
function onResize() { function onResize() {
@@ -1094,7 +1200,9 @@ function mountHouseEditor(container, callbacks) {
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
function toggleView(fn) { 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); requestAnimationFrame(onResize);
} }
@@ -1108,12 +1216,15 @@ function mountHouseEditor(container, callbacks) {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
if (rafId !== null) cancelAnimationFrame(rafId); if (rafId !== null) cancelAnimationFrame(rafId);
setWalk(false); // orbitcamera terug vóór het opslaan
walk.dispose();
CameraMemory.save(overviewCamKey, camera, controls); CameraMemory.save(overviewCamKey, camera, controls);
renderer.dispose(); renderer.dispose();
}, },
refresh, refresh,
setTool, setTool,
toggleFullscreen3D: () => toggleView(UI.cycle3D), toggleFullscreen3D: () => toggleView(UI.cycle3D),
toggleWalk: () => setWalk(!walk.active),
toggleFullscreen2D: () => toggleView(UI.toggle2D) toggleFullscreen2D: () => toggleView(UI.toggle2D)
}; };
} }
@@ -1229,6 +1340,7 @@ function mountHouseEditor(container, callbacks) {
triggerImport, triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'), mirrorHorizontal: forward('mirrorHorizontal'),
toggleFullscreen3D: forward('toggleFullscreen3D'), toggleFullscreen3D: forward('toggleFullscreen3D'),
toggleWalk: forward('toggleWalk'),
toggleFullscreen2D: forward('toggleFullscreen2D'), toggleFullscreen2D: forward('toggleFullscreen2D'),
openProjectProperties, openProjectProperties,
getStateJson, 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)))); .filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return; 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); 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)]); 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), // spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY),
// indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten- // indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten-
// lampen (overdag lager: dan vallen ze nauwelijks op). // lampen (overdag lager: dan vallen ze nauwelijks op).
const OUTDOOR = { lamp: 2.0, spot: 1.6, angle: 0.5 }; // Zacht en breed i.p.v. fel en smal: lagere intensiteit, grotere bundel
const INDOOR = { intensity: 4.0, angle: 1.0, reach: 1.12, decay: 0.6 }; // 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; const DAY_OUTDOOR_FACTOR = 0.35;
function buildLights3D(lamps, spots, spotY, indoor, o) { function buildLights3D(lamps, spots, spotY, indoor, o) {
const toM = 0.001; const toM = 0.001;
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1; const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
const group = new THREE.Group(); const group = new THREE.Group();
// Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt. // 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 add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, decay); const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
light.position.set(x, y, z); light.position.set(x, y, z);
light.target.position.set(x + tx, y + ty, z + tz); light.target.position.set(x + tx, y + ty, z + tz);
group.add(light); group.add(light);
@@ -358,18 +361,18 @@ window.LightingModel = (() => {
lamps.forEach(l => { 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 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; 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 !== '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); 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 // Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een
// hoek diagonaal). // hoek diagonaal).
(spots || []).forEach(p => { (spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; 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 => { (indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak 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; return group;
} }
+37 -5
View File
@@ -69,12 +69,18 @@ window.RoofModel = (() => {
// de stengels lopen langs de helling. // de stengels lopen langs de helling.
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer // Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer
// hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2). // 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({ 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.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). // Werkelijke bedekking voor een dakvorm (riet niet op een plat dak).
function coveringFor(key, shapeType) { function coveringFor(key, shapeType) {
const c = COVERINGS[key]; const c = COVERINGS[key];
@@ -379,7 +385,14 @@ window.RoofModel = (() => {
// dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per // dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per
// ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel // ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel
// gevuld (met y = onderkant boeideel ter plaatse). // 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 s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect); const rects = roofs.map(r => r.rect);
if (!roofs.length) return null; if (!roofs.length) return null;
@@ -441,6 +454,25 @@ window.RoofModel = (() => {
overRoof: !!nb && !dropped, dropped 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): // Naast een vervallen ribbe (tegen een hoger dak):
// - ligt het verlengde van de rand in het hoge deel, dan loopt hij // - ligt het verlengde van de rand in het hoge deel, dan loopt hij
// recht door tot die gevel (spot op spotafstand van de gevel); // recht door tot die gevel (spot op spotafstand van de gevel);
@@ -559,7 +591,7 @@ window.RoofModel = (() => {
} }
return { 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, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
computeEdge, buildEdge3D, normalizeSettings computeEdge, buildEdge3D, normalizeSettings
+2 -2
View File
@@ -204,7 +204,7 @@ window.UnitModel = (() => {
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) .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 hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
const params = Object.assign({}, wallState.wall, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)), 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; if (Math.min(hA, hB) < MIN_RAISE_MM) return;
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) .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, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB), width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB),
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
+172 -131
View File
@@ -15,7 +15,7 @@
// //
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...}, // { 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. // 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 ' + '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 ' + '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(' '); '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 isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'); const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'); const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
@@ -146,6 +173,24 @@ window.WallModel = (() => {
// koningsstijl (stevigheid, computeFrame). // koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur'; 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 // Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat). // binnenwanden (aan beide zijden spaanplaat).
// 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het // '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 if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = 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); resolved.sort((a, b) => a.x - b.x);
@@ -274,10 +319,11 @@ window.WallModel = (() => {
// 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar // 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// (bijv. een raam boven een deur), dan mogen ze in x overlappen. // (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. // 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 vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => { 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 [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, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.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; return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
}; };
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing // 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; if (cur.x > maxX) cur.x = maxX;
} }
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen // Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per
// de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van // opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu
// die opening — het punt waar een verticale lijn op die plek de // definitieve) x-positie van die opening — per kant: de linkerhoogte
// (eventueel hellende) horizontale topbalk zou snijden. Bij een // tegen de daklijn boven de linkerrand, de rechterhoogte tegen die
// opening die zelf een breedte heeft, wordt het laagste van de twee // boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen.
// uiteinden aangehouden (de daklijn is lineair of heeft een nok als // Daartussen blijft de bovenkant onder het dak: de daklijn is lineair
// hoogste punt, dus dat is voldoende). // of heeft een nok als hoogste punt (bol), dus de rechte lijn tussen
const localRoofHeightAt = roofAt; // twee punten eronder blijft eronder.
resolved.forEach(o => { resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w)); const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2)); const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH); const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
const maxOpY = Math.max(0, localCeiling - h - plateThick); const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
const y = Math.max(0, Math.min(o.rawY, maxOpY)); o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = h; o.h = hL;
o.y = y; o.hR = hR;
}); });
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede // 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 // kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth; const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({ // Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw):
op, x0: startX + op.x, x1: startX + op.x + op.w, // topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan.
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw 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 = []; const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' })); cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
ops.forEach(op => { ops.forEach(op => {
@@ -470,8 +521,11 @@ window.WallModel = (() => {
cands.forEach(c => { cands.forEach(c => {
const x = Math.round(c.x); 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); 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; // cur = onderkant van het volgende stuk [links, rechts]: schuin als
const push = (y0, y1, above) => { // 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') { if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening // Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de // 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; const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return; 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 => { hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd); if (bd.y0 > curMax() + 1) push(bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = 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. // Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = []; const studs = [];
@@ -509,19 +566,30 @@ window.WallModel = (() => {
// bij een sterk hellende wand met een opening dicht bij de lage kant kan // bij een sterk hellende wand met een opening dicht bij de lage kant kan
// de marge anders lokaal boven het dak uitkomen (kapotte uitsparing). // de marge anders lokaal boven het dak uitkomen (kapotte uitsparing).
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen). // 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) { function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2; const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at; const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10; const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => { return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth; const hx0 = -halfW + op.x - sw;
const hx1 = -halfW + op.x + op.w + CONFIG.studWidth; const hx1 = -halfW + op.x + op.w + sw;
let hy1 = op.y + op.h + CONFIG.studWidth; const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin; const clampTop = (y, x) => {
if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling); const ceil = topYAt(x) - roofSafetyMargin;
return { hx0, hx1, hy0: op.y, hy1 }; 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 // Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de // liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
@@ -532,12 +600,15 @@ window.WallModel = (() => {
const range = topLine(CONFIG).rangeAbove(y); const range = topLine(CONFIG).rangeAbove(y);
if (!range) return []; if (!range) return [];
let segments = [range]; 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]) => { 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 = []; const out = [];
if (h.hx0 > a) out.push([a, h.hx0]); if (hx0 > a) out.push([a, hx0]);
if (h.hx1 < b) out.push([h.hx1, b]); if (hx1 < b) out.push([hx1, b]);
return out; return out;
}); });
}); });
@@ -598,11 +669,13 @@ window.WallModel = (() => {
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2)); const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
let segs = [[y0, yTop]]; let segs = [[y0, yTop]];
holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => { 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]) => { 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 = []; const out = [];
if (h.hy0 > a) out.push([a, h.hy0]); 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; return out;
}); });
}); });
@@ -788,7 +861,8 @@ window.WallModel = (() => {
xMin: startXM + op.x * toM, xMin: startXM + op.x * toM,
xMax: startXM + (op.x + op.w) * toM, xMax: startXM + (op.x + op.w) * toM,
yMin: op.y * 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 wM: op.w * toM
})); }));
@@ -837,21 +911,24 @@ window.WallModel = (() => {
const xM = st.x * toM; const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2); const xLeft = xM - (sWidthM / 2);
const xRight = 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 const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()] ? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...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 }, { 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)); wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
}); });
// D. DORPELS, per opening // D. DORPELS, per opening
opsM.forEach(op => { 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(); const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax); opTopShape.moveTo(op.xMin, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax); opTopShape.lineTo(op.xMax, op.yMaxR);
opTopShape.lineTo(op.xMax, op.yMax + sWidthM); opTopShape.lineTo(op.xMax, op.yMaxR + vo);
opTopShape.lineTo(op.xMin, op.yMax + sWidthM); opTopShape.lineTo(op.xMin, op.yMax + vo);
opTopShape.closePath(); opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2)); wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++; horizBeamCount++;
@@ -922,7 +999,7 @@ window.WallModel = (() => {
// Hoekdoorloop per buitenlaag, in m (zie cornerExt). // 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 extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; };
const holeExtents = sheathingHoles(CONFIG).map(hole => ({ 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 // Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand
// en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop // 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 trimR = skinExt && skinExt.right < 0 ? -skinExt.right * toM : 0;
const trimTop = opts.trimTop || {}; const trimTop = opts.trimTop || {};
const topOf = (v) => (v > 0 ? v : 1e7) * toM; 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 = []; const trimHoles = [];
if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: topOf(trimTop.left) }); 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: topOf(trimTop.right) }); 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 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 })); 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 xL = -halfW - ext.left, xR = halfW + ext.right;
const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR; 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 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) => { const shapesIn = (a, b) => {
if (b - a <= edgeEps) return []; if (b - a <= edgeEps) return [];
const capped = yCap === Infinity; const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects, 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)) }); 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))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
return shape; return shape;
@@ -1047,42 +1128,13 @@ window.WallModel = (() => {
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er // over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen. // geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt, 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 }); { yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
let boardShapes; const boardShapes = pieces.map(piece => {
if (pieces) {
boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape; 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];
}
if (!boardShapes.length) continue; if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
@@ -1205,7 +1257,6 @@ window.WallModel = (() => {
// transparant zijn). // transparant zijn).
const doekZStepM = 0.0002; const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte 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; const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM; let rowY0 = skinFrom * toM;
@@ -1220,41 +1271,13 @@ window.WallModel = (() => {
const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0]; const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0];
const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks); const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks);
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)), 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; const rowShapes = pieces.map(piece => {
if (pieces) {
rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); 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))))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape; 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);
}
}
if (rowShapes.length) { if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings)); const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
@@ -1357,9 +1380,7 @@ window.WallModel = (() => {
const envelopeAreaM2 = polygonArea(envelopePoints); const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM; const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => { const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0);
return sum + (op.w * toM) * (op.h * toM) * sDepthM;
}, 0);
let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3; let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3;
if (voidVolumeM3 < 0) voidVolumeM3 = 0; if (voidVolumeM3 < 0) voidVolumeM3 = 0;
@@ -1393,6 +1414,10 @@ window.WallModel = (() => {
// Materiaallijst (BOM): elk los stuk hout met zijn maat, soort en // Materiaallijst (BOM): elk los stuk hout met zijn maat, soort en
// positie (voor de PDF-tekening en de unit-totalen). // 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) { function computeBOM(CONFIG, calcData) {
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight; const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const halfW = wallWidth / 2; const halfW = wallWidth / 2;
@@ -1407,12 +1432,12 @@ window.WallModel = (() => {
let cursor = -halfW; let cursor = -halfW;
floorCuts.forEach(cut => { floorCuts.forEach(cut => {
if (cut.min > cursor) { 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); cursor = Math.max(cursor, cut.max);
}); });
if (cursor < halfW) { 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 // Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
@@ -1421,7 +1446,8 @@ window.WallModel = (() => {
const sg = tl.segs[i]; const sg = tl.segs[i];
pieces.push({ 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', 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 x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = st.y0; const yBottom = st.y0;
const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk'; 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) { if (st.y1 !== null) {
pieces.push({ pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2, 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; return;
} }
@@ -1450,17 +1479,26 @@ window.WallModel = (() => {
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y; const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({ pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4, y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight, yBottomLeft, yBottomRight, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()] 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 => { CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x; 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) { 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') { 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`); 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]) { 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})`); throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
} }
@@ -1546,8 +1587,8 @@ window.WallModel = (() => {
} }
return { 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, 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, sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs createMaterials, build3D, computeSpecs
}; };
+5 -4
View File
@@ -154,16 +154,17 @@ window.WallPdf = (() => {
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
const opXWorld = -wallWidth / 2 + op.x; 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); // Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S'); 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, y0, x1, y1);
doc.line(x0, y1, x1, y0); doc.line(x0, y1, x1, y0r);
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(6); doc.setFontSize(6);
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant), // Dagmaat-label BINNEN de opening zetten (net onder de bovenkant),
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek // i.p.v. erboven — daar overlapte het met de maatketen/omtrek
// erboven. // 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); 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 wallGroup3D = null;
let lastBaseHeight = 2400; 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: // 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening // left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant 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 // Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van // (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles // de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// alleen de breedte — precies zoals bij de meeste 2D-tekentools. // (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 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 frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront() let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
@@ -315,10 +320,17 @@ function mountWallEditor(container, callbacks) {
op.w = w; 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) { function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return; 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}"]`); const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]); if (input && document.activeElement !== input) input.value = Math.round(op[field]);
}); });
@@ -429,6 +441,8 @@ function mountWallEditor(container, callbacks) {
hRightInput.value = tmp; hRightInput.value = tmp;
openings = openings.map(o => { 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 === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' }; if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset }; 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><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>
<div class="control-group"> <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><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
</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"> <div class="control-group">
<label>Afstand tot vloer</label> <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> <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> <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><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</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> </div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></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]; const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value; op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y; 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(); renderOpeningsList();
} else if (field === 'anchor') { } else if (field === 'anchor') {
op.anchor = e.target.value; op.anchor = e.target.value;
@@ -532,9 +559,24 @@ function mountWallEditor(container, callbacks) {
if (e.target.dataset.action === 'remove') { if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10)); 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); 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 + // JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug. // ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
@@ -569,7 +611,7 @@ function mountWallEditor(container, callbacks) {
return { return {
wall: collectWallParams(), wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone }, 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) stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
syncMaterialButtons(); 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; openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null; selectedOpeningId = null;
@@ -980,39 +1022,53 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM); const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w); const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y); 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; 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.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.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1; ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]); ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1); ctxFront.stroke();
ctxFront.setLineDash([]); ctxFront.setLineDash([]);
ctxFront.lineWidth = 1; ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff'; ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif'; ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center'; 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) { if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2; const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [ 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: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY }, { handle: 'se', cx: x1, cy: y0 }
{ handle: 'w', cx: x0, cy: midY }
]; ];
handles.forEach(hd => { handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy }); 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.strokeStyle = '#0a1a2a';
ctxFront.beginPath(); 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.fill();
ctxFront.stroke(); 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); return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
} }
function frontOpeningAt(mx, my) { function frontOpeningAt(mx, my) {
return frontOpeningRects.find(r => // Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) && return frontOpeningRects.find(r => {
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1)); 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'); canvas2DFront.classList.add('interactive');
@@ -1077,9 +1136,9 @@ function mountWallEditor(container, callbacks) {
if (hit) { if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id); const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = { 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, 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(); e.preventDefault();
return; return;
@@ -1092,7 +1151,7 @@ function mountWallEditor(container, callbacks) {
dragInfo = { dragInfo = {
type: 'move', openingId: bodyHit.id, type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY, 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(); highlightSelectedCard();
draw2D(); draw2D();
@@ -1121,17 +1180,32 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth); applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM)); op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') { } else if (dragInfo.type === 'resize') {
if (dragInfo.handle === 'n') { // Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
op.h = Math.max(400, snap50(dragInfo.startH + dyMM)); const hd = dragInfo.handle;
} else if (dragInfo.handle === 's') { const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
const top = dragInfo.startY + dragInfo.startH; if (hd === 'n') {
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400); // 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.y = Math.max(0, newY);
op.h = top - op.y; op.h = topL - op.y;
} else if (dragInfo.handle === 'e') { if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM)); const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth); 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 right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400); const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth); applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);