(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) => `
`;
// 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 `