v0.7.0: exports voor CAD, BIM en renders (Bestand › Exporteren, los van Opslaan/Ctrl+S): CAD (DXF R12, plattegrond met wanden/sparingen/gevelbekleding/kozijnen met draaicirkel, ruimtes, inrichting, dakrand, elektra en echte maatvoering, plus vier gevels als lijntekening met verborgen lijnen weg; lagen PL-/AZ-, 1:1 mm, afgestemd op 1:50; in CAD op te slaan als DWG), BIM (IFC4 met alleen de bouwconstructie: stijlen, regels, vloer- en dakbalken, stalen nokbalk, palen, spaanplaat/OSB/cementplaat en elektra per leiding, gegroepeerd per unit en element met Pset_Tectum en kleur per soort) en Render (OBJ + MTL + texturen in een ZIP voor Blender, volledige afgewerkte woning, kleuren lineair, PBR); schoorsteen tegen een buitenwand (ChimneyModel: sparing in wand en dak met raveelwerk, steenstrip/stuc, verslepen langs de gevel); kozijnen koppelen met een eigen schakelaar; kozijnindeling (deurblad, zij-/bovenlicht, schuifbladen) gedeeld tussen 3D, plattegrond en aanzicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-12 20:00:32 +02:00
co-authored by Claude Opus 5.5
parent 86fcd57b96
commit adb3c74f85
22 changed files with 2371 additions and 184 deletions
+172 -17
View File
@@ -328,6 +328,7 @@ function mountHouseEditor(container, callbacks) {
const lightingMaterials = LightingModel.createMaterials();
const sandwichMaterials = SandwichModel.createMaterials();
const postMaterials = PostModel.createMaterials();
const chimneyMaterials = ChimneyModel.createMaterials();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
@@ -462,9 +463,17 @@ function mountHouseEditor(container, callbacks) {
}
const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key);
// Klik op een schoorsteen: de wand waar hij aan hangt (daar staat het schoorsteenpaneel).
function chimneyWallHit(ch) {
const u = parts.units.find(q => q.id === ch.unitId), w = u && u.walls.find(q => q.side === ch.side);
const pc = w && w.pieces.find(p => !p.baseY);
return pc ? { type: 'wall', unit: u.id, side: ch.side, element: pc.element } : null;
}
function hitTest(x, z) {
const eh = elektraHit(x, z);
if (eh) return { type: 'elektra', id: eh.key };
const ch = ChimneyModel.hit(parts.chimneys || [], x, z, 0);
if (ch && chimneyWallHit(ch)) return chimneyWallHit(ch);
const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4));
if (iw) return { type: 'iw', id: iw.id };
for (const u of parts.units) {
@@ -562,6 +571,7 @@ function mountHouseEditor(container, callbacks) {
let roomHover = null;
let fxHover = null;
let fxDrag = null;
let chDrag = null;
function setTool(t) {
tool = t;
@@ -681,6 +691,38 @@ function mountHouseEditor(container, callbacks) {
refresh();
}
window.addEventListener('mouseup', onFixtureDrop);
// Schoorsteen slepen: alleen langs de buitengevel.
canvas.addEventListener('mousedown', (e) => {
if (tool !== 'select' || !parts || e.button !== 0 || elDrag || fxDrag) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0);
if (ch) chDrag = { id: ch.id, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
});
canvas.addEventListener('mousemove', (e) => {
if (!chDrag) return;
if (!chDrag.moved && Math.hypot(e.clientX - chDrag.sx, e.clientY - chDrag.sy) < 4) return;
chDrag.moved = true;
canvas.style.cursor = 'grabbing';
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = (parts.chimneys || []).find(c => c.id === chDrag.id);
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
drawPlan();
});
window.addEventListener('mouseup', () => {
if (!chDrag) return;
const d = chDrag;
chDrag = null;
canvas.style.cursor = 'default';
if (!d.moved || !d.snap) return;
suppressClick = true;
const cs = (state.chimneys || []).find(c => c.id === d.id);
if (cs) Object.assign(cs, { unitId: d.snap.unitId, side: d.snap.side, u: d.snap.u });
const u = parts.units.find(q => q.id === d.snap.unitId), wl = u && u.walls.find(q => q.side === d.snap.side), pc = wl && wl.pieces.find(p => !p.baseY);
if (pc) selection = { type: 'wall', unit: u.id, side: d.snap.side, element: pc.element };
refresh();
});
const newFixtureItem = (cx, cz, rot) => {
const T = FixtureModel.TYPES[fixtureType];
return { type: fixtureType, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}), rot: rot || 0, cx, cz };
@@ -844,7 +886,7 @@ function mountHouseEditor(container, callbacks) {
ctx.fill();
ctx.strokeStyle = '#8a6836'; ctx.lineWidth = 1;
ctx.stroke();
p.config.openings.forEach(op => {
p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w));
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff');
ctx.fill();
@@ -982,6 +1024,22 @@ function mountHouseEditor(container, callbacks) {
const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale;
ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz);
});
(parts.chimneys || []).forEach(ch => {
const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); };
const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null;
if (sn) {
// Voorbeeld tijdens het slepen: dezelfde diepte, op de nieuwe plek.
const S = ChimneyModel.SIDES[sn.side], W = (t, s) => S.axis === 'z' ? { x: sn.plane + S.n.x * s, z: t } : { x: t, z: sn.plane + S.n.z * s };
path([W(sn.t - ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sFront), W(sn.t - ch.w / 2, ch.sFront)]);
ctx.fillStyle = 'rgba(77, 166, 255, 0.45)'; ctx.fill(); ctx.strokeStyle = '#4da6ff'; ctx.stroke();
return;
}
path(ch.footprint);
ctx.fillStyle = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
ctx.fill(); ctx.strokeStyle = '#2b2b2b'; ctx.stroke();
path(ch.flueFootprint);
ctx.fillStyle = '#1b1a19'; ctx.fill();
});
ctx.restore();
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
@@ -1130,6 +1188,9 @@ function mountHouseEditor(container, callbacks) {
if (a === 'add-unit') addUnit();
else if (a === 'project') openProjectProperties();
else if (a === 'save') exportDesign();
else if (a === 'dxf') exportDxf();
else if (a === 'ifc') exportIfc();
else if (a === 'obj') exportObj();
else if (a === 'costs') CostSettings.open();
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
@@ -1235,7 +1296,7 @@ function mountHouseEditor(container, callbacks) {
toggle('hs-roof-house', 'Over hele woning', hr.enabled, { hint: 'Eén dakvorm over alle units (bijv. een grote wolfskap); uit = dak per unit. De daken per unit blijven bewaard.' }) +
(houseRoofUi ? sel('hs-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(state.units[0].roof.covering, shapeType), { hint: 'Voor alle units (per unit aan te passen)' }) : '') +
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken (alle units); uit = verwijderen' }) }
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken en van een dubbel lessenaardak (gelijke goot en nok, samen een zadel); alle units; uit = verwijderen' }) }
];
if (houseRoofUi) groups.push(...houseRoofUi.groups());
else groups.push({ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') });
@@ -1487,7 +1548,10 @@ function mountHouseEditor(container, callbacks) {
key: 'rapport', label: 'Rapport', order: 60,
groups: () => [
{ label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') +
big('CAD (DXF)', 'vector-pen', 'data-ho-act="dxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG"') +
big('BIM (IFC)', 'boxes', 'data-ho-act="ifc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting"') +
big('Render (OBJ)', 'badge-3d', 'data-ho-act="obj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender"') },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') }
],
wire: (body) => { live = []; wireButtons(body); }
@@ -1877,7 +1941,7 @@ function mountHouseEditor(container, callbacks) {
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }),
thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) +
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) +
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf'].includes(shapeNow)
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf', 'lessenaar'].includes(shapeNow)
? toggle('hp-steel-ridge', 'Stalen nokbalk', u.roof.steelRidge !== false, { hint: pu && pu.roof.unitRoof.ridgeBeam ? `${pu.roof.unitRoof.ridgeBeam.profile.name}, ${Math.round(pu.roof.unitRoof.ridgeBeam.steelKg)} kg; uit = verwijderen` : 'Uit = verwijderd' }) : '') +
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
if (u.roof.followFloor) groups.push({ label: 'Overkapping', html:
@@ -1931,6 +1995,52 @@ function mountHouseEditor(container, callbacks) {
};
}
// Schoorstenen in het vlak van deze wand (ook als ze aan een andere unit hangen, bijv. over een naad).
function chimneysOnWall(pu, side) {
const r = pu.rect, plane = { voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 }[side];
const ids = (parts.chimneys || []).filter(c => c.side === side && Math.abs(c.plane - plane) < 1).map(c => c.id);
return (state.chimneys || []).filter(c => ids.includes(c.id));
}
function chimneyGroup(pu, wl, piece, e0) {
const list = chimneysOnWall(pu, wl.side);
const fin = (pre, label, f, types) => sel(pre, label, types, f.type) +
(f.type === 'steen' ? pair(sel(pre + '-stone', 'Steen', stoneOptions, f.stone), thumb(WallModel.stoneThumbUrl(f.stone, 64), f.stone)) : '') +
(f.type === 'stuc' ? pair(sel(pre + '-stuc', 'Kleur', stucColors, f.stuc), swatchOf(WallModel.stucHex(f.stuc))) : '');
const html = list.map(c => {
const r = (parts.chimneys || []).find(x => x.id === c.id);
const id = 'hw-ch' + c.id;
return kv('Schoorsteen', `${c.id} (unit ${c.unitId})`) +
num(id + '-u', 'Positie', c.u, { step: 10, hint: `Hart vanaf de linker vloerhoek van unit ${c.unitId}, van buitenaf gezien` }) +
num(id + '-w', 'Breedte', c.w, { step: 10, hint: 'Buitenwerks; de wand krijgt een sparing zoals een raam' }) +
num(id + '-out', 'Voor de gevel', c.out, { step: 10, hint: r ? `Minstens tot voorbij de dakoverstek: nu ${r.protrudes} mm voor de gevel` : '' }) +
num(id + '-above', 'Boven het dak', c.above, { step: 50, hint: r ? `Bovenkant ${r.yTop - r.yFloor} mm boven de vloer (hoogste dakpunt + dit)` : '' }) +
fin(id + '-out-type', 'Buiten', c.outside, ChimneyModel.OUTSIDE_TYPES) +
fin(id + '-in-type', 'Binnen', c.inside, ChimneyModel.INSIDE_TYPES) +
small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'rb-danger');
}).join('');
return { label: 'Schoorsteen', html: html + small('Schoorsteen toevoegen', 'plus-lg', `data-hw-ch="add" data-u="${Math.round(e0 + (piece.u0 + piece.u1) / 2)}"`) };
}
function wireChimneys(body, pu, wl) {
chimneysOnWall(pu, wl.side).forEach(c => {
const id = 'hw-ch' + c.id;
UI.onNum(id + '-u', v => { c.u = Math.round(v); }, after);
UI.onNum(id + '-w', v => { if (v >= 400) c.w = Math.round(v); }, after);
UI.onNum(id + '-out', v => { if (v >= 0) c.out = Math.round(v); }, after);
UI.onNum(id + '-above', v => { if (v >= 0) c.above = Math.round(v); }, after);
[['out', c.outside], ['in', c.inside]].forEach(([k, f]) => {
UI.onSel(`${id}-${k}-type`, v => { f.type = v; }, rerender);
UI.onSel(`${id}-${k}-type-stone`, v => { f.stone = v; }, rerender);
UI.onSel(`${id}-${k}-type-stuc`, v => { f.stuc = v; }, rerender);
});
});
body.querySelectorAll('[data-hw-ch]').forEach(b => b.addEventListener('click', () => {
state.chimneys = state.chimneys || [];
if (b.dataset.hwCh === 'add') state.chimneys.push(ChimneyModel.create(state.chimneys, pu.id, wl.side, Number(b.dataset.u)));
else state.chimneys = state.chimneys.filter(c => c.id !== Number(b.dataset.ch));
refresh();
}));
}
function wallTab() {
const pu = parts.units.find(x => x.id === selection.unit);
const wl = pu && pu.walls.find(x => x.side === selection.side);
@@ -1970,6 +2080,7 @@ function mountHouseEditor(container, callbacks) {
(own.facade && own.facade.type === 'steen' ? sel('hw-piece-stone', 'Steen', stoneOptions, own.facade.stone || fc.stone) : '') +
(own.facade && own.facade.type === 'stuc' ? sel('hw-piece-stuc', 'Kleur', stucColors, own.facade.stuc || fc.stuc) : '') +
small('Splitsing weg', 'x-lg', 'data-hw="unsplit"') : '') },
chimneyGroup(pu, wl, piece, e0),
{ label: 'Gevellampen', html: lt.mode === 'gevel'
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto',
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) +
@@ -1984,6 +2095,7 @@ function mountHouseEditor(container, callbacks) {
wireButtons(body);
const fc = HouseModel.facadeFor(state, su, wl.side);
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
wireChimneys(body, pu, wl);
UI.onSel('hw-facade', v => {
if (v === 'woning') delete su.facade.walls[wl.side];
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : { type: v };
@@ -2198,23 +2310,19 @@ function mountHouseEditor(container, callbacks) {
const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking');
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
houseGroup = new THREE.Group();
planCaps = null;
const toM = 0.001;
parts.units.forEach(u => {
u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
houseGroup.add(u.floorGroup);
});
HouseModel.placeGroups(parts);
parts.units.forEach(u => houseGroup.add(u.floorGroup));
// Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
const section = view3D.section;
const clip = section ? [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 => {
@@ -2227,12 +2335,10 @@ function mountHouseEditor(container, callbacks) {
});
placeSection();
if (view3D.dak && !section) {
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);
});
parts.units.forEach(u => houseGroup.add(u.roof.group));
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
if (parts.chimneysGroup) houseGroup.add(parts.chimneysGroup);
}
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
@@ -2344,12 +2450,13 @@ function mountHouseEditor(container, callbacks) {
}
const WALK_RADIUS = 250;
const isWalkDoor = (op) => op.type === 'deur' || op.type === 'voordeur' ||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50;
const isWalkDoor = (op) => op.type !== 'schoorsteen' && (op.type === 'deur' || op.type === 'voordeur' ||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50);
function wallBlocked(xm, zm) {
if (!parts) return false;
const x = xm * 1000, z = zm * 1000;
if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true;
if ((parts.chimneys || []).length && ChimneyModel.covers(parts.chimneys, x, z, WALK_RADIUS)) return true;
return HouseModel.allWallPieces(parts).some(p => {
if (p.place.baseY) return false; // opbouw boven een lager dak
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
@@ -2634,6 +2741,51 @@ function mountHouseEditor(container, callbacks) {
return filename;
}
function exportAs(ext, title, run, done) {
flushActivePart();
const naam = String((state.project && state.project.naam) || '').replace(/[\\/:*?"<>|]+/g, '-').trim();
const suggested = (naam || 'woning') + '-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt(`Bestandsnaam voor de ${title}:`, suggested);
if (filename === null) { setIoStatus('Export geannuleerd.', ''); return null; }
filename = filename.trim() || suggested;
if (!new RegExp(`\\.${ext}$`, 'i').test(filename)) filename += '.' + ext;
try {
const res = run(filename);
setIoStatus(`Geëxporteerd als "${filename}" (${typeof done === 'function' ? done(res) : done}).`, 'ok');
} catch (err) {
setIoStatus(`${ext.toUpperCase()}-export mislukt: ${err.message}`, 'err');
}
return filename;
}
const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG');
const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra');
// Render-export: de volledige afgewerkte woning, los van de gekozen bouwfase, doorsnede en weergave-opties.
// Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong.
const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen, ZIP)', (filename) => {
const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials();
if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
const full = HouseModel.computeParts(state, {
wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(),
sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(),
elektraMaterials: ElektraModel.createMaterials(), stage: BuildStages.FINAL, lampenVisible: true, elektraVisible: true
});
HouseModel.placeGroups(full);
if (full.roomsGroup && full.roomsGroup.userData.ceilings) full.roomsGroup.userData.ceilings.visible = true;
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 out = ObjWriter.build(roots, {
name: filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning', app: APP_VERSION,
origin: { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 }
});
ZipWriter.save(out.files, filename);
return out.stats;
}, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen; uitpakken en in Blender importeren`);
function triggerImport() {
document.getElementById('h-file-import-json').click();
}
@@ -2679,6 +2831,9 @@ function mountHouseEditor(container, callbacks) {
activeEditor = null;
},
exportDesign,
exportDxf,
exportIfc,
exportObj,
triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'),
toggleFullscreen3D: forward('toggleFullscreen3D'),