diff --git a/js/core/glb-writer.js b/js/core/glb-writer.js index e8e0cd3..4c96cfc 100644 --- a/js/core/glb-writer.js +++ b/js/core/glb-writer.js @@ -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++; diff --git a/js/house/house-model.js b/js/house/house-model.js index 2dd7ea5..0ac42ad 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -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) {