/* 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]) => `