v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)

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