Compare commits
4
Commits
v0.7.6
...
0da1769f51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da1769f51 | ||
|
|
4bb1548125 | ||
|
|
73248a69c9 | ||
|
|
b8cc39275b |
@@ -350,7 +350,9 @@ assets/ logo, texturen, skybox
|
||||
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
|
||||
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
|
||||
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
||||
`TectumLight.mount(el, { model, frames, url, modelViewer })`.
|
||||
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
|
||||
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
|
||||
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
|
||||
|
||||
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
|
||||
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
|
||||
@@ -393,6 +395,10 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
||||
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
|
||||
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
||||
in `LightPdf.COMPANY` en `STEPS`.
|
||||
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
|
||||
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
|
||||
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
|
||||
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
|
||||
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
|
||||
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
|
||||
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.7.6';
|
||||
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 = [];
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
*/
|
||||
// Plain script, geen ES-module: imports breken bij file:// (CORS).
|
||||
|
||||
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
|
||||
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
|
||||
// een modelpagina van de site). Paden in de data blijven relatief.
|
||||
const TECTUM_BASE = (() => {
|
||||
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
|
||||
return s ? new URL('../../', s).href : '';
|
||||
})();
|
||||
function tectumUrl(path) {
|
||||
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
|
||||
}
|
||||
|
||||
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
if (spanWidth <= 0) return [];
|
||||
let numBays = Math.ceil(spanWidth / maxSp);
|
||||
|
||||
+43
-1
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
|
||||
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
|
||||
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
|
||||
function build(objects, opts) {
|
||||
const o = opts || {};
|
||||
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
|
||||
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
|
||||
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
|
||||
const images = new Map(), samplers = new Map(), textures = new Map();
|
||||
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
||||
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
|
||||
}
|
||||
return (remap[ci] = b.n++);
|
||||
};
|
||||
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
|
||||
const raw = (ci) => {
|
||||
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
|
||||
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
|
||||
const t = new THREE.Vector2();
|
||||
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
|
||||
return { p, n, t };
|
||||
};
|
||||
const lerp = (A, B, f) => ({
|
||||
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
|
||||
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
|
||||
});
|
||||
const push = (q) => {
|
||||
b.pos.push(q.p.x, q.p.y, q.p.z);
|
||||
b.nrm.push(q.n.x, q.n.y, q.n.z);
|
||||
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
|
||||
return b.n++;
|
||||
};
|
||||
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
|
||||
const len = face.length();
|
||||
if (len < 2e-7) continue;
|
||||
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
||||
if (clip) {
|
||||
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
|
||||
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
|
||||
if (!nIn) continue;
|
||||
if (nIn < 3) {
|
||||
const q = s.map(raw), poly = [];
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const k2 = (j + 1) % 3;
|
||||
if (inside[j]) poly.push(q[j]);
|
||||
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
|
||||
}
|
||||
const c = poly.map(push);
|
||||
for (let j = 1; j + 1 < c.length; j++) {
|
||||
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
|
||||
triCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const c = s.map(add);
|
||||
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
|
||||
triCount++;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+125
-7
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
|
||||
return st;
|
||||
}
|
||||
|
||||
// Model Van den Berg (examples/van den berg.pdf): dubbel chalet 6,94 × 12,25 m. West een smal, laag chalet van
|
||||
// 2,97 × 12,25 m (wandhoogte 2330: garderobe, twee slaapkamers, toilet, badkamer, cv-kast, hal), oost een breed,
|
||||
// hoog chalet van 3,97 × 9,5 m (wandhoogte 3080: woonkamer met keuken), allebei een plat dak (EPDM) met boeidelen.
|
||||
// In de noordwesthoek een overdekt portiek van 1,36 × 1,75 m voor de voordeur: de garderobe is een eigen unit
|
||||
// (B) waarvan het dak naar het westen doorloopt. Ten zuiden van het oostchalet een overdekte vlonder van 3,97 × 2,75 m
|
||||
// (het dak loopt 2,75 m door, palen op de hoeken). Gevel: het lage chalet donker (Eternit potdeksel, hier
|
||||
// antraciet felsplaten), het hoge chalet eiken rabat horizontaal; het deel van de oostwand boven het lage dak
|
||||
// krijgt dezelfde rabat. De twee bovenlichten van 200 × 30 in die wand zijn weggelaten: het stuk boven het lage
|
||||
// dak is 488 mm hoog en een opening is minimaal 400 mm. Maten buitenwerks, x vanaf de westgevel en z vanaf de
|
||||
// noordgevel van de tekening. Het lage chalet is de eerste unit A (noordwesthoek op z 1750), het hoge chalet C.
|
||||
function createVandenBerg() {
|
||||
const st = newModelState('Van den Berg');
|
||||
const S = st.settings, HL = 2330, HH = 3080, WA = 2970, WB = 3970, P = 1750, PW = 1360, FELS = 'antraciet';
|
||||
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
|
||||
S.cladding = 'horizontaal';
|
||||
S.trasraam.enabled = false;
|
||||
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
|
||||
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 120, maxSpan: 4500 });
|
||||
S.lighting.mode = 'spots';
|
||||
S.lighting.direction = 'both';
|
||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: false, oven: true, vaatwasser: true });
|
||||
if (S.elektra) S.elektra.plateColor = 'wit';
|
||||
|
||||
const A = createUnitEntry('A', null, { width: WA, depth: 12250 - P, height: HL, windowOn: [] });
|
||||
const B = createUnitEntry('B', { to: 'A', side: 'voor', align: 'end', offset: 0 }, { width: WA - PW, depth: P, height: HL, windowOn: [] });
|
||||
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'start', offset: 0 }, { width: WB, depth: 9500, height: HH, windowOn: [] });
|
||||
st.units.push(A, B, C);
|
||||
B.roof.extend = { f: 0, r: 0, b: 0, l: PW }; // portiek
|
||||
C.roof.extend = { f: 0, r: 0, b: 2750, l: 0 }; // vlonder
|
||||
[A, B].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.facade.walls[side] = { type: 'fels', fels: FELS }; }));
|
||||
|
||||
const tE = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
|
||||
const { binnen, ops, iw, room, fx } = modelKit(st, HL, 90);
|
||||
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), extra || {});
|
||||
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
|
||||
// binnenkant van de noordgevel. z in de tekening; zA = z in unit A.
|
||||
const zA = (z) => z - P;
|
||||
ops(A.unit.walls.links, [
|
||||
['raam', 700, 1100, 950, 'left', zA(3897) - tE, K('raam', { rooster: true })], // slaapkamer 2
|
||||
['raam', 600, 700, 1350, 'left', zA(5550) - tE, K('raam')], // toilet
|
||||
['raam', 600, 700, 1350, 'left', zA(6590) - tE, K('raam')]]); // badkamer
|
||||
ops(A.unit.walls.achter, [['raam', 1200, 1100, 950, 'left', 827, K('raam', { rooster: true })]]); // slaapkamer 1
|
||||
ops(B.unit.walls.links, [['voordeur', 900, 2110, 0, 'left', 787 - tE, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
|
||||
ops(C.unit.walls.voor, [['raam', 1400, 900, 1200, 'right', 1651, K('raam', { rooster: true })]]); // boven de spoelbak
|
||||
// Oostgevel: kozijnen van 180 × 300, het noordelijke met openslaande deuren (alleen van binnen te bedienen).
|
||||
ops(C.unit.walls.rechts, [
|
||||
['deur', 900, 2100, 0, 'right', 2267 - tE, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||
['deur', 900, 2100, 0, 'right', 3167 - tE, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||
['raam', 1800, 650, 2200, 'right', 2267 - tE, K('raam')],
|
||||
['raam', 1800, 2100, 0, 'right', 5946 - tE, K('raam')],
|
||||
['raam', 1800, 650, 2200, 'right', 5946 - tE, K('raam')]]);
|
||||
C.unit.walls.rechts.openings[1].koppel = true;
|
||||
ops(C.unit.walls.achter, [['deur', 2400, 2100, 0, 'left', 792, // schuifpui naar de vlonder
|
||||
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
|
||||
|
||||
const DH = 2115, DW = 730;
|
||||
// Binnenwanden in wereldcoördinaten (t.o.v. de noordwesthoek van A). Noord–zuid: 'buiten' = west, 'left' =
|
||||
// vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||
const xE = WA - 100; // tegen de naadwand
|
||||
const w = (x1, z1, x2, z2, openings) => iw(x1, zA(z1), x2, zA(z2), openings);
|
||||
w(tE, 5321, xE, 5321, [['deur', DW, DH, 'left', 1879 - tE, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // slaapkamer 2 | toilet, hal
|
||||
w(1376, 5366, 1376, 6368, [['deur', DW, DH, 'left', 200, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | hal
|
||||
w(tE, 6413, 1891, 6413); // toilet | badkamer
|
||||
w(1846, 6458, 1846, 8651, [ // badkamer, cv-kast | hal
|
||||
['deur', DW, DH, 'left', 20, binnen({ hinge: 'links', swing: 'binnen' })],
|
||||
['deur', DW, DH, 'left', 1330, binnen({ hinge: 'links', swing: 'binnen' })]]);
|
||||
w(1155, 7700, 1801, 7700); // badkamer | cv-kast
|
||||
w(1200, 7745, 1200, 8651); // douche | cv-kast
|
||||
w(tE, 8696, xE, 8696, [['deur', DW, DH, 'left', 1985 - tE, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | slaapkamer 1
|
||||
|
||||
ensureSeamWalls(st);
|
||||
const seamOf = (a, b) => st.interiorWalls.find(iw => iw.seam && sameSeam(iw.seam, a, b));
|
||||
const toog = (wd) => ({ id: 1, type: 'deur', w: wd, h: 2100, y: 0, anchor: 'left', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) });
|
||||
const sAC = seamOf('A', 'C'), sBC = seamOf('B', 'C'), sAB = seamOf('A', 'B');
|
||||
// Naadwanden noord–zuid: 'left' = vanaf de zuidkant (A: binnenkant zuidgevel van C, z 9244; B: de naad met A, z 1750).
|
||||
if (sAC) sAC.wall.openings = [Object.assign(toog(1000), { offset: 2854 })]; // hal → woonkamer (z 5390–6390)
|
||||
if (sBC) sBC.wall.openings = [Object.assign(toog(900), { offset: 350 })]; // garderobe → woonkamer (z 500–1400)
|
||||
if (sAB) sAB.wall.openings = [];
|
||||
|
||||
room('A', 1400, zA(3000), 'slaapkamer', 'Slaapkamer 2');
|
||||
room('A', 800, zA(5900), 'toilet');
|
||||
room('A', 1500, zA(7000), 'badkamer');
|
||||
room('A', 1500, zA(8200), 'techniek', 'Cv-kast');
|
||||
room('A', 2400, zA(7000), 'hal', 'Hal');
|
||||
room('A', 1400, zA(11000), 'slaapkamer', 'Slaapkamer 1');
|
||||
room('B', 800, 900, 'hal', 'Garderobe');
|
||||
room('C', 2000, 4500, 'woonkeuken', 'Woonkamer/keuken');
|
||||
|
||||
fx('bed', 'A', 670, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
|
||||
fx('bed', 'A', 2290, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'A', 1056, zA(4976), 180, { w: 1600 });
|
||||
fx('toilet', 'A', 601, zA(5950), 270);
|
||||
fx('fontein', 'A', 1108, zA(5491), 0);
|
||||
fx('douche', 'A', 706, zA(8051), 180, { w: 900, d: 1200 }, { glas: 'rechts', glasLengte: 900 });
|
||||
fx('wastafel', 'A', 1478, zA(7405), 180, { w: 600 });
|
||||
fx('boiler', 'A', 1470, zA(8350), 270);
|
||||
fx('kledingkast', 'A', 1056, zA(9041), 0, { w: 1600 });
|
||||
fx('bed', 'A', tE + 1000, zA(10744), 270, { w: 1600, d: 2000 }, { nachtkastjes: false });
|
||||
const xC = (x) => x - WA;
|
||||
fx('keuken', 'C', xC(4930), tE + 300, 0, { w: 3500 }, { front: 'wit', hogeKast: 'rechts', spoelbak: 0.6, kookplaat: true, kookplaatPlek: 0.26 });
|
||||
fx('eettafel', 'C', xC(4912), 3172, 0, { w: 1400, d: 900 }, { vorm: 'rechthoek', stoelen: 6 });
|
||||
fx('bank', 'C', 560, 7700, 270, { w: 2200, d: 900 }, { chaise: 'geen' });
|
||||
fx('salontafel', 'C', 1700, 7700, 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||
fx('fauteuil', 'C', 2900, 7200, 90);
|
||||
|
||||
computeParts(st, { skip3D: true });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||
const MODELS = {
|
||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
|
||||
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
||||
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
|
||||
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht }
|
||||
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht },
|
||||
vandenberg: { label: 'Woning Van den Berg', icon: 'house-dash', create: createVandenBerg }
|
||||
};
|
||||
|
||||
function createDefaultState(template) {
|
||||
@@ -1500,10 +1610,10 @@ window.HouseModel = (() => {
|
||||
});
|
||||
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
||||
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels) {
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps) {
|
||||
if (!build) return { group: null, stats: null, specs: null };
|
||||
const built = WallModel.build3D(config, frame, {
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels,
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps,
|
||||
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
|
||||
});
|
||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||
@@ -1918,14 +2028,22 @@ window.HouseModel = (() => {
|
||||
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
|
||||
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||||
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||
if (!sg) return;
|
||||
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
|
||||
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
|
||||
// Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
|
||||
if (!sg || (!sg.fascia && !sg.fels)) return;
|
||||
p.battenAbove = (p.skinAbove || 0) + sg.above - RoofModel.edgeHeight(roofEdge, sg);
|
||||
});
|
||||
// Vrije uiteinden van binnenwanden: vlak voorbij het eind geen andere wand.
|
||||
const allPieces = extPieces.concat(interior);
|
||||
interior.forEach(p => {
|
||||
if (p.buitenMateriaal !== 'spaanplaat') return;
|
||||
const W = p.place.width / 2, against = (q) => allPieces.some(o => o !== p && pointInPoly(q,
|
||||
[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([i, j]) => UnitModel.localToWorld(o.place, i * o.place.width / 2, j * o.place.thickness / 2))));
|
||||
p.endCaps = { left: !against(UnitModel.localToWorld(p.place, -W - 15, 0)), right: !against(UnitModel.localToWorld(p.place, W + 15, 0)) };
|
||||
});
|
||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||
extPieces.concat(interior).forEach(p => {
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels);
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels, p.endCaps);
|
||||
if (built.group && p.shiftZ) {
|
||||
const outer = new THREE.Group();
|
||||
built.group.position.z = p.shiftZ * 0.001;
|
||||
|
||||
+1
-1
@@ -1413,7 +1413,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
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: 'Bij units zonder boeidelen' }) },
|
||||
{ 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` }) +
|
||||
|
||||
+22
-17
@@ -5,8 +5,8 @@
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en
|
||||
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
|
||||
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||
window.LightPdf = (() => {
|
||||
const COMPANY = {
|
||||
naam: 'Van Emous Mantelzorgwoningen',
|
||||
@@ -135,20 +135,25 @@ window.LightPdf = (() => {
|
||||
], 'Kerncijfers');
|
||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
||||
|
||||
// Prijs
|
||||
const py = Math.max(y + 4, 196);
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Indicatieve prijs', M + 7, py + 9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(24);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27);
|
||||
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
|
||||
const py = Math.max(y + 4, 196), price = d.price !== false;
|
||||
if (price) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, py, M + 150, py);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Indicatieve prijs', M, py + 9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(20);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text('incl. btw, onder voorbehoud', M, py + 25);
|
||||
}
|
||||
|
||||
// Beeld van de woning
|
||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
||||
@@ -159,7 +164,7 @@ window.LightPdf = (() => {
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text('Dit ontwerp en de prijs zijn indicatief; hieraan kunnen geen rechten worden ontleend.', M, PH - 6);
|
||||
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
|
||||
}
|
||||
|
||||
function process(doc, d) {
|
||||
|
||||
+30
-8
@@ -8,8 +8,12 @@
|
||||
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
|
||||
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
|
||||
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
|
||||
// TectumLight.mount(el, { model, frames, url, modelViewer }); url = adres voor de QR-code in de PDF (standaard de
|
||||
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
|
||||
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
|
||||
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
|
||||
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
|
||||
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
|
||||
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
|
||||
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
|
||||
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
|
||||
// Viewer, Quick Look); op de desktop schermvullend.
|
||||
window.TectumLight = (() => {
|
||||
@@ -66,6 +70,7 @@ window.TectumLight = (() => {
|
||||
function mount(root, opts) {
|
||||
const o = opts || {};
|
||||
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
|
||||
const showPrice = o.price !== false && root.dataset.price !== 'false';
|
||||
let ch = clone(model.initial);
|
||||
let unitSel = model.base.units[0].id;
|
||||
let cur = null; // { st, parts, info }
|
||||
@@ -499,9 +504,10 @@ window.TectumLight = (() => {
|
||||
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
|
||||
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
|
||||
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
|
||||
$('.tl-buy').innerHTML = `<div class="tl-price"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
|
||||
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
|
||||
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>`;
|
||||
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
|
||||
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
|
||||
}
|
||||
|
||||
// Keuzes (knoppen en kleuren).
|
||||
@@ -542,6 +548,7 @@ window.TectumLight = (() => {
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a || !cur) return;
|
||||
if (a.dataset.act === 'pdf') savePdf();
|
||||
else if (a.dataset.act === 'save') o.save.onSave(design());
|
||||
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
|
||||
});
|
||||
panel.addEventListener('keydown', (e) => {
|
||||
@@ -608,6 +615,18 @@ window.TectumLight = (() => {
|
||||
}
|
||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||
|
||||
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
|
||||
function design() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Ontwerp uit de light-versie';
|
||||
return {
|
||||
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
|
||||
label: model.label, key: model.key, info: Object.assign({}, cur.info),
|
||||
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
function savePdf() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
@@ -615,7 +634,7 @@ window.TectumLight = (() => {
|
||||
try {
|
||||
let image = null;
|
||||
try { image = snapshot(); } catch (err) { console.warn(err); }
|
||||
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image })
|
||||
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
|
||||
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -652,11 +671,13 @@ window.TectumLight = (() => {
|
||||
io.observe(stage);
|
||||
|
||||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
||||
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
|
||||
let glb = null;
|
||||
function makeGlb() {
|
||||
if (glb && glb.parts === cur.parts) return glb;
|
||||
if (glb) URL.revokeObjectURL(glb.url);
|
||||
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
|
||||
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
|
||||
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
|
||||
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||||
return glb;
|
||||
}
|
||||
@@ -685,7 +706,7 @@ window.TectumLight = (() => {
|
||||
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
|
||||
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
|
||||
let arView = null, arOpen = false;
|
||||
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
|
||||
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
|
||||
function closeAr() {
|
||||
if (!arOpen) return;
|
||||
arOpen = false;
|
||||
@@ -701,7 +722,7 @@ window.TectumLight = (() => {
|
||||
arView.setAttribute('role', 'dialog');
|
||||
arView.setAttribute('aria-modal', 'true');
|
||||
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
|
||||
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
|
||||
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
|
||||
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
|
||||
</model-viewer>
|
||||
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
|
||||
@@ -768,6 +789,7 @@ window.TectumLight = (() => {
|
||||
root.innerHTML = '';
|
||||
},
|
||||
getChoices: () => clone(ch),
|
||||
design: () => cur && design(),
|
||||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||||
glb: () => cur && makeGlb()
|
||||
};
|
||||
|
||||
+31
-21
@@ -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,7 +26,7 @@ window.RoofModel = (() => {
|
||||
fasciaHeight: 300,
|
||||
spotMin: 800,
|
||||
spotMax: 1200,
|
||||
trimHeight: 80,
|
||||
trimHeight: 30,
|
||||
fasciaColor: 'antraciet',
|
||||
vent: true,
|
||||
ventColor: 'antraciet'
|
||||
@@ -87,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];
|
||||
@@ -105,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;
|
||||
@@ -1007,9 +1010,9 @@ 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
|
||||
@@ -1033,13 +1036,13 @@ window.RoofModel = (() => {
|
||||
// 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;
|
||||
});
|
||||
@@ -1078,7 +1081,7 @@ window.RoofModel = (() => {
|
||||
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
|
||||
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
||||
vent: s.vent, ventColor: s.ventColor,
|
||||
top, edgeAbove: EDGE_ABOVE_MM,
|
||||
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.
|
||||
@@ -1125,7 +1128,7 @@ window.RoofModel = (() => {
|
||||
}
|
||||
// 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) {
|
||||
@@ -1155,20 +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 nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z, face = ventFace(sg);
|
||||
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 => {
|
||||
const mesh = new THREE.Mesh(prismGeometry(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, u), ventMaterial(edge.ventColor));
|
||||
mesh.visible = o.edgeVisible !== false;
|
||||
group.add(mesh);
|
||||
});
|
||||
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;
|
||||
@@ -1199,7 +1209,7 @@ window.RoofModel = (() => {
|
||||
}
|
||||
|
||||
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, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
||||
|
||||
+13
-2
@@ -234,8 +234,8 @@ window.WallModel = (() => {
|
||||
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
|
||||
};
|
||||
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
|
||||
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
|
||||
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
|
||||
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
|
||||
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
|
||||
// UV's in meters, dus repeat = 1/tegel.
|
||||
const stoneMaterials = {};
|
||||
function stoneMaterial(code) {
|
||||
@@ -1257,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;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"fasciaHeight": 300,
|
||||
"spotMin": 800,
|
||||
"spotMax": 1200,
|
||||
"trimHeight": 80,
|
||||
"trimHeight": 30,
|
||||
"fasciaColor": "antraciet"
|
||||
},
|
||||
"lighting": {
|
||||
|
||||
Reference in New Issue
Block a user