3 Commits
12 changed files with 232 additions and 39 deletions
+7 -1
View File
@@ -350,7 +350,9 @@ assets/ logo, texturen, skybox
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
`TectumLight.mount(el, { model, frames, url, modelViewer })`.
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
@@ -393,6 +395,10 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
in `LightPdf.COMPANY` en `STEPS`.
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
+1 -1
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.7.7';
const APP_VERSION = '0.7.8';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
+11
View File
@@ -6,6 +6,17 @@
*/
// Plain script, geen ES-module: imports breken bij file:// (CORS).
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
// een modelpagina van de site). Paden in de data blijven relatief.
const TECTUM_BASE = (() => {
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
return s ? new URL('../../', s).href : '';
})();
function tectumUrl(path) {
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
}
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp);
+43 -1
View File
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
} catch (e) { return null; }
}
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
function build(objects, opts) {
const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 };
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
const images = new Map(), samplers = new Map(), textures = new Map();
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
}
return (remap[ci] = b.n++);
};
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
const raw = (ci) => {
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
const t = new THREE.Vector2();
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
return { p, n, t };
};
const lerp = (A, B, f) => ({
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
});
const push = (q) => {
b.pos.push(q.p.x, q.p.y, q.p.z);
b.nrm.push(q.n.x, q.n.y, q.n.z);
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
return b.n++;
};
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) {
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
const len = face.length();
if (len < 2e-7) continue;
face.multiplyScalar(flip ? -1 / len : 1 / len);
if (clip) {
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
if (!nIn) continue;
if (nIn < 3) {
const q = s.map(raw), poly = [];
for (let j = 0; j < 3; j++) {
const k2 = (j + 1) % 3;
if (inside[j]) poly.push(q[j]);
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
}
const c = poly.map(push);
for (let j = 1; j + 1 < c.length; j++) {
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
triCount++;
}
continue;
}
}
const c = s.map(add);
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
triCount++;
+1 -1
View File
@@ -23,7 +23,7 @@ window.Skybox = (() => {
const cache = {};
function loadTexture(url) {
if (!cache[url]) {
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
cache[url].catch(() => { delete cache[url]; });
}
return cache[url];
+1 -1
View File
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
}
function loadList(list) {
const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' })
return fetch(tectumUrl(url), { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null);
}
+2 -2
View File
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
}
function finishThumbUrl(key, px) {
const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
}
const finishMaterials = {};
function finishMaterial(key) {
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
}
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(f.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
tex.anisotropy = 8;
+111 -1
View File
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
return st;
}
// Model Van den Berg (examples/van den berg.pdf): dubbel chalet 6,94 × 12,25 m. West een smal, laag chalet van
// 2,97 × 12,25 m (wandhoogte 2330: garderobe, twee slaapkamers, toilet, badkamer, cv-kast, hal), oost een breed,
// hoog chalet van 3,97 × 9,5 m (wandhoogte 3080: woonkamer met keuken), allebei een plat dak (EPDM) met boeidelen.
// In de noordwesthoek een overdekt portiek van 1,36 × 1,75 m voor de voordeur: de garderobe is een eigen unit
// (B) waarvan het dak naar het westen doorloopt. Ten zuiden van het oostchalet een overdekte vlonder van 3,97 × 2,75 m
// (het dak loopt 2,75 m door, palen op de hoeken). Gevel: het lage chalet donker (Eternit potdeksel, hier
// antraciet felsplaten), het hoge chalet eiken rabat horizontaal; het deel van de oostwand boven het lage dak
// krijgt dezelfde rabat. De twee bovenlichten van 200 × 30 in die wand zijn weggelaten: het stuk boven het lage
// dak is 488 mm hoog en een opening is minimaal 400 mm. Maten buitenwerks, x vanaf de westgevel en z vanaf de
// noordgevel van de tekening. Het lage chalet is de eerste unit A (noordwesthoek op z 1750), het hoge chalet C.
function createVandenBerg() {
const st = newModelState('Van den Berg');
const S = st.settings, HL = 2330, HH = 3080, WA = 2970, WB = 3970, P = 1750, PW = 1360, FELS = 'antraciet';
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
S.cladding = 'horizontaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 120, maxSpan: 4500 });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: false, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'wit';
const A = createUnitEntry('A', null, { width: WA, depth: 12250 - P, height: HL, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'voor', align: 'end', offset: 0 }, { width: WA - PW, depth: P, height: HL, windowOn: [] });
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'start', offset: 0 }, { width: WB, depth: 9500, height: HH, windowOn: [] });
st.units.push(A, B, C);
B.roof.extend = { f: 0, r: 0, b: 0, l: PW }; // portiek
C.roof.extend = { f: 0, r: 0, b: 2750, l: 0 }; // vlonder
[A, B].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.facade.walls[side] = { type: 'fels', fels: FELS }; }));
const tE = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
const { binnen, ops, iw, room, fx } = modelKit(st, HL, 90);
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), extra || {});
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
// binnenkant van de noordgevel. z in de tekening; zA = z in unit A.
const zA = (z) => z - P;
ops(A.unit.walls.links, [
['raam', 700, 1100, 950, 'left', zA(3897) - tE, K('raam', { rooster: true })], // slaapkamer 2
['raam', 600, 700, 1350, 'left', zA(5550) - tE, K('raam')], // toilet
['raam', 600, 700, 1350, 'left', zA(6590) - tE, K('raam')]]); // badkamer
ops(A.unit.walls.achter, [['raam', 1200, 1100, 950, 'left', 827, K('raam', { rooster: true })]]); // slaapkamer 1
ops(B.unit.walls.links, [['voordeur', 900, 2110, 0, 'left', 787 - tE, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.voor, [['raam', 1400, 900, 1200, 'right', 1651, K('raam', { rooster: true })]]); // boven de spoelbak
// Oostgevel: kozijnen van 180 × 300, het noordelijke met openslaande deuren (alleen van binnen te bedienen).
ops(C.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 2267 - tE, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 3167 - tE, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['raam', 1800, 650, 2200, 'right', 2267 - tE, K('raam')],
['raam', 1800, 2100, 0, 'right', 5946 - tE, K('raam')],
['raam', 1800, 650, 2200, 'right', 5946 - tE, K('raam')]]);
C.unit.walls.rechts.openings[1].koppel = true;
ops(C.unit.walls.achter, [['deur', 2400, 2100, 0, 'left', 792, // schuifpui naar de vlonder
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
const DH = 2115, DW = 730;
// Binnenwanden in wereldcoördinaten (t.o.v. de noordwesthoek van A). Noord–zuid: 'buiten' = west, 'left' =
// vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
const xE = WA - 100; // tegen de naadwand
const w = (x1, z1, x2, z2, openings) => iw(x1, zA(z1), x2, zA(z2), openings);
w(tE, 5321, xE, 5321, [['deur', DW, DH, 'left', 1879 - tE, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // slaapkamer 2 | toilet, hal
w(1376, 5366, 1376, 6368, [['deur', DW, DH, 'left', 200, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | hal
w(tE, 6413, 1891, 6413); // toilet | badkamer
w(1846, 6458, 1846, 8651, [ // badkamer, cv-kast | hal
['deur', DW, DH, 'left', 20, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', DW, DH, 'left', 1330, binnen({ hinge: 'links', swing: 'binnen' })]]);
w(1155, 7700, 1801, 7700); // badkamer | cv-kast
w(1200, 7745, 1200, 8651); // douche | cv-kast
w(tE, 8696, xE, 8696, [['deur', DW, DH, 'left', 1985 - tE, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | slaapkamer 1
ensureSeamWalls(st);
const seamOf = (a, b) => st.interiorWalls.find(iw => iw.seam && sameSeam(iw.seam, a, b));
const toog = (wd) => ({ id: 1, type: 'deur', w: wd, h: 2100, y: 0, anchor: 'left', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) });
const sAC = seamOf('A', 'C'), sBC = seamOf('B', 'C'), sAB = seamOf('A', 'B');
// Naadwanden noord–zuid: 'left' = vanaf de zuidkant (A: binnenkant zuidgevel van C, z 9244; B: de naad met A, z 1750).
if (sAC) sAC.wall.openings = [Object.assign(toog(1000), { offset: 2854 })]; // hal → woonkamer (z 5390–6390)
if (sBC) sBC.wall.openings = [Object.assign(toog(900), { offset: 350 })]; // garderobe → woonkamer (z 500–1400)
if (sAB) sAB.wall.openings = [];
room('A', 1400, zA(3000), 'slaapkamer', 'Slaapkamer 2');
room('A', 800, zA(5900), 'toilet');
room('A', 1500, zA(7000), 'badkamer');
room('A', 1500, zA(8200), 'techniek', 'Cv-kast');
room('A', 2400, zA(7000), 'hal', 'Hal');
room('A', 1400, zA(11000), 'slaapkamer', 'Slaapkamer 1');
room('B', 800, 900, 'hal', 'Garderobe');
room('C', 2000, 4500, 'woonkeuken', 'Woonkamer/keuken');
fx('bed', 'A', 670, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('bed', 'A', 2290, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('kledingkast', 'A', 1056, zA(4976), 180, { w: 1600 });
fx('toilet', 'A', 601, zA(5950), 270);
fx('fontein', 'A', 1108, zA(5491), 0);
fx('douche', 'A', 706, zA(8051), 180, { w: 900, d: 1200 }, { glas: 'rechts', glasLengte: 900 });
fx('wastafel', 'A', 1478, zA(7405), 180, { w: 600 });
fx('boiler', 'A', 1470, zA(8350), 270);
fx('kledingkast', 'A', 1056, zA(9041), 0, { w: 1600 });
fx('bed', 'A', tE + 1000, zA(10744), 270, { w: 1600, d: 2000 }, { nachtkastjes: false });
const xC = (x) => x - WA;
fx('keuken', 'C', xC(4930), tE + 300, 0, { w: 3500 }, { front: 'wit', hogeKast: 'rechts', spoelbak: 0.6, kookplaat: true, kookplaatPlek: 0.26 });
fx('eettafel', 'C', xC(4912), 3172, 0, { w: 1400, d: 900 }, { vorm: 'rechthoek', stoelen: 6 });
fx('bank', 'C', 560, 7700, 270, { w: 2200, d: 900 }, { chaise: 'geen' });
fx('salontafel', 'C', 1700, 7700, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'C', 2900, 7200, 90);
computeParts(st, { skip3D: true });
return st;
}
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht }
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht },
vandenberg: { label: 'Woning Van den Berg', icon: 'house-dash', create: createVandenBerg }
};
function createDefaultState(template) {
+6 -4
View File
@@ -5,8 +5,8 @@
* 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.
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
// d.price === false, 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',
@@ -136,7 +136,8 @@ window.LightPdf = (() => {
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
const py = Math.max(y + 4, 196);
const py = Math.max(y + 4, 196), price = d.price !== false;
if (price) {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
@@ -152,6 +153,7 @@ window.LightPdf = (() => {
doc.setFontSize(9);
doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
}
// Beeld van de woning
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
@@ -162,7 +164,7 @@ window.LightPdf = (() => {
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);
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
}
function process(doc, d) {
+30 -8
View File
@@ -8,8 +8,12 @@
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
// TectumLight.mount(el, { model, frames, url, modelViewer }); url = adres voor de QR-code in de PDF (standaard de
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
// Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => {
@@ -66,6 +70,7 @@ window.TectumLight = (() => {
function mount(root, opts) {
const o = opts || {};
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
const showPrice = o.price !== false && root.dataset.price !== 'false';
let ch = clone(model.initial);
let unitSel = model.base.units[0].id;
let cur = null; // { st, parts, info }
@@ -499,9 +504,10 @@ window.TectumLight = (() => {
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>
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<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>`;
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
}
// Keuzes (knoppen en kleuren).
@@ -542,6 +548,7 @@ window.TectumLight = (() => {
const a = e.target.closest('[data-act]');
if (!a || !cur) return;
if (a.dataset.act === 'pdf') savePdf();
else if (a.dataset.act === 'save') o.save.onSave(design());
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
});
panel.addEventListener('keydown', (e) => {
@@ -608,6 +615,18 @@ window.TectumLight = (() => {
}
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
function design() {
const st = clone(cur.st);
st.project.naam = model.label;
st.project.opmerking = 'Ontwerp uit de light-versie';
return {
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
label: model.label, key: model.key, info: Object.assign({}, cur.info),
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
};
}
function savePdf() {
const st = clone(cur.st);
st.project.naam = model.label;
@@ -615,7 +634,7 @@ window.TectumLight = (() => {
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 })
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (err) {
console.error(err);
@@ -652,11 +671,13 @@ window.TectumLight = (() => {
io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb;
}
@@ -685,7 +706,7 @@ window.TectumLight = (() => {
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false;
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
function closeAr() {
if (!arOpen) return;
arOpen = false;
@@ -701,7 +722,7 @@ window.TectumLight = (() => {
arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer>
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
@@ -768,6 +789,7 @@ window.TectumLight = (() => {
root.innerHTML = '';
},
getChoices: () => clone(ch),
design: () => cur && design(),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
};
+2 -2
View File
@@ -90,7 +90,7 @@ window.RoofModel = (() => {
}
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null;
}
function coveringFor(key, shapeType) {
const c = COVERINGS[key];
@@ -108,7 +108,7 @@ window.RoofModel = (() => {
if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(c.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(c.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
tex.anisotropy = 8;
+2 -2
View File
@@ -234,8 +234,8 @@ window.WallModel = (() => {
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
};
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
// UV's in meters, dus repeat = 1/tegel.
const stoneMaterials = {};
function stoneMaterial(code) {