Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units, woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1374 lines
61 KiB
JavaScript
1374 lines
61 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
// ============================================================
|
|
// wall/wall.js — Wand-editor (ruwbouw compleet: regelwerk, openingen, OSB
|
|
// achterzijde, vochtdoek voorzijde, isolatie/specificaties). Ongewijzigde
|
|
// logica t.o.v. de vorige single-file versie, alleen verpakt als los te
|
|
// laden/lossen module via mount()/unmount(), en met de gedeelde geometrie-
|
|
// functies (calculateSegmentPositions, polygonArea, clip*, shapeAreaWithHoles)
|
|
// verplaatst naar core/geometry.js (moet VOOR dit bestand geladen worden).
|
|
//
|
|
// De rekenlogica (ankerresolutie, regelwerk, 3D-opbouw, specificaties) staat
|
|
// in wall/wall-model.js (WallModel) — gedeeld met de unit-editor. Dit bestand
|
|
// bevat alleen de UI: invoer, 2D-tekeningen, interactie, JSON/PDF-export.
|
|
//
|
|
// Plain script (geen ES-module) — werkt zo ook bij rechtstreeks openen van
|
|
// index.html via file://.
|
|
// ============================================================
|
|
|
|
const WALL_TEMPLATE = `
|
|
<div id="app-container">
|
|
<div id="left-column">
|
|
<div id="pane-2d-container">
|
|
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
|
<div class="pane-label">2D BUITENAANZICHT</div>
|
|
<canvas id="canvas-2d-front"></canvas>
|
|
</div>
|
|
<div class="pane-2d-sub pane-2d-row" id="pane-2d-bottom-row">
|
|
<div class="pane-2d-half" id="pane-2d-top">
|
|
<div class="pane-label">2D BOVENAANZICHT</div>
|
|
<canvas id="canvas-2d-top"></canvas>
|
|
</div>
|
|
<div class="pane-2d-half" id="pane-2d-side">
|
|
<div class="pane-label">2D ZIJAANZICHT</div>
|
|
<canvas id="canvas-2d-side"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="control-panel">
|
|
<div class="io-toolbar">
|
|
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
|
<span id="io-status" class="io-status"></span>
|
|
</div>
|
|
<div class="tab-bar">
|
|
<button class="tab-btn active" data-tab="tab-wall">1. Wand Opbouw</button>
|
|
<button class="tab-btn" data-tab="tab-opening">2. Openingen</button>
|
|
<button class="tab-btn" data-tab="tab-materials">3. Materiaalkeuze</button>
|
|
</div>
|
|
|
|
<!-- TAB 1: WAND -->
|
|
<div id="tab-wall" class="tab-content active">
|
|
<div class="controls-grid">
|
|
<div>
|
|
<h1>Wand Dimensies (mm)</h1>
|
|
<div class="control-group">
|
|
<label for="inp-wall-w">Wand Breedte:</label>
|
|
<div><input type="number" id="inp-wall-w" value="6000" step="50" min="800" max="10000"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-wall-h-all">Totale Hoogte:</label>
|
|
<div><input type="number" id="inp-wall-h-all" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-wall-h-left">Hoogte Links ($Y_1$):</label>
|
|
<div><input type="number" id="inp-wall-h-left" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-wall-h-right">Hoogte Rechts ($Y_2$):</label>
|
|
<div><input type="number" id="inp-wall-h-right" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
<h1>Verticale Balken & Dorpels</h1>
|
|
<div class="control-group">
|
|
<label for="inp-stud-w">Verticale Balk Dikte/Breedte:</label>
|
|
<div><input type="number" id="inp-stud-w" value="38" step="5" min="20" max="300"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-stud-d">Verticale Balk Diepte:</label>
|
|
<div><input type="number" id="inp-stud-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h1>Muur Liggers (Top & Onder)</h1>
|
|
<div class="control-group">
|
|
<label for="inp-plate-w">Ligger Dikte:</label>
|
|
<div><input type="number" id="inp-plate-w" value="38" step="5" min="20" max="200"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-plate-d">Ligger Diepte:</label>
|
|
<div><input type="number" id="inp-plate-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-sheathing-t">Beplating Dikte:</label>
|
|
<div><input type="number" id="inp-sheathing-t" value="10" step="1" min="3" max="30"><span class="unit">mm</span></div>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
<h1>Regelwerk Afstand (h.o.h.)</h1>
|
|
<div class="control-group">
|
|
<label for="inp-min-sp">Min. h.o.h.:</label>
|
|
<div><input type="number" id="inp-min-sp" value="240" step="10" min="100" max="1000"><span class="unit">mm</span></div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="inp-max-sp">Max. h.o.h.:</label>
|
|
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
<div class="result-row"><span>Totaal verticale balken:</span><span data-spec="vert-beams" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Totaal horizontale balken:</span><span data-spec="horiz-beams" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Dakhelling:</span><span data-spec="roof-angle" class="highlight">-</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB 2: OPENINGEN (meerdere, ankerbaar) -->
|
|
<div id="tab-opening" class="tab-content">
|
|
<button id="btn-add-opening" class="add-opening-btn">+ Opening Toevoegen</button>
|
|
<div id="openings-global-warning" class="global-warning" style="display:none;"></div>
|
|
<div id="openings-list"></div>
|
|
</div>
|
|
|
|
<!-- TAB 3: MATERIAALKEUZE -->
|
|
<div id="tab-materials" class="tab-content">
|
|
<h1>Binnenzijde</h1>
|
|
|
|
<div class="divider"></div>
|
|
<h1>Buitenzijde</h1>
|
|
<div class="material-toggle-group">
|
|
<button type="button" id="btn-mat-vochtdoek" class="material-toggle-btn active" data-material="vochtdoek">Vochtdoek</button>
|
|
<button type="button" id="btn-mat-cementplaat" class="material-toggle-btn" data-material="cementplaat">Cement-gebonden platen</button>
|
|
<button type="button" id="btn-mat-spaanplaat" class="material-toggle-btn" data-material="spaanplaat">Spaanplaat</button>
|
|
</div>
|
|
<div id="cladding-section">
|
|
<div class="divider"></div>
|
|
<h1>Gevelbekleding (WEO/Novalit) — richting</h1>
|
|
<div class="material-toggle-group">
|
|
<button type="button" class="material-toggle-btn active" data-cladding="horizontaal">Horizontaal</button>
|
|
<button type="button" class="material-toggle-btn" data-cladding="verticaal">Verticaal</button>
|
|
</div>
|
|
<div class="material-info">Op het vochtdoek komt een kruislat van 20×30 mm, 400 mm h.o.h. in beide richtingen. De bovenste lat staat haaks op de gevelbekleding.</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div id="right-column">
|
|
<div id="pane-3d">
|
|
<div class="pane-label">3D PERSPECTIEF</div>
|
|
<div class="toolbar-3d" id="wall-toolbar-3d"></div>
|
|
</div>
|
|
|
|
<div id="specs-panel-3d">
|
|
<div class="controls-grid">
|
|
<div>
|
|
<div class="spec-section">
|
|
<h1>Balken & Dak</h1>
|
|
<div class="result-row"><span>Verticale balken:</span><span data-spec="vert-beams" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Horizontale balken:</span><span data-spec="horiz-beams" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Dakhelling:</span><span data-spec="roof-angle" class="highlight">-</span></div>
|
|
</div>
|
|
<div class="spec-section">
|
|
<h1>Houtwerk</h1>
|
|
<div class="result-row"><span>Volume hout:</span><span data-spec="wood-m3" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Gewicht hout:</span><span data-spec="wood-kg" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Inkoop balken:</span><span data-spec="stock-list" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Snijverlies:</span><span data-spec="stock-waste" class="highlight">-</span></div>
|
|
</div>
|
|
<div class="spec-section">
|
|
<h1>Spaanplaat (binnenzijde)</h1>
|
|
<div class="result-row"><span>Oppervlak:</span><span data-spec="spaanplaat-m2" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Volume:</span><span data-spec="spaanplaat-m3" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Gewicht:</span><span data-spec="spaanplaat-kg" class="highlight">-</span></div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="spec-section">
|
|
<h1>Buitenzijde (<span data-spec="buiten-materiaalnaam">Vochtdoek</span>)</h1>
|
|
<div class="result-row"><span>Oppervlak:</span><span data-spec="buiten-m2" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Volume:</span><span data-spec="buiten-m3" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Gewicht:</span><span data-spec="buiten-kg" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Latwerk 20x30 (kruislat):</span><span data-spec="latten" class="highlight">-</span></div>
|
|
</div>
|
|
<div class="spec-section">
|
|
<h1>Isolatie & totaal</h1>
|
|
<div class="result-row"><span>Inhoud leegtes:</span><span data-spec="void-m3" class="highlight">-</span></div>
|
|
<div class="result-row"><span>Totaal gewicht wand:</span><span data-spec="total-kg" class="highlight">-</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wall-project-overlay">
|
|
<div id="wall-project-modal">
|
|
<div class="modal-header">
|
|
<h2>Project eigenschappen</h2>
|
|
<button type="button" id="wall-project-close-x" class="modal-close-x">×</button>
|
|
</div>
|
|
<div class="controls-grid">
|
|
<div>
|
|
<div class="control-group"><label for="inp-proj-naam">Projectnaam:</label>
|
|
<div><input type="text" id="inp-proj-naam" value=""></div></div>
|
|
<div class="control-group"><label for="inp-proj-plaats">Plaats:</label>
|
|
<div><input type="text" id="inp-proj-plaats" value=""></div></div>
|
|
<div class="control-group"><label for="inp-proj-straat">Straat:</label>
|
|
<div><input type="text" id="inp-proj-straat" value=""></div></div>
|
|
<div class="control-group"><label for="inp-proj-nummer">Projectnummer:</label>
|
|
<div><input type="text" id="inp-proj-nummer" value=""></div></div>
|
|
</div>
|
|
<div>
|
|
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar:</label>
|
|
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
|
|
<div class="control-group"><label for="inp-proj-titel">Titel:</label>
|
|
<div><input type="text" id="inp-proj-titel" value="BuitenWand"></div></div>
|
|
<div class="control-group"><label for="inp-proj-element">Element nummer:</label>
|
|
<div><input type="text" id="inp-proj-element" value=""></div></div>
|
|
<div class="control-group"><label for="inp-proj-opmerking">Opmerking:</label>
|
|
<div><input type="text" id="inp-proj-opmerking" value=""></div></div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
|
|
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
function mountWallEditor(container, callbacks) {
|
|
const onSaved = callbacks && callbacks.onSaved;
|
|
container.innerHTML = WALL_TEMPLATE;
|
|
|
|
// Binnen een unit volgt de wandbreedte uit de vloer (zie unit/unit.js);
|
|
// callbacks.lockWidth is dan de uitleg-tekst voor de tooltip.
|
|
if (callbacks && callbacks.lockWidth) {
|
|
const widthInput = document.getElementById('inp-wall-w');
|
|
widthInput.disabled = true;
|
|
widthInput.title = callbacks.lockWidth;
|
|
}
|
|
// Binnen een woning is de richting van de gevelbekleding (voorlopig) globaal.
|
|
const lockCladding = callbacks && callbacks.lockCladding;
|
|
|
|
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
|
|
if (callbacks && callbacks.lockSpacing) {
|
|
['inp-min-sp', 'inp-max-sp'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
el.disabled = true;
|
|
el.title = callbacks.lockSpacing;
|
|
});
|
|
}
|
|
// Binnen een woning: delen van de wand die op een naad met een andere unit
|
|
// vallen (en dus vervallen), als [vanaf-links, tot] in mm.
|
|
const seamCuts = (callbacks && callbacks.seamCuts) || [];
|
|
// Binnen een woning: gevellampen op deze wand ({ u vanaf links, y }), zie LightingModel.
|
|
const lampMarks = (callbacks && callbacks.lamps) || [];
|
|
|
|
// ============================================================
|
|
// STATE
|
|
// ============================================================
|
|
// Dichtheden/gewichten: zie WallModel (wall/wall-model.js).
|
|
|
|
let CONFIG = {};
|
|
let calcData = { fullStuds: [], jackStuds: [] };
|
|
let wallGroup3D = null;
|
|
let lastBaseHeight = 2400;
|
|
|
|
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset }
|
|
// 'offset' betekent afhankelijk van anchor:
|
|
// left -> afstand vanaf linker wandrand tot linkerkant opening
|
|
// right -> afstand vanaf rechter wandrand tot rechterkant opening
|
|
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
|
|
let openings = [
|
|
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }
|
|
];
|
|
let openingIdCounter = 2;
|
|
|
|
// ------------------------------------------------------------
|
|
// 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.
|
|
// ------------------------------------------------------------
|
|
let selectedOpeningId = null;
|
|
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH }
|
|
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
|
|
|
|
function snap50(mm) { return Math.round(mm / 50) * 50; }
|
|
|
|
// Zet offset (en evt. breedte) zodat de opening's linkerrand op
|
|
// `leftLocalX` komt te liggen met breedte `w`, ongeacht welk ankertype
|
|
// actief is — dit is de kern die verslepen/resizen mogelijk maakt zonder
|
|
// dat het ankertype zelf hoeft te veranderen.
|
|
function applyOpeningPosition(op, leftLocalX, w, wallWidth) {
|
|
if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w;
|
|
else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2;
|
|
else op.offset = leftLocalX; // 'left'
|
|
op.w = w;
|
|
}
|
|
|
|
function syncOpeningCardInputs(op) {
|
|
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
|
|
if (!card) return;
|
|
['w', 'h', 'y', 'offset'].forEach(field => {
|
|
const input = card.querySelector(`input[data-field="${field}"]`);
|
|
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
|
|
});
|
|
}
|
|
|
|
function highlightSelectedCard() {
|
|
document.querySelectorAll('.opening-card').forEach(card => {
|
|
const id = parseInt(card.dataset.openingId, 10);
|
|
card.classList.toggle('opening-selected', id === selectedOpeningId);
|
|
});
|
|
}
|
|
|
|
function switchTab(tabId) {
|
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
|
|
document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
|
|
document.getElementById(tabId).classList.add('active');
|
|
}
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
|
});
|
|
|
|
// ============================================================
|
|
// OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen)
|
|
// ============================================================
|
|
function anchorOffsetLabel(anchor) {
|
|
if (anchor === 'right') return 'Afstand vanaf rechts:';
|
|
if (anchor === 'middle') return 'Verschuiving t.o.v. midden:';
|
|
return 'Afstand vanaf links:';
|
|
}
|
|
|
|
function addOpening() {
|
|
const n = openings.length;
|
|
const preset = WallModel.OPENING_TYPES.raam;
|
|
openings.push({
|
|
id: openingIdCounter++,
|
|
type: 'raam',
|
|
w: preset.w,
|
|
h: preset.h,
|
|
y: preset.y,
|
|
anchor: 'left',
|
|
offset: 300 + n * 1400
|
|
});
|
|
renderOpeningsList();
|
|
updateAll();
|
|
}
|
|
|
|
function toggleFullscreen3D() {
|
|
const el = document.getElementById('app-container');
|
|
el.classList.remove('fullscreen-2d');
|
|
el.classList.toggle('fullscreen-3d');
|
|
requestAnimationFrame(onWindowResize);
|
|
}
|
|
|
|
function toggleFullscreen2D() {
|
|
const el = document.getElementById('app-container');
|
|
el.classList.remove('fullscreen-3d');
|
|
el.classList.toggle('fullscreen-2d');
|
|
requestAnimationFrame(onWindowResize);
|
|
}
|
|
|
|
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
|
|
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
|
let projectPropsSnapshot = null;
|
|
|
|
function openProjectProperties() {
|
|
projectPropsSnapshot = {};
|
|
PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; });
|
|
document.getElementById('wall-project-overlay').classList.add('open');
|
|
}
|
|
function closeProjectProperties() {
|
|
document.getElementById('wall-project-overlay').classList.remove('open');
|
|
}
|
|
document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties);
|
|
document.getElementById('wall-project-cancel').addEventListener('click', () => {
|
|
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
|
closeProjectProperties();
|
|
});
|
|
document.getElementById('wall-project-close-x').addEventListener('click', () => {
|
|
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
|
closeProjectProperties();
|
|
});
|
|
document.getElementById('wall-project-overlay').addEventListener('click', (e) => {
|
|
if (e.target.id === 'wall-project-overlay') {
|
|
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
|
|
closeProjectProperties();
|
|
}
|
|
});
|
|
|
|
function removeOpening(id) {
|
|
openings = openings.filter(o => o.id !== id);
|
|
if (selectedOpeningId === id) selectedOpeningId = null;
|
|
renderOpeningsList();
|
|
updateAll();
|
|
}
|
|
|
|
// Horizontaal spiegelen (Bewerken-menu): wisselt links/rechts om. De
|
|
// hoogte-links/-rechts worden verwisseld (de schuine kant "klapt om"),
|
|
// en elke opening wordt zo herankerd dat 'ie fysiek op dezelfde plek
|
|
// blijft t.o.v. zijn (nu omgewisselde) referentierand:
|
|
// - anchor 'left' <-> 'right', offset ONGEWIJZIGD (het is en blijft
|
|
// een afstand-tot-een-rand, alleen de rand zelf wisselt)
|
|
// - anchor 'middle': offset wordt genegeerd (spiegelbeeld t.o.v. midden)
|
|
// Verticaal spiegelen is bewust niet geïmplementeerd: een wand mag aan de
|
|
// onderzijde niet scheef worden, dus dat menu-item blijft permanent
|
|
// disabled (zie index.html/app.js).
|
|
function mirrorHorizontal() {
|
|
const hLeftInput = document.getElementById('inp-wall-h-left');
|
|
const hRightInput = document.getElementById('inp-wall-h-right');
|
|
const tmp = hLeftInput.value;
|
|
hLeftInput.value = hRightInput.value;
|
|
hRightInput.value = tmp;
|
|
|
|
openings = openings.map(o => {
|
|
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 };
|
|
return o;
|
|
});
|
|
|
|
renderOpeningsList();
|
|
updateAll();
|
|
setIoStatus('Horizontaal gespiegeld.', 'ok');
|
|
}
|
|
|
|
function renderOpeningsList() {
|
|
const list = document.getElementById('openings-list');
|
|
list.innerHTML = openings.map(op => `
|
|
<div class="opening-card" data-opening-id="${op.id}">
|
|
<div class="opening-card-header">
|
|
<span class="opening-card-title">${(WallModel.OPENING_TYPES[op.type] || WallModel.OPENING_TYPES.raam).label} #${op.id}</span>
|
|
<button class="opening-remove-btn" data-action="remove" data-id="${op.id}" title="Verwijderen">×</button>
|
|
</div>
|
|
<div class="controls-grid">
|
|
<div>
|
|
<div class="control-group">
|
|
<label>Type:</label>
|
|
<div>
|
|
<select data-field="type" data-id="${op.id}">
|
|
${Object.entries(WallModel.OPENING_TYPES).map(([k, t]) => `<option value="${k}" ${(op.type || 'raam') === k ? 'selected' : ''}>${t.label}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Breedte:</label>
|
|
<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>
|
|
<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>
|
|
<div class="control-group">
|
|
<label>Hoogte vloer (Y):</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>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="control-group">
|
|
<label>Ankerpunt:</label>
|
|
<div>
|
|
<select data-field="anchor" data-id="${op.id}">
|
|
<option value="left" ${op.anchor === 'left' ? 'selected' : ''}>Links</option>
|
|
<option value="middle" ${op.anchor === 'middle' ? 'selected' : ''}>Midden</option>
|
|
<option value="right" ${op.anchor === 'right' ? 'selected' : ''}>Rechts</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<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>
|
|
</div>
|
|
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
|
|
</div>
|
|
`).join('');
|
|
highlightSelectedCard();
|
|
}
|
|
|
|
// Klik op de titelbalk van een kaart selecteert 'm ook (symmetrisch met
|
|
// klikken op de opening in het vooraanzicht).
|
|
document.getElementById('openings-list').addEventListener('click', (e) => {
|
|
const header = e.target.closest('.opening-card-header');
|
|
if (header && !e.target.closest('.opening-remove-btn')) {
|
|
const card = header.closest('.opening-card');
|
|
selectedOpeningId = parseInt(card.dataset.openingId, 10);
|
|
highlightSelectedCard();
|
|
draw2D();
|
|
}
|
|
});
|
|
|
|
// Event delegation: 1 listener voor alle inputs/knoppen in de openingen-lijst,
|
|
// in plaats van opnieuw listeners koppelen na elke re-render.
|
|
document.getElementById('openings-list').addEventListener('input', (e) => {
|
|
const id = parseInt(e.target.dataset.id, 10);
|
|
const field = e.target.dataset.field;
|
|
if (!field) return;
|
|
const op = openings.find(o => o.id === id);
|
|
if (!op) return;
|
|
if (field === 'type') {
|
|
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
|
|
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;
|
|
renderOpeningsList();
|
|
} else if (field === 'anchor') {
|
|
op.anchor = e.target.value;
|
|
renderOpeningsList(); // label van 'offset' hangt af van anchor
|
|
} else {
|
|
op[field] = parseFloat(e.target.value) || 0;
|
|
}
|
|
updateAll();
|
|
});
|
|
document.getElementById('openings-list').addEventListener('click', (e) => {
|
|
if (e.target.dataset.action === 'remove') {
|
|
removeOpening(parseInt(e.target.dataset.id, 10));
|
|
}
|
|
});
|
|
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
|
|
|
|
// ============================================================
|
|
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
|
|
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
|
|
// ============================================================
|
|
function setIoStatus(msg, cls) {
|
|
const el = document.getElementById('io-status');
|
|
el.textContent = msg;
|
|
el.className = 'io-status ' + (cls || '');
|
|
clearTimeout(setIoStatus._t);
|
|
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
|
|
}
|
|
|
|
function collectWallParams() {
|
|
return {
|
|
width: parseFloat(document.getElementById('inp-wall-w').value),
|
|
heightAll: parseFloat(document.getElementById('inp-wall-h-all').value),
|
|
heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value),
|
|
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
|
|
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
|
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
|
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
|
plateDepth: parseFloat(document.getElementById('inp-plate-d').value),
|
|
sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value),
|
|
minSpacing: parseFloat(document.getElementById('inp-min-sp').value),
|
|
maxSpacing: parseFloat(document.getElementById('inp-max-sp').value)
|
|
};
|
|
}
|
|
|
|
function getPureState() {
|
|
return {
|
|
wall: collectWallParams(),
|
|
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam },
|
|
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 }))
|
|
};
|
|
}
|
|
|
|
function applyPureState(state) {
|
|
document.getElementById('inp-wall-w').value = state.wall.width;
|
|
document.getElementById('inp-wall-h-all').value = state.wall.heightAll;
|
|
document.getElementById('inp-wall-h-left').value = state.wall.heightLeft;
|
|
document.getElementById('inp-wall-h-right').value = state.wall.heightRight;
|
|
document.getElementById('inp-stud-w').value = state.wall.studWidth;
|
|
document.getElementById('inp-stud-d').value = state.wall.studDepth;
|
|
document.getElementById('inp-plate-w').value = state.wall.plateThick;
|
|
document.getElementById('inp-plate-d').value = state.wall.plateDepth;
|
|
document.getElementById('inp-sheathing-t').value = state.wall.sheathingThick;
|
|
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
|
|
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
|
|
lastBaseHeight = state.wall.heightAll;
|
|
|
|
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
|
|
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
|
|
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
|
|
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 }));
|
|
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
|
|
selectedOpeningId = null;
|
|
|
|
renderOpeningsList();
|
|
updateAll();
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// PDF-export: het tekenblad zelf komt uit WallPdf (wall/wall-pdf.js),
|
|
// gedeeld met de unit-PDF.
|
|
// ------------------------------------------------------------
|
|
function generateWallPdf() {
|
|
recalculate();
|
|
const doc = PdfSheet.createDoc();
|
|
const project = PdfSheet.readProjectFromDom();
|
|
WallPdf.drawSheet(doc, CONFIG, calcData, { buitenMateriaal, cladding, trasraam, project, titel: project.titel, element: project.element });
|
|
return doc;
|
|
}
|
|
|
|
function buildDesignObject() {
|
|
return { type: 'wall-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() };
|
|
}
|
|
|
|
function exportDesign() {
|
|
const suggested = 'wand-ontwerp-' + new Date().toISOString().slice(0, 10);
|
|
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
|
|
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
|
|
filename = filename.trim();
|
|
if (!filename) filename = suggested;
|
|
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
|
|
|
|
const design = buildDesignObject();
|
|
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
const pdfBase = filename.replace(/\.json$/i, '');
|
|
try {
|
|
const pdfDoc = generateWallPdf();
|
|
pdfDoc.save(pdfBase + '.pdf');
|
|
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
|
|
} catch (err) {
|
|
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
|
|
}
|
|
if (typeof onSaved === 'function') onSaved(filename);
|
|
return filename;
|
|
}
|
|
|
|
function validateDesignJson(design) {
|
|
if (!design || design.type !== 'wall-design') throw new Error('geen geldig wand-ontwerp (type ontbreekt of onjuist)');
|
|
WallModel.validateState(design);
|
|
}
|
|
|
|
function importDesign(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const design = JSON.parse(e.target.result);
|
|
validateDesignJson(design);
|
|
applyPureState(design);
|
|
setIoStatus(`Geïmporteerd (${openings.length} opening(en)).`, 'ok');
|
|
if (typeof onSaved === 'function') onSaved(file.name);
|
|
} catch (err) {
|
|
setIoStatus('Import mislukt: ' + err.message, 'err');
|
|
}
|
|
};
|
|
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function triggerImport() {
|
|
document.getElementById('file-import-json').click();
|
|
}
|
|
|
|
document.getElementById('file-import-json').addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) importDesign(file);
|
|
e.target.value = '';
|
|
});
|
|
|
|
// ============================================================
|
|
// OPENINGEN: ankerresolutie + overlap-handling — zie
|
|
// WallModel.resolveOpenings (wall/wall-model.js).
|
|
// ============================================================
|
|
|
|
function updateOpeningWarnings(resolvedOpenings) {
|
|
const anyOverflow = resolvedOpenings.some(o => o.overflowing);
|
|
const globalWarn = document.getElementById('openings-global-warning');
|
|
if (anyOverflow) {
|
|
globalWarn.style.display = 'block';
|
|
globalWarn.textContent = 'Let op: de opgegeven openingen passen niet allemaal binnen deze wandbreedte op hun gekozen ankerpunt. Rood gemarkeerde openingen zijn tegen de rand of tegen elkaar aan geduwd.';
|
|
} else {
|
|
globalWarn.style.display = 'none';
|
|
}
|
|
resolvedOpenings.forEach(o => {
|
|
const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`);
|
|
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';
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function readInputs() {
|
|
const wallWidth = Math.round(parseFloat(document.getElementById('inp-wall-w').value) || 3000);
|
|
const hAllInput = document.getElementById('inp-wall-h-all');
|
|
const hLeftInput = document.getElementById('inp-wall-h-left');
|
|
const hRightInput = document.getElementById('inp-wall-h-right');
|
|
|
|
let hAll = Math.round(parseFloat(hAllInput.value) || 2400);
|
|
let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400);
|
|
let hRight = Math.round(parseFloat(hRightInput.value) || 2900);
|
|
|
|
if (hAll !== lastBaseHeight) {
|
|
const delta = hAll - lastBaseHeight;
|
|
hLeft = Math.max(500, hLeft + delta);
|
|
hRight = Math.max(500, hRight + delta);
|
|
hLeftInput.value = hLeft;
|
|
hRightInput.value = hRight;
|
|
lastBaseHeight = hAll;
|
|
}
|
|
|
|
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel.
|
|
CONFIG = WallModel.buildConfig({
|
|
width: wallWidth,
|
|
heightLeft: hLeft,
|
|
heightRight: hRight,
|
|
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
|
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
|
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
|
plateDepth: parseFloat(document.getElementById('inp-plate-d').value),
|
|
sheathingThick: parseFloat(document.getElementById('inp-sheathing-t').value),
|
|
minSpacing: parseFloat(document.getElementById('inp-min-sp').value),
|
|
maxSpacing: parseFloat(document.getElementById('inp-max-sp').value)
|
|
}, openings);
|
|
updateOpeningWarnings(CONFIG.openings);
|
|
}
|
|
|
|
function recalculate() {
|
|
readInputs();
|
|
calcData = WallModel.computeFrame(CONFIG);
|
|
|
|
setSpecAll('vert-beams', (calcData.fullStuds.length + calcData.jackStuds.length) + ' stuks');
|
|
setSpecAll('roof-angle', calcData.angleDeg + '°');
|
|
}
|
|
|
|
// --- 2D CANVAS RENDERER: BOVENAANZICHT ---
|
|
const canvas2DTop = document.getElementById('canvas-2d-top');
|
|
const ctx = canvas2DTop.getContext('2d');
|
|
|
|
function draw2DTop() {
|
|
const rect = canvas2DTop.parentElement.getBoundingClientRect();
|
|
canvas2DTop.width = rect.width;
|
|
canvas2DTop.height = rect.height;
|
|
|
|
const w = canvas2DTop.width;
|
|
const h = canvas2DTop.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const maxDepth = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
|
|
const scale = Math.min(w, h) / (Math.max(CONFIG.wallWidth, 1500) * 1.4);
|
|
const centerX = w / 2;
|
|
const centerY = h / 2;
|
|
|
|
ctx.strokeStyle = '#222';
|
|
ctx.lineWidth = 1;
|
|
const gridSize = 100 * scale;
|
|
for (let x = 0; x < w; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
|
for (let y = 0; y < h; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
|
|
|
const plateWidthPx = CONFIG.wallWidth * scale;
|
|
const sheathingPx = CONFIG.sheathingThick * scale;
|
|
const maxDepthPx = maxDepth * scale;
|
|
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.strokeStyle = '#666';
|
|
ctx.fillRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
|
|
ctx.strokeRect(centerX - plateWidthPx / 2, centerY - (maxDepthPx / 2) - sheathingPx, plateWidthPx, sheathingPx);
|
|
ctx.fillRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
|
|
ctx.strokeRect(centerX - plateWidthPx / 2, centerY + (maxDepthPx / 2), plateWidthPx, sheathingPx);
|
|
|
|
const studWidthPx = CONFIG.studWidth * scale;
|
|
const studDepthPx = CONFIG.studDepth * scale;
|
|
|
|
const drawStud2D = (xPos, isJack = false) => {
|
|
const screenX = centerX + (xPos * scale) - (studWidthPx / 2);
|
|
const screenY = centerY - (studDepthPx / 2);
|
|
|
|
ctx.fillStyle = isJack ? '#a37c45' : '#c29b62';
|
|
ctx.fillRect(screenX, screenY, studWidthPx, studDepthPx);
|
|
ctx.strokeStyle = '#8a6836';
|
|
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
|
|
};
|
|
|
|
calcData.fullStuds.forEach(x => drawStud2D(x, false));
|
|
calcData.jackStuds.forEach(j => drawStud2D(j.x, true));
|
|
|
|
const startX = -CONFIG.wallWidth / 2;
|
|
CONFIG.openings.forEach(op => {
|
|
const opLeft = startX + op.x;
|
|
const opLeftPx = centerX + (opLeft * scale);
|
|
const opW_Px = op.w * scale;
|
|
|
|
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
|
|
ctx.fillRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2);
|
|
ctx.strokeStyle = '#ff3333';
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.strokeRect(opLeftPx, centerY - (maxDepthPx / 2) - sheathingPx, opW_Px, maxDepthPx + sheathingPx * 2);
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '9px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('#' + op.id, opLeftPx + opW_Px / 2, centerY - (maxDepthPx / 2) - sheathingPx - 4);
|
|
});
|
|
}
|
|
|
|
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
|
|
const canvas2DFront = document.getElementById('canvas-2d-front');
|
|
const ctxFront = canvas2DFront.getContext('2d');
|
|
|
|
function draw2DFront() {
|
|
const rect = canvas2DFront.parentElement.getBoundingClientRect();
|
|
canvas2DFront.width = rect.width;
|
|
canvas2DFront.height = rect.height;
|
|
|
|
const w = canvas2DFront.width;
|
|
const h = canvas2DFront.height;
|
|
ctxFront.clearRect(0, 0, w, h);
|
|
|
|
const wallWidthMM = CONFIG.wallWidth;
|
|
const hLeftMM = CONFIG.wallHeightLeft;
|
|
const hRightMM = CONFIG.wallHeightRight;
|
|
const halfWMM = wallWidthMM / 2;
|
|
const slopeMM = (hRightMM - hLeftMM) / wallWidthMM;
|
|
const topYAtMM = (x) => hLeftMM + (x + halfWMM) * slopeMM;
|
|
const vertOffsetMM = CONFIG.plateThick * Math.sqrt(1 + slopeMM * slopeMM);
|
|
const maxRoofMM = Math.max(hLeftMM, hRightMM);
|
|
|
|
const scale = Math.min(w / (wallWidthMM * 1.3), h / (maxRoofMM * 1.3));
|
|
const centerX = w / 2;
|
|
const floorY = h - 20;
|
|
frontView = { scale, centerX, floorY };
|
|
|
|
const toCanvasX = (xmm) => centerX + xmm * scale;
|
|
const toCanvasY = (ymm) => floorY - ymm * scale;
|
|
|
|
ctxFront.strokeStyle = '#222';
|
|
ctxFront.lineWidth = 1;
|
|
const gridSize = 100 * scale;
|
|
for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); }
|
|
for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); }
|
|
|
|
// Referentielijnen vochtdoek-banen (1500mm h.o.h. vanaf de vloer).
|
|
// Zelfde uitsparingen als de fill (WallModel.skinSegmentsAtY): de
|
|
// lijnen stoppen bij openingen en bij de (schuine) bovenrand.
|
|
ctxFront.strokeStyle = '#555';
|
|
ctxFront.setLineDash([4, 4]);
|
|
for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) {
|
|
WallModel.skinSegmentsAtY(CONFIG, bandY).forEach(([x0, x1]) => {
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(toCanvasX(x0), toCanvasY(bandY));
|
|
ctxFront.lineTo(toCanvasX(x1), toCanvasY(bandY));
|
|
ctxFront.stroke();
|
|
});
|
|
}
|
|
ctxFront.setLineDash([]);
|
|
|
|
// Bovenkant trasraam (cement-gebonden plaat, woning-instelling).
|
|
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
|
|
ctxFront.strokeStyle = '#a0a3a8';
|
|
ctxFront.lineWidth = 1.5;
|
|
WallModel.skinSegmentsAtY(CONFIG, trasraam.top).forEach(([x0, x1]) => {
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(toCanvasX(x0), toCanvasY(trasraam.top));
|
|
ctxFront.lineTo(toCanvasX(x1), toCanvasY(trasraam.top));
|
|
ctxFront.stroke();
|
|
});
|
|
ctxFront.fillStyle = '#a0a3a8';
|
|
ctxFront.font = '10px sans-serif';
|
|
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
|
|
}
|
|
|
|
// Wand-omtrek (incl. schuine bovenrand)
|
|
ctxFront.strokeStyle = '#666';
|
|
ctxFront.lineWidth = 2;
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0));
|
|
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0));
|
|
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM));
|
|
ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM));
|
|
ctxFront.closePath();
|
|
ctxFront.stroke();
|
|
|
|
// Onder- en bovenregel als volwaardig gevuld vlak (was alleen de
|
|
// omtreklijn hierboven — dat liet lege ruimtes zien i.p.v. de
|
|
// daadwerkelijke leggers).
|
|
ctxFront.fillStyle = '#c29b62';
|
|
ctxFront.strokeStyle = '#8a6836';
|
|
ctxFront.lineWidth = 1;
|
|
ctxFront.beginPath();
|
|
ctxFront.rect(toCanvasX(-halfWMM), toCanvasY(CONFIG.plateThick), toCanvasX(halfWMM) - toCanvasX(-halfWMM), toCanvasY(0) - toCanvasY(CONFIG.plateThick));
|
|
ctxFront.fill();
|
|
ctxFront.stroke();
|
|
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM));
|
|
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM));
|
|
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM - vertOffsetMM));
|
|
ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM - vertOffsetMM));
|
|
ctxFront.closePath();
|
|
ctxFront.fill();
|
|
ctxFront.stroke();
|
|
|
|
// X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er
|
|
// niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js.
|
|
const xAxisY = Math.min(h - 6, toCanvasY(0) + 16);
|
|
drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY,
|
|
'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11));
|
|
|
|
const yAxisLeftX = Math.max(10, toCanvasX(-halfWMM) - 14);
|
|
drawCanvasDim(ctxFront, yAxisLeftX, toCanvasY(0), yAxisLeftX, toCanvasY(hLeftMM),
|
|
'Y: ' + Math.round(hLeftMM) + 'mm', Math.max(10, yAxisLeftX - 4), (toCanvasY(0) + toCanvasY(hLeftMM)) / 2);
|
|
|
|
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
|
|
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
|
|
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
|
|
|
|
const drawStudFront = (xMM, isJack) => {
|
|
const xLeft = xMM - CONFIG.studWidth / 2;
|
|
const xRight = xMM + CONFIG.studWidth / 2;
|
|
const yTopLeft = topYAtMM(xLeft) - vertOffsetMM;
|
|
const yTopRight = topYAtMM(xRight) - vertOffsetMM;
|
|
const yBot = CONFIG.plateThick;
|
|
ctxFront.fillStyle = isJack ? '#a37c45' : '#c29b62';
|
|
ctxFront.strokeStyle = '#8a6836';
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(yBot));
|
|
ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yBot));
|
|
ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yTopRight));
|
|
ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(yTopLeft));
|
|
ctxFront.closePath();
|
|
ctxFront.fill();
|
|
ctxFront.stroke();
|
|
};
|
|
calcData.fullStuds.forEach(x => drawStudFront(x, false));
|
|
calcData.jackStuds.forEach(j => drawStudFront(j.x, true));
|
|
|
|
const startXMM = -halfWMM;
|
|
frontOpeningRects = [];
|
|
frontHandleRects = [];
|
|
|
|
CONFIG.openings.forEach(op => {
|
|
const opLeftMM = startXMM + op.x;
|
|
const x0 = toCanvasX(opLeftMM);
|
|
const x1 = toCanvasX(opLeftMM + op.w);
|
|
const y0 = toCanvasY(op.y);
|
|
const y1 = toCanvasY(op.y + op.h);
|
|
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 });
|
|
|
|
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.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
|
|
ctxFront.lineWidth = isSelected ? 2.5 : 1;
|
|
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
|
|
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1);
|
|
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);
|
|
|
|
if (isSelected) {
|
|
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2;
|
|
const handles = [
|
|
{ handle: 'n', cx: midX, cy: y1 },
|
|
{ handle: 's', cx: midX, cy: y0 },
|
|
{ handle: 'e', cx: x1, cy: midY },
|
|
{ handle: 'w', cx: x0, cy: midY }
|
|
];
|
|
handles.forEach(hd => {
|
|
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
|
|
ctxFront.fillStyle = '#4da6ff';
|
|
ctxFront.strokeStyle = '#0a1a2a';
|
|
ctxFront.beginPath();
|
|
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
|
|
ctxFront.fill();
|
|
ctxFront.stroke();
|
|
});
|
|
}
|
|
});
|
|
|
|
drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM);
|
|
|
|
// Gevellampen (buitenverlichting woning)
|
|
lampMarks.forEach(l => {
|
|
const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y);
|
|
ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1;
|
|
ctxFront.fillRect(x - 4, y - 7, 8, 14);
|
|
ctxFront.strokeRect(x - 4, y - 7, 8, 14);
|
|
});
|
|
}
|
|
|
|
// Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay.
|
|
function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) {
|
|
seamCuts.forEach(([u0, u1]) => {
|
|
const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1);
|
|
const yTop = toCanvasY(maxRoofMM), yBot = toCanvasY(0);
|
|
ctxFront.save();
|
|
ctxFront.fillStyle = 'rgba(17, 17, 17, 0.8)';
|
|
ctxFront.fillRect(x0, yTop, x1 - x0, yBot - yTop);
|
|
ctxFront.beginPath();
|
|
ctxFront.rect(x0, yTop, x1 - x0, yBot - yTop);
|
|
ctxFront.clip();
|
|
ctxFront.strokeStyle = '#ff9f43';
|
|
ctxFront.lineWidth = 1;
|
|
for (let d = -(yBot - yTop); d < x1 - x0; d += 12) {
|
|
ctxFront.beginPath();
|
|
ctxFront.moveTo(x0 + d, yBot);
|
|
ctxFront.lineTo(x0 + d + (yBot - yTop), yTop);
|
|
ctxFront.stroke();
|
|
}
|
|
ctxFront.restore();
|
|
ctxFront.fillStyle = '#ff9f43';
|
|
ctxFront.font = 'bold 10px sans-serif';
|
|
ctxFront.textAlign = 'center';
|
|
ctxFront.fillText('NAAD — VERVALT', (x0 + x1) / 2, (yTop + yBot) / 2);
|
|
});
|
|
}
|
|
|
|
function frontHandleAt(mx, my) {
|
|
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));
|
|
}
|
|
|
|
canvas2DFront.classList.add('interactive');
|
|
canvas2DFront.addEventListener('mousedown', (e) => {
|
|
const rect = canvas2DFront.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
|
|
if (selectedOpeningId !== null) {
|
|
const hit = frontHandleAt(mx, my);
|
|
if (hit) {
|
|
const resolved = frontOpeningRects.find(r => r.id === hit.id);
|
|
dragInfo = {
|
|
type: '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
|
|
};
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const bodyHit = frontOpeningAt(mx, my);
|
|
if (bodyHit) {
|
|
selectedOpeningId = bodyHit.id;
|
|
dragInfo = {
|
|
type: 'move', openingId: bodyHit.id,
|
|
startMouseX: e.clientX, startMouseY: e.clientY,
|
|
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h
|
|
};
|
|
highlightSelectedCard();
|
|
draw2D();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (selectedOpeningId !== null) {
|
|
selectedOpeningId = null;
|
|
highlightSelectedCard();
|
|
draw2D();
|
|
}
|
|
});
|
|
|
|
function onFrontMouseMove(e) {
|
|
if (!dragInfo) return;
|
|
const dxMM = (e.clientX - dragInfo.startMouseX) / frontView.scale;
|
|
const dyMM = -(e.clientY - dragInfo.startMouseY) / frontView.scale;
|
|
|
|
const op = openings.find(o => o.id === dragInfo.openingId);
|
|
if (!op) { dragInfo = null; return; }
|
|
const wallWidth = CONFIG.wallWidth;
|
|
|
|
if (dragInfo.type === 'move') {
|
|
const newLocalX = snap50(dragInfo.startLocalX + dxMM);
|
|
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);
|
|
op.y = Math.max(0, newY);
|
|
op.h = top - op.y;
|
|
} else if (dragInfo.handle === 'e') {
|
|
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
|
|
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
|
|
} else if (dragInfo.handle === 'w') {
|
|
const right = dragInfo.startLocalX + dragInfo.startW;
|
|
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
|
|
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);
|
|
}
|
|
}
|
|
|
|
syncOpeningCardInputs(op);
|
|
updateAll();
|
|
}
|
|
function onFrontMouseUp() { dragInfo = null; }
|
|
|
|
window.addEventListener('mousemove', onFrontMouseMove);
|
|
window.addEventListener('mouseup', onFrontMouseUp);
|
|
|
|
const canvas2DSide = document.getElementById('canvas-2d-side');
|
|
const ctxSide = canvas2DSide.getContext('2d');
|
|
|
|
function draw2DSide() {
|
|
const rect = canvas2DSide.parentElement.getBoundingClientRect();
|
|
canvas2DSide.width = rect.width;
|
|
canvas2DSide.height = rect.height;
|
|
const w = canvas2DSide.width, h = canvas2DSide.height;
|
|
ctxSide.clearRect(0, 0, w, h);
|
|
|
|
const buitenThick = CONFIG.sheathingThick;
|
|
const spaanThick = CONFIG.sheathingThick;
|
|
const totalDepth = buitenThick + CONFIG.studDepth + spaanThick;
|
|
const maxH = Math.max(CONFIG.wallHeightLeft, CONFIG.wallHeightRight);
|
|
|
|
const scale = Math.min((w * 0.45) / totalDepth, (h * 0.8) / maxH);
|
|
const originX = w / 2 - (totalDepth * scale) / 2;
|
|
const floorY = h - 24;
|
|
const toX = (mm) => originX + mm * scale;
|
|
const toY = (mm) => floorY - mm * scale;
|
|
|
|
const layers = [
|
|
{ thick: buitenThick, color: { cementplaat: '#808285', spaanplaat: '#1e4620' }[buitenMateriaal] || '#444444' },
|
|
{ thick: CONFIG.studDepth, color: '#c29b6255' },
|
|
{ thick: spaanThick, color: '#1e4620' }
|
|
];
|
|
let cursor = 0;
|
|
layers.forEach(layer => {
|
|
ctxSide.fillStyle = layer.color;
|
|
ctxSide.strokeStyle = '#000';
|
|
ctxSide.lineWidth = 0.5;
|
|
ctxSide.fillRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH));
|
|
ctxSide.strokeRect(toX(cursor), toY(maxH), layer.thick * scale, toY(0) - toY(maxH));
|
|
ctxSide.fillStyle = '#fff';
|
|
ctxSide.font = '9px sans-serif';
|
|
ctxSide.textAlign = 'center';
|
|
ctxSide.fillText(String(layer.thick) + 'mm', toX(cursor) + (layer.thick * scale) / 2, floorY + 14);
|
|
cursor += layer.thick;
|
|
});
|
|
|
|
ctxSide.strokeStyle = '#4da6ff';
|
|
ctxSide.beginPath();
|
|
ctxSide.moveTo(toX(totalDepth) + 15, toY(0));
|
|
ctxSide.lineTo(toX(totalDepth) + 15, toY(maxH));
|
|
ctxSide.stroke();
|
|
ctxSide.fillStyle = '#4da6ff';
|
|
ctxSide.font = '9px sans-serif';
|
|
ctxSide.save();
|
|
ctxSide.translate(toX(totalDepth) + 24, (toY(0) + toY(maxH)) / 2);
|
|
ctxSide.rotate(-Math.PI / 2);
|
|
ctxSide.textAlign = 'center';
|
|
ctxSide.fillText('Y: ' + Math.round(maxH) + 'mm', 0, 0);
|
|
ctxSide.restore();
|
|
}
|
|
|
|
function draw2D() {
|
|
draw2DTop();
|
|
draw2DFront();
|
|
draw2DSide();
|
|
}
|
|
|
|
// --- THREE.JS 3D RENDERER ---
|
|
const pane3D = document.getElementById('pane-3d');
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x1a1a1a);
|
|
|
|
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 100);
|
|
camera.position.set(3.5, 2.0, 4.5);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.shadowMap.enabled = true;
|
|
pane3D.appendChild(renderer.domElement);
|
|
|
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
const cameraKey = callbacks && callbacks.cameraKey;
|
|
if (!CameraMemory.restore(cameraKey, camera, controls)) {
|
|
controls.target.set(0, 1.2, 0);
|
|
controls.update();
|
|
}
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
dirLight.position.set(5, 8, 5);
|
|
dirLight.castShadow = true;
|
|
scene.add(dirLight);
|
|
|
|
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222));
|
|
|
|
const wallMaterials = WallModel.createMaterials();
|
|
|
|
// Buitenzijde-materiaalkeuze (tab 3): 'vochtdoek' of 'cementplaat',
|
|
// wederzijds exclusief. Binnenzijde is altijd spaanplaat (geen alternatief).
|
|
// (Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van
|
|
// Project eigenschappen heeft ook de klasse material-toggle-btn.)
|
|
let buitenMateriaal = 'vochtdoek';
|
|
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
|
|
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
|
|
function syncMaterialButtons() {
|
|
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
|
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
|
document.getElementById('cladding-section').style.display = buitenMateriaal === 'vochtdoek' ? '' : 'none';
|
|
if (lockCladding) document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => { b.disabled = true; b.title = lockCladding; });
|
|
}
|
|
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(btn => {
|
|
btn.addEventListener('click', () => { buitenMateriaal = btn.dataset.material; syncMaterialButtons(); updateAll(); });
|
|
});
|
|
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(btn => {
|
|
btn.addEventListener('click', () => { cladding = btn.dataset.cladding; syncMaterialButtons(); updateAll(); });
|
|
});
|
|
|
|
let spaanplaatVisible = true;
|
|
let buitenVisible = true;
|
|
let isolatieVisible = true;
|
|
// Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie.
|
|
createViewFilter(document.getElementById('wall-toolbar-3d'), [
|
|
{ key: 'binnen', label: 'Beplating binnenzijde' },
|
|
{ key: 'buiten', label: 'Beplating buitenzijde' },
|
|
{ key: 'isolatie', label: 'Isolatie' }
|
|
], (v) => {
|
|
spaanplaatVisible = v.binnen;
|
|
buitenVisible = v.buiten;
|
|
isolatieVisible = v.isolatie;
|
|
update3DScene();
|
|
});
|
|
|
|
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
|
|
// met de unit-editor).
|
|
let lastWallStats = null;
|
|
|
|
function update3DScene() {
|
|
if (wallGroup3D) scene.remove(wallGroup3D);
|
|
const result = WallModel.build3D(CONFIG, calcData, {
|
|
materials: wallMaterials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, isolatieVisible
|
|
});
|
|
wallGroup3D = result.group;
|
|
lastWallStats = result.stats;
|
|
scene.add(wallGroup3D);
|
|
|
|
updateSpecificaties();
|
|
}
|
|
|
|
// ============================================================
|
|
// SPECIFICATIES (tab 3)
|
|
// ============================================================
|
|
function fmt(n, decimals = 1) {
|
|
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
}
|
|
|
|
function setSpecAll(key, text) {
|
|
document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; });
|
|
}
|
|
|
|
function updateSpecificaties() {
|
|
const specs = WallModel.computeSpecs(CONFIG, lastWallStats, buitenMateriaal);
|
|
const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek';
|
|
const buitenVolumeText = specs.buitenVolumeM3 === null ? 'n.v.t. (rolmateriaal)' : fmt(specs.buitenVolumeM3, 3) + ' m3';
|
|
|
|
setSpecAll('wood-m3', fmt(lastWallStats.woodVolumeM3, 3) + ' m3');
|
|
setSpecAll('wood-kg', fmt(specs.woodKg) + ' kg');
|
|
|
|
setSpecAll('spaanplaat-m2', fmt(lastWallStats.spaanplaatAreaM2) + ' m2');
|
|
setSpecAll('spaanplaat-m3', fmt(lastWallStats.spaanplaatVolumeM3, 3) + ' m3');
|
|
setSpecAll('spaanplaat-kg', fmt(specs.spaanplaatKg) + ' kg');
|
|
|
|
setSpecAll('buiten-materiaalnaam', buitenNaam);
|
|
setSpecAll('buiten-m2', fmt(specs.buitenAreaM2) + ' m2');
|
|
setSpecAll('buiten-m3', buitenVolumeText);
|
|
setSpecAll('buiten-kg', fmt(specs.buitenKg) + ' kg');
|
|
setSpecAll('latten', lastWallStats.battenCount
|
|
? `${lastWallStats.battenCount} st. \u00e0 ${WallModel.BATTEN.length} mm (${fmt(lastWallStats.battenLengthM, 1)} m, ${fmt(specs.battenKg)} kg)`
|
|
: 'n.v.t.');
|
|
|
|
setSpecAll('total-kg', fmt(specs.totalKg) + ' kg');
|
|
|
|
setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3');
|
|
setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks');
|
|
|
|
// Inkoop in handelslengtes (zaagplan uit WallModel).
|
|
const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData));
|
|
const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0);
|
|
const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0);
|
|
setSpecAll('stock-list', WallModel.purchaseText(purchase));
|
|
setSpecAll('stock-waste', fmt(wasteMM / 1000, 2) + ' m (' + fmt(stockMM ? wasteMM / stockMM * 100 : 0) + '%)');
|
|
}
|
|
|
|
function updateAll() {
|
|
recalculate();
|
|
draw2D();
|
|
update3DScene();
|
|
}
|
|
|
|
const inputs = document.querySelectorAll('#tab-wall input');
|
|
inputs.forEach(input => {
|
|
input.addEventListener('input', updateAll);
|
|
});
|
|
|
|
let __rafId = null;
|
|
function animate() {
|
|
__rafId = requestAnimationFrame(animate);
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
function onWindowResize() {
|
|
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
|
|
draw2D();
|
|
}
|
|
|
|
window.addEventListener('resize', onWindowResize);
|
|
|
|
renderOpeningsList();
|
|
updateAll();
|
|
animate();
|
|
|
|
return {
|
|
unmount() {
|
|
window.removeEventListener('resize', onWindowResize);
|
|
window.removeEventListener('mousemove', onFrontMouseMove);
|
|
window.removeEventListener('mouseup', onFrontMouseUp);
|
|
if (__rafId !== null) cancelAnimationFrame(__rafId);
|
|
CameraMemory.save(cameraKey, camera, controls);
|
|
renderer.dispose();
|
|
},
|
|
exportDesign,
|
|
triggerImport,
|
|
mirrorHorizontal,
|
|
toggleFullscreen3D,
|
|
toggleFullscreen2D,
|
|
openProjectProperties,
|
|
getStateJson: getPureState,
|
|
loadStateJson: applyPureState
|
|
};
|
|
}
|
|
|
|
window.WallEditor = {
|
|
mount: mountWallEditor,
|
|
getDefaultState: () => ({
|
|
wall: {
|
|
width: 6000, heightAll: 2400, heightLeft: 2400, heightRight: 2400,
|
|
studWidth: 38, studDepth: 235, plateThick: 38, plateDepth: 235,
|
|
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
|
|
},
|
|
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
|
|
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }]
|
|
})
|
|
};
|