806 lines
46 KiB
JavaScript
806 lines
46 KiB
JavaScript
/*!
|
||
* 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: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
|
||
// TectumLight.mount(el, { model, frames, url, modelViewer, save }); url = adres voor de QR-code in de PDF (standaard de
|
||
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
|
||
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
|
||
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
|
||
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
|
||
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
|
||
// Viewer, Quick Look); op de desktop schermvullend.
|
||
window.TectumLight = (() => {
|
||
const esc = UI.esc;
|
||
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
|
||
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
|
||
let mvLoading = null;
|
||
function loadModelViewer(src) {
|
||
if (window.customElements.get('model-viewer')) return Promise.resolve();
|
||
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
|
||
const s = document.createElement('script');
|
||
s.src = src;
|
||
s.onload = ok;
|
||
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
|
||
document.head.appendChild(s);
|
||
});
|
||
return mvLoading;
|
||
}
|
||
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||
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 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' };
|
||
|
||
// 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 = `<div class="tl-wrap">
|
||
<div class="tl-stage">
|
||
<div class="tl-3d"></div>
|
||
<canvas class="tl-plan" aria-label="Plattegrond, noord boven"></canvas>
|
||
<div class="tl-viewbar" role="group" aria-label="Weergave">
|
||
<button type="button" data-view="3d" class="on"><i class="bi bi-badge-3d"></i>3D</button>
|
||
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer"><i class="bi bi-scissors"></i>Doorsnede</button>
|
||
<button type="button" data-view="plan"><i class="bi bi-grid-1x2"></i>Plattegrond</button>
|
||
</div>
|
||
<label class="tl-cutbar"><span>Snijhoogte</span>
|
||
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
|
||
<output>${meters(SECTION.start)}</output></label>
|
||
<button type="button" class="tl-xbtn" hidden></button>
|
||
<div class="tl-hint"></div>
|
||
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
|
||
<div class="tl-toast" role="status" aria-live="polite"></div>
|
||
</div>
|
||
<aside class="tl-panel">
|
||
<header class="tl-head"><h2 class="tl-title"></h2><div class="tl-sub"></div></header>
|
||
<div class="tl-sections"></div>
|
||
<div class="tl-facts"></div>
|
||
<div class="tl-buy"></div>
|
||
</aside></div>`;
|
||
|
||
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 = HouseModel.rootGroups;
|
||
|
||
// 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();
|
||
}
|
||
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
|
||
function as3d(fn) {
|
||
const prev = view;
|
||
if (prev !== '3d') { view = '3d'; applyView(); }
|
||
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
|
||
}
|
||
// 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) => `<div class="tl-field${cls ? ' ' + cls : ''}"><div class="tl-label"><span>${label}</span>${extra ? `<span class="tl-val">${extra}</span>` : ''}</div>${html}</div>`;
|
||
// disabled(k) geeft false of de reden (tooltip).
|
||
const seg = (key, options, value, disabled) => `<div class="tl-seg" role="radiogroup">${Object.entries(options).map(([k, label]) => {
|
||
const why = disabled && disabled(k);
|
||
return `<button type="button" role="radio" aria-checked="${k === value}" class="${k === value ? 'on' : ''}" data-set="${key}" data-val="${esc(k)}"${why ? ` disabled title="${esc(why)}"` : ''}>${esc(label)}</button>`;
|
||
}).join('')}</div>`;
|
||
// 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 `<div class="tl-swatches" role="radiogroup" data-name="${sel ? esc(sel.label) : ''}">${items.map(it => {
|
||
const why = disabled && disabled(it.key);
|
||
return `<button type="button" role="radio" aria-checked="${it.key === value}" class="tl-sw${it.key === value ? ' on' : ''}" data-set="${key}" data-val="${esc(it.key)}" title="${esc(why ? `${it.label}: ${why}` : it.label)}" aria-label="${esc(it.label)}" style="--sw:${it.hex}"${why ? ' disabled' : ''}>${it.img ? `<img src="${esc(it.img)}" alt="" loading="lazy">` : ''}</button>`;
|
||
}).join('')}</div>`;
|
||
};
|
||
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 `<label class="tl-slider" title="${esc(hint || '')}">
|
||
<span class="tl-sl-head"><span>${label}</span><output>${meters(value)}${extra || ''}</output></span>
|
||
<input type="range" data-num="${key}" min="${lo}" max="${hi}" step="${LightModel.STEP}" value="${value}" aria-label="${esc(label.replace(/<[^>]+>/g, ''))}">
|
||
</label>`;
|
||
};
|
||
// 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 `<svg class="tl-map" viewBox="${x0} ${z0} ${x1 - x0} ${z1 - z0}" role="group" aria-label="Units, noord boven">${rs.map(r =>
|
||
`<g class="tl-map-u${r.id === unitSel ? ' on' : ''}" data-set="unit" data-val="${esc(r.id)}" role="button" tabindex="0" aria-label="Unit ${esc(r.id)}">
|
||
<rect x="${r.x0 + 40}" y="${r.z0 + 40}" width="${r.W - 80}" height="${r.D - 80}" rx="${fs * 0.2}"/>
|
||
<text x="${r.cx}" y="${r.cz}" font-size="${fs}" dy="0.35em" text-anchor="middle">${esc(r.id)}</text></g>`).join('')}</svg>`;
|
||
}
|
||
|
||
// 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 ? `<div class="tl-mapbox">${unitMap()}<span class="tl-note">Klik op een unit</span></div>` : '') +
|
||
(wR ? slider('width', 'Breedte <small>west–oost</small>', u.width, wR) : '') +
|
||
(dR ? slider('depth', 'Diepte <small>noord–zuid</small>', 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 roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel)
|
||
? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES;
|
||
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', roofTypes, 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')) +
|
||
field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) +
|
||
(ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor',
|
||
Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : '');
|
||
|
||
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 <small>afwijkende gevel van het model</small>', 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 <small>stenen strook onderaan de gevel</small>', 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') : '') +
|
||
(LightModel.trimUses(ch) ? swatchField(`Zetwerk <small>${LightModel.trimUses(ch)}</small>`,
|
||
swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : '');
|
||
|
||
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 `<section class="tl-sec${open ? ' open' : ''}">
|
||
<button type="button" class="tl-sec-head" data-sec="${key}" aria-expanded="${open}"><i class="bi bi-${icon}"></i><span class="tl-sec-title">${title}</span>
|
||
<span class="tl-sec-sum">${esc(sum)}</span><i class="bi bi-chevron-down tl-chev"></i></button>
|
||
${open ? `<div class="tl-sec-body">${html}</div>` : ''}</section>`;
|
||
};
|
||
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) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
|
||
$('.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 = `<div class="tl-price"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
|
||
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
|
||
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
|
||
}
|
||
|
||
// 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 === 'trim') next.trim = val;
|
||
else if (key === 'fascia') next.fascia.on = val === 'aan';
|
||
else if (key === 'fasciaColor') next.fascia.color = 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 === 'save') o.save.onSave(design());
|
||
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, other = view !== '3d', pr = renderer.getPixelRatio();
|
||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||
const url = as3d(() => {
|
||
if (other && 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);
|
||
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||
});
|
||
renderer.setPixelRatio(pr);
|
||
if (other) {
|
||
camera.position.copy(keep.pos);
|
||
controls.target.copy(keep.target);
|
||
controls.update();
|
||
}
|
||
resize();
|
||
return { url, width: W, height: H };
|
||
}
|
||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||
|
||
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
|
||
function design() {
|
||
const st = clone(cur.st);
|
||
st.project.naam = model.label;
|
||
st.project.opmerking = 'Ontwerp uit de light-versie';
|
||
return {
|
||
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
|
||
label: model.label, key: model.key, info: Object.assign({}, cur.info),
|
||
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
|
||
};
|
||
}
|
||
|
||
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 (of schermvullend); 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' || fsEl() === stageEl) return;
|
||
e.stopPropagation();
|
||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||
}, { capture: true });
|
||
io.observe(stage);
|
||
|
||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
||
let glb = null;
|
||
function makeGlb() {
|
||
if (glb && glb.parts === cur.parts) return glb;
|
||
if (glb) URL.revokeObjectURL(glb.url);
|
||
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
|
||
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||
return glb;
|
||
}
|
||
|
||
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
|
||
const xbtn = $('.tl-xbtn');
|
||
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
|
||
function setXbtn() {
|
||
const full = fsEl() === stageEl;
|
||
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
|
||
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
|
||
xbtn.setAttribute('aria-label', xbtn.title);
|
||
}
|
||
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
|
||
function toggleFull() {
|
||
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
|
||
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||
}
|
||
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
|
||
document.addEventListener('fullscreenchange', onFull);
|
||
document.addEventListener('webkitfullscreenchange', onFull);
|
||
|
||
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
|
||
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
|
||
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
|
||
let arView = null, arOpen = false;
|
||
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
|
||
function closeAr() {
|
||
if (!arOpen) return;
|
||
arOpen = false;
|
||
arView.classList.remove('on');
|
||
document.documentElement.classList.remove('tl-noscroll');
|
||
}
|
||
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
|
||
document.addEventListener('keydown', arKey);
|
||
function showAr(url) {
|
||
if (!arView) {
|
||
arView = document.createElement('div');
|
||
arView.className = 'tl-arview';
|
||
arView.setAttribute('role', 'dialog');
|
||
arView.setAttribute('aria-modal', 'true');
|
||
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
|
||
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
|
||
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
|
||
</model-viewer>
|
||
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
|
||
<div class="tl-ar-note"></div>`;
|
||
document.body.appendChild(arView);
|
||
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
|
||
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
|
||
mv.addEventListener('load', () => {
|
||
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
|
||
});
|
||
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
|
||
}
|
||
const mv = arView.querySelector('model-viewer');
|
||
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
|
||
arView.querySelector('.tl-ar-title').textContent = model.label;
|
||
mv.alt = `3D-model van ${model.label}`;
|
||
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
|
||
arView.classList.add('on');
|
||
document.documentElement.classList.add('tl-noscroll');
|
||
arOpen = true;
|
||
}
|
||
async function openAr() {
|
||
if (!cur || busy) return;
|
||
busy = true;
|
||
root.classList.add('is-busy');
|
||
try {
|
||
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
|
||
const g = makeGlb();
|
||
await loadModelViewer(mvSrc);
|
||
showAr(g.url);
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast('De AR-weergave kon niet worden geopend.');
|
||
}
|
||
busy = false;
|
||
root.classList.remove('is-busy');
|
||
}
|
||
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
|
||
|
||
(function loop() {
|
||
raf = requestAnimationFrame(loop);
|
||
if (visible && !arOpen && 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();
|
||
document.removeEventListener('keydown', arKey);
|
||
document.removeEventListener('fullscreenchange', onFull);
|
||
document.removeEventListener('webkitfullscreenchange', onFull);
|
||
closeAr();
|
||
if (arView) arView.remove();
|
||
if (glb) URL.revokeObjectURL(glb.url);
|
||
if (group) disposeGeo(group);
|
||
renderer.dispose();
|
||
root.innerHTML = '';
|
||
},
|
||
getChoices: () => clone(ch),
|
||
design: () => cur && design(),
|
||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||
glb: () => cur && makeGlb()
|
||
};
|
||
}
|
||
|
||
function autoMount() {
|
||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||
if (el.dataset.mounted) return;
|
||
el.dataset.mounted = '1';
|
||
try { el.tectumLight = 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 };
|
||
})();
|