Files
crm/static/viewer.js
T

511 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
})();
})();