diff --git a/README.md b/README.md index 8999318..e9e4374 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/README.txt b/assets/README.txt index 527d19f..0adc399 100644 --- a/assets/README.txt +++ b/assets/README.txt @@ -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: " - 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_.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_.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png diff --git a/assets/textures/dak/thumb/128/riet_001.png b/assets/textures/dak/thumb/128/riet_001.png new file mode 100644 index 0000000..ee14e3a Binary files /dev/null and b/assets/textures/dak/thumb/128/riet_001.png differ diff --git a/assets/textures/dak/thumb/128/riet_002.png b/assets/textures/dak/thumb/128/riet_002.png new file mode 100644 index 0000000..d59ba44 Binary files /dev/null and b/assets/textures/dak/thumb/128/riet_002.png differ diff --git a/assets/textures/dak/thumb/128/riet_003.png b/assets/textures/dak/thumb/128/riet_003.png new file mode 100644 index 0000000..2892ee6 Binary files /dev/null and b/assets/textures/dak/thumb/128/riet_003.png differ diff --git a/assets/textures/dak/thumb/32/riet_001.png b/assets/textures/dak/thumb/32/riet_001.png new file mode 100644 index 0000000..51edf4d Binary files /dev/null and b/assets/textures/dak/thumb/32/riet_001.png differ diff --git a/assets/textures/dak/thumb/32/riet_002.png b/assets/textures/dak/thumb/32/riet_002.png new file mode 100644 index 0000000..5156a4d Binary files /dev/null and b/assets/textures/dak/thumb/32/riet_002.png differ diff --git a/assets/textures/dak/thumb/32/riet_003.png b/assets/textures/dak/thumb/32/riet_003.png new file mode 100644 index 0000000..cd663f5 Binary files /dev/null and b/assets/textures/dak/thumb/32/riet_003.png differ diff --git a/assets/textures/dak/thumb/64/riet_001.png b/assets/textures/dak/thumb/64/riet_001.png new file mode 100644 index 0000000..6c91bbb Binary files /dev/null and b/assets/textures/dak/thumb/64/riet_001.png differ diff --git a/assets/textures/dak/thumb/64/riet_002.png b/assets/textures/dak/thumb/64/riet_002.png new file mode 100644 index 0000000..a7ab54d Binary files /dev/null and b/assets/textures/dak/thumb/64/riet_002.png differ diff --git a/assets/textures/dak/thumb/64/riet_003.png b/assets/textures/dak/thumb/64/riet_003.png new file mode 100644 index 0000000..74c6728 Binary files /dev/null and b/assets/textures/dak/thumb/64/riet_003.png differ diff --git a/assets/textures/vloer/thumb/128/visgraat_001.png b/assets/textures/vloer/thumb/128/visgraat_001.png new file mode 100644 index 0000000..b6bfcdd Binary files /dev/null and b/assets/textures/vloer/thumb/128/visgraat_001.png differ diff --git a/assets/textures/vloer/thumb/32/visgraat_001.png b/assets/textures/vloer/thumb/32/visgraat_001.png new file mode 100644 index 0000000..a99180b Binary files /dev/null and b/assets/textures/vloer/thumb/32/visgraat_001.png differ diff --git a/assets/textures/vloer/thumb/64/visgraat_001.png b/assets/textures/vloer/thumb/64/visgraat_001.png new file mode 100644 index 0000000..5f100e5 Binary files /dev/null and b/assets/textures/vloer/thumb/64/visgraat_001.png differ diff --git a/css/app.css b/css/app.css index ae97cdd..9740b0a 100644 --- a/css/app.css +++ b/css/app.css @@ -644,3 +644,13 @@ 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); } diff --git a/index.html b/index.html index 4be8d40..f5fe54f 100644 --- a/index.html +++ b/index.html @@ -55,6 +55,23 @@ + @@ -116,6 +133,7 @@ + diff --git a/js/app.js b/js/app.js index 48c417a..84a9bd8 100644 --- a/js/app.js +++ b/js/app.js @@ -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.16'; // 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') { diff --git a/js/core/pdf-sheet.js b/js/core/pdf-sheet.js index cd53417..cc91681 100644 --- a/js/core/pdf-sheet.js +++ b/js/core/pdf-sheet.js @@ -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 }; })(); diff --git a/js/core/walk-controls.js b/js/core/walk-controls.js new file mode 100644 index 0000000..e6ca121 --- /dev/null +++ b/js/core/walk-controls.js @@ -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); + } + }; +} diff --git a/js/floor/floor-model.js b/js/floor/floor-model.js index b4494dd..5a5bc38 100644 --- a/js/floor/floor-model.js +++ b/js/floor/floor-model.js @@ -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//.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 }; })(); diff --git a/js/house/house-model.js b/js/house/house-model.js index 20e4afc..a5237fe 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -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). @@ -693,20 +708,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 +746,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 +802,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 + op.w * op.h, 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 + op.w * op.h, 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 +899,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 +932,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 +1084,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 }; })(); diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index abe0dec..83369bf 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -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,245 @@ 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 (op.y + op.h <= y0) return; + const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y), yb = op.y + op.h; + const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur; + faces.push({ pts: [[x0, ya], [x1, ya], [x1, yb], [x0, yb]].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 +443,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 +467,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 +517,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 }; })(); diff --git a/js/house/house.js b/js/house/house.js index 1da1ee5..92f4477 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -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 }) + - `${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 ? `${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 = `
${cards.join('')}
`; } // --- 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 + op.h > 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, diff --git a/js/lighting/lighting-model.js b/js/lighting/lighting-model.js index 34bf067..d3c530c 100644 --- a/js/lighting/lighting-model.js +++ b/js/lighting/lighting-model.js @@ -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; } diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index 8011174..9882d02 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -69,12 +69,18 @@ window.RoofModel = (() => { // de stengels lopen langs de helling. // Riet: één keuze per textuur assets/textures/dak/riet_.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//.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 diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index 89dbf9c..4ecef58 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -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'); @@ -933,9 +960,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 })); @@ -1393,6 +1424,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 +1442,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 +1456,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)] }); }); @@ -1442,7 +1478,7 @@ window.WallModel = (() => { 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: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [0, 0] }); return; } @@ -1452,15 +1488,16 @@ window.WallModel = (() => { 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()] + outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()], + cutAngles: [0].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 }); + 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, cutAngles: [0, 0] }); 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] }); } }); @@ -1546,7 +1583,7 @@ 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, + 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, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs