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:
co-authored by
Claude Opus 5.5
parent
23bfe153a2
commit
8cdfa21f46
+107
-33
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user