/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met // aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen). // Inbedden:
(vanzelf gemount), of // TectumLight.mount(el, { model, frames, url }); url = adres voor de QR-code in de PDF (standaard de modelpagina). window.TectumLight = (() => { const esc = UI.esc; const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d }); const meters = (mm) => `${nl(mm / 1000, 2)} m`; const clone = (o) => JSON.parse(JSON.stringify(o)); // Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator. const SECTION = { start: 1500, min: 300, max: 3000 }; const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' }; // tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet // van het scherm: in de HUGO-site staat hij in een Bootstrap col-12). const TEMPLATE = `
Berekenen…
`; function mount(root, opts) { const o = opts || {}; const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames }); let ch = clone(model.initial); let unitSel = model.base.units[0].id; let cur = null; // { st, parts, info } root.classList.add('tectum-light'); root.innerHTML = TEMPLATE; const $ = (sel) => root.querySelector(sel); $('.tl-title').textContent = model.label; // 3D: afgewerkte woning (zoals de render-export), dag-omgeving. const stage = $('.tl-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 }); 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 S0 = model.base.settings; if (S0.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S0.interiorColor); mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef'); const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats)); const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); }); const rootsOf = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group), [p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean); // Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond. let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, 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 applyView() { if (!cur) return; const p = cur.parts, cut = view === 'cut'; p.units.forEach(u => { if (u.roof.group) u.roof.group.visible = !cut; }); [p.roofEdgeGroup, p.postsGroup].forEach(g => { if (g) g.visible = !cut; }); if (p.roomsGroup && p.roomsGroup.userData.ceilings) p.roomsGroup.userData.ceilings.visible = !cut; const planes = cut ? [cutPlane] : []; HouseModel.allWallPieces(p).forEach(x => clip(x.group, planes)); // Lampen snijden mee: binnenspots onder de snijhoogte blijven zichtbaar, niets zweeft. [p.elektraGroup, p.chimneysGroup, p.roomsGroup, p.fixturesGroup, p.lightingGroup].forEach(g => clip(g, planes)); placeCut(); if (view === 'plan') drawPlan(); } // Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven. function placeCut() { const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop)); cutPlane.constant = (floorTop + sectionMM) * 0.001; if (caps) { group.remove(caps); disposeGeo(caps); caps = null; } if (view === 'cut') { caps = HouseModel.sectionCaps(p, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); } } const cutInput = $('.tl-cutbar input'); // Niet hoger dan de goot (plat dak: de dakhoogte); bij units met een andere goot de laagste, zodat de snede altijd // door alle gevels gaat. Loopt mee met de gekozen hoogte. function fitCutRange() { const lows = ch.house ? [ch.house.low] : Object.values(ch.units).map(u => u.low); const max = Math.max(SECTION.min, Math.floor(Math.min(...lows) / 50) * 50); cutInput.max = max; if (sectionMM > max) sectionMM = max; cutInput.value = sectionMM; $('.tl-cutbar output').textContent = meters(sectionMM); } cutInput.addEventListener('input', () => { sectionMM = Number(cutInput.value); $('.tl-cutbar output').textContent = meters(sectionMM); if (cur) placeCut(); }); // Elke weergave onthoudt zijn eigen camerastand; de doorsnede begint schuin van boven. 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)); $('.tl-hint').textContent = HINTS[v]; applyView(); } $('.tl-hint').textContent = HINTS['3d']; root.querySelector('.tl-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); }); function show(st, parts) { if (group) { scene.remove(group); disposeGeo(group); caps = null; } fitCutRange(); HouseModel.placeGroups(parts); group = new THREE.Group(); rootsOf(parts).forEach(g => group.add(g)); scene.add(group); cur = { st, parts, info: LightModel.summary(st, parts) }; const rs = parts.rects, toM = 0.001; 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)); center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM }; const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - st.settings.groundLevel - 2) * toM; sky.fitTo(group, groundY); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); if (!fitted) { 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(); fitted = true; } applyView(); HousePdf.materialThumbUrls(st).forEach(PdfSheet.preloadImage); renderPanel(); renderSummary(); } // Plattegrond (noord boven): ruimtes met naam en netto m², wanden met kozijnen en draairichting, inrichting, // dakrand, palen en de buitenmaten. Zelfde gegevens als de PDF-plattegrond. function drawPlan() { const cv = $('.tl-plan'), W = cv.clientWidth, H = cv.clientHeight; if (!cur || !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 = cur.parts, rs = p.rects; const ext = rs.concat(p.units.map(u => u.roof.rect)); const bx0 = Math.min(...ext.map(r => r.x0)), bx1 = Math.max(...ext.map(r => r.x1)); const bz0 = Math.min(...ext.map(r => r.z0)), bz1 = Math.max(...ext.map(r => r.z1)); const top = 64, side = 56; const scale = Math.min((W - 2 * side) / (bx1 - bx0), (H - top - side) / (bz1 - bz0)); const ox = W / 2 - (bx0 + bx1) / 2 * scale, oy = top + (H - top - side) / 2 - (bz0 + bz1) / 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 const ux0 = Math.min(...rs.map(r => r.x0)), ux1 = Math.max(...rs.map(r => r.x1)); const uz0 = Math.min(...rs.map(r => r.z0)), uz1 = Math.max(...rs.map(r => r.z1)); 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(ux0, ux1, true, P(0, bz0)[1] - 22); dim(uz0, uz1, false, P(bx0, 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); } // Keuze doorrekenen; past de indeling niet, dan blijft de vorige keuze staan. Bij een maat (fit) eerst de // dichtstbijzijnde maat zoeken die nog wel past. let toastTimer = null; function toast(msg) { const t = $('.tl-toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('on'), 5000); } let busy = false; function tryApply(next, fit) { if (busy) return; busy = true; root.classList.add('is-busy'); setTimeout(() => { try { let st = LightModel.build(model, next), parts = compute(st); let msg = LightModel.check(model, st, parts), note = null; if (msg && fit) { rootsOf(parts).forEach(disposeGeo); const v = LightModel.fit(model, next, fit.set, fit.ok, fit.want); if (v !== fit.ok) { fit.set(next, v); st = LightModel.build(model, next); parts = compute(st); note = `${msg} Dichtstbijzijnde maat die past: ${meters(v)}.`; msg = LightModel.check(model, st, parts); } else parts = null; } if (msg) { if (parts) rootsOf(parts).forEach(disposeGeo); toast(msg); renderPanel(); } else { ch = next; show(st, parts); if (note) toast(note); } } catch (err) { console.error(err); toast('Dit kon niet worden doorgerekend.'); renderPanel(); } busy = false; root.classList.remove('is-busy'); }, 30); } // Paneel: alleen keuzes, geen vrije invoer. const field = (label, html, extra, cls) => `
${label}${extra ? `${extra}` : ''}
${html}
`; // disabled(k) geeft false of de reden (tooltip). const seg = (key, options, value, disabled) => `
${Object.entries(options).map(([k, label]) => { const why = disabled && disabled(k); return ``; }).join('')}
`; // Kleurstalen; de naam van de gekozen kleur komt in de labelregel (swatchField). const swatches = (key, items, value, disabled) => { const sel = items.find(it => it.key === value); return `
${items.map(it => { const why = disabled && disabled(it.key); return ``; }).join('')}
`; }; const swatchField = (label, html) => field(label, html, (html.match(/data-name="([^"]*)"/) || [])[1]); // Het bereik omvat altijd de maat van het model zelf; extra staat achter de waarde (bijv. de helling). const slider = (key, label, value, range, hint, extra) => { const lo = Math.min(range[0], value), hi = Math.max(range[1], value); return ``; }; // Wolfseind: hoogte waarop de kopgevels stoppen (tussen goot en nok); daarboven de schuine wolfseinden. const wolfSlider = (key, f) => { const at = (r) => Math.round((f.low + r * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; return slider(key, 'Wolfseind', at(f.cutRatio), [at(0.05), at(0.95)], 'Hoogte waarop de kopgevels stoppen; daarboven de schuine wolfseinden tot de nok'); }; const colorItems = (obj) => Object.entries(obj).map(([key, c]) => ({ key, label: c.label, hex: c.hex })); const facadeColors = (type, current) => { if (type === 'rabat') return colorItems(WallModel.RABAT_COLORS); if (type === 'stuc') return colorItems(WallModel.STUC_COLORS); if (type === 'fels') return colorItems(WallModel.FELS.COLORS); const codes = LightModel.STONES.includes(current) || !current ? LightModel.STONES : [current].concat(LightModel.STONES); return codes.map(code => ({ key: code, label: `Steenstrip ${code}`, hex: WallModel.STONE_COLORS[code] || '#a08070', img: WallModel.stoneThumbUrl(code, 64) })); }; const facadeColor = (fc) => ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]; const roofTypes = UnitModel.ROOF_TYPES; // Plattegrondje om een unit te kiezen (noord boven). function unitMap() { const rs = cur.parts.rects, pad = 300; const x0 = Math.min(...rs.map(r => r.x0)) - pad, x1 = Math.max(...rs.map(r => r.x1)) + pad; const z0 = Math.min(...rs.map(r => r.z0)) - pad, z1 = Math.max(...rs.map(r => r.z1)) + pad; const fs = Math.min(x1 - x0, z1 - z0) * 0.16; return `${rs.map(r => ` ${esc(r.id)}`).join('')}`; } // Accordeon: één sectie open, in de kop een samenvatting van de keuze. let openSec = 'maten'; const roofName = (t) => ({ plat: 'Plat dak', lessenaar: 'Lessenaardak', zadel: 'Zadeldak', wolf: 'Wolfsdak' })[t]; function renderPanel() { if (!cur) return; const multi = model.base.units.length > 1, houseRoof = !!ch.house; const u = ch.units[unitSel], fr = model.frame.unit(unitSel); const sloped = cur.parts.units.some(x => x.roof.shape.type !== 'plat'); const short = UnitModel.LIMITS.short; // Een unit mag maar in één richting langer zijn dan de transportmaat. const range = (r, other) => r && [r[0], other > short ? Math.min(r[1], short) : r[1]]; const wR = range(fr.width, u.depth), dR = range(fr.depth, u.width); const sizeHtml = (multi ? `
${unitMap()}Klik op een unit
` : '') + (wR ? slider('width', 'Breedte west–oost', u.width, wR) : '') + (dR ? slider('depth', 'Diepte noord–zuid', u.depth, dR) : ''); // Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal. const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st); const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch); const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) })); const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') + field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) + (c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') + (UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') + slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') + (c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high, [Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') + (c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') + swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')); const fc = ch.facade; const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) + (fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') + swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) + (model.hasAccent ? field('Accent afwijkende gevel van het model', seg('accentType', Object.assign({ geen: 'Geen' }, LightModel.FACADES), ch.accent ? ch.accent.type : 'geen')) + (ch.accent ? swatchField('Kleur accent', swatches('accentColor', facadeColors(ch.accent.type, ch.accent.color), ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '') : ''); // Trasraam: strook onderaan de gevel (onder rabat én steen). const tr = ch.trasraam; const trasraamHtml = field('Trasraam stenen strook onderaan de gevel', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) + (tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') + (tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : ''); const L = ch.lighting; const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) + field('Buiten', seg('lightMode', LightModel.LIGHTING, L.mode)) + (L.mode === 'gevel' ? field('Lichtrichting', seg('lightDir', LightingModel.DIRECTIONS, L.direction)) : ''); const i = cur.info; const types = houseRoof ? [ch.house.type] : [...new Set(Object.values(ch.units).map(x => x.type))]; const covLabel = sloped ? RoofModel.COVERINGS[ch.covering].label : 'Dakleer'; const facadeSum = `${LightModel.FACADES[fc.type]} · ${LightModel.colorName(fc.type, facadeColor(fc))}${tr.on ? ' · trasraam' : ''}`; const lightSum = [L.indoor ? 'Spots binnen' : null, L.mode === 'geen' ? 'geen buitenlampen' : LightModel.LIGHTING[L.mode].toLowerCase()].filter(Boolean).join(' · '); const sec = (key, title, icon, sum, html) => { const open = openSec === key; return `
${open ? `
${html}
` : ''}
`; }; const el = $('.tl-sections'), scroll = el.scrollTop; el.innerHTML = sec('maten', 'Afmetingen', 'rulers', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`, sizeHtml) + sec('dak', 'Dak', 'house', `${types.length > 1 ? 'Gemengd' : roofName(types[0])} · ${covLabel}`, roofHtml) + sec('gevel', 'Gevel', 'bricks', facadeSum, facadeHtml + trasraamHtml) + sec('licht', 'Verlichting', 'lightbulb', lightSum, lightHtml); el.scrollTop = scroll; } function renderSummary() { const i = cur.info; $('.tl-sub').textContent = `${meters(i.lengthX)} × ${meters(i.lengthZ)} · ${nl(i.brutoM2, 1)} m² · ${meters(i.height)} hoog`; const kv = (k, v) => `
${k}${v}
`; $('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) + kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`); $('.tl-buy').innerHTML = `
${CostModel.eur(Math.round(i.price / 100) * 100)}indicatief, incl. btw
`; } // Keuzes (knoppen en kleuren). const panel = $('.tl-panel'); function choose(key, val) { if (key === 'unit') { unitSel = val; renderPanel(); return; } const R = (k) => k.startsWith('house') ? next.house : next.units[unitSel]; const next = clone(ch), fc = next.facade; // Andere dakvorm: de nok volgt weer uit de helling (of uit het model, bij zijn eigen dakvorm). if (key === 'unitType' || key === 'houseType') Object.assign(R(key), { type: val, high: null }); else if (key === 'unitDir' || key === 'houseDir') R(key).dir = val; else if (key === 'unitRidge' || key === 'houseRidge') R(key).ridge = val; else if (key === 'indoor') next.lighting.indoor = val === 'aan'; else if (key === 'covering') next.covering = val; else if (key === 'facadeType') fc.type = val; else if (key === 'cladding') fc.cladding = val; else if (key === 'facadeColor') fc[{ rabat: 'rabat', stuc: 'stuc', fels: 'fels', steen: 'stone' }[fc.type]] = val; else if (key === 'accentType') next.accent = val === 'geen' ? null : { type: val, color: val === 'rabat' ? null : facadeColor(Object.assign({}, fc, { type: val })) }; else if (key === 'accentColor') { if (next.accent.type === 'rabat') fc.rabat = val; else next.accent.color = val; } else if (key === 'trasraamType') { const t = next.trasraam; t.on = val === 'steen'; if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0]; } else if (key === 'trasraamStone') next.trasraam.finish = val; else if (key === 'lightMode') next.lighting.mode = val; else if (key === 'lightDir') next.lighting.direction = val; else return; tryApply(next); } panel.addEventListener('click', (e) => { const h = e.target.closest('[data-sec]'); if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; } const b = e.target.closest('[data-set]'); if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; } const a = e.target.closest('[data-act]'); if (!a || !cur) return; if (a.dataset.act === 'pdf') savePdf(); else if (a.dataset.act === 'reset') tryApply(clone(model.initial)); }); panel.addEventListener('keydown', (e) => { const g = e.target.closest('g[data-set]'); if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); } }); // Maten: label volgt het schuiven, doorrekenen bij loslaten. const NUM = { width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } }, depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } }, // De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager). unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) }, houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) }, trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } } }; function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; } // Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte. const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st); NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } }; NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } }; [['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => { NUM[k] = { form, get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; }, set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); } }; }); panel.addEventListener('input', (e) => { const r = e.target.closest('[data-num]'); if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value)); }); panel.addEventListener('change', (e) => { const r = e.target.closest('[data-num]'); if (!r) return; const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch); if (v === old) return; const next = clone(ch); n.set(next, v); tryApply(next, { set: n.set, ok: old, want: v }); }); // Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie. function snapshot() { const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio(); const keep = { pos: camera.position.clone(), target: controls.target.clone() }; 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 (prev !== '3d') { view = prev; camera.position.copy(keep.pos); controls.target.copy(keep.target); controls.update(); applyView(); } resize(); return { url, width: W, height: H }; } const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key); function savePdf() { const st = clone(cur.st); st.project.naam = model.label; st.project.opmerking = 'Indicatief ontwerp'; try { let image = null; try { image = snapshot(); } catch (err) { console.warn(err); } LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image }) .save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`); } catch (err) { console.error(err); toast('De PDF kon niet worden gemaakt.'); } } // Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn. const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } }); let visible = true, raf = 0; function resize() { const w = stage.clientWidth, h = stage.clientHeight; if (!w || !h) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }); ro.observe(stage); // Alleen tekenen als de configurator in beeld is (modelpagina scrolt). const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); }); // In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of // Ctrl/⌘ ingedrukt houdt; dan zoomt het. let engaged = false; const stageEl = $('.tl-stage'); stageEl.addEventListener('pointerdown', () => { engaged = true; }); stageEl.addEventListener('pointerleave', () => { engaged = false; }); stageEl.addEventListener('wheel', (e) => { if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return; e.stopPropagation(); toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.'); }, { capture: true }); io.observe(stage); (function loop() { raf = requestAnimationFrame(loop); if (visible && view !== 'plan') renderer.render(scene, camera); })(); root.classList.add('is-busy'); setTimeout(() => { const st = LightModel.build(model, ch); show(st, compute(st)); root.classList.remove('is-busy'); }, 30); return { unmount() { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); offCosts(); controls.dispose(); if (group) disposeGeo(group); renderer.dispose(); root.innerHTML = ''; }, getChoices: () => clone(ch) }; } function autoMount() { document.querySelectorAll('.tectum-light[data-model]').forEach(el => { if (el.dataset.mounted) return; el.dataset.mounted = '1'; try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; } }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount); else autoMount(); return { mount, autoMount }; })();