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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-12 20:00:32 +02:00
co-authored by Claude Opus 5.5
parent 86fcd57b96
commit adb3c74f85
22 changed files with 2371 additions and 184 deletions
+114 -43
View File
@@ -92,6 +92,11 @@ window.KozijnModel = (() => {
});
return out;
}
// >= 0 onder de bovenrand q[3]-q[2] van een vierhoek.
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
@@ -134,6 +139,11 @@ window.KozijnModel = (() => {
g.add(mesh);
};
// Gekoppelde zijde (op.joinL/joinR, zie WallModel.resolveOpenings): geen speling en geen kaderstijl;
// 'stijl' = de helft van een tussenstijl die het kozijn ernaast afmaakt, 'stolp' = niets (bladen tegen elkaar).
const sideGap = (join) => join ? 0 : P.gap;
const sideKader = (join) => join === 'stolp' ? 0 : join ? P.tussen / 2 : P.kader;
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
function build3D(op, fill, place) {
const f = normalize(fill);
@@ -143,15 +153,12 @@ window.KozijnModel = (() => {
const depth = Math.min(P.depth, place.depth || P.depth);
const z1 = place.zOut, z0 = z1 - depth;
const hR = op.hR !== undefined ? op.hR : op.h;
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]);
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
slab(g, outer, [inner], z0, z1, mIn, mOut);
let pane = inner;
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
@@ -171,8 +178,8 @@ window.KozijnModel = (() => {
const sill = place.exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
const u = [...(kL ? [frameOuter[0], inner[0]] : []), inner[3], inner[2], ...(kR ? [inner[1], frameOuter[1]] : []), frameOuter[2], frameOuter[3]];
slab(g, u, null, z0, z1, mIn, mOut);
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
@@ -187,12 +194,9 @@ window.KozijnModel = (() => {
return g;
}
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const lowTop = Math.min(topAt(ix0), topAt(ix1));
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
const ly1 = transom ? iy0 + P.leafH : Infinity;
const hingeR = f.hinge === 'rechts';
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
const { side, leafR, la, lb, transom, ly1 } = d;
if (side) {
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
bar(region(t0, t0 + P.tussen, iy0, ly1));
@@ -204,20 +208,10 @@ window.KozijnModel = (() => {
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
const leaf = clipPoly(rect(la + 3, lb - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
if (leaf.length < 3) return g;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
const L = doorLeaf(f, d, iy0, sill, top, topAt);
if (!L) return g;
const { leaf, lx0, lxR, lb0, lt, lw, holes } = L;
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
let holes = null;
const lw = lxR - lx0;
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
}
const outward = f.swing === 'buiten';
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
const pivot = new THREE.Group(), lg = new THREE.Group();
@@ -245,28 +239,64 @@ window.KozijnModel = (() => {
}
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
function buildSlideLeaves(g, f, o) {
// Deurblad en zij-/bovenlicht binnen de kaderopening ix0..ix1 vanaf iy0 (lowTop = laagste bovenkant).
function doorSplit(f, ix0, ix1, iy0, lowTop) {
const leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
return { side, leafR, la, lb, transom, ly1: transom ? iy0 + P.leafH : Infinity };
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer) met het glas erin.
function doorLeaf(f, d, iy0, sill, top, topAt) {
const leaf = clipPoly(rect(d.la + 3, d.lb - 3, iy0 + (sill ? 3 : 10), Math.min(d.ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
if (leaf.length < 3) return null;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), d.ly1) - 3;
const lw = lxR - lx0;
let holes = null;
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = f.hinge === 'rechts' ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)];
}
return { leaf, lx0, lxR, lb0, lt, lw, holes };
}
function slideLeaves(f, ix0, ix1, iy0, sill, topAt) {
const n = f.leaves, opbouw = f.mount === 'opbouw';
const ov = opbouw ? SLIDE.overlap : -3;
const xa = ix0 - ov, xb = ix1 + ov;
const yb = iy0 + (sill ? 3 : 10);
const yt = Math.min(topAt(ix0), topAt(ix1)) + (opbouw ? SLIDE.overlap : -3);
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
const leaves = dirs.map((dir, i) => ({
dir, x0: xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1: xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0)
}));
const pane = (l) => f.glas && l.x1 - l.x0 > 2 * SLIDE.frame + 100 ? rect(l.x0 + SLIDE.frame, l.x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame) : null;
return { n, opbouw, xa, xb, yb, yt, lw, dirs, leaves, pane };
}
function buildSlideLeaves(g, f, o) {
const S = slideLeaves(f, o.ix0, o.ix1, o.iy0, o.sill, o.topAt);
const { n, opbouw, xa, xb, yb, yt, lw, dirs } = S;
const out = f.side === 'buiten';
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
const ov = opbouw ? SLIDE.overlap : -3;
const xa = o.ix0 - ov, xb = o.ix1 + ov;
const yb = o.iy0 + (o.sill ? 3 : 10);
const yt = Math.min(o.topAt(o.ix0), o.topAt(o.ix1)) + (opbouw ? SLIDE.overlap : -3);
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [lz0, lz1] = opbouw
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
dirs.forEach((dir, i) => {
const x0 = xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1 = xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0);
S.leaves.forEach(l => {
const { dir, x0, x1 } = l;
const lg = new THREE.Group();
g.add(lg);
const pts = rect(x0, x1, yb, yt);
if (f.glas && x1 - x0 > 2 * SLIDE.frame + 100) {
const pane = rect(x0 + SLIDE.frame, x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame);
const pane = S.pane(l);
if (pane) {
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
} else {
@@ -291,12 +321,11 @@ window.KozijnModel = (() => {
const f = normalize(fill);
if (!f || f.type === 'raam') return [];
const zIn = faces.zIn, zOut = faces.zOut;
const ix0 = P.gap + P.kader, ix1 = op.w - P.gap - P.kader;
const ix0 = sideGap(op.joinL) + sideKader(op.joinL), ix1 = op.w - sideGap(op.joinR) - sideKader(op.joinR);
const out = [];
if (f.type === 'deur') {
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const hingeR = f.hinge === 'rechts';
const { la, lb } = doorSplit(f, ix0, ix1, 0, 0);
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 1 : -1;
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
@@ -326,5 +355,47 @@ window.KozijnModel = (() => {
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, colorMaterial: colorMat };
// Aanzicht van buiten, lokaal als build3D (x vanaf links, y vanaf de onderkant van de opening): gesloten omtrekken.
function viewLines(op, fill, exterior) {
const f = normalize(fill);
if (!f) return [];
const hR = op.hR !== undefined ? op.hR : op.h;
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
const out = [outer];
const add = (pts) => { if (pts && pts.length > 2 && areaOf(pts) > 1) out.push(pts); };
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
add(inner);
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) add(insetPoly(inner, [0, 0, P.rooster, 0]));
return out;
}
const sill = exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
if (sill) add(frameOuter);
add(inner);
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
const top = below(inner);
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
if (f.type === 'schuifdeur') {
const S = slideLeaves(f, ix0, ix1, iy0, sill, topAt);
if (!S.opbouw || f.side === 'buiten') S.leaves.forEach(l => { add(region(l.x0, l.x1, S.yb, S.yt)); add(S.pane(l)); });
return out;
}
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
if (d.side) {
const t0 = d.leafR ? d.la - P.tussen : d.lb;
add(region(t0, t0 + P.tussen, iy0, d.ly1));
const [g0, g1] = d.leafR ? [ix0, d.la - P.tussen] : [d.lb + P.tussen, ix1];
add(region(g0, g1, iy0, iy0 + P.kader));
}
if (d.transom) add(region(ix0, ix1, d.ly1, d.ly1 + P.tussen));
const L = doorLeaf(f, d, iy0, sill, top, topAt);
if (L) { add(L.leaf); (L.holes || []).forEach(add); }
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, viewLines, colorMaterial: colorMat };
})();
+100 -33
View File
@@ -320,20 +320,26 @@ window.WallModel = (() => {
const edgeMargin = studWidth * CORNER_STUDS;
let resolved = rawOpenings.map(raw => {
const w = Math.min(Math.max(400, raw.w), maxOpW);
// Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 400, raw.w), maxOpW);
let x;
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
if (raw.chimney) o.chimney = raw.chimney;
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
return o;
});
resolved.sort((a, b) => a.x - b.x);
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
resolved.forEach(o => {
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin));
o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
});
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
@@ -347,22 +353,42 @@ window.WallModel = (() => {
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
const FAR = 'staat niet tegen de opening ernaast aan';
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
resolved.forEach((a, i) => {
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
let why = null;
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
if (why) { blame(a, b, why); return; }
rightOf.set(a, b);
leftOf.set(b, a);
});
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
for (let i = 1; i < resolved.length; i++) {
const cur = resolved[i];
const cur = resolved[i], partner = leftOf.get(cur);
if (partner) cur.x = partner.x + partner.w;
for (let j = 0; j < i; j++) {
const prev = resolved[j];
if (stacked(prev, cur)) continue;
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur));
cur.x = Math.max(cur.x, prev.x + prev.w + gap(prev, cur));
}
}
const rightLimit = wallWidth - edgeMargin;
for (let i = resolved.length - 1; i >= 0; i--) {
const cur = resolved[i];
let maxX = rightLimit - cur.w;
let maxX = wallWidth - mR(cur) - cur.w;
for (let j = i + 1; j < resolved.length; j++) {
const next = resolved[j];
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w);
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gap(cur, next) - cur.w);
}
if (cur.x > maxX) cur.x = maxX;
}
@@ -370,22 +396,34 @@ window.WallModel = (() => {
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
resolved.forEach(o => {
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
// Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = hL;
o.hR = hR;
});
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
const swing = (o) => !!o.fill && o.fill.type === 'deur';
rightOf.forEach((b, a) => {
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
});
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
resolved.forEach(o => {
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5);
o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 0.5);
});
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i], b = resolved[j];
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
if (!stacked(a, b, true) && r.x < l.x + l.w + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
}
}
@@ -464,10 +502,14 @@ window.WallModel = (() => {
});
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
ops.forEach(op => {
const x0 = startX + op.x, x1 = x0 + op.w;
cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' });
if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' });
if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
else cands.push({ x: x1, kind: 'kreupel', seam: true });
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
});
const cols = [];
@@ -489,16 +531,17 @@ window.WallModel = (() => {
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
const segs = [];
cands.forEach(c => {
cands.filter(inWall).forEach(c => {
const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => {
if (c.kind === 'kreupel') {
if (c.kind === 'kreupel' && !c.seam) {
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
@@ -535,16 +578,20 @@ window.WallModel = (() => {
const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - sw;
const hx1 = -halfW + op.x + op.w + sw;
const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
// Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
const m = op.type === 'schoorsteen' ? 0 : sw;
// Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
const hx0 = -halfW + op.x - mL;
const hx1 = -halfW + op.x + op.w + mR;
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
const clampTop = (y, x) => {
const ceil = topYAt(x) - roofSafetyMargin;
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
};
const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r };
const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
});
}
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
@@ -604,7 +651,7 @@ window.WallModel = (() => {
const fixed = [lo, hi];
holes.forEach(h => {
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); });
[!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
});
fixed.sort((a, b) => a - b);
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
@@ -794,7 +841,8 @@ window.WallModel = (() => {
yMin: op.y * toM,
yMax: (op.y + op.h) * toM,
yMaxR: (op.y + op.hR) * toM,
wM: op.w * toM
wM: op.w * toM,
chimney: op.type === 'schoorsteen'
}));
const floorCuts = opsM
@@ -825,7 +873,21 @@ window.WallModel = (() => {
horizBeamCount++;
}
topPlateOutlines(tl).forEach(pts => {
// Bovenregel onderbroken waar een schoorsteen doorloopt.
const clipX = (pts, x, keepBelow) => {
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
pts.forEach((p, k) => {
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
if (fp >= 0) out.push(p);
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
});
return out;
};
let plates = topPlateOutlines(tl);
opsM.filter(op => op.chimney).forEach(op => {
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
});
plates.forEach(pts => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++;
});
@@ -863,6 +925,7 @@ window.WallModel = (() => {
});
opsM.forEach(op => {
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax);
@@ -1284,7 +1347,7 @@ window.WallModel = (() => {
const holes = sheathingHoles(CONFIG);
CONFIG.openings.forEach((op, i) => {
const h = holes[i];
if (!h) return;
if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
const hTop = (x) => holeTopAt(h, x);
@@ -1295,24 +1358,28 @@ window.WallModel = (() => {
].filter(pts => area(pts) > 1);
const fill = KozijnModel.normalize(op.fill);
const isWindow = op.y >= CONFIG.plateThick;
// Gekoppelde zijde: geen dagkant; vensterbank en waterslag lopen door tot de naad.
const over = (d) => [op.joinL ? x0 : x0 - d, op.joinR ? x1 : x1 + d];
if (finishIn) {
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
if (zB !== null) {
const boards = [
[{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
[{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
!op.joinL && [{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
!op.joinR && [{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
];
].filter(Boolean);
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
if (isWindow) prism([{ x: x0 - 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 + 5 }, { x: x0 - 30, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
const [sa, sb] = over(30);
if (isWindow) prism([{ x: sa, y: y0 - SILL + 5 }, { x: sb, y: y0 - SILL + 5 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
}
}
if (finishOut) {
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
if (isWindow && fill) prism([{ x: x0 - 20, y: y0 - 25 }, { x: x1 + 20, y: y0 - 25 }, { x: x1 + 20, y: y0 + 5 }, { x: x0 - 20, y: y0 + 5 }],
const [sa, sb] = over(20);
if (isWindow && fill) prism([{ x: sa, y: y0 - 25 }, { x: sb, y: y0 - 25 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }],
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
}
});
@@ -1331,7 +1398,7 @@ window.WallModel = (() => {
// Dubbelzijdig: de schuine regel heeft geen vaste oriëntatie.
const wedgeMats = new WeakMap();
function wedgeMaterial(wood) {
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; wedgeMats.set(wood, m); }
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; m.userData.base = wood; wedgeMats.set(wood, m); }
return wedgeMats.get(wood);
}
@@ -1553,7 +1620,7 @@ window.WallModel = (() => {
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+10 -7
View File
@@ -424,7 +424,7 @@ function mountWallEditor(container, callbacks) {
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') +
pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar, bladen naar het midden, scharnieren buiten') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar en gekoppeld, bladen naar het midden, scharnieren buiten') +
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
@@ -499,6 +499,10 @@ function mountWallEditor(container, callbacks) {
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
<div class="control-group" title="Tegen de opening ernaast aan: één sparing zonder stijlen, dagkanten of kozijnstijlen ertussen. Twee draaideuren worden openslaand (bladen tegen elkaar), anders één tussenstijl. Onderkant en hoogte gelijk aan de buur; samen max. 2850 mm.">
<label>Koppelen</label>
<div><label class="ui-switch"><input type="checkbox" data-field="koppel" data-id="${op.id}" ${op.koppel ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div>
</div>
@@ -543,6 +547,8 @@ function mountWallEditor(container, callbacks) {
fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value;
if (key === 'leaves' || key === 'mount') renderOpeningsList();
} else if (field === 'koppel') {
if (e.target.checked) op.koppel = true; else delete op.koppel;
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
@@ -730,12 +736,9 @@ function mountWallEditor(container, callbacks) {
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
if (!card || !warnEl) return;
card.classList.toggle('opening-warn', !!o.overflowing);
if (o.overflowing) {
warnEl.style.display = 'block';
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
} else {
warnEl.style.display = 'none';
}
const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : '';
warnEl.style.display = msg ? 'block' : 'none';
warnEl.textContent = msg;
});
}