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
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
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
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
@@ -210,7 +214,10 @@ assets/ logo, texturen, skybox
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
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
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
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
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
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.
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
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
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
`TectumLight.mount(el, { model, frames, url, modelViewer })`.
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
@@ -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:
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),
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
woning alleen als de units samen een rechthoek vormen (zoals de configurator). Geen openingen,
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
(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.
- **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
@@ -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
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
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).
- **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
@@ -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
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
in `LightPdf.COMPANY` en `STEPS`.
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
+3 -2
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* 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.
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'],
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
['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],
['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]
+11
View File
@@ -6,6 +6,17 @@
*/
// Plain script, geen ES-module: imports breken bij file:// (CORS).
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
// een modelpagina van de site). Paden in de data blijven relatief.
const TECTUM_BASE = (() => {
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
return s ? new URL('../../', s).href : '';
})();
function tectumUrl(path) {
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
}
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp);
+43 -1
View File
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
} catch (e) { return null; }
}
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
function build(objects, opts) {
const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 };
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
const images = new Map(), samplers = new Map(), textures = new Map();
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
}
return (remap[ci] = b.n++);
};
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
const raw = (ci) => {
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
const t = new THREE.Vector2();
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
return { p, n, t };
};
const lerp = (A, B, f) => ({
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
});
const push = (q) => {
b.pos.push(q.p.x, q.p.y, q.p.z);
b.nrm.push(q.n.x, q.n.y, q.n.z);
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
return b.n++;
};
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) {
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
const len = face.length();
if (len < 2e-7) continue;
face.multiplyScalar(flip ? -1 / len : 1 / len);
if (clip) {
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
if (!nIn) continue;
if (nIn < 3) {
const q = s.map(raw), poly = [];
for (let j = 0; j < 3; j++) {
const k2 = (j + 1) % 3;
if (inside[j]) poly.push(q[j]);
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
}
const c = poly.map(push);
for (let j = 1; j + 1 < c.length; j++) {
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
triCount++;
}
continue;
}
}
const c = s.map(add);
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
triCount++;
+1 -1
View File
@@ -23,7 +23,7 @@ window.Skybox = (() => {
const cache = {};
function loadTexture(url) {
if (!cache[url]) {
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
cache[url].catch(() => { delete cache[url]; });
}
return cache[url];
+1 -1
View File
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
}
function loadList(list) {
const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' })
return fetch(tectumUrl(url), { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null);
}
+5 -1
View File
@@ -97,7 +97,9 @@ window.COST_DEFAULTS = {
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
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 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
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 },
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 },
+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));
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
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;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 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', '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', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
}
if (totals.posts) {
const pp = totals.posts;
+2 -2
View File
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
}
function finishThumbUrl(key, px) {
const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
}
const finishMaterials = {};
function finishMaterial(key) {
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
}
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(f.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
tex.anisotropy = 8;
+156 -11
View File
@@ -36,7 +36,7 @@ window.HouseModel = (() => {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
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,
interiorColor: '#f2efe9',
ceilingColor: '#f4f3ef',
@@ -67,7 +67,7 @@ window.HouseModel = (() => {
}
function facadeMaterials(state, fc) {
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;
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 };
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
return st;
}
// Model Van den Berg (examples/van den berg.pdf): dubbel chalet 6,94 × 12,25 m. West een smal, laag chalet van
// 2,97 × 12,25 m (wandhoogte 2330: garderobe, twee slaapkamers, toilet, badkamer, cv-kast, hal), oost een breed,
// hoog chalet van 3,97 × 9,5 m (wandhoogte 3080: woonkamer met keuken), allebei een plat dak (EPDM) met boeidelen.
// In de noordwesthoek een overdekt portiek van 1,36 × 1,75 m voor de voordeur: de garderobe is een eigen unit
// (B) waarvan het dak naar het westen doorloopt. Ten zuiden van het oostchalet een overdekte vlonder van 3,97 × 2,75 m
// (het dak loopt 2,75 m door, palen op de hoeken). Gevel: het lage chalet donker (Eternit potdeksel, hier
// antraciet felsplaten), het hoge chalet eiken rabat horizontaal; het deel van de oostwand boven het lage dak
// krijgt dezelfde rabat. De twee bovenlichten van 200 × 30 in die wand zijn weggelaten: het stuk boven het lage
// dak is 488 mm hoog en een opening is minimaal 400 mm. Maten buitenwerks, x vanaf de westgevel en z vanaf de
// noordgevel van de tekening. Het lage chalet is de eerste unit A (noordwesthoek op z 1750), het hoge chalet C.
function createVandenBerg() {
const st = newModelState('Van den Berg');
const S = st.settings, HL = 2330, HH = 3080, WA = 2970, WB = 3970, P = 1750, PW = 1360, FELS = 'antraciet';
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
S.cladding = 'horizontaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 120, maxSpan: 4500 });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: false, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'wit';
const A = createUnitEntry('A', null, { width: WA, depth: 12250 - P, height: HL, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'voor', align: 'end', offset: 0 }, { width: WA - PW, depth: P, height: HL, windowOn: [] });
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'start', offset: 0 }, { width: WB, depth: 9500, height: HH, windowOn: [] });
st.units.push(A, B, C);
B.roof.extend = { f: 0, r: 0, b: 0, l: PW }; // portiek
C.roof.extend = { f: 0, r: 0, b: 2750, l: 0 }; // vlonder
[A, B].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.facade.walls[side] = { type: 'fels', fels: FELS }; }));
const tE = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
const { binnen, ops, iw, room, fx } = modelKit(st, HL, 90);
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), extra || {});
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
// binnenkant van de noordgevel. z in de tekening; zA = z in unit A.
const zA = (z) => z - P;
ops(A.unit.walls.links, [
['raam', 700, 1100, 950, 'left', zA(3897) - tE, K('raam', { rooster: true })], // slaapkamer 2
['raam', 600, 700, 1350, 'left', zA(5550) - tE, K('raam')], // toilet
['raam', 600, 700, 1350, 'left', zA(6590) - tE, K('raam')]]); // badkamer
ops(A.unit.walls.achter, [['raam', 1200, 1100, 950, 'left', 827, K('raam', { rooster: true })]]); // slaapkamer 1
ops(B.unit.walls.links, [['voordeur', 900, 2110, 0, 'left', 787 - tE, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.voor, [['raam', 1400, 900, 1200, 'right', 1651, K('raam', { rooster: true })]]); // boven de spoelbak
// Oostgevel: kozijnen van 180 × 300, het noordelijke met openslaande deuren (alleen van binnen te bedienen).
ops(C.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 2267 - tE, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 3167 - tE, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['raam', 1800, 650, 2200, 'right', 2267 - tE, K('raam')],
['raam', 1800, 2100, 0, 'right', 5946 - tE, K('raam')],
['raam', 1800, 650, 2200, 'right', 5946 - tE, K('raam')]]);
C.unit.walls.rechts.openings[1].koppel = true;
ops(C.unit.walls.achter, [['deur', 2400, 2100, 0, 'left', 792, // schuifpui naar de vlonder
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
const DH = 2115, DW = 730;
// Binnenwanden in wereldcoördinaten (t.o.v. de noordwesthoek van A). Noord–zuid: 'buiten' = west, 'left' =
// vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
const xE = WA - 100; // tegen de naadwand
const w = (x1, z1, x2, z2, openings) => iw(x1, zA(z1), x2, zA(z2), openings);
w(tE, 5321, xE, 5321, [['deur', DW, DH, 'left', 1879 - tE, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // slaapkamer 2 | toilet, hal
w(1376, 5366, 1376, 6368, [['deur', DW, DH, 'left', 200, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | hal
w(tE, 6413, 1891, 6413); // toilet | badkamer
w(1846, 6458, 1846, 8651, [ // badkamer, cv-kast | hal
['deur', DW, DH, 'left', 20, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', DW, DH, 'left', 1330, binnen({ hinge: 'links', swing: 'binnen' })]]);
w(1155, 7700, 1801, 7700); // badkamer | cv-kast
w(1200, 7745, 1200, 8651); // douche | cv-kast
w(tE, 8696, xE, 8696, [['deur', DW, DH, 'left', 1985 - tE, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | slaapkamer 1
ensureSeamWalls(st);
const seamOf = (a, b) => st.interiorWalls.find(iw => iw.seam && sameSeam(iw.seam, a, b));
const toog = (wd) => ({ id: 1, type: 'deur', w: wd, h: 2100, y: 0, anchor: 'left', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) });
const sAC = seamOf('A', 'C'), sBC = seamOf('B', 'C'), sAB = seamOf('A', 'B');
// Naadwanden noord–zuid: 'left' = vanaf de zuidkant (A: binnenkant zuidgevel van C, z 9244; B: de naad met A, z 1750).
if (sAC) sAC.wall.openings = [Object.assign(toog(1000), { offset: 2854 })]; // hal → woonkamer (z 5390–6390)
if (sBC) sBC.wall.openings = [Object.assign(toog(900), { offset: 350 })]; // garderobe → woonkamer (z 500–1400)
if (sAB) sAB.wall.openings = [];
room('A', 1400, zA(3000), 'slaapkamer', 'Slaapkamer 2');
room('A', 800, zA(5900), 'toilet');
room('A', 1500, zA(7000), 'badkamer');
room('A', 1500, zA(8200), 'techniek', 'Cv-kast');
room('A', 2400, zA(7000), 'hal', 'Hal');
room('A', 1400, zA(11000), 'slaapkamer', 'Slaapkamer 1');
room('B', 800, 900, 'hal', 'Garderobe');
room('C', 2000, 4500, 'woonkeuken', 'Woonkamer/keuken');
fx('bed', 'A', 670, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('bed', 'A', 2290, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('kledingkast', 'A', 1056, zA(4976), 180, { w: 1600 });
fx('toilet', 'A', 601, zA(5950), 270);
fx('fontein', 'A', 1108, zA(5491), 0);
fx('douche', 'A', 706, zA(8051), 180, { w: 900, d: 1200 }, { glas: 'rechts', glasLengte: 900 });
fx('wastafel', 'A', 1478, zA(7405), 180, { w: 600 });
fx('boiler', 'A', 1470, zA(8350), 270);
fx('kledingkast', 'A', 1056, zA(9041), 0, { w: 1600 });
fx('bed', 'A', tE + 1000, zA(10744), 270, { w: 1600, d: 2000 }, { nachtkastjes: false });
const xC = (x) => x - WA;
fx('keuken', 'C', xC(4930), tE + 300, 0, { w: 3500 }, { front: 'wit', hogeKast: 'rechts', spoelbak: 0.6, kookplaat: true, kookplaatPlek: 0.26 });
fx('eettafel', 'C', xC(4912), 3172, 0, { w: 1400, d: 900 }, { vorm: 'rechthoek', stoelen: 6 });
fx('bank', 'C', 560, 7700, 270, { w: 2200, d: 900 }, { chaise: 'geen' });
fx('salontafel', 'C', 1700, 7700, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'C', 2900, 7200, 90);
computeParts(st, { skip3D: true });
return st;
}
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht }
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht },
vandenberg: { label: 'Woning Van den Berg', icon: 'house-dash', create: createVandenBerg }
};
function createDefaultState(template) {
@@ -1121,10 +1231,33 @@ window.HouseModel = (() => {
}
});
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);
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;
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)))]));
@@ -1477,10 +1610,10 @@ window.HouseModel = (() => {
});
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 };
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
});
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 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 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.
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);
if (!sg) return;
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
// Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
if (!sg || (!sg.fascia && !sg.fels)) return;
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)));
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
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) {
const outer = new THREE.Group();
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),
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) },
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,
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
plaatwerk: [
@@ -2292,6 +2434,8 @@ window.HouseModel = (() => {
overhang: parts.roofEdge.overhang,
soffitM2: parts.roofEdge.soffitM2,
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,
spots: parts.roofEdge.spots.length
} : null,
@@ -2357,6 +2501,7 @@ window.HouseModel = (() => {
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
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.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet';
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.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
@@ -2393,7 +2538,7 @@ window.HouseModel = (() => {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
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
};
})();
+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 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 });
// 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 => {
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 });
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 });
});
}
@@ -371,7 +388,11 @@ window.HousePdf = (() => {
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: '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 => {
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
@@ -618,6 +639,8 @@ window.HousePdf = (() => {
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.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(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
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 stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
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 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>`;
@@ -1352,7 +1354,10 @@ function mountHouseEditor(container, callbacks) {
(st.trasraam.enabled
? 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),
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,
{ 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.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-cap', v => { st.trasraam.capColor = v; }, rerender);
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
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,
groups: () => {
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, {
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,
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
apply: () => HouseModel.applyGlobalSettings(state),
@@ -1404,8 +1410,10 @@ function mountHouseEditor(container, callbacks) {
{ 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-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) },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
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:
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` }) +
@@ -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-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
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.onToggle('hs-posts', v => { r.posts.enabled = 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 groups = () => {
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) : '') +
(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)` }) }
@@ -1959,6 +1969,7 @@ function mountHouseEditor(container, callbacks) {
const form = UnitModel.roofForm(u.unit);
const roofUi = roofFormUi('hp', form, {
gable: true,
types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null,
span: () => {
const pu = partsOf();
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-steel-ridge', v => { u.roof.steelRidge = 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 => {
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));
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
}, afterCover));
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; }, afterCover);
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover);
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover);
}
};
}
@@ -2334,6 +2347,8 @@ function mountHouseEditor(container, callbacks) {
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.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.']);
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';
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 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));
}
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;
}
@@ -326,12 +333,17 @@ window.LightModel = (() => {
];
const tr = ch.trasraam;
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']);
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
return rows;
}
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// 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 };
})();
+22 -17
View File
@@ -5,8 +5,8 @@
* Zie LICENTIE.md.
*/
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
window.LightPdf = (() => {
const COMPANY = {
naam: 'Van Emous Mantelzorgwoningen',
@@ -135,20 +135,25 @@ window.LightPdf = (() => {
], 'Kerncijfers');
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs
const py = Math.max(y + 4, 196);
doc.setFillColor(...ORANGE);
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text('Indicatieve prijs', M + 7, py + 9);
doc.setFont('helvetica', 'bold');
doc.setFontSize(24);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27);
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
const py = Math.max(y + 4, 196), price = d.price !== false;
if (price) {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
}
// Beeld van de woning
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
@@ -159,7 +164,7 @@ window.LightPdf = (() => {
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
doc.setFontSize(7);
doc.setTextColor(...MUTED);
doc.text('Dit ontwerp en de prijs zijn indicatief; hieraan kunnen geen rechten worden ontleend.', M, PH - 6);
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
}
function process(doc, d) {
+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
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
// TectumLight.mount(el, { model, frames, url, modelViewer }); url = adres voor de QR-code in de PDF (standaard de
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
// Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => {
@@ -35,7 +39,7 @@ window.TectumLight = (() => {
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
const SECTION = { start: 1500, min: 300, max: 3000 };
const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
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
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
@@ -66,6 +70,7 @@ window.TectumLight = (() => {
function mount(root, opts) {
const o = opts || {};
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
const showPrice = o.price !== false && root.dataset.price !== 'false';
let ch = clone(model.initial);
let unitSel = model.base.units[0].id;
let cur = null; // { st, parts, info }
@@ -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.
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 covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
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)) : '') +
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer'));
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 facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
@@ -459,7 +469,9 @@ window.TectumLight = (() => {
const tr = ch.trasraam;
const trasraamHtml = field('Trasraam <small>stenen strook onderaan de gevel</small>', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) +
(tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '');
(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 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>`;
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
$('.tl-buy').innerHTML = `<div class="tl-price"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>`;
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
}
// Keuzes (knoppen en kleuren).
@@ -519,6 +532,9 @@ window.TectumLight = (() => {
t.on = val === 'steen';
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0];
} else if (key === 'trasraamStone') next.trasraam.finish = val;
else if (key === '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 === 'lightDir') next.lighting.direction = val;
else return;
@@ -532,6 +548,7 @@ window.TectumLight = (() => {
const a = e.target.closest('[data-act]');
if (!a || !cur) return;
if (a.dataset.act === 'pdf') savePdf();
else if (a.dataset.act === 'save') o.save.onSave(design());
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
});
panel.addEventListener('keydown', (e) => {
@@ -598,6 +615,18 @@ window.TectumLight = (() => {
}
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
function design() {
const st = clone(cur.st);
st.project.naam = model.label;
st.project.opmerking = 'Ontwerp uit de light-versie';
return {
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
label: model.label, key: model.key, info: Object.assign({}, cur.info),
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
};
}
function savePdf() {
const st = clone(cur.st);
st.project.naam = model.label;
@@ -605,7 +634,7 @@ window.TectumLight = (() => {
try {
let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); }
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image })
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (err) {
console.error(err);
@@ -642,11 +671,13 @@ window.TectumLight = (() => {
io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb;
}
@@ -675,7 +706,7 @@ window.TectumLight = (() => {
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false;
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
function closeAr() {
if (!arOpen) return;
arOpen = false;
@@ -691,7 +722,7 @@ window.TectumLight = (() => {
arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer>
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
@@ -758,6 +789,7 @@ window.TectumLight = (() => {
root.innerHTML = '';
},
getChoices: () => clone(ch),
design: () => cur && design(),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
};
+83 -23
View File
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25;
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
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
@@ -23,9 +26,36 @@ window.RoofModel = (() => {
fasciaHeight: 300,
spotMin: 800,
spotMax: 1200,
trimHeight: 80,
fasciaColor: 'antraciet'
trimHeight: 30,
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 = {
wit: { label: 'Wit', hex: 0xf2f2f0 },
@@ -60,7 +90,7 @@ window.RoofModel = (() => {
}
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null;
}
function coveringFor(key, shapeType) {
const c = COVERINGS[key];
@@ -78,7 +108,7 @@ window.RoofModel = (() => {
if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(c.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(c.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
tex.anisotropy = 8;
@@ -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.
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 above = fels ? FELS_EDGE.aboveMM : EDGE_ABOVE_MM;
const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM;
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],
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
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 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;
@@ -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])
};
});
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
// door tot de 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;
// Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
// 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) : 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 });
segs.forEach((sg, i) => {
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));
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 {
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
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),
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.
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),
@@ -1071,22 +1105,30 @@ window.RoofModel = (() => {
return out;
}
function prismGeometry(poly, topFn, h) {
const toM = 0.001, n = poly.length, pos = [];
// uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven.
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 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);
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); }
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]); }
const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); };
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();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.computeVertexNormals();
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).
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).
function edgeColor(edge, sg) {
@@ -1116,11 +1158,27 @@ window.RoofModel = (() => {
});
const h = edgeHeight(edge, sg);
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
polys.forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), mat);
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
const add = (pl, topFn, hh, m, u) => {
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
mesh.visible = o.edgeVisible !== false;
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;
@@ -1145,13 +1203,15 @@ window.RoofModel = (() => {
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
s.posts = PostModel.normalizeSettings(s.posts);
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 {
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,
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
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
// `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).
//
// Moet NA three.min.js geladen worden (leunt op window.THREE).
@@ -50,7 +51,9 @@
};
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;
isPanning = true;
} else {
@@ -138,11 +141,29 @@
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);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
this.domElement.addEventListener('wheel', onWheel, { passive: false });
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('touchmove', onTouchMove, { passive: false });
this.domElement.addEventListener('touchend', onTouchEnd);
@@ -154,6 +175,9 @@
window.removeEventListener('mouseup', onPointerUp);
scope.domElement.removeEventListener('wheel', onWheel);
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('touchmove', onTouchMove);
scope.domElement.removeEventListener('touchend', onTouchEnd);
+72 -7
View File
@@ -116,6 +116,23 @@ window.WallModel = (() => {
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) {
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
if (buitenMateriaal !== 'cementplaat') return 0;
@@ -175,9 +192,9 @@ window.WallModel = (() => {
// Hoogte vanaf het loopvlak (= onderkant van de wand).
const TRASRAAM_DEFAULT_MM = 350;
function trasraamFor(heightMM, finish) {
function trasraamFor(heightMM, finish, capColor) {
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);
@@ -217,8 +234,8 @@ window.WallModel = (() => {
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
};
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
// UV's in meters, dus repeat = 1/tegel.
const stoneMaterials = {};
function stoneMaterial(code) {
@@ -849,7 +866,7 @@ window.WallModel = (() => {
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
let stucAreaM2 = 0;
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0;
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
let horizBeamCount = 0;
function createExtrudedBeam(shape, depth, positionZ) {
@@ -1108,6 +1125,41 @@ window.WallModel = (() => {
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) {
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
const t = STUC.THICK_MM * toM;
@@ -1205,6 +1257,17 @@ window.WallModel = (() => {
cap.position.set(x + dir * 0.0003, h / 2, 0);
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;
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 });
trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3;
if (stucKey) trasraamCapM = stoneCap(trasraam);
y0 = trasraam.top * toM;
} else if (trasraam) {
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 });
trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3;
trasraamCapM = stoneCap(trasraam);
} else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
@@ -1445,7 +1510,7 @@ window.WallModel = (() => {
stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
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 {
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,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
+1 -1
View File
@@ -41,7 +41,7 @@
"fasciaHeight": 300,
"spotMin": 800,
"spotMax": 1200,
"trimHeight": 80,
"trimHeight": 30,
"fasciaColor": "antraciet"
},
"lighting": {