v0.4.17: openingen met schuine bovenzijde (hoogte links/rechts, volg dakhelling), 9 sleeppunten in het vooraanzicht; regelwerk, beplating, doek, steen en latwerk volgen de schuinte (columnMinusRects met schuine uitsparingen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-01 20:01:54 +02:00
co-authored by Claude Opus 5.5
parent 23bfe153a2
commit 8cdfa21f46
11 changed files with 374 additions and 242 deletions
+137 -133
View File
@@ -15,7 +15,7 @@
//
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] }
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
@@ -173,6 +173,24 @@ window.WallModel = (() => {
// koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
// Schuine bovenzijde (zoals de wand zelf: schuin bovenin mag, onderin
// niet): h = hoogte links, hR = hoogte rechts. Ruwe openingen hebben hR
// alleen als hij (bewust) schuin is; opgeloste openingen altijd.
// x is lokaal (0..wandbreedte), zoals op.x.
const hRightOf = (op) => op.hR !== undefined ? op.hR : op.h;
const openingTopAt = (op, x) => op.y + op.h + (x - op.x) / op.w * (hRightOf(op) - op.h);
const openingTopMax = (op) => op.y + Math.max(op.h, hRightOf(op));
const openingArea = (op) => op.w * (op.h + hRightOf(op)) / 2;
// Stuk [x0, x1] van een vlak met waterpas onderkant y0 en schuine
// bovenkant (yl links, yr rechts) op hoogte y, of null.
function slopedSpanAtY(x0, x1, y0, yl, yr, y) {
if (y <= y0 || y >= Math.max(yl, yr)) return null;
if (y <= Math.min(yl, yr)) return [x0, x1];
const xc = x0 + (y - yl) / (yr - yl) * (x1 - x0);
return yr > yl ? [xc, x1] : [x0, xc];
}
const openingSpanAtY = (op, y) => slopedSpanAtY(op.x, op.x + op.w, op.y, op.y + op.h, op.y + hRightOf(op), y);
// Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat).
// 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het
@@ -287,7 +305,7 @@ window.WallModel = (() => {
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawY: raw.y };
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
});
resolved.sort((a, b) => a.x - b.x);
@@ -301,10 +319,11 @@ window.WallModel = (() => {
// 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// (bijv. een raam boven een deur), dan mogen ze in x overlappen.
// Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd.
// (Schuine bovenzijde: de hoogste kant telt.)
const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const stacked = (a, b, useFinal) => {
const [a0, a1] = useFinal ? vBand(a, a.h, a.y) : vBand(a, Math.max(400, a.rawH), a.rawY);
const [b0, b1] = useFinal ? vBand(b, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.rawY);
const [a0, a1] = useFinal ? vBand(a, Math.max(a.h, a.hR), a.y) : vBand(a, Math.max(400, a.rawH, a.rawHR), a.rawY);
const [b0, b1] = useFinal ? vBand(b, Math.max(b.h, b.hR), b.y) : vBand(b, Math.max(400, b.rawH, b.rawHR), b.rawY);
return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
};
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing
@@ -337,23 +356,22 @@ window.WallModel = (() => {
if (cur.x > maxX) cur.x = maxX;
}
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen
// de daklijn zoals die loopt op de EIGEN (nu definitieve) x-positie van
// die opening — het punt waar een verticale lijn op die plek de
// (eventueel hellende) horizontale topbalk zou snijden. Bij een
// opening die zelf een breedte heeft, wordt het laagste van de twee
// uiteinden aangehouden (de daklijn is lineair of heeft een nok als
// hoogste punt, dus dat is voldoende).
const localRoofHeightAt = roofAt;
// Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per
// opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu
// definitieve) x-positie van die opening — per kant: de linkerhoogte
// tegen de daklijn boven de linkerrand, de rechterhoogte tegen die
// boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen.
// Daartussen blijft de bovenkant onder het dak: de daklijn is lineair
// of heeft een nok als hoogste punt (bol), dus de rechte lijn tussen
// twee punten eronder blijft eronder.
resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w));
const maxAllowedOpH = Math.max(400, localCeiling - (plateThick * 2));
const h = Math.min(Math.max(400, o.rawH), maxAllowedOpH);
const maxOpY = Math.max(0, localCeiling - h - plateThick);
const y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = h;
o.y = y;
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));
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;
});
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede
@@ -457,10 +475,16 @@ window.WallModel = (() => {
// kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({
op, x0: startX + op.x, x1: startX + op.x + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw
}));
// Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw):
// topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan.
const bands = ops.map(op => {
const x0 = startX + op.x, s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
return {
op, x0, x1: x0 + op.w,
y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + Math.max(op.h, op.hR) + vo,
topAt: (x) => op.y + op.h + (x - x0) * s + vo
};
});
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
ops.forEach(op => {
@@ -497,8 +521,11 @@ window.WallModel = (() => {
cands.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, below = null;
const push = (y0, y1, above) => {
// cur = onderkant van het volgende stuk [links, rechts]: schuin als
// het op een schuine bovendorpel staat (dan ook y0l/y0r; y0 = laagste).
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => {
if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de
@@ -506,13 +533,16 @@ window.WallModel = (() => {
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
}
segs.push({ x, y0, y1, kind: c.kind });
const sg = { x, y0: Math.min(cur[0], cur[1]), y1, kind: c.kind };
if (Math.abs(cur[0] - cur[1]) > 0.05) { sg.y0l = cur[0]; sg.y0r = cur[1]; }
segs.push(sg);
};
hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = bd; }
if (bd.y0 > curMax() + 1) push(bd.y0, bd);
const t = [bd.topAt(x - sw / 2), bd.topAt(x + sw / 2)];
if (Math.max(t[0], t[1]) > curMax()) { cur = t; below = bd; }
});
if (roofMin(x) > cur + 1) push(cur, null, null);
if (roofMin(x) > curMax() + 1) push(null, null);
});
// Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = [];
@@ -536,19 +566,30 @@ window.WallModel = (() => {
// bij een sterk hellende wand met een opening dicht bij de lage kant kan
// de marge anders lokaal boven het dak uitkomen (kapotte uitsparing).
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen).
// Schuine bovenzijde: hy1 = bovenkant links (bij hx0), hy1r = rechts (bij
// hx1), langs de bovenkant van de schuine bovendorpel; per kant geklemd
// (de daklijn is bol, dus de lijn ertussen blijft eronder).
function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2;
const topYAt = topLine(CONFIG).at;
const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth;
const hx1 = -halfW + op.x + op.w + CONFIG.studWidth;
let hy1 = op.y + op.h + CONFIG.studWidth;
const localRoofCeiling = Math.min(topYAt(hx0), topYAt(hx1)) - roofSafetyMargin;
if (hy1 > localRoofCeiling) hy1 = Math.max(op.y + roofSafetyMargin, localRoofCeiling);
return { hx0, hx1, hy0: op.y, hy1 };
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);
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 holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
const holeTopAt = (h, x) => h.hy1 + (x - h.hx0) / (h.hx1 - h.hx0) * (holeTopR(h) - h.hy1);
const holeRect = (h) => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1, y1r: holeTopR(h) });
// Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
@@ -559,12 +600,15 @@ window.WallModel = (() => {
const range = topLine(CONFIG).rangeAbove(y);
if (!range) return [];
let segments = [range];
sheathingHoles(CONFIG).concat(extraHoles || []).filter(h => y > h.hy0 && y < h.hy1).forEach(h => {
sheathingHoles(CONFIG).concat(extraHoles || []).forEach(h => {
const span = slopedSpanAtY(h.hx0, h.hx1, h.hy0, h.hy1, holeTopR(h), y);
if (!span) return;
const [hx0, hx1] = span;
segments = segments.flatMap(([a, b]) => {
if (h.hx1 <= a || h.hx0 >= b) return [[a, b]];
if (hx1 <= a || hx0 >= b) return [[a, b]];
const out = [];
if (h.hx0 > a) out.push([a, h.hx0]);
if (h.hx1 < b) out.push([h.hx1, b]);
if (hx0 > a) out.push([a, hx0]);
if (hx1 < b) out.push([hx1, b]);
return out;
});
});
@@ -625,11 +669,13 @@ window.WallModel = (() => {
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
let segs = [[y0, yTop]];
holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => {
// Schuine bovenkant: de hoogste waarde over de latbreedte.
const hy1 = Math.max(holeTopAt(h, Math.max(h.hx0, x - w / 2)), holeTopAt(h, Math.min(h.hx1, x + w / 2)));
segs = segs.flatMap(([a, b]) => {
if (h.hy1 <= a || h.hy0 >= b) return [[a, b]];
if (hy1 <= a || h.hy0 >= b) return [[a, b]];
const out = [];
if (h.hy0 > a) out.push([a, h.hy0]);
if (h.hy1 < b) out.push([h.hy1, b]);
if (hy1 < b) out.push([hy1, b]);
return out;
});
});
@@ -815,7 +861,8 @@ window.WallModel = (() => {
xMin: startXM + op.x * toM,
xMax: startXM + (op.x + op.w) * toM,
yMin: op.y * toM,
yMax: (op.y + op.h) * toM,
yMax: (op.y + op.h) * toM, // bovenkant links
yMaxR: (op.y + op.hR) * toM, // bovenkant rechts (schuine bovenzijde)
wM: op.w * toM
}));
@@ -864,21 +911,24 @@ window.WallModel = (() => {
const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const y0 = st.y0 * toM;
// Onderkant schuin als het stuk op een schuine bovendorpel staat.
const y0l = (st.y0l !== undefined ? st.y0l : st.y0) * toM, y0r = (st.y0r !== undefined ? st.y0r : st.y0) * toM;
const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
});
// D. DORPELS, per opening
opsM.forEach(op => {
// Bovendorpel / linteel (horizontale balk)
// Bovendorpel / linteel (liggende balk, volgt een schuine bovenzijde;
// loodrechte dikte = stijldikte)
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);
opTopShape.lineTo(op.xMax, op.yMax);
opTopShape.lineTo(op.xMax, op.yMax + sWidthM);
opTopShape.lineTo(op.xMin, op.yMax + sWidthM);
opTopShape.lineTo(op.xMax, op.yMaxR);
opTopShape.lineTo(op.xMax, op.yMaxR + vo);
opTopShape.lineTo(op.xMin, op.yMax + vo);
opTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2));
horizBeamCount++;
@@ -949,7 +999,7 @@ window.WallModel = (() => {
// Hoekdoorloop per buitenlaag, in m (zie cornerExt).
const extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; };
const holeExtents = sheathingHoles(CONFIG).map(hole => ({
hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM
hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM, hy1r: hole.hy1r * toM
}));
// Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand
// en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop
@@ -1008,13 +1058,13 @@ window.WallModel = (() => {
const xL = -halfW - ext.left, xR = halfW + ext.right;
const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR;
const topClamped = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x)));
const rects = outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 }));
const rects = outerHoles.map(holeRect);
const shapesIn = (a, b) => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) });
return (pieces || []).map(piece => {
return pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
return shape;
@@ -1078,42 +1128,13 @@ window.WallModel = (() => {
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt,
HX.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })),
HX.map(holeRect),
{ yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks });
let boardShapes;
if (pieces) {
boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude
// gat-methode, net binnen de plaatrand afgekapt.
const boardShape = new THREE.Shape();
boardShape.moveTo(ex0, yB);
boardShape.lineTo(ex1, yB);
boardShape.lineTo(ex1, skinTopAt(ex1));
TL.kinks.filter(x => x > ex0 && x < ex1).forEach(x => boardShape.lineTo(x, skinTopAt(x)));
boardShape.lineTo(ex0, skinTopAt(ex0));
boardShape.closePath();
holeExtents.forEach(h => {
const chx0 = Math.max(ex0 + edgeEps, h.hx0);
const chx1 = Math.min(ex1 - edgeEps, h.hx1);
if (chx1 > chx0) {
const holePath = new THREE.Path();
const chy0 = Math.max(yB + edgeEps, h.hy0), chy1 = Math.min(yCap - edgeEps, h.hy1);
if (chy1 <= chy0) return;
holePath.moveTo(chx0, chy0);
holePath.lineTo(chx0, chy1);
holePath.lineTo(chx1, chy1);
holePath.lineTo(chx1, chy0);
holePath.closePath();
boardShape.holes.push(holePath);
}
});
boardShapes = [boardShape];
}
const boardShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
if (!boardShapes.length) continue;
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
@@ -1236,7 +1257,6 @@ window.WallModel = (() => {
// transparant zijn).
const doekZStepM = 0.0002;
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
const wallOutlinePts = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...tlOut.pts.slice().reverse()];
const maxRoofY = tlOut.maxY;
let rowY0 = skinFrom * toM;
@@ -1251,41 +1271,13 @@ window.WallModel = (() => {
const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0];
const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks);
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)),
outerHoles.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : [];
outerHoles.map(holeRect), { yCap: rowY1, extraXs }) : [];
let rowShapes;
if (pieces) {
rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
} else {
// Terugval (uitsparing tot in de schuine bovenrand): oude methode.
let rowPts = clipPolygonYLE(wallOutlinePts, rowY1);
rowPts = clipPolygonYGE(rowPts, rowY0);
rowShapes = [];
if (rowPts.length >= 3) {
const rowShape = new THREE.Shape();
rowShape.moveTo(rowPts[0].x, rowPts[0].y);
for (let k = 1; k < rowPts.length; k++) rowShape.lineTo(rowPts[k].x, rowPts[k].y);
rowShape.closePath();
holeExtents.forEach(h => {
const chy0 = Math.max(rowY0 + edgeEps, h.hy0);
const chy1 = Math.min(rowY1 - edgeEps, h.hy1);
if (chy1 > chy0) {
const holePath = new THREE.Path();
holePath.moveTo(h.hx0, chy0);
holePath.lineTo(h.hx0, chy1);
holePath.lineTo(h.hx1, chy1);
holePath.lineTo(h.hx1, chy0);
holePath.closePath();
rowShape.holes.push(holePath);
}
});
rowShapes.push(rowShape);
}
}
const rowShapes = pieces.map(piece => {
const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y)));
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y)))));
return shape;
});
if (rowShapes.length) {
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
@@ -1388,9 +1380,7 @@ window.WallModel = (() => {
const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => {
return sum + (op.w * toM) * (op.h * toM) * sDepthM;
}, 0);
const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0);
let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3;
if (voidVolumeM3 < 0) voidVolumeM3 = 0;
@@ -1474,11 +1464,14 @@ window.WallModel = (() => {
const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = st.y0;
const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk';
// Schuine onderkant: stuk op een schuine bovendorpel.
const yBottomLeft = st.y0l !== undefined ? st.y0l : yBottom, yBottomRight = st.y0r !== undefined ? st.y0r : yBottom;
const botCut = cutDeg(yBottomRight - yBottomLeft, xRight - xLeft);
if (st.y1 !== null) {
pieces.push({
length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [0, 0]
yBottomLeft, yBottomRight, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [botCut, 0]
});
return;
}
@@ -1486,16 +1479,24 @@ window.WallModel = (() => {
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()],
cutAngles: [0].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x)))
y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4,
yBottomLeft, yBottomRight, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()],
cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x)))
});
});
CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x;
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2, cutAngles: [0, 0] });
// Schuine bovenzijde: dorpel langs de helling (loodrechte sneden,
// zoals de bovenregel), met de werkelijke omtrek voor de tekening.
const dh = op.hR - op.h;
const header = { length: Math.hypot(op.w, dh), width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + (op.h + op.hR) / 2 + CONFIG.studWidth / 2, cutAngles: [cutDeg(dh, op.w), cutDeg(dh, op.w)] };
if (Math.abs(dh) > 0.5) {
const vo = CONFIG.studWidth * Math.hypot(op.w, dh) / op.w;
header.outline = [{ x: opXWorld, y: op.y + op.h }, { x: opXWorld + op.w, y: op.y + op.hR }, { x: opXWorld + op.w, y: op.y + op.hR + vo }, { x: opXWorld, y: op.y + op.h + vo }];
}
pieces.push(header);
if (op.y > CONFIG.plateThick) {
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2, cutAngles: [0, 0] });
}
@@ -1573,6 +1574,9 @@ window.WallModel = (() => {
if (typeof o.w !== 'number' || typeof o.h !== 'number' || typeof o.y !== 'number' || typeof o.offset !== 'number') {
throw new Error(`opening #${i + 1}: w/h/y/offset moeten getallen zijn`);
}
if (o.hR !== undefined && (typeof o.hR !== 'number' || !isFinite(o.hR))) {
throw new Error(`opening #${i + 1}: hR (hoogte rechts) moet een getal zijn`);
}
if (o.type !== undefined && !OPENING_TYPES[o.type]) {
throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
}
@@ -1584,7 +1588,7 @@ window.WallModel = (() => {
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines,
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+5 -4
View File
@@ -154,16 +154,17 @@ window.WallPdf = (() => {
doc.setLineWidth(0.2);
CONFIG.openings.forEach(op => {
const opXWorld = -wallWidth / 2 + op.x;
const x0 = px(opXWorld), y0 = py(op.y + op.h), x1 = px(opXWorld + op.w), y1 = py(op.y);
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S');
// Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y);
[[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l));
doc.line(x0, y0, x1, y1);
doc.line(x0, y1, x1, y0);
doc.line(x0, y1, x1, y0r);
doc.setTextColor(...BLACK);
doc.setFontSize(6);
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant),
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek
// erboven.
doc.text(`${op.w}x${op.h}`, (x0 + x1) / 2, y0 + 5, { align: 'center' });
doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' });
});
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
+107 -33
View File
@@ -280,7 +280,9 @@ function mountWallEditor(container, callbacks) {
let wallGroup3D = null;
let lastBaseHeight = 2400;
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset }
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset }
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
// recht en is h de hoogte. De onderkant is altijd waterpas.
// 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant opening
@@ -293,11 +295,14 @@ function mountWallEditor(container, callbacks) {
// ------------------------------------------------------------
// Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles
// alleen de breedte — precies zoals bij de meeste 2D-tekentools.
// de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// (verplaatsen). N/Z wijzigen de hoogte, O/W de breedte. De bovenste
// hoeken (NW/NO) verslepen ook alleen hun eigen kant in de hoogte: zo
// ontstaat een schuine bovenzijde. Onderin blijft het altijd waterpas
// (ZW/ZO: breedte + onderkant).
// ------------------------------------------------------------
let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH }
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH, startHR }
let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
@@ -315,10 +320,17 @@ function mountWallEditor(container, callbacks) {
op.w = w;
}
const isSloped = (op) => op.hR !== undefined;
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
o.hR !== undefined ? { hR: o.hR } : {});
function syncOpeningCardInputs(op) {
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
if (!card) return;
['w', 'h', 'y', 'offset'].forEach(field => {
// Schuin aan/uit veranderd (hoek slepen): kaart opnieuw opbouwen.
if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
});
@@ -429,6 +441,8 @@ function mountWallEditor(container, callbacks) {
hRightInput.value = tmp;
openings = openings.map(o => {
// Schuine bovenzijde klapt mee om.
if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h };
if (o.anchor === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset };
@@ -463,9 +477,13 @@ function mountWallEditor(container, callbacks) {
<div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<label>Hoogte</label>
<label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
</div>
${isSloped(op) ? `<div class="control-group">
<label>Hoogte rechts</label>
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
</div>` : ''}
<div class="control-group">
<label>Afstand tot vloer</label>
<div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
@@ -486,6 +504,11 @@ function mountWallEditor(container, callbacks) {
<label>${anchorOffsetLabel(op.anchor)}</label>
<div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</div>
<div class="control-group">
<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>
${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>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
@@ -519,6 +542,10 @@ function mountWallEditor(container, callbacks) {
const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
renderOpeningsList();
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
} else if (field === 'anchor') {
op.anchor = e.target.value;
@@ -532,9 +559,24 @@ function mountWallEditor(container, callbacks) {
if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10));
}
const roofBtn = e.target.closest('[data-action="follow-roof"]');
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
});
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// Bovenkant evenwijdig aan de daklijn: hoogte rechts = hoogte links +
// het verschil in daklijn tussen de rechter- en linkerrand (op de
// opgeloste positie; met een nok ertussen het rechte stuk ertussen).
function followRoof(id) {
const op = openings.find(o => o.id === id);
const res = (CONFIG.openings || []).find(o => o.id === id);
if (!op || !res) return;
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
syncOpeningCardInputs(op);
updateAll();
}
// ============================================================
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
@@ -569,7 +611,7 @@ function mountWallEditor(container, callbacks) {
return {
wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }))
openings: openings.map(rawOpening)
};
}
@@ -596,7 +638,7 @@ function mountWallEditor(container, callbacks) {
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
syncMaterialButtons();
openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }));
openings = state.openings.map(rawOpening);
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null;
@@ -980,39 +1022,53 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h);
const y1 = toCanvasY(op.y + op.h); // bovenkant links
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde)
const isSelected = op.id === selectedOpeningId;
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, localX: op.x, y: op.y, w: op.w, h: op.h });
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
const outline = () => {
ctxFront.beginPath();
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
ctxFront.closePath();
};
outline();
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.fill();
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1);
ctxFront.stroke();
ctxFront.setLineDash([]);
ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4);
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2;
const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [
{ handle: 'n', cx: midX, cy: y1 },
{ handle: 'nw', cx: x0, cy: y1 },
{ handle: 'n', cx: midX, cy: midTop },
{ handle: 'ne', cx: x1, cy: y1r },
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
{ handle: 'sw', cx: x0, cy: y0 },
{ handle: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY },
{ handle: 'w', cx: x0, cy: midY }
{ handle: 'se', cx: x1, cy: y0 }
];
handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = '#4da6ff';
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath();
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
// Midden (verplaatsen) rond, de rest vierkant.
if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2);
else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
ctxFront.fill();
ctxFront.stroke();
});
@@ -1062,9 +1118,12 @@ function mountWallEditor(container, callbacks) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
}
function frontOpeningAt(mx, my) {
return frontOpeningRects.find(r =>
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) &&
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1));
// Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
return frontOpeningRects.find(r => {
if (mx < r.x0 || mx > r.x1) return false;
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
return my <= r.y0 && my >= yTop;
});
}
canvas2DFront.classList.add('interactive');
@@ -1077,9 +1136,9 @@ function mountWallEditor(container, callbacks) {
if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = {
type: 'resize', handle: hit.handle, openingId: hit.id,
type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
};
e.preventDefault();
return;
@@ -1092,7 +1151,7 @@ function mountWallEditor(container, callbacks) {
dragInfo = {
type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
};
highlightSelectedCard();
draw2D();
@@ -1121,17 +1180,32 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') {
if (dragInfo.handle === 'n') {
op.h = Math.max(400, snap50(dragInfo.startH + dyMM));
} else if (dragInfo.handle === 's') {
const top = dragInfo.startY + dragInfo.startH;
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400);
// Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
const hd = dragInfo.handle;
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
if (hd === 'n') {
// Hele bovenkant omhoog/omlaag (helling blijft).
const dh = snap50(dyMM);
op.h = Math.max(400, dragInfo.startH + dh);
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
} else if (hd === 'nw' || hd === 'ne') {
// Eén bovenhoek: alleen die kant in de hoogte -> schuine bovenzijde.
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
if (op.h === op.hR) delete op.hR; // weer recht
}
if (hd === 's' || hd === 'sw' || hd === 'se') {
// Onderkant; de bovenkant blijft staan (onderin altijd waterpas).
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400);
op.y = Math.max(0, newY);
op.h = top - op.y;
} else if (dragInfo.handle === 'e') {
op.h = topL - op.y;
if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (dragInfo.handle === 'w') {
} else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);