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:
co-authored by
Claude Opus 5.5
parent
86fcd57b96
commit
adb3c74f85
@@ -0,0 +1,322 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Schoorsteen tegen een buitenwand: hol, binnen vlak met de wand, buiten (iets) voor de gevel. Hij maakt in elke
|
||||
// buitenwand in hetzelfde vlak die hij raakt een sparing zoals een raam (ook over een naad tussen units heen) en
|
||||
// onderbreekt de dakrand; aan de voorkant steekt hij minstens tot voorbij de dakoverstek uit.
|
||||
// Lokaal assenstelsel per schoorsteen: t langs de wand (wereld-x of -z), s loodrecht erop, 0 = buitenkant regelwerk
|
||||
// van de unit (rand van de vloer), positief naar buiten.
|
||||
|
||||
window.ChimneyModel = (() => {
|
||||
const EPS = 0.5;
|
||||
const SHELL_MM = 100; // wand rond het rookkanaal
|
||||
const CAP_MM = 60, CAP_OVER_MM = 30;
|
||||
const FLUE_SHOW_MM = 500; // zichtbare diepte van het rookkanaal onder de kap
|
||||
const INSET_MM = 2; // sparingen in wand, dak en dakrand net binnen de schoorsteen (geen z-fighting)
|
||||
const BEAR_MM = -4; // nokbalk stopt net vóór de binnenkant van de schoorsteen
|
||||
const MIN_CUT_MM = 100;
|
||||
const CORNER_CLEAR_MM = 200; // slepen: zoveel vrij van een buitenhoek (gevelbekleding van de haakse wand)
|
||||
const INSIDE_TYPES = { wand: 'Zoals de wanden', stuc: 'Stucwerk', steen: 'Steenstrip' };
|
||||
const OUTSIDE_TYPES = { steen: 'Steenstrip', stuc: 'Stucwerk' };
|
||||
const DEFAULTS = { w: 800, out: 180, above: 600, inside: { type: 'wand', stone: 'R777', stuc: 'wit' }, outside: { type: 'steen', stone: 'R777', stuc: 'wit' } };
|
||||
const SIDES = {
|
||||
voor: { axis: 'x', n: { x: 0, z: -1 } }, achter: { axis: 'x', n: { x: 0, z: 1 } },
|
||||
links: { axis: 'z', n: { x: -1, z: 0 } }, rechts: { axis: 'z', n: { x: 1, z: 0 } }
|
||||
};
|
||||
|
||||
const skinOf = (finish) => finish.type === 'steen' ? WallModel.STONE.STRIP_MM : finish.type === 'stuc' ? WallModel.STUC.THICK_MM : 0;
|
||||
|
||||
function normalizeFinish(f, types, def) {
|
||||
const o = Object.assign({}, def, f || {});
|
||||
if (!types[o.type]) o.type = def.type;
|
||||
if (!WallModel.isStoneFinish(o.stone)) o.stone = def.stone;
|
||||
if (!WallModel.isStucColor(o.stuc)) o.stuc = def.stuc;
|
||||
return { type: o.type, stone: o.stone, stuc: o.stuc };
|
||||
}
|
||||
|
||||
function normalize(list, unitIds) {
|
||||
const used = new Set();
|
||||
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
|
||||
return (Array.isArray(list) ? list : []).filter(c => c && unitIds.includes(c.unitId) && SIDES[c.side]).map(c => {
|
||||
let id = Number(c.id) > 0 ? Math.round(c.id) : 1;
|
||||
while (used.has(id)) id++;
|
||||
used.add(id);
|
||||
return {
|
||||
id, unitId: c.unitId, side: c.side, u: num(c.u, 0, -20000, 40000),
|
||||
w: num(c.w, DEFAULTS.w, 400, 2000), out: num(c.out, DEFAULTS.out, 0, 1000), above: num(c.above, DEFAULTS.above, 0, 3000),
|
||||
inside: normalizeFinish(c.inside, INSIDE_TYPES, DEFAULTS.inside),
|
||||
outside: normalizeFinish(c.outside, OUTSIDE_TYPES, DEFAULTS.outside)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function create(list, unitId, side, u) {
|
||||
const id = (list || []).reduce((m, c) => Math.max(m, c.id || 0), 0) + 1;
|
||||
return normalize([{ id, unitId, side, u }], [unitId])[0];
|
||||
}
|
||||
|
||||
// Vlak van een zijde van een unit; u = hart vanaf de linker vloerhoek, van buitenaf gezien.
|
||||
const planeOf = (r, side) => ({ voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 })[side];
|
||||
// opts: { wallThickness(unitId, side), faceDepth(unitId, side) (gevelbekleding), overhang } voor de voetafdruk
|
||||
// (sIn..sFront), die het dak al nodig heeft voordat de gevels gebouwd zijn.
|
||||
function place(list, rects, opts) {
|
||||
const o = opts || {};
|
||||
return list.map(c => {
|
||||
const r = rects.find(q => q.id === c.unitId);
|
||||
if (!r || r.error) return null;
|
||||
const S = SIDES[c.side];
|
||||
const t = { voor: r.x1 - c.u, achter: r.x0 + c.u, links: r.z0 + c.u, rechts: r.z1 - c.u }[c.side];
|
||||
const T = o.wallThickness ? o.wallThickness(c.unitId, c.side) : 260, face = o.faceDepth ? o.faceDepth(c.unitId, c.side) : 0;
|
||||
return Object.assign({}, c, {
|
||||
axis: S.axis, n: S.n, plane: planeOf(r, c.side), t0: t - c.w / 2, t1: t + c.w / 2,
|
||||
sIn: -T, sFront: Math.max(face + c.out, (o.overhang || 0) + 20)
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
// Sparingen in het dak van een unit (plattegrondmaten t.o.v. het unitmidden, zoals RoofModel.computeUnitRoof):
|
||||
// de hele voetafdruk binnen het dak; een nokbalk stopt ervoor (BEAR_MM).
|
||||
function roofCuts(placed, rect, roofRect) {
|
||||
const out = [];
|
||||
placed.forEach(c => {
|
||||
const S = SIDES[c.side];
|
||||
const k = S.axis === 'z' ? S.n.x : S.n.z, sA = c.plane + k * (c.sIn + INSET_MM), sB = c.plane + k * c.sFront;
|
||||
const w = S.axis === 'z'
|
||||
? { x0: Math.min(sA, sB), x1: Math.max(sA, sB), z0: c.t0 + INSET_MM, z1: c.t1 - INSET_MM }
|
||||
: { x0: c.t0 + INSET_MM, x1: c.t1 - INSET_MM, z0: Math.min(sA, sB), z1: Math.max(sA, sB) };
|
||||
const x0 = Math.max(w.x0, roofRect.x0), x1 = Math.min(w.x1, roofRect.x1), z0 = Math.max(w.z0, roofRect.z0), z1 = Math.min(w.z1, roofRect.z1);
|
||||
if (x1 - x0 < 1 || z1 - z0 < 1) return;
|
||||
out.push({ x0: x0 - rect.cx, x1: x1 - rect.cx, z0: z0 - rect.cz, z1: z1 - rect.cz, bear: BEAR_MM, chimney: c.id });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe sparingen voor één unitwand (wl uit UnitModel.computeLayout; rect en rects in wereld-mm).
|
||||
// Loopt de schoorsteen over het wandeinde heen en gaat de wand daar over in die van een andere unit in hetzelfde
|
||||
// vlak (naad), dan loopt de sparing tot het einde door: geen hoekstijlen in de schoorsteen.
|
||||
function wallCuts(placed, rect, wl, studWidth, rects) {
|
||||
const S = SIDES[wl.side], sw = studWidth, plane = planeOf(rect, wl.side);
|
||||
const dir = UnitModel.WALL_SIDES.find(x => x.key === wl.side).dir;
|
||||
const mid = S.axis === 'x' ? wl.cx : wl.cz, c = S.axis === 'x' ? rect.cx : rect.cz;
|
||||
const s0 = mid - wl.width / 2, s1 = mid + wl.width / 2;
|
||||
const toU = (t) => { const p = t - c; return dir > 0 ? p - s0 : s1 - p; };
|
||||
const toT = (u) => c + (dir > 0 ? u + s0 : s1 - u);
|
||||
const range = (r) => S.axis === 'x' ? [r.x0, r.x1] : [r.z0, r.z1];
|
||||
const seamEnd = (u) => {
|
||||
const t = toT(u);
|
||||
return (rects || []).some(r => r !== rect && r.id !== rect.id && !r.error && Math.abs(planeOf(r, wl.side) - plane) < EPS &&
|
||||
range(r)[0] <= t + EPS && range(r)[1] >= t - EPS);
|
||||
};
|
||||
const edge = sw * WallModel.CORNER_STUDS;
|
||||
const out = [];
|
||||
placed.filter(ch => ch.side === wl.side && Math.abs(ch.plane - plane) < EPS).forEach(ch => {
|
||||
const ua = toU(ch.t0 + INSET_MM), ub = toU(ch.t1 - INSET_MM);
|
||||
const atStart = Math.min(ua, ub) < edge && Math.max(toU(ch.t0), toU(ch.t1)) > 0 && seamEnd(0);
|
||||
const atEnd = Math.max(ua, ub) > wl.width - edge && Math.min(toU(ch.t0), toU(ch.t1)) < wl.width && seamEnd(wl.width);
|
||||
const a = atStart ? 0 : Math.max(edge, Math.min(ua, ub)), b = atEnd ? wl.width : Math.min(wl.width - edge, Math.max(ua, ub));
|
||||
if (b - a < MIN_CUT_MM) return;
|
||||
out.push({ id: 1000 + ch.id, type: 'schoorsteen', w: Math.round(b - a), h: 99999, y: 0, anchor: 'left', offset: Math.round(a), fill: null,
|
||||
chimney: { id: ch.id, inside: ch.inside.type, atStart, atEnd } });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// units: parts.units (met walls/pieces en roof), roofEdge: RoofModel.computeEdge (krijgt gaps), groundY absoluut.
|
||||
function resolve(placed, units, roofEdge, groundY, overhang) {
|
||||
return placed.map(ch => {
|
||||
const S = SIDES[ch.side];
|
||||
const toW = (t, s) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, z: t } : { x: t, z: ch.plane + S.n.z * s };
|
||||
const sOf = (p) => S.axis === 'z' ? (p.x - ch.plane) * S.n.x : (p.z - ch.plane) * S.n.z;
|
||||
const tOf = (p) => S.axis === 'z' ? p.z : p.x;
|
||||
let T = 0, face = 0, floorTop = null;
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
if (p.baseY || !p.place.normal || p.place.normal.x !== S.n.x || p.place.normal.y !== S.n.z) return;
|
||||
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
||||
if (Math.abs(sOf(m)) > 2) return;
|
||||
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, 0), b = UnitModel.localToWorld(p.place, p.place.width / 2, 0);
|
||||
if (Math.min(ch.t1, Math.max(tOf(a), tOf(b))) - Math.max(ch.t0, Math.min(tOf(a), tOf(b))) < EPS) return;
|
||||
T = Math.max(T, p.place.thickness);
|
||||
face = Math.max(face, WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc));
|
||||
if (floorTop === null) floorTop = p.place.floorTop;
|
||||
})));
|
||||
if (floorTop === null) {
|
||||
const u0 = units.find(u => u.id === ch.unitId);
|
||||
floorTop = u0 ? u0.layout.floorTop : 0;
|
||||
T = T || (u0 && u0.walls.find(w => w.side === ch.side) || { thickness: 260 }).thickness;
|
||||
}
|
||||
// De dakrand onderbreken en de voorkant tot voorbij de overstek.
|
||||
let roofOut = 0;
|
||||
if (roofEdge) roofEdge.segments.forEach(sg => {
|
||||
if (Math.abs(sg.normal.x - S.n.x) > 0.01 || Math.abs(sg.normal.z - S.n.z) > 0.01) return;
|
||||
if (Math.abs(sOf(sg.a)) > Math.max(overhang, 50) + face + 5) return;
|
||||
const ta = tOf(sg.outerA), tb = tOf(sg.outerB);
|
||||
if (Math.min(ch.t1, Math.max(ta, tb)) - Math.max(ch.t0, Math.min(ta, tb)) < EPS) return;
|
||||
sg.gaps = (sg.gaps || []).concat([[ch.t0 + INSET_MM, ch.t1 - INSET_MM]]);
|
||||
roofOut = Math.max(roofOut, sOf(sg.outerA) + 20);
|
||||
});
|
||||
const sIn = -T, sFace = face, sFront = Math.max(face + ch.out, roofOut);
|
||||
|
||||
const roofs = units.map(u => u.roof);
|
||||
const roofAt = (p) => roofs.filter(r => p.x > r.rect.x0 - EPS && p.x < r.rect.x1 + EPS && p.z > r.rect.z0 - EPS && p.z < r.rect.z1 + EPS);
|
||||
const ridgeTs = roofs.flatMap(r => r.ridges.filter(rd => rd.axis === (S.axis === 'x' ? 'x' : 'z') && rd.value > ch.t0 && rd.value < ch.t1).map(rd => rd.value));
|
||||
// Ook de randen van de daken: twee lessenaars die op een naad de nok vormen hebben geen eigen nok.
|
||||
const edgeTs = roofs.flatMap(r => (S.axis === 'x' ? [r.rect.x0, r.rect.x1] : [r.rect.z0, r.rect.z1])).filter(t => t > ch.t0 + 1 && t < ch.t1 - 1);
|
||||
const ts = [...new Set([ch.t0, ch.t1, ...ridgeTs, ...edgeTs].map(Math.round))].sort((a, b) => a - b);
|
||||
let roofTop = -Infinity;
|
||||
ts.forEach(t => [sIn, 0, sFront].forEach(s => roofAt(toW(t, s)).forEach(r => { roofTop = Math.max(roofTop, r.topAt(toW(t, s).x, toW(t, s).z)); })));
|
||||
// Binnenkant tot de onderkant van het dak (vlak achter de wand gemeten).
|
||||
const insideTop = ts.map(t => {
|
||||
const p = toW(t, sIn - 1), r = roofAt(p);
|
||||
return { t, y: r.length ? Math.min(...r.map(q => q.ceilingAt(p.x, p.z))) : floorTop + 2600 };
|
||||
});
|
||||
const under = Math.max(...insideTop.map(q => q.y));
|
||||
if (!isFinite(roofTop)) roofTop = under + 300;
|
||||
const yTop = Math.round(roofTop + ch.above);
|
||||
const flue = {
|
||||
t0: ch.t0 + SHELL_MM, t1: Math.max(ch.t0 + SHELL_MM + MIN_CUT_MM, ch.t1 - SHELL_MM),
|
||||
s0: sIn + SHELL_MM, s1: Math.max(sIn + SHELL_MM + MIN_CUT_MM, sFront - SHELL_MM)
|
||||
};
|
||||
const corners = (q) => [toW(q.t0, q.s0), toW(q.t1, q.s0), toW(q.t1, q.s1), toW(q.t0, q.s1)];
|
||||
return Object.assign({}, ch, {
|
||||
T, sIn, sFace, sFront, yGround: groundY, yFloor: floorTop, yTop, yUnder: under, insideTop, flue,
|
||||
footprint: corners({ t0: ch.t0, t1: ch.t1, s0: sIn, s1: sFront }), flueFootprint: corners(flue),
|
||||
protrudes: Math.round(sFront - sFace), height: yTop - groundY
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Ligt een punt (wereld-mm) in of vlak voor een schoorsteen?
|
||||
function covers(list, x, z, margin) {
|
||||
const m = margin || 0;
|
||||
return list.some(c => {
|
||||
const S = SIDES[c.side];
|
||||
const t = S.axis === 'z' ? z : x, s = S.axis === 'z' ? (x - c.plane) * S.n.x : (z - c.plane) * S.n.z;
|
||||
return t > c.t0 - m && t < c.t1 + m && s > c.sIn - m && s < c.sFront + m;
|
||||
});
|
||||
}
|
||||
|
||||
function hit(list, x, z, margin) {
|
||||
return list.find(c => covers([c], x, z, margin)) || null;
|
||||
}
|
||||
|
||||
// Slepen: dichtstbijzijnde stuk buitengevel (omtrek van de units samen), hart van de schoorsteen erlangs zodat hij
|
||||
// er helemaal tegen staat; ook over een naad tussen units heen. Geeft { unitId, side, u, t, plane, n } of null.
|
||||
function snapToFacade(rects, x, z, w) {
|
||||
const rs = rects.filter(r => !r.error);
|
||||
if (!rs.length) return null;
|
||||
const loop = rectUnionLoops(rs)[0];
|
||||
if (!loop) return null;
|
||||
const pts = loop.pts, n = pts.length;
|
||||
let best = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n], nrm = rectilinearNormal(a, b);
|
||||
const side = Object.keys(SIDES).find(k => Math.abs(SIDES[k].n.x - nrm.x) < 1e-6 && Math.abs(SIDES[k].n.z - nrm.z) < 1e-6);
|
||||
if (!side) continue;
|
||||
const axis = SIDES[side].axis, lo = Math.min(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x), hi = Math.max(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x);
|
||||
if (hi - lo < w + 2 * CORNER_CLEAR_MM) continue;
|
||||
const plane = axis === 'z' ? a.x : a.z, along = axis === 'z' ? z : x, across = axis === 'z' ? x : z;
|
||||
const t = Math.max(lo + w / 2 + CORNER_CLEAR_MM, Math.min(hi - w / 2 - CORNER_CLEAR_MM, along));
|
||||
const d = Math.hypot(along - t, across - plane);
|
||||
if (best && d >= best.d) continue;
|
||||
const owner = rs.find(r => Math.abs(planeOf(r, side) - plane) < EPS && (axis === 'z' ? r.z0 <= t + EPS && r.z1 >= t - EPS : r.x0 <= t + EPS && r.x1 >= t - EPS));
|
||||
if (!owner) continue;
|
||||
const u = { voor: owner.x1 - t, achter: t - owner.x0, links: t - owner.z0, rechts: owner.z1 - t }[side];
|
||||
best = { d, unitId: owner.id, side, u: Math.round(u), t, plane, n: SIDES[side].n };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
cap: new THREE.MeshStandardMaterial({ color: 0x77736d, roughness: 0.85 }),
|
||||
flue: new THREE.MeshStandardMaterial({ color: 0x1b1a19, roughness: 1, side: THREE.DoubleSide }),
|
||||
plain: new THREE.MeshStandardMaterial({ color: 0xf2efe9, roughness: 0.9 })
|
||||
};
|
||||
}
|
||||
|
||||
const finishMaterial = (f, fallback) => f.type === 'steen' ? WallModel.stoneMaterial(f.stone) : f.type === 'stuc' ? WallModel.stucMaterial(f.stuc) : fallback;
|
||||
|
||||
// opts: { materials, wallMaterial (binnenkant 'wand': hetzelfde materiaal als de wanden) }
|
||||
function build3D(list, opts) {
|
||||
const o = opts || {}, m = o.materials || createMaterials(), toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
list.forEach(c => {
|
||||
const S = SIDES[c.side];
|
||||
const W = (t, s) => S.axis === 'z' ? { x: c.plane + S.n.x * s, z: t } : { x: t, z: c.plane + S.n.z * s };
|
||||
const buckets = new Map();
|
||||
const add = (mat, tri) => { if (!buckets.has(mat)) buckets.set(mat, { pos: [], uv: [] }); const b = buckets.get(mat); tri.forEach(v => { b.pos.push(v.x * toM, v.y * toM, v.z * toM); b.uv.push(v.u, v.v); }); };
|
||||
// Verticaal vlak tussen p0 en p1 (wereld), buitennormaal nrm; hoogtes per kant. UV in meters (steen/stuc).
|
||||
const quad = (mat, p0, p1, y0a, y1a, y0b, y1b, nrm) => {
|
||||
const right = { x: nrm.z, z: -nrm.x };
|
||||
if ((p1.x - p0.x) * right.x + (p1.z - p0.z) * right.z < 0) { [p0, p1] = [p1, p0]; [y0a, y0b] = [y0b, y0a]; [y1a, y1b] = [y1b, y1a]; }
|
||||
const v = (p, y) => ({ x: p.x, y, z: p.z, u: (p.x * right.x + p.z * right.z) * toM, v: (y - c.yFloor) * toM });
|
||||
const BL = v(p0, y0a), BR = v(p1, y0b), TR = v(p1, y1b), TL = v(p0, y1a);
|
||||
add(mat, [BL, BR, TR]); add(mat, [BL, TR, TL]);
|
||||
};
|
||||
const nOut = S.n, nIn = { x: -S.n.x, z: -S.n.z };
|
||||
const tDir = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 }, tNeg = { x: -tDir.x, z: -tDir.z };
|
||||
const outMat = finishMaterial(c.outside, m.plain);
|
||||
const yG = c.yGround, yT = c.yTop;
|
||||
quad(outMat, W(c.t0, c.sFront), W(c.t1, c.sFront), yG, yT, yG, yT, nOut);
|
||||
quad(outMat, W(c.t0, c.sIn), W(c.t0, c.sFront), yG, yT, yG, yT, tNeg);
|
||||
quad(outMat, W(c.t1, c.sIn), W(c.t1, c.sFront), yG, yT, yG, yT, tDir);
|
||||
quad(outMat, W(c.t0, c.sIn), W(c.t1, c.sIn), c.yUnder, yT, c.yUnder, yT, nIn); // achterkant boven het dak
|
||||
|
||||
// Binnenkant: van de vloer tot de onderkant van het dak.
|
||||
const k = skinOf(c.inside), inMat = finishMaterial(c.inside, o.wallMaterial || m.plain), sI = c.sIn - k;
|
||||
for (let i = 0; i < c.insideTop.length - 1; i++) {
|
||||
const a = c.insideTop[i], b = c.insideTop[i + 1];
|
||||
quad(inMat, W(a.t, sI), W(b.t, sI), c.yFloor, a.y, c.yFloor, b.y, nIn);
|
||||
}
|
||||
if (k > 0) {
|
||||
const first = c.insideTop[0], last = c.insideTop[c.insideTop.length - 1];
|
||||
quad(inMat, W(c.t0, sI), W(c.t0, c.sIn), c.yFloor, first.y, c.yFloor, first.y, tNeg);
|
||||
quad(inMat, W(c.t1, sI), W(c.t1, c.sIn), c.yFloor, last.y, c.yFloor, last.y, tDir);
|
||||
}
|
||||
|
||||
// Rookkanaal: zwarte binnenwanden en bodem tot FLUE_SHOW_MM onder de kap.
|
||||
const f = c.flue, yF = yT - Math.min(FLUE_SHOW_MM, c.above + 100);
|
||||
quad(m.flue, W(f.t0, f.s0), W(f.t1, f.s0), yF, yT, yF, yT, nOut);
|
||||
quad(m.flue, W(f.t0, f.s1), W(f.t1, f.s1), yF, yT, yF, yT, nIn);
|
||||
quad(m.flue, W(f.t0, f.s0), W(f.t0, f.s1), yF, yT, yF, yT, tDir);
|
||||
quad(m.flue, W(f.t1, f.s0), W(f.t1, f.s1), yF, yT, yF, yT, tNeg);
|
||||
const fp = [W(f.t0, f.s0), W(f.t1, f.s0), W(f.t1, f.s1), W(f.t0, f.s1)].map(p => ({ x: p.x, y: yF, z: p.z, u: 0, v: 0 }));
|
||||
add(m.flue, [fp[0], fp[1], fp[2]]); add(m.flue, [fp[0], fp[2], fp[3]]);
|
||||
|
||||
buckets.forEach((b, mat) => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(b.uv, 2));
|
||||
geo.computeVertexNormals();
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = mat !== m.flue;
|
||||
mesh.receiveShadow = true;
|
||||
group.add(mesh);
|
||||
});
|
||||
|
||||
// Afdekplaat rond het rookkanaal (vier stroken).
|
||||
const capY = (yT + CAP_MM / 2) * toM;
|
||||
const strips = [
|
||||
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, c.sIn - CAP_OVER_MM, f.s0],
|
||||
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, f.s1, c.sFront + CAP_OVER_MM],
|
||||
[c.t0 - CAP_OVER_MM, f.t0, f.s0, f.s1],
|
||||
[f.t1, c.t1 + CAP_OVER_MM, f.s0, f.s1]
|
||||
];
|
||||
strips.forEach(([ta, tb, sa, sb]) => {
|
||||
if (tb - ta < 1 || sb - sa < 1) return;
|
||||
const p = W((ta + tb) / 2, (sa + sb) / 2);
|
||||
const [dx, dz] = S.axis === 'z' ? [sb - sa, tb - ta] : [tb - ta, sb - sa];
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(dx * toM, CAP_MM * toM, dz * toM), m.cap);
|
||||
box.position.set(p.x * toM, capY, p.z * toM);
|
||||
box.castShadow = box.receiveShadow = true;
|
||||
group.add(box);
|
||||
});
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { SHELL_MM, INSIDE_TYPES, OUTSIDE_TYPES, DEFAULTS, SIDES, normalize, create, place, wallCuts, roofCuts, resolve, covers, hit, snapToFacade, createMaterials, build3D };
|
||||
})();
|
||||
@@ -0,0 +1,392 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// CAD-export van de woning (DXF): plattegrond en vier gevels in modelruimte, 1:1 in mm, met lagen per onderdeel.
|
||||
// Plattegrond: noord boven, zuidwesthoek van de units = 0,0. Gevels in een 2×2-raster eronder, maaiveld als basis.
|
||||
// Tekst, maatvoering en lijntypes zijn afgestemd op plotten op 1:50.
|
||||
|
||||
window.HouseDxf = (() => {
|
||||
const SCALE = 50;
|
||||
const CUT = 1500; // snijvlak plattegrond boven de vloer: openingen die lager beginnen zijn doorgesneden
|
||||
const LAYERS = {
|
||||
'PL-WAND-BUITEN': [7], 'PL-WAND-OPBOUW': [8], 'PL-GEVELBEKLEDING': [32], 'PL-WAND-BINNEN': [7],
|
||||
'PL-KOZIJNEN': [4], 'PL-DRAAIRICHTING': [151], 'PL-SPARING-BOVEN': [4, 'STREEP'],
|
||||
'PL-RUIMTE-GRENS': [3], 'PL-RUIMTE-TEKST': [7], 'PL-INRICHTING': [8], 'PL-DAKRAND': [8, 'STREEP'],
|
||||
'PL-PALEN': [32], 'PL-SCHOORSTEEN': [8], 'PL-VERLICHTING': [2], 'PL-ELEKTRA': [6], 'PL-NADEN': [30, 'STREEPSTIP'],
|
||||
'PL-ELEMENTCODES': [8],
|
||||
'AZ-GEVEL': [7], 'AZ-TRASRAAM': [7], 'AZ-KOZIJNEN': [4], 'AZ-DAK': [7], 'AZ-DAKRAND': [8], 'AZ-SCHOORSTEEN': [8], 'AZ-MAAIVELD': [7],
|
||||
'MAATVOERING': [5], 'TEKST': [7]
|
||||
};
|
||||
const FACE_LAYERS = { wand: 'AZ-GEVEL', opening: 'AZ-GEVEL', trasraam: 'AZ-TRASRAAM', dak: 'AZ-DAK', dakrand: 'AZ-DAKRAND', schoorsteen: 'AZ-SCHOORSTEEN' };
|
||||
const mm = (paper) => paper * SCALE;
|
||||
|
||||
// Intervallen [a, b]: samenvoegen en aftrekken.
|
||||
function mergeIv(list) {
|
||||
const s = list.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
|
||||
const out = [];
|
||||
s.forEach(([a, b]) => { const l = out[out.length - 1]; if (l && a <= l[1] + 1e-9) l[1] = Math.max(l[1], b); else out.push([a, b]); });
|
||||
return out;
|
||||
}
|
||||
function subtractIv(from, cuts) {
|
||||
let out = from.map(iv => iv.slice());
|
||||
mergeIv(cuts).forEach(([c0, c1]) => {
|
||||
out = out.flatMap(([a, b]) => {
|
||||
if (c1 <= a || c0 >= b) return [[a, b]];
|
||||
const r = [];
|
||||
if (c0 > a) r.push([a, c0]);
|
||||
if (c1 < b) r.push([c1, b]);
|
||||
return r;
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Randen van een ruimte (RoomModel: as x/z, waarde v, s0..s1) tot gesloten lussen.
|
||||
function roomLoops(edges) {
|
||||
const key = (p) => Math.round(p.x) + ',' + Math.round(p.z);
|
||||
const segs = edges.map(e => e.axis === 'x' ? [{ x: e.v, z: e.s0 }, { x: e.v, z: e.s1 }] : [{ x: e.s0, z: e.v }, { x: e.s1, z: e.v }]);
|
||||
const at = new Map();
|
||||
segs.forEach((s, i) => s.forEach(p => { const k = key(p); if (!at.has(k)) at.set(k, []); at.get(k).push(i); }));
|
||||
const used = new Set(), out = [];
|
||||
segs.forEach((s, i) => {
|
||||
if (used.has(i)) return;
|
||||
used.add(i);
|
||||
const loop = [s[0], s[1]];
|
||||
let cur = s[1];
|
||||
while (key(cur) !== key(loop[0])) {
|
||||
const j = (at.get(key(cur)) || []).find(n => !used.has(n));
|
||||
if (j === undefined) break;
|
||||
used.add(j);
|
||||
cur = key(segs[j][0]) === key(cur) ? segs[j][1] : segs[j][0];
|
||||
loop.push(cur);
|
||||
}
|
||||
const closed = key(loop[loop.length - 1]) === key(loop[0]);
|
||||
if (closed) loop.pop();
|
||||
const n = loop.length;
|
||||
const pts = loop.filter((p, k) => {
|
||||
if (!closed && (k === 0 || k === n - 1)) return true;
|
||||
const a = loop[(k - 1 + n) % n], b = loop[(k + 1) % n];
|
||||
return !((Math.abs(a.x - p.x) < 0.5 && Math.abs(b.x - p.x) < 0.5) || (Math.abs(a.z - p.z) < 0.5 && Math.abs(b.z - p.z) < 0.5));
|
||||
});
|
||||
out.push({ pts, closed });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cirkelboog door het eerste, middelste en laatste punt van een polylijn (draaicirkel van een deur).
|
||||
function arcThrough(pts) {
|
||||
const a = pts[0], b = pts[Math.floor(pts.length / 2)], c = pts[pts.length - 1];
|
||||
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||
if (Math.abs(d) < 1e-9) return null;
|
||||
const s = (p) => p.x * p.x + p.y * p.y;
|
||||
const ctr = { x: (s(a) * (b.y - c.y) + s(b) * (c.y - a.y) + s(c) * (a.y - b.y)) / d, y: (s(a) * (c.x - b.x) + s(b) * (a.x - c.x) + s(c) * (b.x - a.x)) / d };
|
||||
const ang = (p) => Math.atan2(p.y - ctr.y, p.x - ctr.x);
|
||||
const ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0;
|
||||
return { c: ctr, r: Math.hypot(a.x - ctr.x, a.y - ctr.y), a0: ccw ? ang(a) : ang(c), a1: ccw ? ang(c) : ang(a) };
|
||||
}
|
||||
|
||||
// Tekstrichting langs een wand, altijd leesbaar (van onder of van rechts).
|
||||
const readable = (deg) => { let a = ((deg % 360) + 360) % 360; if (a > 90.01 && a <= 270.01) a -= 180; return a; };
|
||||
|
||||
function drawPlan(dxf, parts, T) {
|
||||
const W2 = (place, lx, lz) => T(UnitModel.localToWorld(place, lx, lz));
|
||||
const ext = new Set();
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.add(p))));
|
||||
const P = KozijnModel.PROFILE;
|
||||
|
||||
HouseModel.allWallPieces(parts).forEach(p => {
|
||||
const pl = p.place;
|
||||
if (pl.baseY) return;
|
||||
const isExt = ext.has(p);
|
||||
const W = pl.width, zi = -pl.thickness / 2, zo = pl.thickness / 2;
|
||||
const ops = (p.config.openings || []).filter(op => op.type !== 'schoorsteen');
|
||||
const cutOps = ops.filter(op => op.y < CUT);
|
||||
const holes = mergeIv(cutOps.map(op => [Math.max(-W / 2, -W / 2 + op.x), Math.min(W / 2, -W / 2 + op.x + op.w)]));
|
||||
const solid = subtractIv([[-W / 2, W / 2]], holes);
|
||||
const lay = isExt ? 'PL-WAND-BUITEN' : 'PL-WAND-BINNEN';
|
||||
const box = (a, b, z0, z1, layer, eo) => dxf.polyline([W2(pl, a, z0), W2(pl, b, z0), W2(pl, b, z1), W2(pl, a, z1)], true, layer, eo);
|
||||
solid.forEach(([a, b]) => box(a, b, zi, zo, lay));
|
||||
|
||||
const c = isExt ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0;
|
||||
if (isExt) {
|
||||
const sh = p.config.sheathingThick || 0;
|
||||
if (sh > 0) solid.forEach(([a, b]) => [zi + sh, zo - sh].forEach(z => dxf.line(W2(pl, a, z), W2(pl, b, z), 'PL-WAND-OPBOUW')));
|
||||
if (c > 0) {
|
||||
// Hoek: buitenhoek loopt de bekleding door over de buurwand, binnenhoek stopt ze ervoor (skinExt < 0).
|
||||
const e = p.skinExt || {};
|
||||
const xl = -W / 2 - (e.left > 0 ? e.left + c : (e.left || 0)), xr = W / 2 + (e.right > 0 ? e.right + c : (e.right || 0));
|
||||
subtractIv([[xl, xr]], holes).forEach(([a, b]) => box(a, b, zo, zo + c, 'PL-GEVELBEKLEDING'));
|
||||
}
|
||||
}
|
||||
|
||||
cutOps.forEach(op => {
|
||||
const f = KozijnModel.normalize(op.fill);
|
||||
if (!f) return;
|
||||
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
|
||||
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
|
||||
const kz1 = zo, kz0 = exterior ? Math.max(zi, zo - P.depth) : zi;
|
||||
const a = x0 + P.gap, b = x1 - P.gap;
|
||||
if (f.type === 'raam') {
|
||||
box(a, b, kz0, kz1, 'PL-KOZIJNEN');
|
||||
[a + P.kader, b - P.kader].forEach(x => dxf.line(W2(pl, x, kz0), W2(pl, x, kz1), 'PL-KOZIJNEN'));
|
||||
dxf.line(W2(pl, a + P.kader, kz1 - 50), W2(pl, b - P.kader, kz1 - 50), 'PL-KOZIJNEN');
|
||||
return;
|
||||
}
|
||||
box(a, a + P.kader, kz0, kz1, 'PL-KOZIJNEN');
|
||||
box(b - P.kader, b, kz0, kz1, 'PL-KOZIJNEN');
|
||||
KozijnModel.planSymbol(op, op.fill, { zIn: zi, zOut: zo }).forEach(sh => {
|
||||
const pts = sh.pts.map(q => W2(pl, x0 + q.x, q.z));
|
||||
if (sh.kind === 'arc') {
|
||||
const arc = arcThrough(pts);
|
||||
if (arc) dxf.arc(arc.c, arc.r, arc.a0, arc.a1, 'PL-DRAAIRICHTING');
|
||||
} else if (sh.kind === 'rail') dxf.polyline(pts, !!sh.closed, 'PL-DRAAIRICHTING', { ltype: 'STIP' });
|
||||
else dxf.polyline(pts, !!sh.closed, sh.kind === 'arrow' ? 'PL-DRAAIRICHTING' : 'PL-KOZIJNEN');
|
||||
});
|
||||
});
|
||||
// Sparingen boven het snijvlak (bovenlichten, hoge ramen) gestreept, tenzij ze boven een doorgesneden opening liggen.
|
||||
ops.filter(op => op.y >= CUT).forEach(op => {
|
||||
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
|
||||
if (holes.some(([a, b]) => x0 >= a - 1 && x1 <= b + 1)) return;
|
||||
box(x0, x1, zi, zo, 'PL-SPARING-BOVEN');
|
||||
});
|
||||
|
||||
// Elementcode binnen langs de wand, verticaal gecentreerd op 2,5 mm (papier) van het binnenvlak.
|
||||
const rot = readable(Math.atan2(W2(pl, 1, 0).y - W2(pl, 0, 0).y, W2(pl, 1, 0).x - W2(pl, 0, 0).x) * 180 / Math.PI) * Math.PI / 180;
|
||||
const mid = W2(pl, 0, zi - mm(2.5)), th = mm(2);
|
||||
dxf.text({ x: mid.x + Math.sin(rot) * th / 2, y: mid.y - Math.cos(rot) * th / 2 }, th, p.element, 'PL-ELEMENTCODES', { align: 'center', rot: rot * 180 / Math.PI });
|
||||
|
||||
if (isExt) {
|
||||
const xs = [-W / 2, W / 2];
|
||||
holes.forEach(([a, b]) => xs.push(a, b));
|
||||
const ticks = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((m, n) => m - n);
|
||||
const zf = zo + c, row1 = zf + 700, row2 = row1 + 450;
|
||||
for (let i = 0; i < ticks.length - 1; i++) {
|
||||
dxf.dim(W2(pl, ticks[i], zf), W2(pl, ticks[i + 1], zf), W2(pl, (ticks[i] + ticks[i + 1]) / 2, row1), 'MAATVOERING');
|
||||
}
|
||||
if (ticks.length > 2) dxf.dim(W2(pl, -W / 2, zf), W2(pl, W / 2, zf), W2(pl, 0, row2), 'MAATVOERING');
|
||||
}
|
||||
});
|
||||
|
||||
const R = parts.rooms;
|
||||
R.rooms.concat(R.free).forEach(r => {
|
||||
roomLoops(r.edges || []).forEach(lp => dxf.polyline(lp.pts.map(T), lp.closed, 'PL-RUIMTE-GRENS'));
|
||||
const named = !!r.name;
|
||||
const lines = [[named ? r.name : 'Ruimte (geen functie)', 3.5], [`${r.areaM2.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m²`, 2.5]];
|
||||
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 2]);
|
||||
const c = T(r.label), step = mm(4.5);
|
||||
lines.forEach(([t, size], i) => dxf.text({ x: c.x, y: c.y + ((lines.length - 1) / 2 - i) * step - mm(size) / 2 }, mm(size), t, 'PL-RUIMTE-TEKST', { align: 'center' }));
|
||||
});
|
||||
|
||||
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||
const eo = sh.kind === 'dash' ? { ltype: 'STREEP' } : null;
|
||||
if (sh.circle) dxf.circle(T(sh.circle), sh.circle.r, 'PL-INRICHTING', eo);
|
||||
else if (sh.text) { const q = T(sh.at); dxf.text({ x: q.x, y: q.y - mm(0.7) }, mm(1.4), sh.text, 'PL-INRICHTING', { align: 'center' }); }
|
||||
else dxf.polyline(sh.pts.map(T), !!sh.closed, 'PL-INRICHTING', eo);
|
||||
}));
|
||||
|
||||
if (parts.roofEdge) {
|
||||
parts.roofEdge.segments.forEach(sg => dxf.line(T(sg.outerA), T(sg.outerB), 'PL-DAKRAND'));
|
||||
parts.roofEdge.spots.forEach(sp => dxf.circle(T(sp), mm(0.8), 'PL-VERLICHTING'));
|
||||
} else {
|
||||
parts.units.forEach(u => {
|
||||
const r = u.roof.rect;
|
||||
dxf.polyline([{ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }].map(T), true, 'PL-DAKRAND');
|
||||
});
|
||||
}
|
||||
parts.posts.posts.forEach(pp => {
|
||||
const s = pp.size / 2;
|
||||
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
|
||||
});
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
});
|
||||
parts.lighting.lamps.forEach(l => {
|
||||
const q = T({ x: l.x + l.normal.x * 80, z: l.z + l.normal.z * 80 }), s = mm(0.9);
|
||||
dxf.polyline([{ x: q.x - s, y: q.y - s }, { x: q.x + s, y: q.y - s }, { x: q.x + s, y: q.y + s }, { x: q.x - s, y: q.y + s }], true, 'PL-VERLICHTING');
|
||||
});
|
||||
const o = T({ x: 0, z: 0 });
|
||||
ElektraPdf.drawPlan(DxfWriter.pdfAdapter(dxf, 'PL-ELEKTRA'), parts.elektra, (x) => (x + o.x) / SCALE, (z) => (z - o.y) / SCALE);
|
||||
parts.seams.forEach(s => dxf.line(T({ x: s.x0, z: s.z0 }), T({ x: s.x1, z: s.z1 }), 'PL-NADEN'));
|
||||
parts.rects.forEach(r => {
|
||||
const q = T({ x: r.x0 + 450, z: r.z0 + 450 });
|
||||
dxf.text({ x: q.x, y: q.y - mm(8) }, mm(8), r.id, 'PL-ELEMENTCODES');
|
||||
});
|
||||
|
||||
// Totaalmaten en noordpijl buiten alles.
|
||||
const rects = parts.rects;
|
||||
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
||||
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
|
||||
const e = dxf.extents();
|
||||
dxf.dim(T({ x: bx0, z: bz0 }), T({ x: bx1, z: bz0 }), { x: 0, y: e.y1 + 700 }, 'MAATVOERING');
|
||||
dxf.dim(T({ x: bx0, z: bz1 }), T({ x: bx0, z: bz0 }), { x: e.x0 - 700, y: 0 }, 'MAATVOERING');
|
||||
const na = { x: e.x1 + mm(10), y: e.y1 - mm(4) }, ns = mm(4);
|
||||
dxf.polyline([{ x: na.x, y: na.y + ns }, { x: na.x - ns * 0.4, y: na.y - ns * 0.6 }, { x: na.x, y: na.y - ns * 0.3 }, { x: na.x + ns * 0.4, y: na.y - ns * 0.6 }], true, 'TEKST');
|
||||
dxf.text({ x: na.x, y: na.y - ns * 0.6 - mm(5) }, mm(3.5), 'N', 'TEKST', { align: 'center' });
|
||||
}
|
||||
|
||||
function simplifyPoly(pts) {
|
||||
let p = pts.filter((q, i) => { const r = pts[(i + 1) % pts.length]; return Math.hypot(r.x - q.x, r.y - q.y) > 0.5; });
|
||||
let changed = true;
|
||||
while (changed && p.length > 3) {
|
||||
changed = false;
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const a = p[(i - 1 + p.length) % p.length], b = p[i], c = p[(i + 1) % p.length];
|
||||
const len = Math.hypot(c.x - a.x, c.y - a.y);
|
||||
if (len > 0 && Math.abs((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / len < 0.5) { p.splice(i, 1); changed = true; break; }
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const insidePoly = (q, poly) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i], b = poly[j];
|
||||
if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const distSeg = (q, a, b) => {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
|
||||
const t = l2 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0;
|
||||
return Math.hypot(q.x - a.x - t * dx, q.y - a.y - t * dy);
|
||||
};
|
||||
// Echt binnen (niet op de rand): lijnen die samenvallen met de rand van een vlak ervoor blijven zichtbaar.
|
||||
const strictlyInside = (q, poly) => insidePoly(q, poly) && poly.every((a, i) => distSeg(q, a, poly[(i + 1) % poly.length]) > 0.5);
|
||||
|
||||
// Delen [t0, t1] van segment a-b die door poly worden afgedekt.
|
||||
function coveredBy(a, b, poly) {
|
||||
const ts = [0, 1], dx = b.x - a.x, dy = b.y - a.y;
|
||||
poly.forEach((c, i) => {
|
||||
const d = poly[(i + 1) % poly.length], ex = d.x - c.x, ey = d.y - c.y;
|
||||
const den = dx * ey - dy * ex;
|
||||
if (Math.abs(den) < 1e-9) return;
|
||||
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / den, s = ((c.x - a.x) * dy - (c.y - a.y) * dx) / den;
|
||||
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t);
|
||||
});
|
||||
ts.sort((m, n) => m - n);
|
||||
const out = [];
|
||||
for (let i = 0; i < ts.length - 1; i++) {
|
||||
const t0 = ts[i], t1 = ts[i + 1];
|
||||
if (t1 - t0 < 1e-7) continue;
|
||||
const tm = (t0 + t1) / 2;
|
||||
if (!strictlyInside({ x: a.x + dx * tm, y: a.y + dy * tm }, poly)) continue;
|
||||
const l = out[out.length - 1];
|
||||
if (l && Math.abs(l[1] - t0) < 1e-7) l[1] = t1; else out.push([t0, t1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Verborgen lijnen weg: vlakken van achter naar voor, elke rand wordt afgedekt door de vlakken die later komen.
|
||||
function hiddenLines(faces) {
|
||||
const boxOf = (pts) => ({ x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)) });
|
||||
const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||
faces.forEach(f => { f.box = boxOf(f.poly); });
|
||||
const out = [];
|
||||
faces.forEach((f, i) => {
|
||||
const segs = [];
|
||||
const ring = (pts, layer) => pts.forEach((a, k) => segs.push({ a, b: pts[(k + 1) % pts.length], layer }));
|
||||
ring(f.poly, f.layer);
|
||||
f.detail.forEach(pl => ring(pl, 'AZ-KOZIJNEN'));
|
||||
segs.forEach(sg => {
|
||||
const len = Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y);
|
||||
if (len < 1) return;
|
||||
const sb = boxOf([sg.a, sg.b]);
|
||||
sb.x0 -= 1; sb.y0 -= 1; sb.x1 += 1; sb.y1 += 1;
|
||||
let vis = [[0, 1]];
|
||||
for (let j = i + 1; j < faces.length && vis.length; j++) {
|
||||
if (!hit(sb, faces[j].box)) continue;
|
||||
const cov = coveredBy(sg.a, sg.b, faces[j].poly);
|
||||
if (cov.length) vis = subtractIv(vis, cov);
|
||||
}
|
||||
const at = (t) => ({ x: sg.a.x + (sg.b.x - sg.a.x) * t, y: sg.a.y + (sg.b.y - sg.a.y) * t });
|
||||
vis.forEach(([t0, t1]) => { if ((t1 - t0) * len > 1) out.push({ a: at(t0), b: at(t1), layer: sg.layer }); });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Samenvallende en in elkaars verlengde liggende stukken per laag samenvoegen (gedeelde randen maar één keer).
|
||||
function mergeCollinear(segs) {
|
||||
const groups = new Map();
|
||||
segs.forEach(s => {
|
||||
let dx = s.b.x - s.a.x, dy = s.b.y - s.a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
dx /= len; dy /= len;
|
||||
if (dx < -1e-9 || (Math.abs(dx) < 1e-9 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
const off = -dy * s.a.x + dx * s.a.y;
|
||||
const key = `${s.layer}|${Math.round(Math.atan2(dy, dx) * 2000)}|${Math.round(off)}`;
|
||||
if (!groups.has(key)) groups.set(key, { dx, dy, off, layer: s.layer, iv: [] });
|
||||
const g = groups.get(key);
|
||||
const ta = s.a.x * dx + s.a.y * dy, tb = s.b.x * dx + s.b.y * dy;
|
||||
g.iv.push([Math.min(ta, tb), Math.max(ta, tb)]);
|
||||
});
|
||||
const out = [];
|
||||
groups.forEach(g => mergeIv(g.iv).forEach(([t0, t1]) => {
|
||||
const p = (t) => ({ x: g.dx * t - g.dy * g.off, y: g.dy * t + g.dx * g.off });
|
||||
out.push({ a: p(t0), b: p(t1), layer: g.layer });
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawViews(dxf, parts, state, planExt) {
|
||||
const { groundY, views } = HousePdf.viewProjections(parts, state);
|
||||
if (!views.length) return;
|
||||
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||||
const maxW = Math.max(...views.map(w => w.u1 - w.u0)), maxH = Math.max(...views.map(w => w.v1 - groundY));
|
||||
const gapX = 5000, cellH = maxH + 4500;
|
||||
views.forEach((w, i) => {
|
||||
const col = i % 2, row = Math.floor(i / 2);
|
||||
const ox = planExt.x0 + col * (maxW + gapX) - w.u0;
|
||||
const base = planExt.y0 - 3500 - maxH - row * cellH;
|
||||
const P = (u, v) => ({ x: ox + u, y: base + (v - groundY) });
|
||||
|
||||
const faces = w.list.map(l => ({
|
||||
poly: simplifyPoly(l.pts.map(p => ({ x: p.u, y: p.v }))),
|
||||
layer: FACE_LAYERS[l.f.kind] || 'AZ-GEVEL',
|
||||
detail: (l.f.detail || []).map(pl => simplifyPoly(pl.map(p => ({ x: w.v.u(p), y: p.y }))))
|
||||
})).filter(f => f.poly.length >= 3);
|
||||
mergeCollinear(hiddenLines(faces)).forEach(s => dxf.line(P(s.a.x, s.a.y), P(s.b.x, s.b.y), s.layer));
|
||||
|
||||
dxf.line(P(w.u0 - 1000, groundY), P(w.u1 + 1000, groundY), 'AZ-MAAIVELD');
|
||||
const left = P(w.u0, groundY), xd = left.x - 900;
|
||||
if (floorTop - groundY > 1) dxf.dim(P(w.u0, groundY), P(w.u0, floorTop), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
|
||||
dxf.dim(P(w.u0, floorTop), P(w.u0, w.v1), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
|
||||
const peil = P(w.u0, floorTop);
|
||||
dxf.line({ x: xd - mm(10), y: peil.y }, { x: xd - mm(2), y: peil.y }, 'MAATVOERING');
|
||||
dxf.text({ x: xd - mm(10), y: peil.y + mm(1) }, mm(2), 'peil', 'MAATVOERING');
|
||||
dxf.dim(P(w.u0, groundY), P(w.u1, groundY), { x: 0, y: base - 700 }, 'MAATVOERING');
|
||||
dxf.text(P((w.u0 + w.u1) / 2, groundY - 1300 - mm(5)), mm(5), w.v.title.toUpperCase(), 'TEKST', { align: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const parts = HouseModel.computeParts(state, { skip3D: true });
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const dxf = DxfWriter.create({ scale: SCALE });
|
||||
Object.entries(LAYERS).forEach(([name, [color, ltype]]) => dxf.layer(name, color, ltype));
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
const T = (p) => ({ x: p.x - bx0, y: bz1 - p.z });
|
||||
|
||||
drawPlan(dxf, parts, T);
|
||||
const planExt = dxf.extents();
|
||||
dxf.text({ x: planExt.x0, y: planExt.y0 - mm(12) }, mm(5), 'PLATTEGROND', 'TEKST');
|
||||
drawViews(dxf, parts, state, planExt);
|
||||
|
||||
const pr = state.project || {};
|
||||
const version = typeof APP_VERSION !== 'undefined' ? ` ${APP_VERSION}` : '';
|
||||
const info = [
|
||||
pr.naam, [pr.straat, pr.nummer].filter(Boolean).join(' '), pr.plaats,
|
||||
`Tectum${version}, ${new Date().toLocaleDateString('nl-NL')}`,
|
||||
'Maten in mm, peil = bovenkant vloer; tekst en maatvoering voor 1:50.'
|
||||
].filter(Boolean);
|
||||
info.forEach((t, i) => dxf.text({ x: planExt.x1 + mm(30), y: planExt.y1 - mm(5) - i * mm(4.5) }, mm(i === 0 && pr.naam ? 3.5 : 2.5), t, 'TEKST'));
|
||||
return dxf;
|
||||
}
|
||||
|
||||
return { generate, LAYERS };
|
||||
})();
|
||||
@@ -0,0 +1,186 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// BIM-export van de bouwconstructie (IFC4): balken, platen en elektra, geen afwerking of inrichting.
|
||||
// De geometrie komt uit de 3D-opbouw (dezelfde als in de app), in twee fasen: 'elektra' (alle balken en
|
||||
// leidingen, nog zonder platen, dus niets weggelaten) en 'beplating' (alleen de platen).
|
||||
// Assen: X = oost, Y = noord, Z = omhoog; 0,0 = zuidwesthoek van de units, Z = 0 op peil (bovenkant vloer).
|
||||
// Opbouw: unit (samenstelling) > element A-W1 / A-V / A-D (samenstelling) > onderdelen; binnenwanden los.
|
||||
|
||||
window.HouseIfc = (() => {
|
||||
const WOOD = 'Hout', ELEKTRA_KIND = { kast: 1, doos: 1, buis: 1 };
|
||||
// Productiekleuren per soort (elektra houdt de kleur uit de app).
|
||||
const COLORS = {
|
||||
Stijl: [0.93, 0.74, 0.42], Regel: [0.85, 0.48, 0.18], Vloerbalk: [0.62, 0.40, 0.20], Dakbalk: [0.78, 0.30, 0.22],
|
||||
Paal: [0.45, 0.28, 0.15], 'Stalen nokbalk': [0.35, 0.42, 0.55],
|
||||
Spaanplaat: [0.40, 0.68, 0.42], OSB: [0.95, 0.86, 0.50], Cementplaat: [0.62, 0.64, 0.68]
|
||||
};
|
||||
const rgbOf = (mesh) => { const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return m && m.color ? [m.color.r, m.color.g, m.color.b] : null; };
|
||||
|
||||
function materialsSet() {
|
||||
return {
|
||||
wall: WallModel.createMaterials(), floor: FloorModel.createMaterials(), roof: RoofModel.createMaterials(),
|
||||
elektra: ElektraModel.createMaterials(), post: PostModel.createMaterials()
|
||||
};
|
||||
}
|
||||
|
||||
// Materiaal -> soort onderdeel (alles wat hier niet in staat, gaat niet mee).
|
||||
function classifier(M) {
|
||||
const map = new Map();
|
||||
map.set(M.wall.woodMaterial, { kind: 'hout', where: 'wand' });
|
||||
map.set(M.floor.woodMaterial, { kind: 'hout', where: 'vloer' });
|
||||
map.set(M.roof.woodMaterial, { kind: 'hout', where: 'dak' });
|
||||
map.set(M.roof.steelMaterial, { kind: 'staal' });
|
||||
map.set(M.wall.spaanplaatMaterial, { kind: 'plaat', label: 'Spaanplaat' });
|
||||
map.set(M.wall.cementplaatMaterial, { kind: 'plaat', label: 'Cementplaat' });
|
||||
map.set(M.floor.osbMaterial, { kind: 'plaat', label: 'OSB' });
|
||||
map.set(M.roof.osbMaterial, { kind: 'plaat', label: 'OSB' });
|
||||
Object.entries(M.elektra.conduit).forEach(([type, m]) => map.set(m, { kind: 'buis', type }));
|
||||
map.set(M.elektra.boxMaterial, { kind: 'doos' });
|
||||
map.set(M.elektra.kastMaterial, { kind: 'kast' });
|
||||
map.set(M.elektra.deurMaterial, { kind: 'kast' });
|
||||
Object.values(M.post.wood).forEach(m => map.set(m, { kind: 'paal' }));
|
||||
return (mesh) => {
|
||||
const m0 = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
return m0 ? map.get((m0.userData && m0.userData.base) || m0) || null : null;
|
||||
};
|
||||
}
|
||||
|
||||
function build(state, stage) {
|
||||
const M = materialsSet();
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||
stage, lampenVisible: false
|
||||
});
|
||||
HouseModel.placeGroups(parts);
|
||||
return { parts, classify: classifier(M) };
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const frame = build(state, BuildStages.INDEX.elektra);
|
||||
const sheet = build(state, BuildStages.INDEX.beplating);
|
||||
const parts = frame.parts;
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
const peil = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||||
|
||||
const v = new THREE.Vector3(), ax = new THREE.Vector3(), box = new THREE.Box3(), size = new THREE.Vector3();
|
||||
function triangles(mesh, out) {
|
||||
const g = mesh.geometry, pos = g.attributes.position, idx = g.index;
|
||||
const n = idx ? idx.count : pos.count;
|
||||
for (let i = 0; i < n; i++) {
|
||||
v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(mesh.matrixWorld);
|
||||
out.push(v.x * 1000 - bx0, bz1 - v.z * 1000, v.y * 1000 - peil);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Maat in de eigen assen (afgeschuinde sporen houden zo lengte en dikte), oplopend in mm.
|
||||
function dims(mesh) {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
|
||||
box.copy(mesh.geometry.boundingBox).getSize(size);
|
||||
const s = [size.x * Math.abs(mesh.scale.x), size.y * Math.abs(mesh.scale.y), size.z * Math.abs(mesh.scale.z)].map(d => Math.round(d * 1000));
|
||||
const longest = s.indexOf(Math.max(...s));
|
||||
ax.set(longest === 0 ? 1 : 0, longest === 1 ? 1 : 0, longest === 2 ? 1 : 0).transformDirection(mesh.matrixWorld);
|
||||
return { sorted: s.slice().sort((a, b) => a - b), vertical: Math.abs(ax.y) > 0.7 };
|
||||
}
|
||||
const label = (d) => d.sorted.join(' × ');
|
||||
|
||||
const groups = new Map(); // sleutel -> { name, tag, objectType, unit, members: [] }
|
||||
const loose = [];
|
||||
const merged = new Map(); // leidingen per run, meterkast
|
||||
const groupOf = (key, info) => { if (!groups.has(key)) groups.set(key, Object.assign({ members: [] }, info)); return groups.get(key); };
|
||||
|
||||
function member(mesh, c, tag) {
|
||||
const d = dims(mesh);
|
||||
if (d.sorted[0] < 1) return null; // vlakken zonder dikte (kopse afdekking)
|
||||
const tri = triangles(mesh, []);
|
||||
const props = (name, material) => ({ Element: tag || null, Onderdeel: name, Afmeting: label(d), Lengte: d.sorted[2], Materiaal: material });
|
||||
if (c.kind === 'plaat') {
|
||||
const name = `${c.label} ${d.sorted[0]} mm`;
|
||||
return { type: 'IFCPLATE', predefined: 'SHEET', name, material: c.label, tri, color: COLORS[c.label], props: props(name, c.label) };
|
||||
}
|
||||
const el = (type, predefined, name, material) => ({ type, predefined, name, material, tri, color: COLORS[name], props: props(name, material) });
|
||||
if (c.kind === 'staal') return el('IFCBEAM', 'BEAM', 'Stalen nokbalk', 'Staal');
|
||||
if (c.kind === 'paal') return el('IFCCOLUMN', 'COLUMN', 'Paal', WOOD);
|
||||
if (c.where === 'vloer') return el('IFCBEAM', 'JOIST', 'Vloerbalk', WOOD);
|
||||
if (c.where === 'dak') return el('IFCMEMBER', 'RAFTER', 'Dakbalk', WOOD);
|
||||
return d.vertical ? el('IFCMEMBER', 'STUD', 'Stijl', WOOD) : el('IFCMEMBER', 'PLATE', 'Regel', WOOD);
|
||||
}
|
||||
|
||||
function collect(src, group, plates, target) {
|
||||
if (!group) return;
|
||||
group.updateMatrixWorld(true);
|
||||
group.traverseVisible(o => {
|
||||
if (!o.isMesh) return;
|
||||
const c = src.classify(o);
|
||||
if (!c || (c.kind === 'plaat') !== plates) return;
|
||||
if (ELEKTRA_KIND[c.kind]) {
|
||||
if (c.kind === 'doos') {
|
||||
loose.push({ type: 'IFCJUNCTIONBOX', predefined: 'POWER', name: 'Inbouwdoos', material: 'Kunststof', tri: triangles(o, []), color: rgbOf(o), props: { Onderdeel: 'Inbouwdoos', Materiaal: 'Kunststof' } });
|
||||
return;
|
||||
}
|
||||
const key = c.kind === 'kast' ? 'kast' : `buis:${o.userData.run}`;
|
||||
if (!merged.has(key)) {
|
||||
merged.set(key, c.kind === 'kast'
|
||||
? { type: 'IFCELECTRICDISTRIBUTIONBOARD', predefined: 'DISTRIBUTIONBOARD', name: 'Meterkast', material: 'Kunststof', tri: [], color: rgbOf(o), props: { Onderdeel: 'Meterkast' } }
|
||||
: { type: 'IFCCABLECARRIERSEGMENT', predefined: 'CONDUITSEGMENT', name: `Installatiebuis ${c.type}`, material: `PVC ${c.type}`, tri: [], len: 0, color: rgbOf(o), props: { Onderdeel: `Installatiebuis ${c.type}`, Materiaal: 'PVC' } });
|
||||
}
|
||||
const e = merged.get(key);
|
||||
triangles(o, e.tri);
|
||||
if (e.len !== undefined && o.geometry.type === 'CylinderGeometry') e.len += o.geometry.parameters.height * 1000;
|
||||
return;
|
||||
}
|
||||
const m = member(o, c, target && target.tag);
|
||||
if (m) (target ? target.members : loose).push(m);
|
||||
});
|
||||
}
|
||||
|
||||
[[frame, false], [sheet, true]].forEach(([src, plates]) => {
|
||||
const P = src.parts;
|
||||
P.units.forEach(u => {
|
||||
collect(src, u.floorGroup, plates, groupOf(u.floorElement, { name: `Unit ${u.id} vloer`, tag: u.floorElement, objectType: 'Vloerelement', unit: u.id }));
|
||||
u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Wandelement', unit: u.id }));
|
||||
}));
|
||||
collect(src, u.roof.group, plates, groupOf(`${u.id}-D`, { name: `Unit ${u.id} dak`, tag: `${u.id}-D`, objectType: 'Dakelement', unit: u.id }));
|
||||
});
|
||||
P.interior.forEach(p => collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Binnenwand', unit: null })));
|
||||
collect(src, P.postsGroup, plates, null);
|
||||
collect(src, P.elektraGroup, plates, null);
|
||||
});
|
||||
merged.forEach(e => {
|
||||
if (e.len) e.props.Lengte = Math.round(e.len);
|
||||
delete e.len;
|
||||
loose.push(e);
|
||||
});
|
||||
|
||||
const pr = state.project || {};
|
||||
const ifc = IfcWriter.create({
|
||||
project: pr.naam || 'Woning', building: pr.naam || 'Woning', author: pr.tekenaar || '',
|
||||
app: `Tectum${typeof APP_VERSION !== 'undefined' ? ' ' + APP_VERSION : ''}`,
|
||||
address: { lines: [pr.straat, pr.nummer].filter(Boolean).join(' '), town: pr.plaats || '' }
|
||||
});
|
||||
const unitParts = new Map(parts.units.map(u => [u.id, []]));
|
||||
groups.forEach(g => {
|
||||
const ids = g.members.map(m => ifc.element(m)).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: g.name, tag: g.tag, objectType: g.objectType, props: { Element: g.tag } });
|
||||
ifc.aggregate(asm, ids);
|
||||
if (g.unit && unitParts.has(g.unit)) unitParts.get(g.unit).push(asm);
|
||||
else ifc.contain(asm);
|
||||
});
|
||||
unitParts.forEach((ids, id) => {
|
||||
if (!ids.length) return;
|
||||
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: `Unit ${id}`, tag: id, objectType: 'Unit' });
|
||||
ifc.aggregate(asm, ids);
|
||||
ifc.contain(asm);
|
||||
});
|
||||
loose.forEach(e => ifc.contain(ifc.element(e)));
|
||||
return ifc;
|
||||
}
|
||||
|
||||
return { generate };
|
||||
})();
|
||||
+94
-13
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
+35
-10
@@ -87,7 +87,7 @@ window.HousePdf = (() => {
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
p.config.openings.forEach(op => {
|
||||
p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
|
||||
poly(c, 'FD');
|
||||
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
|
||||
@@ -139,6 +139,12 @@ window.HousePdf = (() => {
|
||||
doc.setLineWidth(0.2);
|
||||
doc.setFillColor(160, 124, 84);
|
||||
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
doc.setFillColor(150, 150, 150);
|
||||
poly(ch.footprint, 'FD');
|
||||
doc.setFillColor(30, 30, 30);
|
||||
poly(ch.flueFootprint, 'F');
|
||||
});
|
||||
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 211, 107);
|
||||
@@ -252,21 +258,23 @@ window.HousePdf = (() => {
|
||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
const tr = p.trasraam;
|
||||
if (tr && tr.top > y0) {
|
||||
const W = p.place.width;
|
||||
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
|
||||
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
||||
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
||||
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||
}
|
||||
(p.config.openings || []).forEach(op => {
|
||||
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
if (WallModel.openingTopMax(op) <= y0) return;
|
||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
|
||||
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
|
||||
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
|
||||
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
|
||||
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
|
||||
const detail = op.fill ? KozijnModel.viewLines(op, op.fill, exterior).map(pl => pl.map(q => toWorld(p, x0 + q.x, op.y + q.y, zOut))) : [];
|
||||
faces.push({ kind: 'opening', pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true, detail });
|
||||
});
|
||||
};
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
|
||||
@@ -283,7 +291,7 @@ window.HousePdf = (() => {
|
||||
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
||||
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||
const len = Math.hypot(nx, ny, nz) || 1;
|
||||
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
faces.push({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
};
|
||||
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
||||
pushRoof(rg.poly.map(pt => {
|
||||
@@ -316,9 +324,20 @@ window.HousePdf = (() => {
|
||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||
}
|
||||
faces.push({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
});
|
||||
}
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
const S = ChimneyModel.SIDES[ch.side];
|
||||
const W = (t, s, y) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, y, z: t } : { x: t, y, z: ch.plane + S.n.z * s };
|
||||
const color = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
|
||||
const tN = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 };
|
||||
const side = (a, b, ya, n) => faces.push({ kind: 'schoorsteen', pts: [W(a.t, a.s, ya), W(b.t, b.s, ya), W(b.t, b.s, ch.yTop), W(a.t, a.s, ch.yTop)], n, color, bias: 4 });
|
||||
side({ t: ch.t0, s: ch.sFront }, { t: ch.t1, s: ch.sFront }, ch.yGround, S.n);
|
||||
side({ t: ch.t0, s: ch.sIn }, { t: ch.t0, s: ch.sFront }, ch.yGround, { x: -tN.x, z: -tN.z });
|
||||
side({ t: ch.t1, s: ch.sIn }, { t: ch.t1, s: ch.sFront }, ch.yGround, tN);
|
||||
side({ t: ch.t0, s: ch.sIn }, { t: ch.t1, s: ch.sIn }, ch.yUnder, { x: -S.n.x, z: -S.n.z });
|
||||
});
|
||||
return faces;
|
||||
}
|
||||
|
||||
@@ -417,8 +436,8 @@ window.HousePdf = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
// Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor.
|
||||
function viewProjections(parts, state) {
|
||||
const faces = viewFaces(parts, state);
|
||||
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
||||
const views = VIEWS.map(v => {
|
||||
@@ -429,6 +448,12 @@ window.HousePdf = (() => {
|
||||
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
|
||||
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
|
||||
}).filter(x => x.list.length);
|
||||
return { groundY, views };
|
||||
}
|
||||
|
||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
const { groundY, views } = viewProjections(parts, state);
|
||||
|
||||
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
|
||||
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
|
||||
@@ -778,5 +803,5 @@ window.HousePdf = (() => {
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
|
||||
return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
|
||||
})();
|
||||
|
||||
+172
-17
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user