v0.7.4: light-versie voor de modelpagina's (light.html, js/light/, css/light.css; later in de HUGO-site in een Bootstrap col-12): één model in 3D, doorsnede (snijhoogte tot de goot) en 2D-plattegrond, met beperkte keuzes: per unit breedte en diepte (binnen UnitModel.LIMITS, per model/pagina te beperken), dakvorm met richting, goot- en nokhoogte (helling erbij) en wolfseind, dakbedekking, gevelbekleding met kleur en richting, accent van het model, trasraam (geen/steen, hoogte), binnenspots en buitenverlichting; zelfde berekening als de configurator maar alleen prijs (indicatief, incl. btw), maten, m², inhoud en gewicht zichtbaar; LightModel past keuzes toe op een schone kopie van het model (binnenwanden en inrichting schuiven mee, binnenwanden volgen de goot) en weigert keuzes die de indeling breken (ramen/deuren, binnenwanden, inrichting, ruimtes, overlap) met de dichtstbijzijnde maat die wel past; accordeon-paneel, responsief op de breedte van de tool (container queries), scrollwiel scrolt de pagina tenzij je in de 3D klikt of Ctrl houdt; opslaan = PDF (LightPdf): voorblad "Uw woningontwerp" met render, kerncijfers, keuzes, prijs, contact en QR-code naar de modelpagina (qrcode-generator, MIT), aanzichten en plattegrond (HousePdf klantvariant zonder productie-informatie) en het stappenplan met maatwerk en uitnodiging; doorsnede-afdekking naar HouseModel.sectionCaps (ook in de configurator): 1 mm boven de snede en over de wandtop van wanden die lager blijven, geen flikkerend regelwerk meer; OrbitControls met touch (één vinger draaien, twee vingers knijpen/schuiven); model Vecht (U-vorm 13 × 9,5 m, drie units rond een holte)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
51921650e3
commit
decbbb4b72
@@ -0,0 +1,337 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// Light-versie (modelpagina's): hetzelfde model en dezelfde berekeningen als de configurator, met een beperkte set
|
||||
// keuzes. Keuzes worden altijd opnieuw toegepast op een schone kopie van het model, zodat terug naar de
|
||||
// oorspronkelijke keuze precies het model geeft. Een keuze die de indeling breekt (ramen en deuren die niet meer
|
||||
// passen, binnenwanden of inrichting buiten de unit, ruimtes die verdwijnen) wordt geweigerd (check).
|
||||
window.LightModel = (() => {
|
||||
const LIM = UnitModel.LIMITS;
|
||||
// Kaders (mm) als het model of de pagina niets opgeeft; altijd binnen UnitModel.LIMITS. Per model te
|
||||
// beperken met FRAMES, per pagina met mount-optie frames (zelfde vorm).
|
||||
const DEFAULT_FRAME = { width: [2400, LIM.long], depth: [2400, LIM.long], height: [2400, LIM.eave] };
|
||||
const FRAMES = {
|
||||
// amstel: { units: { A: { width: [9000, 13000], depth: null } }, height: [2600, 3000] }
|
||||
};
|
||||
const STEP = 50;
|
||||
// Helling (graden) bij een andere dakvorm dan die van het model.
|
||||
const PITCH = { lessenaar: 10, zadel: 30, wolf: 35 };
|
||||
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
|
||||
const STONES = ['K490', 'K915', 'K911', 'K764', 'K518', 'K706'];
|
||||
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
|
||||
// Punten tot zo ver van de oost- of zuidrand van een unit horen bij die gevel en schuiven mee.
|
||||
const BAND = 450;
|
||||
|
||||
const clone = HouseModel.clone;
|
||||
const rectMap = (st) => Object.fromEntries(HouseModel.unitRects(st).map(r => [r.id, r]));
|
||||
const houseRoof = (st) => !!(st.settings.roof.house.enabled && st.settings.roof.house.form);
|
||||
|
||||
// Alle gevel-overrides van het model (per wand en per wandstuk): het "accent".
|
||||
function accents(st) {
|
||||
const out = [];
|
||||
st.units.forEach(u => Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||||
if (u.facade.walls[side]) out.push({ get: () => u.facade.walls[side], set: (v) => { if (v) u.facade.walls[side] = v; else delete u.facade.walls[side]; } });
|
||||
(ws.splits || []).forEach(sp => { if (sp && sp.facade) out.push({ get: () => sp.facade, set: (v) => { sp.facade = v || null; } }); });
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
const colorOf = (o) => o.type === 'steen' ? o.stone : o.type === 'stuc' ? o.stuc : o.type === 'fels' ? o.fels : null;
|
||||
|
||||
function frameOf(key, override) {
|
||||
const f = Object.assign({}, FRAMES[key] || {}, override || {});
|
||||
const clampR = (r, lim) => r && [Math.max(1000, Math.min(lim, r[0])), Math.min(lim, r[1])];
|
||||
return {
|
||||
unit: (id) => {
|
||||
const u = (f.units || {})[id] || {};
|
||||
const pick = (k, lim) => u[k] === null ? null : clampR(u[k] || DEFAULT_FRAME[k], lim);
|
||||
return { width: pick('width', LIM.long), depth: pick('depth', LIM.long) };
|
||||
},
|
||||
height: clampR(f.height || DEFAULT_FRAME.height, LIM.eave)
|
||||
};
|
||||
}
|
||||
|
||||
// Model inladen: schone basis, oorspronkelijke dakvormen en de keuzes zoals het model ze heeft.
|
||||
function create(key, opts) {
|
||||
const def = HouseModel.MODELS[key];
|
||||
if (!def) throw new Error(`onbekend model "${key}"`);
|
||||
const base = def.create();
|
||||
const forms = Object.fromEntries(base.units.map(u => [u.id, UnitModel.roofForm(u.unit)]));
|
||||
if (houseRoof(base)) forms.house = Object.assign({}, base.settings.roof.house.form);
|
||||
const f = base.settings.facade, acc = accents(base).map(a => a.get());
|
||||
const covering = (base.units.find(u => RoofModel.COVERINGS[u.roof.covering] && RoofModel.COVERINGS[u.roof.covering].sloped) || base.units[0]).roof.covering;
|
||||
const choices = {
|
||||
units: Object.fromEntries(base.units.map(u => {
|
||||
const fl = u.unit.floor.floor;
|
||||
return [u.id, { width: fl.width, depth: fl.depth, low: forms[u.id].low, high: highOf(forms[u.id]), type: forms[u.id].type, cut: cutOf(forms[u.id]), dir: forms[u.id].dir || null, ridge: forms[u.id].ridge || null }];
|
||||
})),
|
||||
house: forms.house ? { low: forms.house.low, high: highOf(forms.house), type: forms.house.type, cut: cutOf(forms.house), dir: forms.house.dir || null, ridge: forms.house.ridge || null } : null,
|
||||
covering: RoofModel.COVERINGS[covering] ? covering : 'dakleer',
|
||||
facade: { type: f.type, rabat: f.rabatColor, cladding: base.settings.cladding, stuc: f.stucColor, fels: f.felsColor, stone: f.stone },
|
||||
accent: acc.length ? { type: acc[0].type, color: colorOf(acc[0]) } : null,
|
||||
// Trasraam: alleen geen of steen; een cementplaat-trasraam van het model wordt geen.
|
||||
trasraam: (() => {
|
||||
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat';
|
||||
return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height };
|
||||
})(),
|
||||
lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false }
|
||||
};
|
||||
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) };
|
||||
const st = build(model, choices);
|
||||
model.reference = issuesOf(st, HouseModel.computeParts(st, { skip3D: true }));
|
||||
return model;
|
||||
}
|
||||
|
||||
// Wolfseind als verhouding tussen goot (0) en nok (1), zoals de configurator: schaalt mee met goot en nok.
|
||||
const WOLF_CUT = 0.6;
|
||||
const cutOf = (form) => form.type !== 'wolf' ? null
|
||||
: form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - form.low) / Math.max(1, form.high - form.low) : WOLF_CUT;
|
||||
|
||||
// Nok (of hoge kant) als keuze; null = afgeleid (plat dak, of een andere dakvorm dan die van het model).
|
||||
const RISE_MIN = 300;
|
||||
const highOf = (form) => form.type === 'plat' ? null : form.high;
|
||||
|
||||
// Dakvorm bij een keuze (c: type, low, high, cut, dir, ridge): dezelfde vorm als het model houdt richting en
|
||||
// dakhoogte boven de goot, een andere krijgt de helling uit PITCH over de korte kant (lessenaar hoog aan de noord-
|
||||
// of westgevel, nok in de lengte). Een gekozen richting (c.dir, c.ridge) en nok (c.high) gaan voor.
|
||||
function formFor(orig, c, W, D) {
|
||||
const { type, low } = c;
|
||||
const dir = (f) => {
|
||||
if (type === 'lessenaar') f.dir = c.dir || orig.dir || (W >= D ? 'voor' : 'links');
|
||||
if (UnitModel.hasRidge(type)) f.ridge = c.ridge || orig.ridge || (W >= D ? 'x' : 'z');
|
||||
return f;
|
||||
};
|
||||
let f;
|
||||
if (type === orig.type) {
|
||||
f = dir(Object.assign({}, orig, { low }));
|
||||
f.high = type === 'plat' ? low : Math.min(LIM.ridge, orig.high + (low - orig.low));
|
||||
} else {
|
||||
f = { type, low, high: low };
|
||||
if (type === 'plat') return f;
|
||||
dir(f);
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
const run = UnitModel.hasRidge(type) ? span / 2 : span;
|
||||
f.high = Math.round(Math.min(LIM.ridge, low + Math.tan(PITCH[type] * Math.PI / 180) * run));
|
||||
}
|
||||
if (type !== 'plat' && c.high > 0) f.high = Math.round(Math.min(LIM.ridge, Math.max(low + RISE_MIN, c.high)));
|
||||
if (type === 'wolf') { f.cutRatio = c.cut > 0 ? c.cut : cutOf(orig) || WOLF_CUT; delete f.cut; }
|
||||
return f;
|
||||
}
|
||||
// Helling (graden) van een dakvorm over zijn overspanning (W/D: maten van de unit of de woning).
|
||||
function pitchOf(f, W, D) {
|
||||
if (f.type === 'plat') return 0;
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
return Math.atan((f.high - f.low) / ((UnitModel.hasRidge(f.type) ? span / 2 : span) || 1)) * 180 / Math.PI;
|
||||
}
|
||||
function houseSize(st) {
|
||||
const xs = HouseModel.unitRects(st);
|
||||
return [Math.max(...xs.map(r => r.x1)) - Math.min(...xs.map(r => r.x0)), Math.max(...xs.map(r => r.z1)) - Math.min(...xs.map(r => r.z0))];
|
||||
}
|
||||
// Dakvorm zoals build hem toepast: per unit (id), of over de hele woning (id null; maten uit de gebouwde st).
|
||||
// Met pitch: de helling in graden.
|
||||
function formOf(model, ch, id, st) {
|
||||
const [W, D] = id ? [ch.units[id].width, ch.units[id].depth] : houseSize(st);
|
||||
const f = formFor(id ? model.forms[id] : model.forms.house, id ? ch.units[id] : ch.house, W, D);
|
||||
return Object.assign(f, { pitch: pitchOf(f, W, D) });
|
||||
}
|
||||
|
||||
// Unit van een punt (woningcoördinaten): de rechthoek waar het punt het diepst in ligt.
|
||||
function ownerOf(rects, x, z) {
|
||||
let best = null, depth = -1;
|
||||
Object.values(rects).forEach(r => {
|
||||
const d = Math.min(x - r.x0, r.x1 - x, z - r.z0, r.z1 - z);
|
||||
if (d > -1 && d > depth) { best = r.id; depth = d; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// Na een maatwijziging: binnenwanden (woningcoördinaten) schuiven mee met hun unit; een eindpunt tegen de oost- of
|
||||
// zuidgevel van een gewijzigde unit schuift mee met die gevel (de wand rekt mee). Inrichting staat al t.o.v. de
|
||||
// noordwesthoek (of de oostgevel) van zijn unit; staat een stuk tegen de oost- of zuidgevel, dan schuift het mee.
|
||||
function follow(st, before, after) {
|
||||
const shift = (id, x, z) => {
|
||||
const b = before[id], a = after[id];
|
||||
let dx = a.x0 - b.x0, dz = a.z0 - b.z0;
|
||||
if (a.W !== b.W && x >= b.x1 - BAND) dx = a.x1 - b.x1;
|
||||
if (a.D !== b.D && z >= b.z1 - BAND) dz = a.z1 - b.z1;
|
||||
return { x: Math.round(x + dx), z: Math.round(z + dz) };
|
||||
};
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
[['x1', 'z1'], ['x2', 'z2']].forEach(([kx, kz]) => {
|
||||
const id = ownerOf(before, iw[kx], iw[kz]);
|
||||
if (!id) return;
|
||||
const p = shift(id, iw[kx], iw[kz]);
|
||||
iw[kx] = p.x; iw[kz] = p.z;
|
||||
});
|
||||
});
|
||||
st.fixtures.forEach(f => {
|
||||
const b = before[f.unitId], a = after[f.unitId];
|
||||
if (!b || !a) return;
|
||||
const r = f.rot * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
|
||||
const ex = c * f.w / 2 + s * f.d / 2, ez = s * f.w / 2 + c * f.d / 2;
|
||||
if (f.anchor !== 'oost' && a.W !== b.W && f.x + ex >= b.W - BAND) f.x += a.W - b.W;
|
||||
if (a.D !== b.D && f.z + ez >= b.D - BAND) f.z += a.D - b.D;
|
||||
});
|
||||
}
|
||||
|
||||
// Keuzes toepassen op een schone kopie van het model.
|
||||
function build(model, ch) {
|
||||
const st = clone(model.base);
|
||||
const before = rectMap(st);
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], fl = u.unit.floor.floor;
|
||||
fl.width = c.width; fl.depth = c.depth;
|
||||
UnitModel.clampSize(fl);
|
||||
});
|
||||
const after = rectMap(st);
|
||||
follow(st, before, after);
|
||||
|
||||
// Dakvorm en hoogte; binnenwanden gaan evenveel mee omhoog of omlaag als de goot.
|
||||
const lift = {};
|
||||
if (model.forms.house) {
|
||||
st.settings.roof.house.form = formOf(model, ch, null, st);
|
||||
st.units.forEach(u => { lift[u.id] = ch.house.low - model.forms.house.low; });
|
||||
} else {
|
||||
// Alleen opnieuw opbouwen wat afwijkt van het model; de rest blijft precies zoals het model het heeft.
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], c0 = model.initial.units[u.id], fl = u.unit.floor.floor;
|
||||
lift[u.id] = c.low - model.forms[u.id].low;
|
||||
if (['type', 'low', 'high', 'cut', 'dir', 'ridge', 'width', 'depth'].every(k => c[k] === c0[k])) return;
|
||||
UnitModel.applyRoofForm(u.unit, formFor(model.forms[u.id], c, fl.width, fl.depth));
|
||||
});
|
||||
}
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
const id = ownerOf(after, (iw.x1 + iw.x2) / 2, (iw.z1 + iw.z2) / 2), dh = id ? lift[id] : 0;
|
||||
if (!dh) return;
|
||||
const w = iw.wall.wall;
|
||||
['heightAll', 'heightLeft', 'heightRight'].forEach(k => { if (w[k] > 0) w[k] += dh; });
|
||||
});
|
||||
|
||||
if (ch.covering !== model.initial.covering) st.units.forEach(u => { u.roof.covering = ch.covering; });
|
||||
const fc = ch.facade, F = st.settings.facade;
|
||||
Object.assign(F, { type: fc.type, rabatColor: fc.rabat, stucColor: fc.stuc, felsColor: fc.fels, stone: fc.stone });
|
||||
st.settings.cladding = fc.cladding;
|
||||
Object.assign(st.settings.trasraam, { enabled: ch.trasraam.on }, ch.trasraam.on ? { finish: ch.trasraam.finish, height: ch.trasraam.height } : {});
|
||||
if (model.hasAccent && JSON.stringify(ch.accent) !== JSON.stringify(model.initial.accent)) {
|
||||
const a = ch.accent;
|
||||
accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null));
|
||||
}
|
||||
Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Wat er aan de indeling schort: per opening (verschoven, ingeklemd, te hoog voor de wand of in geen enkel
|
||||
// wandstuk meer), binnenwand buiten de units, inrichting door een gevel, verdwenen ruimtes en overlappende units.
|
||||
// check vergelijkt met het model zelf, zodat wat daar al zo is geen keuze blokkeert.
|
||||
function issuesOf(st, parts) {
|
||||
const out = new Set();
|
||||
parts.overlaps.forEach(p => out.add('units ' + p.join('+')));
|
||||
parts.rects.forEach(r => { if (r.error) out.add('units ' + r.id); });
|
||||
const ops = (key, config, placed) => config.openings.forEach(o => {
|
||||
if (o.type === 'schoorsteen') return;
|
||||
const W = config.wallWidth;
|
||||
const want = o.anchor === 'right' ? W - o.offset - o.w : o.anchor === 'middle' ? (W - o.w) / 2 + o.offset : o.offset;
|
||||
if (o.overflowing || Math.abs(o.x - want) > 2) out.add(`opening ${key}.${o.id} plek`);
|
||||
if (o.h < o.rawH - 2 || o.hR < o.rawHR - 2 || o.y < o.rawY - 2) out.add(`opening ${key}.${o.id} hoogte`);
|
||||
if (placed && !placed.has(o.id)) out.add(`opening ${key}.${o.id} kwijt`);
|
||||
});
|
||||
// Gevelopeningen staan t.o.v. de hele wand en worden daarna over de wandstukken verdeeld (UnitModel.wallPieces).
|
||||
st.units.forEach(su => {
|
||||
const pu = parts.units.find(u => u.id === su.id);
|
||||
Object.entries(su.unit.walls).forEach(([side, ws]) => {
|
||||
const w = pu && pu.walls.find(x => x.side === side);
|
||||
ops(`${su.id}-${side}`, WallModel.configFromState(ws), new Set(w ? w.pieces.flatMap(p => p.config.openings.map(o => o.id)) : []));
|
||||
});
|
||||
});
|
||||
parts.interior.forEach(p => ops(`bw${p.id}${p.seamUnit || ''}`, p.config));
|
||||
const inside = (r, x, z, m) => r && x >= r.x0 + m && x <= r.x1 - m && z >= r.z0 + m && z <= r.z1 - m;
|
||||
st.interiorWalls.filter(iw => !iw.seam).forEach(iw => {
|
||||
if (![[iw.x1, iw.z1], [iw.x2, iw.z2]].every(([x, z]) => parts.rects.some(r => inside(r, x, z, -1)))) out.add(`indeling bw${iw.id}`);
|
||||
});
|
||||
parts.fixtures.forEach(it => {
|
||||
const r = parts.rects.find(q => q.id === it.unitId);
|
||||
if (it.corners.some(c => !inside(r, c.x, c.z, 100))) out.add(`indeling fx${it.id}`);
|
||||
});
|
||||
return { keys: out, rooms: parts.rooms.rooms.length };
|
||||
}
|
||||
|
||||
// null = in orde, anders een melding voor de klant.
|
||||
function check(model, st, parts) {
|
||||
const now = issuesOf(st, parts), ref = model.reference;
|
||||
const added = [...now.keys].filter(k => !ref.keys.has(k));
|
||||
if (now.rooms < ref.rooms) added.push('indeling ruimtes');
|
||||
if (!added.length) return null;
|
||||
if (added.some(k => k.startsWith('units'))) return 'De units komen dan over elkaar heen.';
|
||||
if (added.some(k => k.startsWith('opening'))) return 'De ramen en deuren van dit model passen dan niet meer.';
|
||||
return 'De indeling van dit model past dan niet meer.';
|
||||
}
|
||||
|
||||
// Grootste of kleinste maat tussen een geldige (ok) en een gevraagde waarde (want) die nog past; rekent zonder 3D.
|
||||
function fit(model, ch, setter, ok, want) {
|
||||
let good = ok, bad = want;
|
||||
while (Math.abs(bad - good) > STEP) {
|
||||
const mid = Math.round((good + bad) / 2 / STEP) * STEP;
|
||||
if (mid === good || mid === bad) break;
|
||||
const c = clone(ch);
|
||||
setter(c, mid);
|
||||
const st = build(model, c);
|
||||
if (check(model, st, HouseModel.computeParts(st, { skip3D: true }))) bad = mid; else good = mid;
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// Kerncijfers voor de klant: geen uren, zaagstukken of inkoop.
|
||||
function summary(st, parts) {
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const cost = CostModel.compute(parts, totals);
|
||||
const d = HouseModel.computeDimensions(parts, st.settings.groundLevel);
|
||||
return {
|
||||
price: cost.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
|
||||
};
|
||||
}
|
||||
|
||||
// De keuzes in woorden (voorblad van de PDF): [onderwerp, tekst].
|
||||
// Lessenaar: hoge kant; zadel/wolf: richting van de nok (UnitModel.RIDGES).
|
||||
const SIDES = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
|
||||
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
|
||||
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
|
||||
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
||||
function colorName(type, key) {
|
||||
if (type === 'steen') return `steen ${key}`;
|
||||
const list = type === 'rabat' ? WallModel.RABAT_COLORS : type === 'stuc' ? WallModel.STUC_COLORS : WallModel.FELS.COLORS;
|
||||
return list[key] ? list[key].label.toLowerCase() : '';
|
||||
}
|
||||
function describe(model, ch, st) {
|
||||
const fc = ch.facade, ids = Object.keys(ch.units), multi = ids.length > 1;
|
||||
const facade = (type, color) => [FACADES[type].toLowerCase(), type === 'rabat' ? fc.cladding : null, colorName(type, color)].filter(Boolean).join(', ');
|
||||
const perUnit = (fn) => {
|
||||
const vals = ids.map(id => fn(ch.units[id], id));
|
||||
return !multi || vals.every(v => v === vals[0]) ? vals[0] : ids.map((id, i) => `unit ${id}: ${vals[i]}`).join(', ');
|
||||
};
|
||||
const sloped = ch.house ? ch.house.type !== 'plat' : ids.some(id => ch.units[id].type !== 'plat');
|
||||
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000)} m`;
|
||||
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir]}kant)` : UnitModel.hasRidge(f.type) ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
|
||||
const rows = [
|
||||
['Dakvorm', cap(ch.house ? `${ROOF_NAMES[ch.house.type]}${way(formOf(model, ch, null, st))} over de hele woning`
|
||||
: perUnit((u, id) => ROOF_NAMES[u.type] + way(formOf(model, ch, id))))],
|
||||
['Hoogte', cap(ch.house ? `goot ${nl(ch.house.low / 1000)} m${nok(formOf(model, ch, null, st))}` : perUnit((u, id) => `goot ${nl(u.low / 1000)} m${nok(formOf(model, ch, id))}`))],
|
||||
['Dakbedekking', (RoofModel.COVERINGS[sloped ? ch.covering : 'dakleer'] || RoofModel.COVERINGS.dakleer).label],
|
||||
['Gevel', cap(facade(fc.type, ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]))]
|
||||
];
|
||||
const tr = ch.trasraam;
|
||||
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
|
||||
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']);
|
||||
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
|
||||
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
|
||||
return rows;
|
||||
}
|
||||
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName };
|
||||
})();
|
||||
@@ -0,0 +1,227 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en
|
||||
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||
window.LightPdf = (() => {
|
||||
const COMPANY = {
|
||||
naam: 'Van Emous Mantelzorgwoningen',
|
||||
adres: ['Keizerswoert 19', '3881 LE Putten'],
|
||||
telefoon: '0341 - 275 147',
|
||||
email: 'info@vanemousmantelzorgwoningen.nl',
|
||||
web: 'https://www.vanemousmantelzorgwoningen.nl',
|
||||
open: 'Ma t/m vr 08:00 - 17:00 uur, za en zo op afspraak'
|
||||
};
|
||||
// Modellen met een eigen pagina op de site; andere verwijzen naar het overzicht.
|
||||
const SITE_MODELS = ['amstel', 'ijssel', 'maas', 'reest', 'rijn', 'waal', 'wetering', 'eem'];
|
||||
const modelUrl = (key) => SITE_MODELS.includes(key) ? `${COMPANY.web}/modellen/model-${key}/` : `${COMPANY.web}/modellen/`;
|
||||
|
||||
// Werkwijze, naar de site (werkwijze/stappenplan).
|
||||
const STEPS = [
|
||||
['Mag u plaatsen?', 'Wij toetsen vrijblijvend uw perceel aan de regels van uw gemeente en geven een eerste indicatie van de mogelijkheden. Voor zekerheid helpen wij u graag bij het overleg met de gemeente.'],
|
||||
['Uw ontwerp uitwerken', 'U start vanuit een van onze standaardmodellen of vanuit uw eigen ontwerp. Onze specialisten werken uw wensen uit tot een productietekening. Omdat wij in onze eigen fabriek bouwen, is vrijwel elk ontwerp technisch haalbaar.'],
|
||||
['Wij gaan bouwen', 'Zodra het ontwerp definitief is en de omgevingsvergunning er is, start de productie in onze fabriek. Tijdens openingstijden bent u welkom om de voortgang te komen bekijken.'],
|
||||
['Transport en plaatsing', 'De afgebouwde modules worden naar uw perceel gebracht, op de fundering geplaatst en aangesloten op de nutsvoorzieningen. Na de laatste afwerking ontvangt u de sleutel.'],
|
||||
['Oplevering en service', 'Samen lopen we de woning na, zodat alles klopt met het ontwerp en uw wensen. Ook na de oplevering blijven wij beschikbaar voor service en onderhoud.']
|
||||
];
|
||||
|
||||
const MAATWERK = ['Dit is pas het begin: alles kan op maat',
|
||||
'Dit ontwerp is gemaakt met een vereenvoudigde versie van onze configurator. Met onze eigen ontwerpsoftware werken wij uw woning ' +
|
||||
'samen met u tot in detail uit: een keuken op maat, een andere indeling met extra binnenwanden, of ramen en deuren op een andere ' +
|
||||
'plek en in een ander formaat. U bent van harte welkom voor een vrijblijvend adviesgesprek, waarin we samen uw wensen en ' +
|
||||
'mogelijkheden doornemen. Ook staat onze fabriek in Putten open voor bezoek: kom kijken hoe onze woningen gebouwd worden en ervaar ' +
|
||||
'de kwaliteit met eigen ogen. Maak gerust een afspraak; wij denken graag met u mee.'];
|
||||
|
||||
const ORANGE = [185, 89, 19], INK = [33, 33, 33], MUTED = [91, 102, 115], SOFT = [249, 246, 240], LINE = [226, 231, 238];
|
||||
const PW = 420, PH = 297, M = 20;
|
||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
|
||||
// QR-code als vlakken (scherp bij elke vergroting), met witte rand.
|
||||
function drawQr(doc, text, x, y, size) {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount(), quiet = 2, s = size / (n + 2 * quiet);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(x, y, size, size, 'F');
|
||||
doc.setFillColor(0, 0, 0);
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!qr.isDark(r, c)) continue;
|
||||
let e = c;
|
||||
while (e + 1 < n && qr.isDark(r, e + 1)) e++;
|
||||
doc.rect(x + (quiet + c) * s, y + (quiet + r) * s, (e - c + 1) * s + 0.02, s + 0.02, 'F');
|
||||
c = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logo(doc, x, y, h) {
|
||||
const L = window.TECTUM_LOGO;
|
||||
if (L) doc.addImage(L.png, 'PNG', x, y, h * L.aspect, h, 'tectum-logo', 'FAST');
|
||||
else { doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...INK); doc.text(COMPANY.naam, x, y + h * 0.7); }
|
||||
}
|
||||
|
||||
// Contactblok met QR-code; y = bovenkant.
|
||||
function contactBand(doc, y, url, label) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, y, PW - M, y);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.text(COMPANY.naam, M, y + 9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
const col = (x, lines) => lines.forEach((t, i) => doc.text(t, x, y + 16 + i * 5.2));
|
||||
col(M, COMPANY.adres.concat([COMPANY.open]));
|
||||
col(M + 95, [`Telefoon ${COMPANY.telefoon}`, COMPANY.email, COMPANY.web.replace(/^https:\/\//, '')]);
|
||||
const q = 34;
|
||||
drawQr(doc, url, PW - M - q, y + 4, q);
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(doc.splitTextToSize(label, 70), PW - M - q - 4, y + 12, { align: 'right' });
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFontSize(8);
|
||||
doc.text(url.replace(/^https:\/\//, ''), PW - M - q - 4, y + 30, { align: 'right' });
|
||||
}
|
||||
|
||||
function keyValues(doc, x, y, w, rows, title) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(title, x, y);
|
||||
let yy = y + 8;
|
||||
rows.forEach(([k, v]) => {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(k, x, yy);
|
||||
doc.setTextColor(...INK);
|
||||
const lines = doc.splitTextToSize(String(v), w - 42);
|
||||
doc.text(lines, x + 42, yy);
|
||||
yy += 5.4 * lines.length + 0.8;
|
||||
});
|
||||
return yy;
|
||||
}
|
||||
|
||||
function cover(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(34);
|
||||
doc.text('Uw woningontwerp', M, 62);
|
||||
doc.setTextColor(...ORANGE);
|
||||
doc.setFontSize(20);
|
||||
doc.text(d.label, M, 74);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`Samengesteld op ${new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`, M, 82);
|
||||
|
||||
const i = d.info;
|
||||
let y = keyValues(doc, M, 98, 150, [
|
||||
['Buitenmaten', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`], ['Bouwhoogte', meters(i.height)],
|
||||
['Vloeroppervlak', `${nl(i.brutoM2, 1)} m² bruto, ${nl(i.leefbaarM2, 1)} m² leefbaar`], ['Inhoud', `${nl(i.inhoudM3, 0)} m³`],
|
||||
['Modules', String(i.units)]
|
||||
], 'Kerncijfers');
|
||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
||||
|
||||
// Prijs
|
||||
const py = Math.max(y + 4, 196);
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Indicatieve prijs', M + 7, py + 9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(24);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27);
|
||||
|
||||
// Beeld van de woning
|
||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
||||
if (d.image) {
|
||||
const ar = d.image.width / d.image.height, w = Math.min(bw, bh * ar), h = w / ar;
|
||||
doc.addImage(d.image.url, 'JPEG', bx + (bw - w) / 2, by + (bh - h) / 2, w, h);
|
||||
}
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text('Dit ontwerp en de prijs zijn indicatief; hieraan kunnen geen rechten worden ontleend.', M, PH - 6);
|
||||
}
|
||||
|
||||
function process(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(28);
|
||||
doc.text('Hoe verloopt het traject?', M, 62);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize('Van het eerste idee tot de sleuteloverdracht: wij begeleiden u in duidelijke stappen naar een mantelzorgwoning die bij u past. Dit ontwerp is de start van stap 2.', 260), M, 74);
|
||||
|
||||
const gap = 8, w = (PW - 2 * M - gap * (STEPS.length - 1)) / STEPS.length, y = 92, h = 98;
|
||||
STEPS.forEach(([title, text], k) => {
|
||||
const x = M + k * (w + gap);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(x, y, w, h, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.circle(x + 12, y + 14, 6.5, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.text(String(k + 1), x + 12, y + 14, { align: 'center', baseline: 'middle' });
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFontSize(13);
|
||||
doc.text(doc.splitTextToSize(title, w - 12), x + 6, y + 32);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(text, w - 12), x + 6, y + 44, { lineHeightFactor: 1.4 });
|
||||
});
|
||||
// Maatwerk: dit ontwerp is een begin, de eigen software kan veel verder.
|
||||
const by = 195, bh = 40;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(M, by, PW - 2 * M, bh, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.rect(M, by + 3, 2.5, bh - 6, 'F');
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(MAATWERK[0], M + 9, by + 10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10.5);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(MAATWERK[1], PW - 2 * M - 18), M + 9, by + 17, { lineHeightFactor: 1.35 });
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
}
|
||||
|
||||
// d: { st, label, info, choices: [[label, tekst]], url, image: { url, width, height } | null }
|
||||
function generate(d) {
|
||||
const doc = PdfSheet.createDoc();
|
||||
cover(doc, d);
|
||||
HousePdf.generate(d.st, { customer: true, doc, pageOffset: 1, pageTotal: 4 });
|
||||
doc.addPage('a3', 'landscape');
|
||||
process(doc, d);
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { COMPANY, modelUrl, generate };
|
||||
})();
|
||||
@@ -0,0 +1,658 @@
|
||||
/*!
|
||||
* 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 }); 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 = `<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>
|
||||
<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 = (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) => `<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 K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch);
|
||||
const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
|
||||
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
|
||||
field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
|
||||
(c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') +
|
||||
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
|
||||
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
|
||||
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
|
||||
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
|
||||
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
|
||||
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer'));
|
||||
|
||||
const fc = ch.facade;
|
||||
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
|
||||
(fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') +
|
||||
swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) +
|
||||
(model.hasAccent ? field('Accent <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') : '');
|
||||
|
||||
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>
|
||||
<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 === 'lightMode') next.lighting.mode = val;
|
||||
else if (key === 'lightDir') next.lighting.direction = val;
|
||||
else return;
|
||||
tryApply(next);
|
||||
}
|
||||
panel.addEventListener('click', (e) => {
|
||||
const h = e.target.closest('[data-sec]');
|
||||
if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; }
|
||||
const b = e.target.closest('[data-set]');
|
||||
if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; }
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a || !cur) return;
|
||||
if (a.dataset.act === 'pdf') savePdf();
|
||||
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
|
||||
});
|
||||
panel.addEventListener('keydown', (e) => {
|
||||
const g = e.target.closest('g[data-set]');
|
||||
if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); }
|
||||
});
|
||||
// Maten: label volgt het schuiven, doorrekenen bij loslaten.
|
||||
const NUM = {
|
||||
width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } },
|
||||
depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } },
|
||||
// De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager).
|
||||
unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) },
|
||||
houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) },
|
||||
trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } }
|
||||
};
|
||||
function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; }
|
||||
// Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte.
|
||||
const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st);
|
||||
NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } };
|
||||
NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } };
|
||||
[['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => {
|
||||
NUM[k] = {
|
||||
form,
|
||||
get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; },
|
||||
set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); }
|
||||
};
|
||||
});
|
||||
panel.addEventListener('input', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value));
|
||||
});
|
||||
panel.addEventListener('change', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (!r) return;
|
||||
const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch);
|
||||
if (v === old) return;
|
||||
const next = clone(ch);
|
||||
n.set(next, v);
|
||||
tryApply(next, { set: n.set, ok: old, want: v });
|
||||
});
|
||||
|
||||
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||
function snapshot() {
|
||||
const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio();
|
||||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
if (view !== '3d') {
|
||||
view = '3d';
|
||||
applyView();
|
||||
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||
}
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||
renderer.setPixelRatio(pr);
|
||||
if (prev !== '3d') {
|
||||
view = prev;
|
||||
camera.position.copy(keep.pos);
|
||||
controls.target.copy(keep.target);
|
||||
controls.update();
|
||||
applyView();
|
||||
}
|
||||
resize();
|
||||
return { url, width: W, height: H };
|
||||
}
|
||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||
|
||||
function savePdf() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Indicatief ontwerp';
|
||||
try {
|
||||
let image = null;
|
||||
try { image = snapshot(); } catch (err) { console.warn(err); }
|
||||
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image })
|
||||
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De PDF kon niet worden gemaakt.');
|
||||
}
|
||||
}
|
||||
|
||||
// Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn.
|
||||
const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } });
|
||||
|
||||
let visible = true, raf = 0;
|
||||
function resize() {
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
if (!w || !h) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
}
|
||||
const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); });
|
||||
ro.observe(stage);
|
||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
||||
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
|
||||
// Ctrl/⌘ ingedrukt houdt; dan zoomt het.
|
||||
let engaged = false;
|
||||
const stageEl = $('.tl-stage');
|
||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||
stageEl.addEventListener('wheel', (e) => {
|
||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return;
|
||||
e.stopPropagation();
|
||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||
}, { capture: true });
|
||||
io.observe(stage);
|
||||
(function loop() {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (visible && view !== 'plan') renderer.render(scene, camera);
|
||||
})();
|
||||
|
||||
root.classList.add('is-busy');
|
||||
setTimeout(() => {
|
||||
const st = LightModel.build(model, ch);
|
||||
show(st, compute(st));
|
||||
root.classList.remove('is-busy');
|
||||
}, 30);
|
||||
|
||||
return {
|
||||
unmount() {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
||||
if (group) disposeGeo(group);
|
||||
renderer.dispose();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
getChoices: () => clone(ch)
|
||||
};
|
||||
}
|
||||
|
||||
function autoMount() {
|
||||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||
if (el.dataset.mounted) return;
|
||||
el.dataset.mounted = '1';
|
||||
try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||
else autoMount();
|
||||
|
||||
return { mount, autoMount };
|
||||
})();
|
||||
Reference in New Issue
Block a user