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
+94 -13
View File
@@ -74,7 +74,7 @@ window.HouseModel = (() => {
}
function newModelState(naam) {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [], chimneys: [] };
st.project.naam = naam || '';
return st;
}
@@ -556,7 +556,7 @@ window.HouseModel = (() => {
// een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die
// op de naad de nok vormen (een woningdak zou ook de vleugel overspannen); de vleugel heeft een plat dak op
// goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand.
// Steenstrips rondom, in de oostgevel een donkere strook waar de schoorsteen staat. Maten buitenwerks vanaf de
// Steenstrips rondom, in de oostgevel een schoorsteen (ChimneyModel) over de naad. Maten buitenwerks vanaf de
// noordwesthoek, zoals de tekening (de steenstrips komen daar in de configurator buiten de units nog bij).
function createRijn() {
const st = newModelState('Rijn');
@@ -582,7 +582,9 @@ window.HouseModel = (() => {
['raam', 500, 1400, 700, 'right', 3630, K('raam')],
['raam', 1200, 500, 1600, 'right', 4760, K('raam')],
['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]);
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', 785, K('raam')]]);
// De oostwand van A begint na de noordwand: offset vanaf het wandeinde.
const tN = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', Math.round(785 - tN), K('raam')]]);
ops(B.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 730, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 1630, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
@@ -593,10 +595,10 @@ window.HouseModel = (() => {
ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, K('raam')]]);
ops(C.unit.walls.rechts, [['voordeur', 1100, 2100, 0, 'right', 70, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]);
// Schoorsteen midden in de oostgevel (z 2870–3670). u loopt van buiten gezien van links (zuid) naar rechts.
A.facade.walls.rechts = { type: 'steen', stone: SCHOORSTEEN };
A.unit.walls.rechts.splits = [{ u: 400, facade: { type: 'steen', stone: BRICK } }];
B.unit.walls.rechts.splits = [{ u: 2870, facade: { type: 'steen', stone: SCHOORSTEEN } }];
// Schoorsteen 800 mm breed midden in de oostgevel, hart op de naad (z 3270), 180 mm voor de gevel.
st.chimneys.push(Object.assign(ChimneyModel.create(st.chimneys, 'B', 'rechts', 3270), {
w: 800, out: 180, above: 600, outside: { type: 'steen', stone: SCHOORSTEEN, stuc: 'wit' }
}));
const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
@@ -1096,6 +1098,40 @@ window.HouseModel = (() => {
return out;
}
// Dubbel lessenaardak = zadeldak: twee units met elk een lessenaar dwars op hun naad, allebei hoog aan de naad, met
// op de mm gelijke goot, nok en hoogte op de naad. Dan een stalen nokbalk onder de naad, net als bij een zadeldak.
// Eén lessenaar, of twee die niet zo op elkaar aansluiten, krijgen er geen. Een schoorsteen op de nok maakt een
// sparing in het dak; de balk stopt dan vóór de schoorsteen (RoofModel.computeRidgeBeam).
function doubleLessenaarPairs(state, rects, cuts) {
const out = {};
const info = state.units.map((u, i) => {
const r = rects[i];
if (r.error || u.roof.steelRidge === false) return null;
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const sh = RoofModel.computeShape(u.unit, layout);
if (sh.type !== 'lessenaar' || sh.profile.length !== 2) return null;
const c = sh.axis === 'x' ? r.cx : r.cz;
const [A, B] = sh.profile.map(q => ({ p: q.p + c, h: q.h })).sort((a, b) => a.p - b.p);
return { u, r, axis: sh.axis, A, B, floorTop: layout.floorTop, slope: (B.h - A.h) / (B.p - A.p) };
});
const at = (L, p) => L.A.h + (p - L.A.p) * L.slope;
info.forEach(M => info.forEach(N => {
if (!M || !N || M === N || M.axis !== N.axis || out[M.u.id] || out[N.u.id]) return;
const ax = M.axis, lo = (r) => ax === 'x' ? r.x0 : r.z0, hi = (r) => ax === 'x' ? r.x1 : r.z1;
const seam = hi(M.r);
if (Math.abs(seam - lo(N.r)) > EPS || !(M.slope > 0 && N.slope < 0) || Math.abs(M.floorTop - N.floorTop) > EPS) return;
if (Math.abs(M.A.h - N.B.h) > EPS || Math.abs(M.B.h - N.A.h) > EPS || Math.abs(at(M, seam) - at(N, seam)) > EPS) return;
const qlo = (r) => ax === 'x' ? r.z0 : r.x0, qhi = (r) => ax === 'x' ? r.z1 : r.x1;
const q0 = Math.max(qlo(M.r), qlo(N.r)), q1 = Math.min(qhi(M.r), qhi(N.r));
if (q1 - q0 < 100) return;
out[M.u.id] = {
axis: ax, a0: lo(M.r), a1: hi(N.r), q0, q1, span: [q0, q1],
profile: [{ p: M.A.p, h: M.A.h }, { p: seam, h: at(M, seam) }, { p: N.B.p, h: N.B.h }]
};
}));
return out;
}
// De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug.
function applyRoofJoins(state) {
const rects = unitRects(state);
@@ -1206,6 +1242,15 @@ window.HouseModel = (() => {
const houseShape = houseRoofShape(state, rects);
const coverExt = coverExtensions(state, rects);
const joins = roofJoins(state, rects);
const chimneysPlaced = ChimneyModel.place(ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id)), rects, {
wallThickness,
faceDepth: (id, side) => {
const fc = facadeFor(state, state.units.find(u => u.id === id), side), m = facadeMaterials(state, fc);
return WallModel.claddingOffset(m.exterior, m.stone, m.stuc);
},
overhang: RoofModel.normalizeSettings(state.settings.roof).overhang
});
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
if (!build) return { group: null, stats: null, specs: null };
@@ -1217,7 +1262,9 @@ window.HouseModel = (() => {
}
function wallOf(u, rect, layout, wl, raised) {
const ws = u.unit.walls[wl.side];
const ws0 = u.unit.walls[wl.side];
const chimneyCuts = ChimneyModel.wallCuts(chimneysPlaced, rect, wl, Math.round(ws0.wall.studWidth || WallModel.STANDARD_BEAM.width), rects);
const ws = chimneyCuts.length ? Object.assign({}, ws0, { openings: (ws0.openings || []).concat(chimneyCuts) }) : ws0;
const wp = UnitModel.wallPieces(wl, ws, raised);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
@@ -1269,7 +1316,16 @@ window.HouseModel = (() => {
planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })),
expr: joins[u.id].expr
};
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims);
const roofCuts = ChimneyModel.roofCuts(chimneysPlaced, rect,
{ x0: rect.cx + roofRect.x0, x1: rect.cx + roofRect.x1, z0: rect.cz + roofRect.z0, z1: rect.cz + roofRect.z1 });
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims, roofCuts);
const dl = !join && ridgePairs[u.id];
if (dl) {
const c = dl.axis === 'x' ? rect.cx : rect.cz, cq = dl.axis === 'x' ? rect.cz : rect.cx;
const zadel = { type: 'zadel', axis: dl.axis, profile: dl.profile.map(q => ({ p: q.p - c, h: q.h })) };
const both = dl.axis === 'x' ? { x0: dl.a0 - c, x1: dl.a1 - c, z0: dl.q0 - cq, z1: dl.q1 - cq } : { x0: dl.q0 - cq, x1: dl.q1 - cq, z0: dl.a0 - c, z1: dl.a1 - c };
RoofModel.sharedRidgeBeam(u.roof, ur, zadel, both, dl.span.map(q => q - cq), roofCuts);
}
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', wolf: 'wolfsdak' }[shape.type];
const multi = ur.planes.length > 1;
@@ -1477,7 +1533,7 @@ window.HouseModel = (() => {
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
// Gespiegeld: de schuine bovenzijde klapt om. Het kozijn (fill) staat alleen in het eerste deel.
const flip = ua > ub;
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
return { id: op.id, type: op.type, koppel: op.koppel, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
@@ -1518,7 +1574,10 @@ window.HouseModel = (() => {
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
const xs = new Set([-half, half].concat(tl.kinks));
for (let x = -half + 250; x < half; x += 250) xs.add(x);
const flues = cfg.openings.filter(op => op.type === 'schoorsteen').map(op => [op.x - half, op.x + op.w - half]);
flues.forEach(([a, b]) => { xs.add(a); xs.add(b); });
const pts = [...xs].sort((a, b) => a - b).map(x => {
if (flues.some(([a, b]) => x > a + 0.5 && x < b - 0.5)) return { x, o: 0, i: 0 }; // geen hout in de schoorsteen
const top = p.place.floorTop + tl.at(x);
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
@@ -1619,7 +1678,10 @@ window.HouseModel = (() => {
Object.assign(p, built);
});
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const chimneys = ChimneyModel.resolve(chimneysPlaced, units, roofEdge, groundY, roofEdge ? roofEdge.overhang : 0);
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
}) : null;
@@ -1632,9 +1694,12 @@ window.HouseModel = (() => {
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
}) : null;
if (chimneysGroup) chimneysGroup.visible = shows('gevel');
const sandwich = state.settings.sandwich && units.length
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
@@ -1648,7 +1713,21 @@ window.HouseModel = (() => {
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
if (fixturesGroup) fixturesGroup.visible = shows('afwerking');
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, rooms, roomsGroup, fixtures, fixturesGroup };
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
}
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
function placeGroups(parts) {
const toM = 0.001;
parts.units.forEach(u => {
if (u.floorGroup) u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
if (u.roof.group) u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
});
allWallPieces(parts).forEach(p => {
if (!p.group) return;
p.group.rotation.y = p.place.rotY;
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
});
}
function allWallPieces(parts) {
@@ -1911,6 +1990,7 @@ window.HouseModel = (() => {
});
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
if (design.chimneys !== undefined && !Array.isArray(design.chimneys)) throw new Error('veld "chimneys" moet een lijst zijn');
}
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
@@ -1924,6 +2004,7 @@ window.HouseModel = (() => {
state.interiorWalls = state.interiorWalls || [];
state.rooms = RoomModel.normalize(state.rooms, state.units.map(u => u.id));
state.fixtures = FixtureModel.normalize(state.fixtures, state.units.map(u => u.id));
state.chimneys = ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id));
const def = createDefaultSettings();
state.settings = state.settings || {};
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
@@ -1977,6 +2058,6 @@ window.HouseModel = (() => {
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, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();