Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73248a69c9 | ||
|
|
b8cc39275b | ||
|
|
118ccf9098 | ||
|
|
432e0f494a |
@@ -20,11 +20,12 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
||||
```
|
||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||
css/app.css alle stijl
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF)
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF),
|
||||
model-viewer 4.3.1 (Apache-2.0, Google; UMD-bundel met eigen three.js, AR in de light-versie)
|
||||
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
||||
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
||||
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||
(obj-writer.js), GLB-schrijver (glb-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||
skybox, licentie, logo
|
||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||
@@ -170,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
|
||||
@@ -209,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
|
||||
@@ -223,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
|
||||
@@ -339,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 })`.
|
||||
`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²,
|
||||
@@ -351,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
|
||||
@@ -370,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
|
||||
@@ -379,6 +395,20 @@ 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
|
||||
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
|
||||
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
|
||||
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
|
||||
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
|
||||
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
|
||||
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
|
||||
`light.html` de testknop GLB).
|
||||
|
||||
## Kosten en prijzen (CSV)
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@
|
||||
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
||||
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
||||
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
/* Rechtsboven: AR (telefoon/tablet) of schermvullend (desktop). */
|
||||
.tectum-light .tl-xbtn {
|
||||
position: absolute; top: 10px; right: 10px; width: 35px; height: 35px; padding: 0; border: 0; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); color: var(--tl-text);
|
||||
}
|
||||
.tectum-light .tl-xbtn:hover { color: var(--tl-primary); }
|
||||
.tectum-light .tl-xbtn[hidden] { display: none; }
|
||||
.tectum-light.has-xbtn .tl-busy { right: 53px; }
|
||||
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
||||
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
||||
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
||||
@@ -174,6 +183,8 @@
|
||||
.tectum-light .tl-hint { display: none; }
|
||||
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
||||
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
||||
.tectum-light.has-xbtn .tl-viewbar { right: 51px; }
|
||||
.tectum-light .tl-xbtn { top: 8px; right: 8px; width: 37px; height: 37px; }
|
||||
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
||||
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
||||
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
||||
@@ -187,3 +198,34 @@
|
||||
.tectum-light .tl-viewbar button .bi { display: none; }
|
||||
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* AR-weergave (model-viewer) over de hele pagina. Staat buiten .tectum-light: die is een container en zou het kader van
|
||||
position: fixed worden. Kleur van de knop = --tl-primary. */
|
||||
html.tl-noscroll, html.tl-noscroll body { overflow: hidden; }
|
||||
.tl-arview {
|
||||
position: fixed; inset: 0; z-index: 2147483000; display: none; background: #dfe6ec; color: #212121;
|
||||
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.tl-arview.on { display: block; }
|
||||
.tl-arview *, .tl-arview *::before, .tl-arview *::after { box-sizing: border-box; }
|
||||
.tl-arview model-viewer { display: block; width: 100%; height: 100%; background: #dfe6ec; --poster-color: transparent; }
|
||||
.tl-arview .tl-ar-top {
|
||||
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: max(10px, env(safe-area-inset-top)) 12px 10px 16px; pointer-events: none;
|
||||
}
|
||||
.tl-arview .tl-ar-title { font-size: 17px; font-weight: 700; }
|
||||
.tl-arview .tl-ar-close {
|
||||
pointer-events: auto; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; color: inherit;
|
||||
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.tl-arview .tl-ar-go {
|
||||
position: absolute; left: 50%; bottom: calc(44px + env(safe-area-inset-bottom)); transform: translateX(-50%);
|
||||
display: inline-flex; align-items: center; gap: 9px; height: 50px; margin: 0; padding: 0 24px; border: 0; border-radius: 25px;
|
||||
background: #b95913; color: #fff; font-family: inherit; font-size: 16px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.tl-arview .tl-ar-go .bi { font-size: 22px; }
|
||||
.tl-arview .tl-ar-note {
|
||||
position: absolute; left: 16px; right: 16px; bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
text-align: center; font-size: 12px; color: #5b6673; pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.7.4';
|
||||
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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
|
||||
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
|
||||
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
|
||||
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
|
||||
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
|
||||
|
||||
window.GlbWriter = (() => {
|
||||
const MAX_TEX = 1024;
|
||||
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
|
||||
|
||||
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
|
||||
|
||||
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
|
||||
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
|
||||
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
|
||||
function imageBytes(image, png) {
|
||||
try {
|
||||
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
|
||||
if (!w0 || !h0) return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = pot(w0); c.height = pot(h0);
|
||||
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
|
||||
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
|
||||
function build(objects, opts) {
|
||||
const o = opts || {};
|
||||
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||
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 = [];
|
||||
const bin = [];
|
||||
let binLength = 0, triCount = 0;
|
||||
function addView(data, target) {
|
||||
const pad = (4 - binLength % 4) % 4;
|
||||
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
|
||||
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
|
||||
if (target) view.target = target;
|
||||
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
binLength += data.byteLength;
|
||||
return view;
|
||||
}
|
||||
const views = [];
|
||||
|
||||
function textureIndex(tex, png) {
|
||||
if (!tex || !tex.image) return null;
|
||||
if (!images.has(tex.image)) {
|
||||
const img = imageBytes(tex.image, png);
|
||||
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
|
||||
images.set(tex.image, img ? gltfImages.length - 1 : null);
|
||||
}
|
||||
const source = images.get(tex.image);
|
||||
if (source === null) return null;
|
||||
const sKey = `${tex.wrapS}/${tex.wrapT}`;
|
||||
if (!samplers.has(sKey)) {
|
||||
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
|
||||
samplers.set(sKey, gltfSamplers.length - 1);
|
||||
}
|
||||
const tKey = `${source}/${samplers.get(sKey)}`;
|
||||
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
|
||||
return textures.get(tKey);
|
||||
}
|
||||
function bucketOf(m) {
|
||||
if (!buckets.has(m)) {
|
||||
const map = m.map && m.map.image ? m.map : null;
|
||||
const tex = map ? textureIndex(map, !!m.transparent) : null;
|
||||
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
|
||||
}
|
||||
return buckets.get(m);
|
||||
}
|
||||
|
||||
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
|
||||
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||
let g = mesh.geometry;
|
||||
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
|
||||
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||
nm.getNormalMatrix(mesh.matrixWorld);
|
||||
const flip = mesh.matrixWorld.determinant() < 0;
|
||||
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
|
||||
groups.forEach(gr => {
|
||||
const m = list[gr.materialIndex] || list[0];
|
||||
if (!m || m.visible === false || isEffect(m)) return;
|
||||
const b = bucketOf(m);
|
||||
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
|
||||
const remap = remaps.get(b);
|
||||
if (b.map) b.map.updateMatrix();
|
||||
const add = (ci) => {
|
||||
if (remap[ci] >= 0) return remap[ci];
|
||||
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
|
||||
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
|
||||
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
|
||||
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
|
||||
b.nrm.push(v.x, v.y, v.z);
|
||||
if (b.map) {
|
||||
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
|
||||
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
|
||||
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
|
||||
}
|
||||
return (remap[ci] = 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);
|
||||
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
|
||||
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
|
||||
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
face.crossVectors(pb, pc);
|
||||
const len = face.length();
|
||||
if (len < 2e-7) continue;
|
||||
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
||||
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++;
|
||||
}
|
||||
});
|
||||
}));
|
||||
objects.forEach(visit);
|
||||
|
||||
// Accessors en materialen per bucket.
|
||||
const accessors = [], materials = [], primitives = [];
|
||||
let unlit = false;
|
||||
const accessor = (arr, type, comps, ctype, target, minMax) => {
|
||||
views.push(addView(arr, target));
|
||||
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
|
||||
if (minMax) Object.assign(a, minMax);
|
||||
accessors.push(a);
|
||||
return accessors.length - 1;
|
||||
};
|
||||
buckets.forEach(b => {
|
||||
if (!b.idx.length) return;
|
||||
const m = b.mat, pos = new Float32Array(b.pos);
|
||||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
|
||||
const attributes = {
|
||||
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
|
||||
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
|
||||
};
|
||||
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
|
||||
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
|
||||
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
|
||||
|
||||
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
|
||||
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
|
||||
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||
const pbr = {
|
||||
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
|
||||
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
|
||||
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
|
||||
};
|
||||
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
|
||||
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
|
||||
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
|
||||
}
|
||||
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
|
||||
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
|
||||
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
|
||||
materials.push(mat);
|
||||
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
|
||||
});
|
||||
|
||||
const json = {
|
||||
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
|
||||
scene: 0,
|
||||
scenes: [{ name: o.name || 'woning', nodes: [0] }],
|
||||
nodes: [{ name: o.name || 'woning', mesh: 0 }],
|
||||
meshes: [{ name: o.name || 'woning', primitives }],
|
||||
materials, accessors, bufferViews: views,
|
||||
buffers: [{ byteLength: 0 }]
|
||||
};
|
||||
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
|
||||
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
|
||||
const binPad = (4 - binLength % 4) % 4;
|
||||
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
|
||||
json.buffers[0].byteLength = binLength;
|
||||
|
||||
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
|
||||
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
||||
const jsonPad = (4 - jsonBytes.length % 4) % 4;
|
||||
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
|
||||
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
|
||||
const head = new DataView(new ArrayBuffer(20));
|
||||
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
|
||||
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
|
||||
const binHead = new DataView(new ArrayBuffer(8));
|
||||
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
|
||||
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
|
||||
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
|
||||
}
|
||||
|
||||
return { build };
|
||||
})();
|
||||
+1
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+61
-11
@@ -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 };
|
||||
@@ -1121,10 +1121,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 +1500,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 +1896,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 +1918,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;
|
||||
@@ -2051,6 +2082,21 @@ window.HouseModel = (() => {
|
||||
return ext.concat(parts.interior);
|
||||
}
|
||||
|
||||
// Alle 3D-groepen van de woning (na placeGroups), bijv. voor een scène of een export.
|
||||
function rootGroups(parts) {
|
||||
return parts.units.map(u => u.floorGroup).concat(allWallPieces(parts).map(p => p.group), parts.units.map(u => u.roof.group),
|
||||
[parts.roofEdgeGroup, parts.postsGroup, parts.chimneysGroup, parts.sandwichGroup, parts.lightingGroup, parts.elektraGroup, parts.roomsGroup, parts.fixturesGroup]).filter(Boolean);
|
||||
}
|
||||
|
||||
// Oorsprong voor exports (m): maaiveld onder het midden van de units.
|
||||
function groundOrigin(st, parts) {
|
||||
const rs = parts.rects;
|
||||
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
|
||||
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
|
||||
const ground = Math.max(...parts.units.map(u => u.layout.floorTop)) - (st.settings.groundLevel || 0);
|
||||
return { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||
}
|
||||
|
||||
function computeDimensions(parts, groundLevel) {
|
||||
const m2 = (mm2) => mm2 / 1e6;
|
||||
const rects = parts.rects;
|
||||
@@ -2204,6 +2250,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: [
|
||||
@@ -2277,6 +2324,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,
|
||||
@@ -2342,6 +2391,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;
|
||||
@@ -2378,7 +2428,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,
|
||||
computeParts, placeGroups, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||
houseRoofShape, houseRoofFits, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||
computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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)`]);
|
||||
|
||||
+28
-18
@@ -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));
|
||||
}
|
||||
@@ -2846,14 +2861,9 @@ function mountHouseEditor(container, callbacks) {
|
||||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
||||
});
|
||||
HouseModel.placeGroups(full);
|
||||
const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group),
|
||||
[full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean);
|
||||
const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1));
|
||||
const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1));
|
||||
const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
||||
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
||||
const origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
|
||||
const origin = HouseModel.groundOrigin(state, full);
|
||||
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||||
const edge = full.roofEdge;
|
||||
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
||||
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
||||
|
||||
+14
-2
@@ -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 };
|
||||
})();
|
||||
|
||||
+11
-8
@@ -135,20 +135,23 @@ window.LightPdf = (() => {
|
||||
], 'Kerncijfers');
|
||||
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);
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
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 + 7, py + 9);
|
||||
doc.text('Indicatieve prijs', M, py + 9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(24);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
|
||||
doc.setFontSize(20);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
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
|
||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
||||
|
||||
+176
-29
@@ -8,16 +8,37 @@
|
||||
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
|
||||
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
|
||||
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
|
||||
// TectumLight.mount(el, { model, frames, url }); url = adres voor de QR-code in de PDF (standaard de modelpagina).
|
||||
// TectumLight.mount(el, { model, frames, url, modelViewer, save }); url = adres voor de QR-code in de PDF (standaard de
|
||||
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
|
||||
// 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 = (() => {
|
||||
const esc = UI.esc;
|
||||
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
|
||||
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
|
||||
let mvLoading = null;
|
||||
function loadModelViewer(src) {
|
||||
if (window.customElements.get('model-viewer')) return Promise.resolve();
|
||||
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = ok;
|
||||
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return mvLoading;
|
||||
}
|
||||
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
|
||||
const SECTION = { start: 1500, min: 300, max: 3000 };
|
||||
const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
|
||||
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).
|
||||
@@ -33,6 +54,7 @@ window.TectumLight = (() => {
|
||||
<label class="tl-cutbar"><span>Snijhoogte</span>
|
||||
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
|
||||
<output>${meters(SECTION.start)}</output></label>
|
||||
<button type="button" class="tl-xbtn" hidden></button>
|
||||
<div class="tl-hint"></div>
|
||||
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
|
||||
<div class="tl-toast" role="status" aria-live="polite"></div>
|
||||
@@ -81,8 +103,7 @@ window.TectumLight = (() => {
|
||||
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
||||
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
||||
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
|
||||
const rootsOf = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group),
|
||||
[p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean);
|
||||
const rootsOf = HouseModel.rootGroups;
|
||||
|
||||
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
||||
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
|
||||
@@ -109,6 +130,12 @@ window.TectumLight = (() => {
|
||||
placeCut();
|
||||
if (view === 'plan') drawPlan();
|
||||
}
|
||||
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
|
||||
function as3d(fn) {
|
||||
const prev = view;
|
||||
if (prev !== '3d') { view = '3d'; applyView(); }
|
||||
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
|
||||
}
|
||||
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
|
||||
function placeCut() {
|
||||
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
||||
@@ -412,17 +439,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)) +
|
||||
@@ -435,7 +467,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')) +
|
||||
@@ -470,7 +504,8 @@ window.TectumLight = (() => {
|
||||
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
|
||||
$('.tl-buy').innerHTML = `<div class="tl-price"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
|
||||
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>`;
|
||||
${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).
|
||||
@@ -495,6 +530,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;
|
||||
@@ -508,6 +546,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) => {
|
||||
@@ -551,33 +590,41 @@ window.TectumLight = (() => {
|
||||
|
||||
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||
function snapshot() {
|
||||
const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio();
|
||||
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
|
||||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
if (view !== '3d') {
|
||||
view = '3d';
|
||||
applyView();
|
||||
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||
}
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||
const url = as3d(() => {
|
||||
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||
});
|
||||
renderer.setPixelRatio(pr);
|
||||
if (prev !== '3d') {
|
||||
view = prev;
|
||||
if (other) {
|
||||
camera.position.copy(keep.pos);
|
||||
controls.target.copy(keep.target);
|
||||
controls.update();
|
||||
applyView();
|
||||
}
|
||||
resize();
|
||||
return { url, width: W, height: H };
|
||||
}
|
||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||
|
||||
// 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;
|
||||
@@ -609,20 +656,111 @@ window.TectumLight = (() => {
|
||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
||||
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
|
||||
// Ctrl/⌘ ingedrukt houdt; dan zoomt het.
|
||||
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
|
||||
let engaged = false;
|
||||
const stageEl = $('.tl-stage');
|
||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||
stageEl.addEventListener('wheel', (e) => {
|
||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return;
|
||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
|
||||
e.stopPropagation();
|
||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||
}, { capture: true });
|
||||
io.observe(stage);
|
||||
|
||||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
||||
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) }));
|
||||
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||||
return glb;
|
||||
}
|
||||
|
||||
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
|
||||
const xbtn = $('.tl-xbtn');
|
||||
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
|
||||
function setXbtn() {
|
||||
const full = fsEl() === stageEl;
|
||||
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
|
||||
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
|
||||
xbtn.setAttribute('aria-label', xbtn.title);
|
||||
}
|
||||
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
|
||||
function toggleFull() {
|
||||
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||||
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
|
||||
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||||
}
|
||||
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
|
||||
document.addEventListener('fullscreenchange', onFull);
|
||||
document.addEventListener('webkitfullscreenchange', onFull);
|
||||
|
||||
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
|
||||
// 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';
|
||||
function closeAr() {
|
||||
if (!arOpen) return;
|
||||
arOpen = false;
|
||||
arView.classList.remove('on');
|
||||
document.documentElement.classList.remove('tl-noscroll');
|
||||
}
|
||||
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
|
||||
document.addEventListener('keydown', arKey);
|
||||
function showAr(url) {
|
||||
if (!arView) {
|
||||
arView = document.createElement('div');
|
||||
arView.className = 'tl-arview';
|
||||
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">
|
||||
<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>
|
||||
<div class="tl-ar-note"></div>`;
|
||||
document.body.appendChild(arView);
|
||||
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
|
||||
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
|
||||
mv.addEventListener('load', () => {
|
||||
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
|
||||
});
|
||||
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
|
||||
}
|
||||
const mv = arView.querySelector('model-viewer');
|
||||
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
|
||||
arView.querySelector('.tl-ar-title').textContent = model.label;
|
||||
mv.alt = `3D-model van ${model.label}`;
|
||||
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
|
||||
arView.classList.add('on');
|
||||
document.documentElement.classList.add('tl-noscroll');
|
||||
arOpen = true;
|
||||
}
|
||||
async function openAr() {
|
||||
if (!cur || busy) return;
|
||||
busy = true;
|
||||
root.classList.add('is-busy');
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
|
||||
const g = makeGlb();
|
||||
await loadModelViewer(mvSrc);
|
||||
showAr(g.url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De AR-weergave kon niet worden geopend.');
|
||||
}
|
||||
busy = false;
|
||||
root.classList.remove('is-busy');
|
||||
}
|
||||
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
|
||||
|
||||
(function loop() {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (visible && view !== 'plan') renderer.render(scene, camera);
|
||||
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
|
||||
})();
|
||||
|
||||
root.classList.add('is-busy');
|
||||
@@ -636,11 +774,20 @@ window.TectumLight = (() => {
|
||||
unmount() {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
||||
document.removeEventListener('keydown', arKey);
|
||||
document.removeEventListener('fullscreenchange', onFull);
|
||||
document.removeEventListener('webkitfullscreenchange', onFull);
|
||||
closeAr();
|
||||
if (arView) arView.remove();
|
||||
if (glb) URL.revokeObjectURL(glb.url);
|
||||
if (group) disposeGeo(group);
|
||||
renderer.dispose();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
getChoices: () => clone(ch)
|
||||
getChoices: () => clone(ch),
|
||||
design: () => cur && design(),
|
||||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||||
glb: () => cur && makeGlb()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -648,7 +795,7 @@ window.TectumLight = (() => {
|
||||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||
if (el.dataset.mounted) return;
|
||||
el.dataset.mounted = '1';
|
||||
try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
||||
try { el.tectumLight = mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||
|
||||
+83
-23
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
Vendored
+26
-2
@@ -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);
|
||||
|
||||
Vendored
+1117
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+72
-7
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"fasciaHeight": 300,
|
||||
"spotMin": 800,
|
||||
"spotMax": 1200,
|
||||
"trimHeight": 80,
|
||||
"trimHeight": 30,
|
||||
"fasciaColor": "antraciet"
|
||||
},
|
||||
"lighting": {
|
||||
|
||||
+18
-3
@@ -19,7 +19,7 @@
|
||||
.page { max-width: 1400px; margin: 0 auto; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.page-head img { height: 34px; }
|
||||
.page-head select { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
||||
.page-head select, .page-head button { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
||||
@media (max-width: 820px) { body { padding: 12px; } }
|
||||
</style>
|
||||
<script src="js/vendor/three.min.js"></script>
|
||||
@@ -30,7 +30,11 @@
|
||||
<div class="page-head">
|
||||
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
||||
<select id="model-pick" aria-label="Model"></select>
|
||||
<div>
|
||||
<select id="model-pick" aria-label="Model"></select>
|
||||
<!-- Alleen voor het testen: de GLB die op een telefoon in AR wordt getoond (bijv. in Blender of een glTF-viewer). -->
|
||||
<button type="button" id="glb-dl" title="De GLB die AR gebruikt downloaden">GLB</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="configurator" class="tectum-light"></div>
|
||||
</div>
|
||||
@@ -40,6 +44,7 @@
|
||||
<script src="js/core/view-layers.js"></script>
|
||||
<script src="js/core/ui-kit.js"></script>
|
||||
<script src="js/core/skybox.js"></script>
|
||||
<script src="js/core/glb-writer.js"></script>
|
||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||
<script src="js/vendor/qrcode.js"></script>
|
||||
<script src="js/core/pdf-sheet.js"></script>
|
||||
@@ -75,7 +80,17 @@
|
||||
const pick = document.getElementById('model-pick');
|
||||
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
||||
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
||||
document.getElementById('configurator').dataset.model = model;
|
||||
const el = document.getElementById('configurator'), btn = document.getElementById('glb-dl');
|
||||
el.dataset.model = model;
|
||||
btn.addEventListener('click', () => {
|
||||
const g = el.tectumLight && el.tectumLight.glb();
|
||||
if (!g) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = g.url;
|
||||
a.download = `${model}.glb`;
|
||||
a.click();
|
||||
btn.textContent = `GLB · ${(g.stats.bytes / 1e6).toLocaleString('nl-NL', { maximumFractionDigits: 1 })} MB · ${g.stats.triangles.toLocaleString('nl-NL')} driehoeken`;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="js/light/light.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user