5 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 0da1769f51 GLB voor AR zonder alles onder het maaiveld (GlbWriter clipBelow, zonder sandwichpanelen), AR op ware grootte; model Van den Berg (dubbel chalet met portiek en overdekte vlonder)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 4bb1548125 Light: optie price: false (of data-price="false"): geen prijs in het paneel en op het voorblad van de PDF; design().info.price blijft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 73248a69c9 v0.7.8: light-versie in te bedden op elke pagina en eigen opslaan: texturen, kleurstalen en kosten/*.csv laden via tectumUrl() (js/core/geometry.js) vanaf de map van de scripts, zodat de engine niet naast de pagina hoeft te staan (modelpagina's van de site met de engine op /configurator/); paden in de data blijven relatief; TectumLight.mount(el, { save: { label, icon, onSave } }) vervangt de PDF-knop, onSave krijgt het ontwerp als house-design-JSON met kerncijfers, prijs en keuzes in woorden (ook el.tectumLight.design())
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:28:02 +02:00
MachielandClaude Opus 5.5 b8cc39275b v0.7.7: daktrim als echt profiel: voorkant 8 mm vóór de gevelbekleding, 30 mm boven het dak en over de bovenste trimHeight van de gevel (standaard 30 mm), opstand 20 mm diep met een flens van 3 mm op het dak over de bovenkant van de bekleding (RoofModel.TRIM); de bekleding loopt bij een daktrim door tot het dak; hoeken met daktrim/felsrand lopen op de buitenkant door, naast een boeideel stopt de trim ertegen; vrije uiteinden van binnenwanden afgewerkt in de binnenwandkleur (endCaps) in plaats van een zichtbare stijl; light-PDF: prijs op het voorblad rustig onder een dunne lijn, bedrag groot en vet, geen oranje vak
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:56 +02:00
MachielandClaude Opus 5.5 118ccf9098 v0.7.6: geen wolfsdak bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit steekt buiten zijn vloer, HouseModel.hasCover): de keuze verdwijnt in configurator en light, een bestaand wolfsdak wordt een zadeldak met dezelfde goot en nok; afdekker op een stenen trasraam onder rabat, felsplaten of stuc (WallModel.TRAS_CAP): zetwerk achter de bekleding, 5° aflopend, neus 25 mm over de steen, onderbroken bij openingen, bij een buitenhoek door tot voorbij de buursteen, zwart/antraciet/wit (WallModel.TRIM_COLORS), in 3D, PDF-aanzichten, materialen, rapport en kosten; ventilatierooster van 40 mm op de gevel direct onder de onderplaat van alle boeidelen (RoofModel.VENT, niet boven een veranda of tegen een hogere gevel), sleuven in de textuur, aan/uit en kleur bij Dak › Boeideel, in 3D, PDF, rapport en kosten; light-versie: boeidelen of daktrim met kleur boeidelen (Dak) en één zetwerk-kleur voor afdekker en rooster (Gevel); 3D zijwaarts schuiven ook met de middelste muisknop, Shift/Ctrl/⌘ + slepen en de pijltjestoetsen met de muis boven de weergave (configurator en light, hints en sneltoetsenoverzicht bijgewerkt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:26 +02:00
19 changed files with 561 additions and 100 deletions
+25 -6
View File
@@ -171,6 +171,10 @@ assets/ logo, texturen, skybox
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant (`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat, in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
steen) erover door, gelijk met de gevel en tot onder de dakrand. steen) erover door, gelijk met de gevel en tot onder de dakrand.
Bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit
steekt buiten zijn vloer, `HouseModel.hasCover`) is er geen wolfsdak: de
keuze verdwijnt en een bestaand wolfsdak wordt een zadeldak met dezelfde
goot en nok.
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één - **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
@@ -210,7 +214,10 @@ assets/ logo, texturen, skybox
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
rand (ook naast daktrim). rand (ook naast daktrim). Onder de boeidelen zit op de gevel een
ventilatierooster van 40 mm (direct onder de onderplaat, op de
gevelbekleding; niet boven een veranda), zwart/antraciet/wit
(`RoofModel.VENT`, Woning › Dak › Boeideel; kosten `dak.ventRoosterM`).
- **Gevel:** gevelbekleding per woning, met override per wand en per - **Gevel:** gevelbekleding per woning, met override per wand en per
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning, wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
getekend als plankentextuur die over naden doorloopt), steen (181 getekend als plankentextuur die over naden doorloopt), steen (181
@@ -224,7 +231,10 @@ assets/ logo, texturen, skybox
(bijv. stuc links, hout rechts). Steen is overal een (bijv. stuc links, hout rechts). Steen is overal een
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`). steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over twee stenen kan. Op een stenen trasraam onder rabat, felsplaten of stuc
(minder ver uitstekend) komt een afdekker: zetwerk 5° aflopend met een
neus van 25 mm over de steen, onderbroken bij openingen, zwart/antraciet/wit
(`WallModel.TRAS_CAP`, `TRIM_COLORS`; kosten `gevel.afdekkerM`). Latwerk, doek en metselwerk lopen per gevel door over
unit-naden. Sandwichpanelen rondom, 600 mm de grond in. unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is - **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
@@ -340,7 +350,9 @@ assets/ logo, texturen, skybox
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via 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` `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 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 - **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², alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
@@ -352,8 +364,10 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar: west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar:
hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het
wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur), wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur),
het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), boeidelen of daktrim
woning alleen als de units samen een rechthoek vormen (zoals de configurator). Geen openingen, (met kleur boeidelen; een model met beide houdt zijn indeling tot je kiest), zetwerk-kleur (afdekker trasraam en
ventilatierooster), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
woning alleen als de units samen een rechthoek vormen en nooit bij een overkapping (zoals de configurator). Geen openingen,
binnenwanden, inrichting of elektra; die volgen het model. binnenwanden, inrichting of elektra; die volgen het model.
- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet - **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet
afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en
@@ -371,7 +385,8 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en
PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
geklikt of Ctrl/⌘ ingedrukt houdt. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen geklikt of Ctrl/⌘ ingedrukt houdt. Zijwaarts schuiven: rechts of middelste knop slepen, Shift/Ctrl/⌘ +
slepen, of de pijltjestoetsen met de muis boven de 3D. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen
OrbitControls, geldt ook voor de configurator). OrbitControls, geldt ook voor de configurator).
- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes - **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes
in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina
@@ -380,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 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 met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
in `LightPdf.COMPANY` en `STEPS`. 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 - **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, (`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 lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
+3 -2
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
const APP_VERSION = '0.7.5'; const APP_VERSION = '0.7.8';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = []; let tabs = [];
@@ -77,7 +77,8 @@ const KEY_HELP = [
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'], ['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]], ['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]], ['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true], ['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'],
['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['Scrollwiel', 'Zoomen']], true],
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true], ['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]], ['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true] ['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
+11
View File
@@ -6,6 +6,17 @@
*/ */
// Plain script, geen ES-module: imports breken bij file:// (CORS). // 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) { function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return []; if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp); let numBays = Math.ceil(spanWidth / maxSp);
+43 -1
View File
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
} catch (e) { return null; } } 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) { function build(objects, opts) {
const o = opts || {}; const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 }; 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 buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
const images = new Map(), samplers = new Map(), textures = new Map(); const images = new Map(), samplers = new Map(), textures = new Map();
const gltfImages = [], gltfSamplers = [], gltfTextures = []; const gltfImages = [], gltfSamplers = [], gltfTextures = [];
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
} }
return (remap[ci] = b.n++); 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); const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) { for (let k = gr.start; k + 2 < end; k += 3) {
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j); const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
const len = face.length(); const len = face.length();
if (len < 2e-7) continue; if (len < 2e-7) continue;
face.multiplyScalar(flip ? -1 / len : 1 / len); 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); 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]); if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
triCount++; triCount++;
+1 -1
View File
@@ -23,7 +23,7 @@ window.Skybox = (() => {
const cache = {}; const cache = {};
function loadTexture(url) { function loadTexture(url) {
if (!cache[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]; }); cache[url].catch(() => { delete cache[url]; });
} }
return cache[url]; return cache[url];
+1 -1
View File
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
} }
function loadList(list) { function loadList(list) {
const url = LISTS[list].url; 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) .then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null); .catch(() => null);
} }
+5 -1
View File
@@ -97,7 +97,9 @@ window.COST_DEFAULTS = {
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 }, stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }, stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 }, felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 } felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 },
afdekkerM: { label: 'Afdekker trasraam (aluminium zetwerk, gepoedercoat)', unit: '€/m', value: 14 },
afdekkerMontage: { label: 'Montage afdekker trasraam', unit: 's/m', value: 300 }
} }
}, },
@@ -118,6 +120,8 @@ window.COST_DEFAULTS = {
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 }, onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 }, daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 }, daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
ventRoosterM: { label: 'Ventilatierooster onder boeideel (40 mm, aluminium)', unit: '€/m', value: 6 },
ventRoosterMontage: { label: 'Montage ventilatierooster', unit: 's/m', value: 120 },
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 }, staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 }, nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 }, nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
+3
View File
@@ -159,6 +159,8 @@ window.CostModel = (() => {
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0)); add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
const cap = (st.trasraamCapM || 0);
add(el, 'Gevel', 'Gevel', 'Afdekker trasraam', cap, 'm', cap * (G.afdekkerM || 0), cap * (G.afdekkerMontage || 0));
const stuc = st.stucAreaM2 || 0; const stuc = st.stucAreaM2 || 0;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0)); add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
const latM = st.battenLengthM || 0; const latM = st.battenLengthM || 0;
@@ -239,6 +241,7 @@ window.CostModel = (() => {
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage); add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage); add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage); add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
add(wo, 'Dak', 'Dak', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
} }
if (totals.posts) { if (totals.posts) {
const pp = totals.posts; const pp = totals.posts;
+2 -2
View File
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
} }
function finishThumbUrl(key, px) { function finishThumbUrl(key, px) {
const f = FINISHES[key]; 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 = {}; const finishMaterials = {};
function finishMaterial(key) { function finishMaterial(key) {
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
} }
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 }); const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { 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.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM); tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
tex.anisotropy = 8; tex.anisotropy = 8;
+156 -11
View File
@@ -36,7 +36,7 @@ window.HouseModel = (() => {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } }, spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' }, facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
cladding: 'horizontaal', cladding: 'horizontaal',
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' }, trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat', capColor: 'antraciet' },
interiorPaint: true, interiorPaint: true,
interiorColor: '#f2efe9', interiorColor: '#f2efe9',
ceilingColor: '#f4f3ef', ceilingColor: '#f4f3ef',
@@ -67,7 +67,7 @@ window.HouseModel = (() => {
} }
function facadeMaterials(state, fc) { function facadeMaterials(state, fc) {
const tr = state.settings.trasraam; const tr = state.settings.trasraam;
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null; const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish, tr.capColor) : null;
const cladding = state.settings.cladding; const cladding = state.settings.cladding;
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null }; if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null };
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null }; if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null };
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
return st; 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. // Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = { const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel }, amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas }, maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }, rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest }, 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) { function createDefaultState(template) {
@@ -1121,10 +1231,33 @@ window.HouseModel = (() => {
} }
}); });
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; }); state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
// Geen wolfsdak boven een overkapping: wordt een zadeldak met dezelfde goot en nok.
if (houseRoofOn(state)) {
if (state.settings.roof.house.form.type === 'wolf' && state.units.some(u => roofCovers(u, cover))) state.settings.roof.house.form.type = 'zadel';
} else state.units.forEach(u => {
const f = UnitModel.roofForm(u.unit);
if (f.type === 'wolf' && roofCovers(u, cover)) UnitModel.applyRoofForm(u.unit, Object.assign(f, { type: 'zadel' }));
});
applyHouseRoof(state); applyHouseRoof(state);
applyRoofJoins(state); applyRoofJoins(state);
} }
// Overkapping (veranda, inpandig of uitpandig): het dak van de unit steekt buiten zijn vloer. Een vrije dakmaat
// telt pas vanaf de kleinste paal-uitkraging; daaronder is het overstek.
function roofCovers(u, cover) {
if (!u.roof.followFloor) {
const d = u.roof.deck.floor, f = u.unit.floor.floor, m = 400;
return (d.width - f.width) / 2 + Math.abs(u.roof.offsetX || 0) > m || (d.depth - f.depth) / 2 + Math.abs(u.roof.offsetZ || 0) > m;
}
const c = cover[u.id] || {}, x = u.roof.extend || {};
return ['l', 'r', 'f', 'b'].some(k => (c[k] || 0) + (x[k] || 0) > EPS);
}
// Overkapping onder het dak van de unit (unitId) of ergens onder de woning (geen unitId): dan geen wolfsdak.
function hasCover(state, unitId) {
const cover = coverExtensions(state);
return state.units.some(u => (!unitId || u.id === unitId) && roofCovers(u, cover));
}
const EXTEND_MAX = 4000; const EXTEND_MAX = 4000;
function normalizeExtend(e) { function normalizeExtend(e) {
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))])); return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
@@ -1477,10 +1610,10 @@ window.HouseModel = (() => {
}); });
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts); const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels) { function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps) {
if (!build) return { group: null, stats: null, specs: null }; if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, { const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps,
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
}); });
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
@@ -1873,7 +2006,7 @@ window.HouseModel = (() => {
}; };
const facadeDepth = (a, b, nrm) => { const facadeDepth = (a, b, nrm) => {
const e = edgePiece(a, b, nrm); const e = edgePiece(a, b, nrm);
return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : 0; return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : null; // null: geen gevel (veranda)
}; };
const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); }; const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); };
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof, const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
@@ -1895,14 +2028,22 @@ window.HouseModel = (() => {
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen. // De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d })) const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean); .map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
if (!sg) return; // Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove; if (!sg || (!sg.fascia && !sg.fels)) return;
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg); p.battenAbove = (p.skinAbove || 0) + sg.above - RoofModel.edgeHeight(roofEdge, sg);
});
// Vrije uiteinden van binnenwanden: vlak voorbij het eind geen andere wand.
const allPieces = extPieces.concat(interior);
interior.forEach(p => {
if (p.buitenMateriaal !== 'spaanplaat') return;
const W = p.place.width / 2, against = (q) => allPieces.some(o => o !== p && pointInPoly(q,
[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([i, j]) => UnitModel.localToWorld(o.place, i * o.place.width / 2, j * o.place.thickness / 2))));
p.endCaps = { left: !against(UnitModel.localToWorld(p.place, -W - 15, 0)), right: !against(UnitModel.localToWorld(p.place, W + 15, 0)) };
}); });
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined))); assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior)); const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
extPieces.concat(interior).forEach(p => { extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels); const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels, p.endCaps);
if (built.group && p.shiftZ) { if (built.group && p.shiftZ) {
const outer = new THREE.Group(); const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001; built.group.position.z = p.shiftZ * 0.001;
@@ -2219,6 +2360,7 @@ window.HouseModel = (() => {
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3), woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg), woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) }, battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
trasraamCap: { lengthM: sumP(p => p.stats.trasraamCapM || 0), color: (WallModel.TRIM_COLORS[(pieces.find(p => p.stats.trasraamCapM > 0 && p.trasraam) || { trasraam: {} }).trasraam.capColor] || {}).label || '' },
isolatie, isolatie,
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0), isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
plaatwerk: [ plaatwerk: [
@@ -2292,6 +2434,8 @@ window.HouseModel = (() => {
overhang: parts.roofEdge.overhang, overhang: parts.roofEdge.overhang,
soffitM2: parts.roofEdge.soffitM2, soffitM2: parts.roofEdge.soffitM2,
trimLengthM: parts.roofEdge.trimLengthMM / 1000, trimLengthM: parts.roofEdge.trimLengthMM / 1000,
ventLengthM: (parts.roofEdge.ventLengthMM || 0) / 1000,
ventColor: parts.roofEdge.vent ? (WallModel.TRIM_COLORS[parts.roofEdge.ventColor] || {}).label || '' : '',
trimHeight: parts.roofEdge.trimHeight, trimHeight: parts.roofEdge.trimHeight,
spots: parts.roofEdge.spots.length spots: parts.roofEdge.spots.length
} : null, } : null,
@@ -2357,6 +2501,7 @@ window.HouseModel = (() => {
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false; state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM; if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat'; if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
if (!WallModel.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet';
state.settings.sandwich = state.settings.sandwich !== false; state.settings.sandwich = state.settings.sandwich !== false;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor; if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor; if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
@@ -2393,7 +2538,7 @@ window.HouseModel = (() => {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials, ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX, houseRoofShape, houseRoofFits, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX,
computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
}; };
})(); })();
+23
View File
@@ -271,6 +271,12 @@ window.HousePdf = (() => {
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat; const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12); const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 }); faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
// Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen).
if (p.stats && p.stats.trasraamCapM > 0) {
const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM;
faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)),
n, color: WallModel.trimHex(tr.capColor), bias: 1.5 });
}
} }
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => { (p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return; if (WallModel.openingTopMax(op) <= y0) return;
@@ -328,6 +334,17 @@ window.HousePdf = (() => {
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z }); top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z }); bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
} }
// Rooster op de gevel, onder de onderplaat.
if (sg.fascia && sg.facadeUnder && edge.vent) {
const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z);
const vTop = [];
for (let k = 0; k <= n; k++) {
const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d };
vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z });
}
faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()),
n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 });
}
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 }); faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
}); });
} }
@@ -371,7 +388,11 @@ window.HousePdf = (() => {
items.push(WallModel.isStoneFinish(tf) items.push(WallModel.isStoneFinish(tf)
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] } ? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat }); : { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
const tc = WallModel.TRIM_COLORS[st.trasraam.capColor];
if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex });
} }
const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor];
if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex });
(parts ? parts.units : state.units).forEach(u => { (parts ? parts.units : state.units).forEach(u => {
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer'); const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer; const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
@@ -618,6 +639,8 @@ window.HousePdf = (() => {
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']); rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
} }
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]); if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]);
if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]);
rows.push(['Boeideel-spots', e.spots + ' stuks']); rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]); rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]); rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
+26 -11
View File
@@ -1312,6 +1312,8 @@ function mountHouseEditor(container, callbacks) {
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ','); const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
const TRIM_OPTS = Object.fromEntries(Object.entries(WallModel.TRIM_COLORS).map(([k, c]) => [k, c.label]));
const NO_WOLF = Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf'));
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label])); const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label])); const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label]));
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`; const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
@@ -1352,7 +1354,10 @@ function mountHouseEditor(container, callbacks) {
(st.trasraam.enabled (st.trasraam.enabled
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) + ? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish), pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') +
(WallModel.isStoneFinish(st.trasraam.finish)
? pair(sel('hs-trasraam-cap', 'Afdekker', TRIM_OPTS, st.trasraam.capColor, { hint: 'Zetwerk op de steen waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): 5° aflopend, neus 25 mm over de steen' }),
swatchOf(WallModel.trimHex(st.trasraam.capColor))) : '')
: '') }, : '') },
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich, { label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) + { hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
@@ -1371,6 +1376,7 @@ function mountHouseEditor(container, callbacks) {
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender); UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after); UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender); UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
UI.onSel('hs-trasraam-cap', v => { st.trasraam.capColor = v; }, rerender);
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender); UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after); UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
} }
@@ -1381,9 +1387,9 @@ function mountHouseEditor(container, callbacks) {
key: 'dak', label: 'Dak', order: 30, key: 'dak', label: 'Dak', order: 30,
groups: () => { groups: () => {
const r = state.settings.roof, hr = r.house; const r = state.settings.roof, hr = r.house;
const wolfOk = HouseModel.houseRoofFits(state); const wolfOk = HouseModel.houseRoofFits(state) && !HouseModel.hasCover(state);
houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, { houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, {
types: wolfOk || hr.form.type === 'wolf' ? null : Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')), types: wolfOk || hr.form.type === 'wolf' ? null : NO_WOLF,
notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null, notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null,
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; }, span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
apply: () => HouseModel.applyGlobalSettings(state), apply: () => HouseModel.applyGlobalSettings(state),
@@ -1404,8 +1410,10 @@ function mountHouseEditor(container, callbacks) {
{ label: 'Boeideel', html: { label: 'Boeideel', html:
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) + UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) + UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) }, sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) }, toggle('hs-vent', 'Ventilatierooster', r.vent !== false, { hint: `${RoofModel.VENT.HEIGHT_MM} mm hoog op de gevel, direct onder alle boeidelen` }) +
(r.vent !== false ? pair(sel('hs-vent-color', 'Kleur rooster', TRIM_OPTS, r.ventColor || 'antraciet'), swatchOf(WallModel.trimHex(r.ventColor))) : '') },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Over de gevel; steekt 30 mm boven het dak uit (units zonder boeidelen)' }) },
{ label: 'Palen overkapping', html: { label: 'Palen overkapping', html:
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) + toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
(r.posts.enabled ? toggle('hs-posts-side', 'Zijwand', r.posts.sideWall, { hint: `Vrije hoek dicht met een gestuukte wand (${PostModel.SIDE_MM} mm) terug tot de gevel, in plaats van een hoekpaal` }) + (r.posts.enabled ? toggle('hs-posts-side', 'Zijwand', r.posts.sideWall, { hint: `Vrije hoek dicht met een gestuukte wand (${PostModel.SIDE_MM} mm) terug tot de gevel, in plaats van een hoekpaal` }) +
@@ -1426,6 +1434,8 @@ function mountHouseEditor(container, callbacks) {
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after); UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after); UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender); UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
UI.onToggle('hs-vent', v => { r.vent = v; }, rerender);
UI.onSel('hs-vent-color', v => { r.ventColor = v; }, rerender);
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos); UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender); UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender); UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
@@ -1889,7 +1899,7 @@ function mountHouseEditor(container, callbacks) {
const f = (k) => `${id}-roof-${k}`; const f = (k) => `${id}-roof-${k}`;
const groups = () => { const groups = () => {
const g = [ const g = [
{ label: 'Dakvorm', html: sel(f('type'), 'Vorm', o.types || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') + { label: 'Dakvorm', html: sel(f('type'), 'Vorm', (typeof o.types === 'function' ? o.types() : o.types) || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') +
(form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') + (form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
(UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') + (UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') +
UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) } UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
@@ -1959,6 +1969,7 @@ function mountHouseEditor(container, callbacks) {
const form = UnitModel.roofForm(u.unit); const form = UnitModel.roofForm(u.unit);
const roofUi = roofFormUi('hp', form, { const roofUi = roofFormUi('hp', form, {
gable: true, gable: true,
types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null,
span: () => { span: () => {
const pu = partsOf(); const pu = partsOf();
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form)); const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
@@ -2030,13 +2041,15 @@ function mountHouseEditor(container, callbacks) {
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender); UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender); UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
// Een overkapping maakt van een wolfsdak een zadeldak: dan het paneel opnieuw opbouwen.
const afterCover = () => (form.type === 'wolf' ? rerender : after)();
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => { ['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) }); u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
}, after)); }, afterCover));
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after); UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, afterCover);
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after); UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, afterCover);
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after); UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover);
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after); UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover);
} }
}; };
} }
@@ -2334,6 +2347,8 @@ function mountHouseEditor(container, callbacks) {
const e = totals.roofEdge, rows = []; const e = totals.roofEdge, rows = [];
if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']); if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']); if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
if (e.ventLengthM > 0) rows.push([`Ventilatierooster · ${e.ventColor.toLowerCase()}`, f(e.ventLengthM, 2), 'm']);
if (totals.trasraamCap.lengthM > 0) rows.push([`Afdekker trasraam · ${totals.trasraamCap.color.toLowerCase()}`, f(totals.trasraamCap.lengthM, 2), 'm']);
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']); rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows)); cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
} }
+14 -2
View File
@@ -77,7 +77,11 @@ window.LightModel = (() => {
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat'; 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 }; 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 } lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false },
// Boeidelen (aan = boeidelen, uit = daktrim) met kleur; een model met beide houdt zijn eigen indeling tot je kiest.
fascia: { on: base.units.some(u => u.roof.fascia !== false), color: RoofModel.FASCIA_COLORS[base.settings.roof.fasciaColor] ? base.settings.roof.fasciaColor : 'antraciet' },
// Zetwerk: één kleur voor de afdekker op het trasraam en het ventilatierooster onder de boeidelen.
trim: WallModel.isTrimColor(base.settings.roof.ventColor) ? base.settings.roof.ventColor : 'antraciet'
}; };
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) }; 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); const st = build(model, choices);
@@ -222,6 +226,9 @@ window.LightModel = (() => {
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)); 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 }); Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
st.settings.trasraam.capColor = st.settings.roof.ventColor = ch.trim;
if (ch.fascia.on !== model.initial.fascia.on) st.units.forEach(u => { u.roof.fascia = ch.fascia.on; });
st.settings.roof.fasciaColor = ch.fascia.color;
return st; return st;
} }
@@ -326,12 +333,17 @@ window.LightModel = (() => {
]; ];
const tr = ch.trasraam; const tr = ch.trasraam;
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']); rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
rows.push(['Dakrand', ch.fascia.on ? `Boeidelen ${RoofModel.FASCIA_COLORS[ch.fascia.color].label.toLowerCase()}` : 'Daktrim']);
const trimUse = trimUses(ch);
if (trimUse) rows.push(['Zetwerk', `${WallModel.TRIM_COLORS[ch.trim].label} (${trimUse})`]);
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '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(['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']); rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
return rows; return rows;
} }
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Waar de zetwerk-kleur voor geldt (leeg: nergens).
const trimUses = (ch) => [ch.trasraam.on ? 'afdekker trasraam' : null, ch.fascia.on ? 'ventilatierooster' : null].filter(Boolean).join(' en ');
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName }; return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, trimUses, create, build, check, fit, summary, describe, colorName };
})(); })();
+17 -12
View File
@@ -5,8 +5,8 @@
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en // PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze. // d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
window.LightPdf = (() => { window.LightPdf = (() => {
const COMPANY = { const COMPANY = {
naam: 'Van Emous Mantelzorgwoningen', naam: 'Van Emous Mantelzorgwoningen',
@@ -135,20 +135,25 @@ window.LightPdf = (() => {
], 'Kerncijfers'); ], 'Kerncijfers');
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes'); y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs // 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;
doc.setFillColor(...ORANGE); if (price) {
doc.roundedRect(M, py, 150, 30, 3, 3, 'F'); doc.setDrawColor(...LINE);
doc.setTextColor(255, 255, 255); doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
doc.setFontSize(10); doc.setFontSize(10);
doc.text('Indicatieve prijs', M + 7, py + 9); doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.setFontSize(24); doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21); doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
doc.setFontSize(9); doc.setFontSize(9);
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27); doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
}
// Beeld van de woning // Beeld van de woning
const bx = 190, by = 18, bw = PW - M - bx, bh = 206; const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
@@ -159,7 +164,7 @@ window.LightPdf = (() => {
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`); contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
doc.setFontSize(7); doc.setFontSize(7);
doc.setTextColor(...MUTED); 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) { function process(doc, d) {
+44 -12
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 // 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). // 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 // 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 // TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script). // 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 // 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. // Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => { window.TectumLight = (() => {
@@ -35,7 +39,7 @@ window.TectumLight = (() => {
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator. // Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
const SECTION = { start: 1500, min: 300, max: 3000 }; 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' }; const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
// tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet // 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). // van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
@@ -66,6 +70,7 @@ window.TectumLight = (() => {
function mount(root, opts) { function mount(root, opts) {
const o = opts || {}; const o = opts || {};
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames }); 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 ch = clone(model.initial);
let unitSel = model.base.units[0].id; let unitSel = model.base.units[0].id;
let cur = null; // { st, parts, info } let cur = null; // { st, parts, info }
@@ -436,17 +441,22 @@ window.TectumLight = (() => {
// Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal. // 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 wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st);
const roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel)
? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES;
const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch); 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 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') : '') + 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')) + field('Dakvorm', seg(K + 'Type', roofTypes, 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)) : '') + (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)) : '') + (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') + 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, (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)}°`) : '') + [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) : '') + (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')); swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')) +
field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) +
(ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor',
Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : '');
const fc = ch.facade; const fc = ch.facade;
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) + const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
@@ -459,7 +469,9 @@ window.TectumLight = (() => {
const tr = ch.trasraam; 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')) + 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 ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : ''); (tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '') +
(LightModel.trimUses(ch) ? swatchField(`Zetwerk <small>${LightModel.trimUses(ch)}</small>`,
swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : '');
const L = ch.lighting; const L = ch.lighting;
const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) + const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
@@ -492,9 +504,10 @@ window.TectumLight = (() => {
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`; 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²`) + $('.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`); 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-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). // Keuzes (knoppen en kleuren).
@@ -519,6 +532,9 @@ window.TectumLight = (() => {
t.on = val === 'steen'; t.on = val === 'steen';
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0]; 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 === 'trasraamStone') next.trasraam.finish = val;
else if (key === 'trim') next.trim = val;
else if (key === 'fascia') next.fascia.on = val === 'aan';
else if (key === 'fasciaColor') next.fascia.color = val;
else if (key === 'lightMode') next.lighting.mode = val; else if (key === 'lightMode') next.lighting.mode = val;
else if (key === 'lightDir') next.lighting.direction = val; else if (key === 'lightDir') next.lighting.direction = val;
else return; else return;
@@ -532,6 +548,7 @@ window.TectumLight = (() => {
const a = e.target.closest('[data-act]'); const a = e.target.closest('[data-act]');
if (!a || !cur) return; if (!a || !cur) return;
if (a.dataset.act === 'pdf') savePdf(); 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)); else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
}); });
panel.addEventListener('keydown', (e) => { panel.addEventListener('keydown', (e) => {
@@ -598,6 +615,18 @@ window.TectumLight = (() => {
} }
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key); 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() { function savePdf() {
const st = clone(cur.st); const st = clone(cur.st);
st.project.naam = model.label; st.project.naam = model.label;
@@ -605,7 +634,7 @@ window.TectumLight = (() => {
try { try {
let image = null; let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); } 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`); .save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -642,11 +671,13 @@ window.TectumLight = (() => {
io.observe(stage); io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp. // 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; let glb = null;
function makeGlb() { function makeGlb() {
if (glb && glb.parts === cur.parts) return glb; if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url); 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 }; glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb; return glb;
} }
@@ -675,7 +706,7 @@ window.TectumLight = (() => {
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan. // 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; const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false; 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() { function closeAr() {
if (!arOpen) return; if (!arOpen) return;
arOpen = false; arOpen = false;
@@ -691,7 +722,7 @@ window.TectumLight = (() => {
arView.setAttribute('role', 'dialog'); arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true'); arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral" 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> <button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer> </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> <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>
@@ -758,6 +789,7 @@ window.TectumLight = (() => {
root.innerHTML = ''; root.innerHTML = '';
}, },
getChoices: () => clone(ch), getChoices: () => clone(ch),
design: () => cur && design(),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR). // GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb() glb: () => cur && makeGlb()
}; };
+82 -22
View File
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
const DENSITY_DAKLEER_KGM2 = 1.4; const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25; const TRIM_THICK_MM = 25;
const EDGE_ABOVE_MM = 80; const EDGE_ABOVE_MM = 80;
// Daktrim: voorkant 8 mm vóór de gevel, over de bovenste trimHeight van de gevel en 30 mm boven het dak; de opstand
// is 20 mm diep, daarachter ligt een flens van 3 mm op het dak over de bovenkant van de bekleding.
const TRIM = { OUT_MM: 8, ABOVE_MM: 30, DEPTH_MM: 20, FLANGE_MM: 3 };
// Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder // Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten. // opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 }; const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
@@ -23,9 +26,36 @@ window.RoofModel = (() => {
fasciaHeight: 300, fasciaHeight: 300,
spotMin: 800, spotMin: 800,
spotMax: 1200, spotMax: 1200,
trimHeight: 80, trimHeight: 30,
fasciaColor: 'antraciet' fasciaColor: 'antraciet',
vent: true,
ventColor: 'antraciet'
}; };
// Ventilatierooster onder de boeidelen: strook op de gevel, direct onder de onderplaat; sleuven in de textuur.
// Kleuren als de afdekker van het trasraam (WallModel.TRIM_COLORS).
const VENT = { HEIGHT_MM: 40, THICK_MM: 12, PITCH_MM: 12 };
const ventMaterials = {};
function ventMaterial(key) {
const k = WallModel.isTrimColor(key) ? key : 'antraciet';
if (ventMaterials[k]) return ventMaterials[k];
const base = new THREE.Color(WallModel.trimHex(k));
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
let mat;
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: base, roughness: 0.5, metalness: 0.3 });
else {
canvas.width = 32; canvas.height = 32;
ctx.fillStyle = '#' + base.getHexString();
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#' + base.clone().multiplyScalar(0.18).getHexString();
ctx.fillRect(11, 6, 11, 20); // sleuf, 4 mm breed en 25 mm hoog
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.anisotropy = 8;
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5, metalness: 0.3 });
}
return (ventMaterials[k] = mat);
}
const FASCIA_COLORS = { const FASCIA_COLORS = {
wit: { label: 'Wit', hex: 0xf2f2f0 }, wit: { label: 'Wit', hex: 0xf2f2f0 },
@@ -60,7 +90,7 @@ window.RoofModel = (() => {
} }
function coveringThumbUrl(key, px) { function coveringThumbUrl(key, px) {
const c = COVERINGS[key]; 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) { function coveringFor(key, shapeType) {
const c = COVERINGS[key]; const c = COVERINGS[key];
@@ -78,7 +108,7 @@ window.RoofModel = (() => {
if (!c.file) return null; if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 }); const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { 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.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM); tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
tex.anisotropy = 8; tex.anisotropy = 8;
@@ -980,15 +1010,16 @@ window.RoofModel = (() => {
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots. // Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
const nb = others.find(r => onRectEdge(r.rect, mid)); const nb = others.find(r => onRectEdge(r.rect, mid));
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z); const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
const above = fels ? FELS_EDGE.aboveMM : EDGE_ABOVE_MM; const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM;
return { return {
a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, above,
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j], outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [], topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped overRoof: !!nb && !dropped, dropped
}; };
}); });
const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0)); const depIn = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) : 0));
const depRaw = depIn.map(d => d || 0);
const dep = depRaw.map(d => Math.min(0, d)); const dep = depRaw.map(d => Math.min(0, d));
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5; const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
@@ -1002,15 +1033,16 @@ window.RoofModel = (() => {
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni]) spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
}; };
}); });
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] })); // facadeUnder: een eigen gevel onder de rand (niet bij een veranda of een hogere gevel ertegenaan).
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i], facadeUnder: depIn[i] !== null && depIn[i] >= 0 }));
// Rand zonder overstek (daktrim, felsrand): de felsrand ligt net vóór de gevelbekleding en loopt bij een hoek // Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
// door tot de buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand. // buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : TRIM_THICK_MM; const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : Math.max(0, depRaw[k]) + TRIM.OUT_MM;
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d }); const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
segs.forEach((sg, i) => { segs.forEach((sg, i) => {
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]]; const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
if (sg.fels) ends.forEach(([k, ok, j]) => { if (!sg.fascia) ends.forEach(([k, ok, j]) => {
const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i)); const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i));
sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q; sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q;
}); });
@@ -1048,8 +1080,10 @@ window.RoofModel = (() => {
return { return {
segments, overhang: s.overhang, spotRatio: SPOT_RATIO, segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
top, edgeAbove: EDGE_ABOVE_MM, vent: s.vent, ventColor: s.ventColor,
top,
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
ventLengthMM: s.vent ? segments.filter(sg => sg.fascia && sg.facadeUnder).reduce((t, sg) => t + Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z), 0) : 0,
// Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten. // Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten.
trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0), trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0),
felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0), felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0),
@@ -1071,22 +1105,30 @@ window.RoofModel = (() => {
return out; return out;
} }
function prismGeometry(poly, topFn, h) { // uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven.
const toM = 0.001, n = poly.length, pos = []; function prismGeometry(poly, topFn, h, uAt) {
const toM = 0.001, n = poly.length, pos = [], uvs = [];
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]); const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]); const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
const U = uAt ? poly.map(uAt) : null;
const tri = (a, b, c) => pos.push(...a, ...b, ...c); const tri = (a, b, c) => pos.push(...a, ...b, ...c);
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); } const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); };
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); } for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); uv([0, 1], [k, 1], [k + 1, 1]); tri(B[0], B[k + 1], B[k]); uv([0, 0], [k + 1, 0], [k, 0]); }
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); uv([k, 1], [k, 0], [j, 0]); tri(T[k], B[j], T[j]); uv([k, 1], [j, 0], [j, 1]); }
const geo = new THREE.BufferGeometry(); const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.computeVertexNormals(); geo.computeVertexNormals();
return geo; return geo;
} }
// Gevelvlak onder een rand (afstand langs de normaal): de dakrand plus de bekleding die erbuiten steekt.
function ventFace(sg) {
return sg.a.x * sg.normal.x + sg.a.z * sg.normal.z + Math.max(0, sg.facadeDepth || 0);
}
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak). // Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
function edgeHeight(edge, sg) { function edgeHeight(edge, sg) {
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + edge.edgeAbove; return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + TRIM.ABOVE_MM;
} }
// Kleur van de rand in tekeningen (hex-getal). // Kleur van de rand in tekeningen (hex-getal).
function edgeColor(edge, sg) { function edgeColor(edge, sg) {
@@ -1116,11 +1158,27 @@ window.RoofModel = (() => {
}); });
const h = edgeHeight(edge, sg); const h = edgeHeight(edge, sg);
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial; const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
polys.forEach(pl => { const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), mat); const add = (pl, topFn, hh, m, u) => {
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
mesh.visible = o.edgeVisible !== false; mesh.visible = o.edgeVisible !== false;
group.add(mesh); group.add(mesh);
};
if (!sg.fascia && !sg.fels) {
// Daktrim: opstand aan de voorkant, flens erachter op het dak.
const inner = nd(sg.outerA) - TRIM.DEPTH_MM;
polys.forEach(pl => {
const up = clipPoly(pl, p => nd(p) - inner), fl = clipPoly(pl, p => inner - nd(p));
if (up.length >= 3) add(up, sg.topAt, h, mat);
if (fl.length >= 3) add(fl, p => sg.topAt(p) - TRIM.ABOVE_MM + TRIM.FLANGE_MM, TRIM.FLANGE_MM, mat);
}); });
} else polys.forEach(pl => add(pl, sg.topAt, h, mat));
// Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel.
if (!sg.fascia || !sg.facadeUnder || !edge.vent) return;
const face = ventFace(sg);
const u = (p) => (alongZ ? p.z : p.x) / VENT.PITCH_MM;
polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3)
.forEach(pl => add(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, ventMaterial(edge.ventColor), u));
}); });
const spotR = 0.035, spotH = 0.012; const spotR = 0.035, spotH = 0.012;
@@ -1145,13 +1203,15 @@ window.RoofModel = (() => {
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover }; s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
s.posts = PostModel.normalizeSettings(s.posts); s.posts = PostModel.normalizeSettings(s.posts);
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
s.vent = s.vent !== false;
if (!WallModel.isTrimColor(s.ventColor)) s.ventColor = DEFAULT_SETTINGS.ventColor;
return s; return s;
} }
return { return {
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, TRIM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
FELS_EDGE, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
}; };
})(); })();
+26 -2
View File
@@ -4,7 +4,8 @@
// hebben om het origineel te vendoren; dekt precies de subset die wall.js // hebben om het origineel te vendoren; dekt precies de subset die wall.js
// gebruikt (`new THREE.OrbitControls(camera, domElement)`, // gebruikt (`new THREE.OrbitControls(camera, domElement)`,
// `controls.target.set(...)`, `controls.update()`), plus interactie: // `controls.target.set(...)`, `controls.update()`), plus interactie:
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen; // links-slepen = roteren, scrollwiel = zoomen; zijwaarts schuiven (pannen) met rechts- of middelste-knop-slepen,
// Shift/Ctrl/⌘ + links-slepen, of de pijltjestoetsen terwijl de muis boven de weergave staat;
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen). // touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
// //
// Moet NA three.min.js geladen worden (leunt op window.THREE). // Moet NA three.min.js geladen worden (leunt op window.THREE).
@@ -50,7 +51,9 @@
}; };
function onPointerDown(event) { function onPointerDown(event) {
if (event.button === 2) { const panButton = event.button === 2 || event.button === 1 || (event.button === 0 && (event.shiftKey || event.ctrlKey || event.metaKey));
if (event.button === 1) event.preventDefault(); // geen autoscroll
if (panButton) {
if (!scope.enablePan) return; if (!scope.enablePan) return;
isPanning = true; isPanning = true;
} else { } else {
@@ -138,11 +141,29 @@
function onContextMenu(event) { event.preventDefault(); } function onContextMenu(event) { event.preventDefault(); }
// Pijltjestoetsen: schuiven terwijl de muis boven de weergave staat (niet in een invoerveld).
let hover = false;
const onEnter = () => { hover = true; };
const onLeave = () => { hover = false; };
const KEY_PAN = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
function onKeyDown(event) {
const d = KEY_PAN[event.key];
if (!d || !hover || !scope.enablePan || event.altKey || event.ctrlKey || event.metaKey) return;
const t = event.target, tag = t && t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return;
event.preventDefault();
const step = (scope.domElement.clientHeight || 400) * (event.shiftKey ? 0.25 : 0.08);
pan(d[0] * step, d[1] * step);
}
this.domElement.addEventListener('mousedown', onPointerDown); this.domElement.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove); window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp); window.addEventListener('mouseup', onPointerUp);
this.domElement.addEventListener('wheel', onWheel, { passive: false }); this.domElement.addEventListener('wheel', onWheel, { passive: false });
this.domElement.addEventListener('contextmenu', onContextMenu); this.domElement.addEventListener('contextmenu', onContextMenu);
this.domElement.addEventListener('mouseenter', onEnter);
this.domElement.addEventListener('mouseleave', onLeave);
window.addEventListener('keydown', onKeyDown);
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false }); this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false }); this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
this.domElement.addEventListener('touchend', onTouchEnd); this.domElement.addEventListener('touchend', onTouchEnd);
@@ -154,6 +175,9 @@
window.removeEventListener('mouseup', onPointerUp); window.removeEventListener('mouseup', onPointerUp);
scope.domElement.removeEventListener('wheel', onWheel); scope.domElement.removeEventListener('wheel', onWheel);
scope.domElement.removeEventListener('contextmenu', onContextMenu); scope.domElement.removeEventListener('contextmenu', onContextMenu);
scope.domElement.removeEventListener('mouseenter', onEnter);
scope.domElement.removeEventListener('mouseleave', onLeave);
window.removeEventListener('keydown', onKeyDown);
scope.domElement.removeEventListener('touchstart', onTouchStart); scope.domElement.removeEventListener('touchstart', onTouchStart);
scope.domElement.removeEventListener('touchmove', onTouchMove); scope.domElement.removeEventListener('touchmove', onTouchMove);
scope.domElement.removeEventListener('touchend', onTouchEnd); scope.domElement.removeEventListener('touchend', onTouchEnd);
+72 -7
View File
@@ -116,6 +116,23 @@ window.WallModel = (() => {
return mat; return mat;
} }
// Afwerking in zetwerk (aluminium, gepoedercoat): afdekker op een stenen trasraam en het ventilatierooster onder
// de boeidelen.
const TRIM_COLORS = {
zwart: { label: 'Zwart', hex: '#1f2022' },
antraciet: { label: 'Antraciet', hex: '#3d4146' },
wit: { label: 'Wit', hex: '#f3f2ee' }
};
const isTrimColor = (key) => !!TRIM_COLORS[key];
const trimHex = (key) => (TRIM_COLORS[key] || TRIM_COLORS.antraciet).hex;
const trimMaterials = {};
function trimMaterial(key) {
const k = isTrimColor(key) ? key : 'antraciet';
return trimMaterials[k] || (trimMaterials[k] = new THREE.MeshStandardMaterial({ color: trimHex(k), roughness: 0.45, metalness: 0.3 }));
}
// Afdekker trasraam: bovenkant 5° aflopend (85° met de neus), neus 25 mm over de steen, 3 mm ervoor.
const TRAS_CAP = { SLOPE_DEG: 5, DROP_MM: 25, GAP_MM: 3, THICK_MM: 2 };
function claddingOffset(buitenMateriaal, stone, stuc) { function claddingOffset(buitenMateriaal, stone, stuc) {
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM; if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
if (buitenMateriaal !== 'cementplaat') return 0; if (buitenMateriaal !== 'cementplaat') return 0;
@@ -175,9 +192,9 @@ window.WallModel = (() => {
// Hoogte vanaf het loopvlak (= onderkant van de wand). // Hoogte vanaf het loopvlak (= onderkant van de wand).
const TRASRAAM_DEFAULT_MM = 350; const TRASRAAM_DEFAULT_MM = 350;
function trasraamFor(heightMM, finish) { function trasraamFor(heightMM, finish, capColor) {
if (!(heightMM > 0)) return null; if (!(heightMM > 0)) return null;
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' }; return { bottom: 0, top: heightMM, finish: finish || 'cementplaat', capColor: isTrimColor(capColor) ? capColor : 'antraciet' };
} }
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px); // Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
@@ -217,8 +234,8 @@ window.WallModel = (() => {
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274' 'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
}; };
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish); const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'); const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'); const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
// UV's in meters, dus repeat = 1/tegel. // UV's in meters, dus repeat = 1/tegel.
const stoneMaterials = {}; const stoneMaterials = {};
function stoneMaterial(code) { function stoneMaterial(code) {
@@ -849,7 +866,7 @@ window.WallModel = (() => {
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
let stucAreaM2 = 0; let stucAreaM2 = 0;
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
let horizBeamCount = 0; let horizBeamCount = 0;
function createExtrudedBeam(shape, depth, positionZ) { function createExtrudedBeam(shape, depth, positionZ) {
@@ -1108,6 +1125,41 @@ window.WallModel = (() => {
return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso }; return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso };
} }
// Afdekker op een stenen trasraam waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): zetwerk
// over de bovenkant van de steen, achter de bekleding beginnend, aan de voorkant een neus over de steen. Onderbroken
// waar een opening door de bovenkant van het trasraam gaat; bij een buitenhoek tot voorbij de steen van de buurwand.
function stoneCap(tr) {
const C = TRAS_CAP, T = tr.top * toM;
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM, zF = zB + STONE.THICK_MM * toM;
const zIn = zF + C.GAP_MM * toM, zOut = zIn + C.THICK_MM * toM, lip = zOut - zF;
const yFront = T + C.THICK_MM * toM, yBack = yFront + Math.tan(C.SLOPE_DEG * Math.PI / 180) * (zOut - zB);
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STONE.THICK_MM);
const a = -halfW - ext.left - (ext.left > 0 ? lip : 0), b = halfW + ext.right + (ext.right > 0 ? lip : 0);
const yLo = T - C.DROP_MM * toM;
const cuts = outerHoles.filter(h => h.hy0 < yBack && h.hy1 > yLo).map(h => [h.hx0, h.hx1]).sort((p, q) => p[0] - q[0]);
const runs = [];
let x = a;
cuts.forEach(([c0, c1]) => { if (c0 > x) runs.push([x, Math.min(b, c0)]); x = Math.max(x, c1); });
if (x < b) runs.push([x, b]);
const shape = new THREE.Shape([
[zB, T + 0.0005], [zIn, T + 0.0005], [zIn, yLo], [zOut, yLo], [zOut, yFront], [zB, yBack]
].map(([pz, py]) => new THREE.Vector2(pz, py)));
let lengthM = 0;
runs.forEach(([r0, r1]) => {
if (r1 - r0 <= edgeEps) return;
lengthM += r1 - r0;
if (!buitenVisible || !show('gevel')) return;
// Profiel in (z, y), langs de wand uitgetrokken: -90° om Y legt de extrusie langs -X.
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: r1 - r0, bevelEnabled: false }), trimMaterial(tr.capColor));
mesh.rotation.y = -Math.PI / 2;
mesh.position.x = r1;
mesh.castShadow = true;
mesh.receiveShadow = true;
wallGroup3D.add(mesh);
});
return lengthM;
}
function stucBlock(colorKey, y0) { function stucBlock(colorKey, y0) {
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM; const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
const t = STUC.THICK_MM * toM; const t = STUC.THICK_MM * toM;
@@ -1205,6 +1257,17 @@ window.WallModel = (() => {
cap.position.set(x + dir * 0.0003, h / 2, 0); cap.position.set(x + dir * 0.0003, h / 2, 0);
if (spaanplaatVisible) wallGroup3D.add(cap); if (spaanplaatVisible) wallGroup3D.add(cap);
}); });
// Vrij uiteinde van een binnenwand (endCaps): afgewerkt zoals de wand, geen stijl in zicht.
const endCaps = opts.endCaps || {};
if (buitenMateriaal === 'spaanplaat' && spaanplaatVisible) [[endCaps.left, -halfW, -1], [endCaps.right, halfW, 1]].forEach(([on, x, dir]) => {
if (!on) return;
const h = topYAt(x), depth = maxDepth + 2 * (skinGapM + sThickM);
if (!(h > 0)) return;
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
cap.rotation.y = dir * Math.PI / 2;
cap.position.set(x + dir * 0.0003, h / 2, 0);
wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2; spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3; spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
@@ -1229,6 +1292,7 @@ window.WallModel = (() => {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2; trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3; trasraamIsolatieM3 = r.isoM3;
if (stucKey) trasraamCapM = stoneCap(trasraam);
y0 = trasraam.top * toM; y0 = trasraam.top * toM;
} else if (trasraam) { } else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
@@ -1306,6 +1370,7 @@ window.WallModel = (() => {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2; trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3; trasraamIsolatieM3 = r.isoM3;
trasraamCapM = stoneCap(trasraam);
} else if (trasraam) { } else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
@@ -1445,7 +1510,7 @@ window.WallModel = (() => {
stats: { stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2, woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount, spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2 battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2
} }
}; };
} }
@@ -1674,7 +1739,7 @@ window.WallModel = (() => {
} }
return { return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, TRIM_COLORS, isTrimColor, trimHex, trimMaterial, TRAS_CAP, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs createMaterials, build3D, computeSpecs
+1 -1
View File
@@ -41,7 +41,7 @@
"fasciaHeight": 300, "fasciaHeight": 300,
"spotMin": 800, "spotMin": 800,
"spotMax": 1200, "spotMax": 1200,
"trimHeight": 80, "trimHeight": 30,
"fasciaColor": "antraciet" "fasciaColor": "antraciet"
}, },
"lighting": { "lighting": {