/* Viewer voor een woningontwerp (house-design-JSON uit de configurator), alleen bekijken. * * Draait op de engine van de configurator (static/configurator/, bijwerken met tools/configurator-sync.sh): * dezelfde HouseModel.computeParts als de configurator en de light-versie, afgewerkte woning in een * dag-omgeving. Weergaven 3D, Doorsnede en Plattegrond zoals de light-versie (js/light/light.js); de * plattegrond is daaruit overgenomen, dus wijzigingen daar hier meenemen. * Kerncijfers (maten, m², inhoud, gewicht, indicatieve prijs) rekent de engine; de prijs komt uit kosten/*.csv. * Lagen: afgewerkt (zoals de light-versie) of per bouwfase / laag (ViewLayers, zoals Lagen in de configurator), * zodat de ploeg bijv. regelwerk of elektra kan bekijken. PDF: werktekening = HousePdf.generate (alle tekenbladen), * klant = LightPdf.generate zoals de light-configurator (voorblad met beeld, kerncijfers, keuzes, prijs en QR-code, * aanzichten en plattegrond, werkwijze); bewaard bij het contact (POST naar de CRM); ontbreekt er een, dan maakt de * viewer hem vanzelf aan. */ (() => { const root = document.getElementById('viewer'); const $ = (sel) => root.querySelector(sel); const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d }); const meters = (mm) => `${nl(mm / 1000, 2)} m`; const SECTION = { start: 1500, min: 300, max: 3000 }; const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' }; let st; try { st = HouseModel.normalize(HouseModel.clone(JSON.parse(document.getElementById('design').textContent))); } catch (err) { console.error(err); root.classList.add('is-error'); $('.vw-msg').textContent = `Dit ontwerp kan niet worden getoond: ${err.message}`; return; } // 3D: afgewerkte woning, dag-omgeving (zoals de light-versie). const stage = $('.vw-3d'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xdfe6ec); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); renderer.shadowMap.enabled = true; renderer.localClippingEnabled = true; stage.appendChild(renderer.domElement); const controls = new THREE.OrbitControls(camera, renderer.domElement); const ambient = new THREE.AmbientLight(0xffffff, 0.6); const sun = new THREE.DirectionalLight(0xffffff, 0.8); sun.position.set(5, 8, 5); scene.add(ambient, sun); const sky = Skybox.createEnvironment(scene); const mats = { wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(), lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials() }; const S = st.settings; if (S.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S.interiorColor); mats.roofMaterials.ceilingMaterial.color.set(S.ceilingColor || '#f4f3ef'); const cfg = JSON.parse((document.getElementById('viewer-cfg') || {}).textContent || '{}'); const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); }); // Lagen: 'af' = afgewerkte woning (finishedOnly, zoals de light-versie); anders de gekozen lagen (bouwfase of los). let layers = ViewLayers.all(), finished = true, parts = null, group = null; function build() { const next = HouseModel.computeParts(st, Object.assign({ layers, finishedOnly: finished }, mats)); HouseModel.placeGroups(next); if (group) { scene.remove(group); disposeGeo(group); caps = null; } parts = next; group = new THREE.Group(); HouseModel.rootGroups(parts).forEach(g => group.add(g)); scene.add(group); } let caps = null; build(); const toM = 0.001, rs = parts.rects; const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1)); const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1)); const center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM }; const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop)); sky.fitTo(group, (floorTop - S.groundLevel - 2) * toM); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); controls.target.set(center.x, 1.2, center.z); camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3); controls.update(); // Weergave: 3D, doorsnede (dak en lampen weg, wanden afgesneden, snede wit afgedekt) of plattegrond. let view = '3d', sectionMM = SECTION.start; const poses = {}; const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); function clip(g, planes) { if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => { if ((m.clippingPlanes || []).length === planes.length) return; m.clippingPlanes = planes; m.needsUpdate = true; })); } function placeCut() { cutPlane.constant = (floorTop + sectionMM) * toM; if (caps) { group.remove(caps); disposeGeo(caps); caps = null; } if (view === 'cut') { caps = HouseModel.sectionCaps(parts, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); } } function applyView() { const cut = view === 'cut', roof = !cut && layers.dak !== false; parts.units.forEach(u => { if (u.roof.group) u.roof.group.visible = roof; }); [parts.roofEdgeGroup, parts.postsGroup].forEach(g => { if (g) g.visible = roof; }); if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = roof; const planes = cut ? [cutPlane] : []; HouseModel.allWallPieces(parts).forEach(x => clip(x.group, planes)); [parts.elektraGroup, parts.chimneysGroup, parts.roomsGroup, parts.fixturesGroup, parts.lightingGroup].forEach(g => clip(g, planes)); placeCut(); if (view === 'plan') drawPlan(); } // Snijhoogte niet hoger dan de laagste goot (plat dak: de dakhoogte), zodat de snede door alle gevels gaat. const cutInput = $('.vw-cutbar input'); const lows = st.settings.roof.house && st.settings.roof.house.enabled && st.settings.roof.house.form ? [st.settings.roof.house.form.low] : st.units.map(u => UnitModel.roofForm(u.unit).low); cutInput.max = Math.max(SECTION.min, Math.floor(Math.min(...lows.filter(Number.isFinite), SECTION.max) / 50) * 50); sectionMM = Math.min(sectionMM, Number(cutInput.max)); cutInput.value = sectionMM; $('.vw-cutbar output').textContent = meters(sectionMM); cutInput.addEventListener('input', () => { sectionMM = Number(cutInput.value); $('.vw-cutbar output').textContent = meters(sectionMM); placeCut(); }); function setView(v) { if (v === view) return; if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() }; view = v; if (v !== 'plan') { const pose = poses[v]; if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); } else { controls.target.set(center.x, 0.6, center.z); camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75); } controls.update(); } root.classList.toggle('view-plan', v === 'plan'); root.classList.toggle('view-cut', v === 'cut'); root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v)); $('.vw-hint').textContent = HINTS[v]; applyView(); } $('.vw-hint').textContent = HINTS['3d']; $('.vw-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); }); // Plattegrond (noord boven), overgenomen uit js/light/light.js (drawPlan). function drawPlan() { const cv = $('.vw-plan'), W = cv.clientWidth, H = cv.clientHeight; if (!W || !H) return; const dpr = Math.min(2, window.devicePixelRatio || 1); cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H); const p = parts; const ext = rs.concat(p.units.map(u => u.roof.rect)); const ex0 = Math.min(...ext.map(r => r.x0)), ex1 = Math.max(...ext.map(r => r.x1)); const ez0 = Math.min(...ext.map(r => r.z0)), ez1 = Math.max(...ext.map(r => r.z1)); const top = 64, side = 56; const scale = Math.min((W - 2 * side) / (ex1 - ex0), (H - top - side) / (ez1 - ez0)); const ox = W / 2 - (ex0 + ex1) / 2 * scale, oy = top + (H - top - side) / 2 - (ez0 + ez1) / 2 * scale; const P = (x, z) => [ox + x * scale, oy + z * scale]; const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); }; const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); }; const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`; ctx.lineJoin = 'round'; // Dakrand ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1; if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); }); else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); }); ctx.setLineDash([]); // Ruimtes const R = p.rooms; R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); }); // Inrichting (p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => { ctx.strokeStyle = '#8b939b'; ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1; ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []); if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; } if (sh.text) return; path(sh.pts, sh.closed); if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); } ctx.stroke(); })); ctx.setLineDash([]); // Wanden met openingen en kozijnsymbolen const piece = (x, fill) => { const pl = x.place, Wp = pl.width; path(corners(pl, -Wp / 2, Wp / 2), true); ctx.fillStyle = fill; ctx.fill(); x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => { path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke(); if (!op.fill) return; KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => { ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8; ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []); path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed); ctx.stroke(); }); ctx.setLineDash([]); }); }; p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247')))); p.interior.forEach(x => piece(x, '#7a828a')); ctx.fillStyle = '#a07c54'; p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); }); p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); }); (p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); }); // Binnenspots ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8; (p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }); // Ruimtenamen ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const fs = Math.max(9, Math.min(13, scale * 260)); R.rooms.forEach(r => { const [x, y] = P(r.label.x, r.label.z); ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`; ctx.fillText(r.name, x, y - fs * 0.6); ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`; ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6); }); // Buitenmaten (units) en noordpijl ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif'; const dim = (a, b, horiz, at) => { const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b); const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1]; ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B); [A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); }); ctx.stroke(); ctx.save(); ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2); if (!horiz) ctx.rotate(-Math.PI / 2); ctx.fillText(nl(Math.round(b - a)), 0, -9); ctx.restore(); }; dim(bx0, bx1, true, P(0, ez0)[1] - 22); dim(bz0, bz1, false, P(ex0, 0)[0] - 22); const nx = W - 28, ny = top + 6; ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill(); ctx.fillText('N', nx, ny + 30); } // Kerncijfers; de prijs volgt zodra de kosten-CSV's geladen zijn. function facts() { const totals = HouseModel.computeTotals(parts); const d = HouseModel.computeDimensions(parts, S.groundLevel); let price = null; try { price = CostModel.compute(parts, totals).summary.inclBtw; } catch (err) { console.warn(err); } const rows = [ ['Buitenmaten', `${nl(d.lengthX / 1000, 2)} × ${nl(d.lengthZ / 1000, 2)} m`], ['Bouwhoogte', meters(d.height)], ['Bruto / leefbaar', `${nl(d.brutoM2, 1)} / ${nl(d.leefbaarM2, 1)} m²`], ['Inhoud', `${nl(d.inhoudM3, 0)} m³`], ['Units', String(st.units.length)], ['Gewicht', `${nl(totals.totalKg / 1000, 1)} t`] ]; if (price) rows.push(['Indicatief incl. btw', `€ ${nl(Math.round(price / 100) * 100)}`]); $('.vw-facts').innerHTML = rows.map(([k, v]) => `
${k}${v}
`).join(''); } facts(); CostModel.onChange(facts); // Scrollwiel: in de CRM-pagina scrolt het de pagina, tenzij je in de 3D hebt geklikt of Ctrl/⌘ ingedrukt houdt. const stageEl = $('.vw-stage'); let engaged = false, toastTimer = null; const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement; function toast(msg) { const t = $('.vw-toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('on'), 3000); } stageEl.addEventListener('pointerdown', () => { engaged = true; }); stageEl.addEventListener('pointerleave', () => { engaged = false; }); stageEl.addEventListener('wheel', (e) => { if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() || window.parent === window) return; e.stopPropagation(); toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.'); }, { capture: true }); // Schermvullend const fullBtn = $('.vw-full'); if (root.requestFullscreen || root.webkitRequestFullscreen) { fullBtn.hidden = false; fullBtn.addEventListener('click', () => { const r = fsEl() ? (document.exitFullscreen || document.webkitExitFullscreen).call(document) : (root.requestFullscreen || root.webkitRequestFullscreen).call(root); if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.')); }); const onFull = () => { root.classList.toggle('is-full', !!fsEl()); fullBtn.title = fsEl() ? 'Schermvullend uit (Esc)' : 'Schermvullend'; }; document.addEventListener('fullscreenchange', onFull); document.addEventListener('webkitfullscreenchange', onFull); } // Afbeelding van de huidige 3D-weergave $('.vw-shot').addEventListener('click', () => { if (view === 'plan') { const a = document.createElement('a'); a.href = $('.vw-plan').toDataURL('image/png'); a.download = 'plattegrond.png'; a.click(); return; } renderer.render(scene, camera); const a = document.createElement('a'); a.href = renderer.domElement.toDataURL('image/png'); a.download = `${(st.project && st.project.naam) || 'ontwerp'}.png`; a.click(); }); // Lagenmenu: bouwfase (alles tot en met) of losse lagen; elke wijziging bouwt de woning opnieuw op. const layerPop = $('.vw-pop-layers'), phaseSel = layerPop.querySelector('select'); phaseSel.innerHTML = `` + ViewLayers.PHASES.slice(0, -1).map((p, i) => ``).join(''); const LAYER_KEYS = ViewLayers.LIST.filter(l => l.key !== 'maten'); layerPop.querySelector('.vw-checks').innerHTML = LAYER_KEYS.map(l => ``).join(''); const syncChecks = () => layerPop.querySelectorAll('[data-layer]').forEach(c => { c.checked = layers[c.dataset.layer] !== false; }); function relayer(next, fin) { layers = next; finished = fin; root.classList.add('is-busy'); $('.vw-layers-btn').classList.toggle('on', !fin); setTimeout(() => { try { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); applyView(); } catch (err) { console.error(err); toast('Deze lagen konden niet worden opgebouwd.'); } root.classList.remove('is-busy'); }, 20); } phaseSel.addEventListener('change', () => { const v = phaseSel.value; // Bij een bouwfase het dak eraf, zodat je in de woning kijkt (aanzetten kan met de laag Dak). relayer(v === 'af' ? ViewLayers.all() : Object.assign(ViewLayers.upTo(Number(v)), { dak: false, maten: true }), v === 'af'); syncChecks(); }); layerPop.addEventListener('change', (e) => { const c = e.target.closest('[data-layer]'); if (!c) return; const next = Object.assign({}, layers, { [c.dataset.layer]: c.checked }); const phase = ViewLayers.phaseOf(next); phaseSel.value = LAYER_KEYS.every(l => next[l.key] !== false) ? 'af' : phase >= 0 && phase < ViewLayers.PHASES.length - 1 ? String(phase) : ''; relayer(next, phaseSel.value === 'af'); }); // Uitklappanelen (lagen, PDF): één open; klik ernaast sluit. root.querySelectorAll('[data-pop]').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); const pop = $(btn.dataset.pop), open = pop.hidden; root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; }); pop.hidden = !open; })); root.querySelectorAll('.vw-pop').forEach(p => p.addEventListener('click', (e) => e.stopPropagation())); document.addEventListener('click', () => root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; })); // PDF (werktekening / klant): de PDF-modules van de configurator pas laden als ze nodig zijn. const PDF_SCRIPTS = ['js/vendor/jspdf.umd.min.js', 'js/core/logo-data.js', 'js/core/pdf-sheet.js', 'js/wall/wall-pdf.js', 'js/floor/floor-pdf.js', 'js/elektra/elektra-pdf.js', 'js/house/house-pdf.js', 'js/vendor/qrcode.js', 'js/light/light-pdf.js']; let pdfLoading = null; const loadScript = (src) => new Promise((ok, fail) => { const sc = document.createElement('script'); sc.src = src; sc.onload = ok; sc.onerror = () => fail(new Error(`${src} niet geladen`)); document.head.appendChild(sc); }); const loadPdf = () => pdfLoading || (pdfLoading = PDF_SCRIPTS.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve()) .then(() => Promise.all(HousePdf.materialThumbUrls(st).map(url => new Promise(ok => { PdfSheet.preloadImage(url); const img = new Image(); img.onload = img.onerror = ok; img.src = url; })))).then(() => new Promise(ok => setTimeout(ok, 100)))); const costsReady = new Promise(ok => { if (CostCsv.status('intern').loadedAt) ok(); else { CostCsv.onChange(ok); setTimeout(ok, 6000); } }); const PDF_LABEL = { werk: 'Werktekening', klant: 'Klant-PDF' }; const pdfs = cfg.pdfs || {}; function renderPdfs() { $('.vw-pop-pdf .vw-pdfs').innerHTML = ['werk', 'klant'].map(k => { const f = pdfs[k]; return `
${PDF_LABEL[k]}${k === 'werk' ? 'Alle tekenbladen, elektra, zaaglijst en kosten (intern)' : 'Zoals de configurator: voorblad, aanzichten, plattegrond en werkwijze (ook in het klantportaal)'} ${f ? `Gemaakt ${f.date}` : ''}
${f ? `Openen` : ''} ${cfg.pdfUrl ? `` : ''}
`; }).join(''); } // Klant-PDF zoals de light-configurator (js/light/light.js savePdf): LightPdf met beeld, kerncijfers en keuzes. // De keuzes leest de CRM uit het ontwerp zelf (de light-keuzes komen niet mee in de house-design-JSON); // zelfde onderwerpen en woorden als LightModel.describe. const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' }; const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' }; const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' }; const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' }; const cap = (t) => t ? t.charAt(0).toUpperCase() + t.slice(1) : t; const label = (list, key) => (list && list[key] && list[key].label) || ''; function choices() { const R = S.roof, F = S.facade, rows = []; const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir] || f.dir}kant)` : UnitModel.hasRidge(f.type) && UnitModel.RIDGES[f.ridge] ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : ''; const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000, 2)} m`; const house = R.house && R.house.enabled && R.house.form; const forms = house ? [R.house.form] : st.units.map(u => UnitModel.roofForm(u.unit)); const per = (fn) => { const v = forms.map(fn); return v.every(x => x === v[0]) ? v[0] : v.map((x, i) => `unit ${st.units[i].id}: ${x}`).join(', '); }; rows.push(['Dakvorm', cap(per(f => (ROOF_NAMES[f.type] || f.type) + way(f)) + (house ? ' over de hele woning' : ''))]); rows.push(['Hoogte', cap(per(f => `goot ${nl(f.low / 1000, 2)} m${nok(f)}`))]); const covers = [...new Set(st.units.map(u => label(RoofModel.COVERINGS, u.roof.covering) || label(RoofModel.COVERINGS, 'dakleer')))]; rows.push(['Dakbedekking', covers.join(', ')]); const color = F.type === 'steen' ? `steen ${F.stone}` : F.type === 'rabat' ? label(WallModel.RABAT_COLORS, F.rabatColor) : F.type === 'stuc' ? label(WallModel.STUC_COLORS, F.stucColor) : label(WallModel.FELS.COLORS, F.felsColor); rows.push(['Gevel', cap([(FACADES[F.type] || F.type || '').toLowerCase(), F.type === 'rabat' ? S.cladding : null, color.toLowerCase()].filter(Boolean).join(', '))]); const tr = S.trasraam || {}; rows.push(['Trasraam', tr.enabled ? `Steen ${tr.finish}, ${nl(tr.height / 1000, 2)} m hoog` : 'Geen']); const fascia = st.units.some(u => u.roof.fascia !== false); rows.push(['Dakrand', fascia ? `Boeidelen ${label(RoofModel.FASCIA_COLORS, R.fasciaColor).toLowerCase()}`.trim() : 'Daktrim']); const L = S.lighting || {}; rows.push(['Buitenverlichting', (LIGHTING[L.mode] || 'Geen') + (L.mode === 'gevel' && LightingModel.DIRECTIONS[L.direction] ? ` (${LightingModel.DIRECTIONS[L.direction].toLowerCase()})` : '')]); rows.push(['Binnenverlichting', L.indoor ? 'Inbouwspots in het plafond' : 'Geen']); return rows; } // Beeld voor het voorblad: de afgewerkte woning in 3D (ook vanuit doorsnede, plattegrond of een laag), vaste resolutie. function snapshot() { const W = 1600, H = 1500, pr = renderer.getPixelRatio(); const keep = { view, layers, finished, pos: camera.position.clone(), target: controls.target.clone() }; const rebuild = () => { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); }; if (!finished) { layers = ViewLayers.all(); finished = true; rebuild(); } if (view !== '3d') { view = '3d'; applyView(); if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); } } renderer.setPixelRatio(1); renderer.setSize(W, H, false); camera.aspect = W / H; camera.updateProjectionMatrix(); controls.update(); renderer.render(scene, camera); const url = renderer.domElement.toDataURL('image/jpeg', 0.88); renderer.setPixelRatio(pr); if (!keep.finished) { layers = keep.layers; finished = false; rebuild(); } view = keep.view; applyView(); camera.position.copy(keep.pos); controls.target.copy(keep.target); controls.update(); resize(); return { url, width: W, height: H }; } function customerPdf() { const raw = JSON.parse(document.getElementById('design').textContent); const key = raw.model || '', model = (HouseModel.MODELS || {})[key]; const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning'; // Afgewerkte woning met 3D, zoals light.js: met skip3D hebben de wandstukken geen specs (gewicht) en faalt computeTotals. const full = HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats)); HouseModel.rootGroups(full).forEach(disposeGeo); const totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel); const info = { price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length, lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3 }; let image = null; try { image = snapshot(); } catch (err) { console.warn(err); } const pdfSt = HouseModel.clone(st); pdfSt.project = Object.assign({}, pdfSt.project, { naam: name, opmerking: (pdfSt.project && pdfSt.project.opmerking) || 'Indicatief ontwerp' }); return LightPdf.generate({ st: pdfSt, label: name, info, choices: choices(), url: LightPdf.modelUrl(key), image }); } let making = Promise.resolve(); function makePdf(kind) { making = making.then(() => { toast(`${PDF_LABEL[kind]} wordt gemaakt…`); return Promise.all([loadPdf(), costsReady]); }).then(() => new Promise(ok => setTimeout(ok, 30))).then(() => { const doc = kind === 'klant' ? customerPdf() : HousePdf.generate(st); const body = new FormData(); body.append('kind', kind); body.append('pdf', doc.output('blob'), `${kind}.pdf`); return fetch(cfg.pdfUrl, { method: 'POST', body, credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))); }).then(res => { pdfs[kind] = res; renderPdfs(); toast(`${PDF_LABEL[kind]} opgeslagen bij het contact.`); if (window.parent !== window) window.parent.postMessage({ type: 'tectum-pdf', kind, url: res.url, date: res.date }, location.origin); }).catch(err => { console.error(err); toast(`${PDF_LABEL[kind]} maken lukte niet: ${err.message}`); }); return making; } $('.vw-pop-pdf').addEventListener('click', (e) => { const b = e.target.closest('[data-make]'); if (b) makePdf(b.dataset.make); }); renderPdfs(); // Direct aanwezig: ontbrekende PDF's maken zodra de weergave staat. if (cfg.pdfUrl && cfg.auto) setTimeout(() => ['werk', 'klant'].filter(k => !pdfs[k]).forEach(makePdf), 1500); function resize() { const w = stage.clientWidth, h = stage.clientHeight; if (!w || !h) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }).observe(stage); resize(); applyView(); root.classList.remove('is-busy'); (function loop() { requestAnimationFrame(loop); if (view !== 'plan' && !document.hidden) renderer.render(scene, camera); })(); })();