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