diff --git a/README.md b/README.md index 92b3b3d..b17db6d 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ assets/ logo, texturen, skybox sandwichpanelen rondom, 600 mm de grond in. - **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld, override per wand) en binnenspots in een raster per unit. -- **3D:** weergavefilters, omgeving Ontwerp/Dag/Nacht, Render met lampen als - echte lichtbronnen. +- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm), + omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen. - **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (plattegrond, vloeren, wandstukken, binnenwanden, totalen). diff --git a/js/app.js b/js/app.js index e9cac08..44f783b 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.4.13'; +const APP_VERSION = '0.4.14'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; diff --git a/js/house/house-model.js b/js/house/house-model.js index d5bc2ca..013b833 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -813,7 +813,13 @@ window.HouseModel = (() => { try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); } }); (design.interiorWalls || []).forEach((iw, i) => { - ['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); }); + if (iw.seam) { + // Naadwand: ligt op de naad tussen twee units, geen eigen coördinaten. + if (!ids.has(iw.seam.a) || !ids.has(iw.seam.b)) throw new Error(`naadwand #${i + 1}: unit ${iw.seam.a} of ${iw.seam.b} bestaat niet`); + if (iw.seam.in !== undefined && !['a', 'b'].includes(iw.seam.in)) throw new Error(`naadwand #${i + 1}: ongeldige plaats`); + } else { + ['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); }); + } try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); } }); } diff --git a/js/house/house.js b/js/house/house.js index bcb13da..51123bf 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -322,6 +322,12 @@ function mountHouseEditor(container, callbacks) { // Opgeslagen camerastand gaat voor; anders eenmalig passend maken. let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls); let beplatingVisible = true, lampenVisible = true; + // Vloerplan: alle wanden afgesneden op VLOERPLAN_CUT_MM boven het loopvlak + // (clipping-vlak, doorsnede blijft open); dak en lampen uit. + const VLOERPLAN_CUT_MM = 500; + let vloerplan = false; + const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); + renderer.localClippingEnabled = true; let fullRender = false; let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); @@ -331,11 +337,13 @@ function mountHouseEditor(container, callbacks) { createViewFilter(document.getElementById('ho-toolbar-3d'), [ { key: 'beplating', label: 'Beplating' }, { key: 'dak', label: 'Dak' }, - { key: 'lampen', label: 'Lampen' } + { key: 'lampen', label: 'Lampen' }, + { key: 'vloerplan', label: 'Vloerplan', checked: false } ], (v) => { beplatingVisible = v.beplating; dakVisible = v.dak; lampenVisible = v.lampen; + vloerplan = v.vloerplan; refresh({ keepProps: true }); }); @@ -354,7 +362,7 @@ function mountHouseEditor(container, callbacks) { if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; } // De nep-lichtbundels (kegels) alleen zonder echte lampen. if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; }); - if (lampsOn && parts && lampenVisible) { + if (lampsOn && parts && lampenVisible && !vloerplan) { const edge = dakVisible ? parts.roofEdge : null; lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, { outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR @@ -919,12 +927,24 @@ function mountHouseEditor(container, callbacks) { u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM); houseGroup.add(u.floorGroup); }); + // Vloerplan: snijvlak op het (hoogste) loopvlak + VLOERPLAN_CUT_MM. De + // wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit). + cutPlane.constant = (Math.max(...parts.units.map(u => u.layout.floorTop)) + VLOERPLAN_CUT_MM) * toM; + const clip = vloerplan ? [cutPlane] : []; HouseModel.allWallPieces(parts).forEach(p => { p.group.rotation.y = p.place.rotY; p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); + p.group.traverse(o => { + if (!o.material) return; + (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { + if (m.clippingPlanes && m.clippingPlanes.length === clip.length) return; + m.clippingPlanes = clip; + m.needsUpdate = true; + }); + }); houseGroup.add(p.group); }); - if (dakVisible) { + if (dakVisible && !vloerplan) { parts.units.forEach(u => { u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM); houseGroup.add(u.roof.group); @@ -932,7 +952,7 @@ function mountHouseEditor(container, callbacks) { if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup); } if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup); - if (parts.lightingGroup) houseGroup.add(parts.lightingGroup); + if (parts.lightingGroup && !vloerplan) houseGroup.add(parts.lightingGroup); scene.add(houseGroup); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index 6d00bc8..97985f2 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -247,17 +247,33 @@ window.RoofModel = (() => { } // 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek, - // Y = 0 op het loopvlak): per dakvlak build3D, gekanteld om de onderkant - // van het plafond zodat die precies op de daklijn van de wanden ligt. + // Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de + // onderkant van het plafond precies op de daklijn van de wanden ligt. + // Geen kanteling maar een afschuiving (lokaal +Z = oplopende as): + // plan = z·cos θ, Y = y/cos θ + z·sin θ + // De onderkant ligt dan op de helling, de dikte haaks op de helling blijft + // gelijk (verticaal = dikte/cos θ, zoals vThick), de lengte langs de + // helling ook (balklengtes), en de kopse kanten staan loodrecht (zoals een + // loodrechte zaagsnede aan sporen). Bij kantelen zouden de kopse kanten + // haaks op de helling staan en ligt de bovenkant aan de hoge kant + // dikte·sin θ terug van de dakrand. function buildUnit3D(unitRoof, opts) { const toM = 0.001; const group = new THREE.Group(); unitRoof.planes.forEach(pl => { const g = new THREE.Group(); - g.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, opts)); g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM); - // Eerst kantelen om lokaal X (lokaal +Z = oplopende as), dan draaien. - g.rotation.set(-pl.theta, pl.yaw, 0, 'YXZ'); + g.rotation.set(0, pl.yaw, 0); + const shear = new THREE.Group(); + const c = Math.cos(pl.theta), sn = Math.sin(pl.theta); + shear.matrixAutoUpdate = false; + shear.matrix.set( + 1, 0, 0, 0, + 0, 1 / c, sn, 0, + 0, 0, c, 0, + 0, 0, 0, 1); + shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, opts)); + g.add(shear); group.add(g); }); return group;