v0.6.3: modellen Waal (12,85 × 7 m, flauw zadeldak dat aan de oostkop 1,5 m doorloopt als veranda), Maas (9,2 × 9,2 m, woningdak met inpandige veranda in de noordwesthoek) en Rijn (L-vorm 13 × 10 m, zadeldak als twee lessenaars op de naad, vleugel met plat dak, steenstrips met schoorsteenstrook, kookeiland, logeerkamer, spots in de boeidelen); overkapping per unit (dak verlengen voorbij de gevel) met palen (PostModel: hout, maat, h.o.h., op de plattegrond/PDF en in de kosten); kleur schakelaars/wcd's (wit, antraciet, zwart); gevellampen naast openingen en per vrij wandstuk gelijk verdeeld, nooit in een opening; logobord niet meer onder een unit bij een voordeur in een binnenhoek

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 14:26:06 +02:00
co-authored by Claude Opus 5.5
parent b137a458d4
commit 86fcd57b96
14 changed files with 586 additions and 35 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.6.2';
const APP_VERSION = '0.6.3';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
+4 -1
View File
@@ -120,7 +120,10 @@ window.COST_DEFAULTS = {
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 }
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 }
}
},
+6 -1
View File
@@ -230,7 +230,7 @@ window.CostModel = (() => {
}
});
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
const wo = newEl('WON', 'Woning (dakrand, overkapping, fundering, verlichting)', null);
const D = S.dak;
if (totals.roofEdge) {
const e = totals.roofEdge;
@@ -238,6 +238,11 @@ window.CostModel = (() => {
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
}
if (totals.posts) {
const pp = totals.posts;
add(wo, 'Hout', 'Dak', `${pp.wood} palen ${pp.size}×${pp.size} mm (${pp.count} st.)`, pp.lengthM, 'm', pp.m3 * D.paalHoutM3, pp.count * D.paalMontage);
add(wo, 'Staal', 'Dak', 'Paalvoeten', pp.count, 'st.', pp.count * D.paalvoet, 0);
}
if (parts.sandwich) {
const sw = parts.sandwich;
add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage);
+15 -6
View File
@@ -12,6 +12,7 @@ window.ElektraModel = (() => {
switchHeight: 1500,
wcdHeight: 400,
wcdPerUnit: 2,
plateColor: 'wit', // schakelaars, wcd's, apparaat-wcd's en vloerdozen
slack: 10, // %
reserve: 300,
meterkastReserve: 500,
@@ -32,6 +33,11 @@ window.ElektraModel = (() => {
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
};
const PLATE_COLORS = {
wit: { label: 'Wit', hex: 0xf6f6f2 },
antraciet: { label: 'Antraciet', hex: 0x3a3d41 },
zwart: { label: 'Zwart', hex: 0x161718 }
};
const BOX_PITCH = 71;
const PLATE = 80;
const WCD_W = 150;
@@ -62,6 +68,7 @@ window.ElektraModel = (() => {
s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
s.enabled = s.enabled !== false;
if (!PLATE_COLORS[s.plateColor]) s.plateColor = DEFAULT_SETTINGS.plateColor;
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
if (!i.v) {
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
@@ -561,6 +568,7 @@ window.ElektraModel = (() => {
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
const m = out.materials;
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
const kleur = ', ' + PLATE_COLORS[s.plateColor].label.toLowerCase();
if (out.meterkast) {
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
add('Hoofdschakelaar 4-polig 40 A', 1);
@@ -571,13 +579,13 @@ window.ElektraModel = (() => {
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
add('Schakelaar enkelpolig (inbouw)', out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
add('Wandcontactdoos dubbel met randaarde', out.wcds.length);
add('Vloerdoos met dubbele wcd (randaarde)', out.floorWcds.length);
add('Schakelaar enkelpolig (inbouw)' + kleur, out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig` + kleur, frames[n]));
add('Wandcontactdoos dubbel met randaarde' + kleur, out.wcds.length);
add('Vloerdoos met dubbele wcd (randaarde)' + kleur, out.floorWcds.length);
const appMat = {};
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
Object.keys(appMat).forEach(n => add(n, appMat[n]));
Object.keys(appMat).forEach(n => add(n + kleur, appMat[n]));
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
@@ -731,6 +739,7 @@ window.ElektraModel = (() => {
const toM = 0.001, group = new THREE.Group();
if (!el || !el.enabled) return group;
const mats = materials || createMaterials();
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
const rough = show('elektra') && !show('beplating');
const box = (w, h, d, c, rotY, mat) => {
@@ -864,5 +873,5 @@ window.ElektraModel = (() => {
};
}
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
return { DEFAULT_SETTINGS, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
})();
+309 -5
View File
@@ -306,7 +306,7 @@ window.HouseModel = (() => {
Object.assign(S.facade, { type: 'stuc', stucColor: 'wit' });
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
S.roof.fasciaColor = 'zwart';
S.lighting.mode = 'gevel';
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
@@ -368,11 +368,298 @@ window.HouseModel = (() => {
return st;
}
// Model Waal (examples/ontwerp_waal.pdf): 12,85 × 7 m, twee units van 3,5 m met één flauw zadeldak over de
// woning (nok west–oost op de naad). Het dak loopt aan de oostkop 1,5 m door als veranda (10,5 m²) op drie
// eiken palen (PostModel). Geen naadwand: slaapkamer en woonkamer/keuken lopen over beide units door; bij de
// naad staat aan elke kant een binnenwand (badkamer/keuken | hal). Alles buitenwerks vanaf de noordwesthoek.
function createWaal() {
const st = newModelState('Waal');
const S = st.settings, LO = 2800, HI = 3400, W = 12850, D = 3500;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'grijs' });
S.cladding = 'verticaal';
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
Object.assign(S.roof, { fasciaColor: 'wit', overhang: RoofModel.OVERHANG_MAX, fasciaHeight: 350 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
const A = createUnitEntry('A', null, { width: W, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI } });
[A, B].forEach(u => { u.roof.extend = { f: 0, r: 1500, b: 0, l: 0 }; });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 800, 500, 1600, 'right', 3490, K('raam')],
['raam', 800, 1100, 1000, 'right', 6530, K('raam')],
['raam', 1400, 1100, 1000, 'right', 8510, K('raam')]]);
ops(A.unit.walls.links, [['raam', 500, 1400, 700, 'left', 1170, K('raam')]]);
ops(B.unit.walls.links, [['raam', 500, 1400, 700, 'left', 390, K('raam')], ['raam', 1400, 1100, 1000, 'left', 1570, K('raam')]]);
// Oostkop onder de veranda: schuifdeur bij de keuken, openslaande deuren bij de woonkamer.
ops(A.unit.walls.rechts, [['deur', 1800, 2100, 0, 'right', 880,
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
ops(B.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 860, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 1760, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
ops(B.unit.walls.achter, [
['raam', 500, 1400, 700, 'left', 3530, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 4310, K('deur', { hinge: 'links', swing: 'buiten', glas: true })],
...[7130, 8230, 10470, 11570].map(x => ['raam', 500, 2100, 0, 'left', x, K('raam')])]);
B.lighting.walls.achter = 'openingen';
const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(3235, 190, 3235, 3430, [['deur', 980, 2130, 'left', 1530, // slaapkamer | badkamer
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(5325, 190, 5325, 3430); // badkamer | keuken
iw(3280, 3510, 6535, 3510, [], 160); // badkamer, keuken | hal (over de naad)
iw(3235, 3590, 3235, 4655, [['deur', 930, DH, 'left', 75, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal → slaapkamer
iw(6490, 3590, 6490, 6810, [['deur', 930, DH, 'left', 72, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal, entree | woonkamer
iw(190, 4700, 6445, 4700, [ // slaapkamer, hal | berging, toilet, entree
['deur', 930, DH, 'left', 120, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', 930, DH, 'left', 4200, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
iw(2248, 4745, 2248, 5819); // berging | toilet
iw(2203, 5864, 3607, 5864); // toilet | berging, entree
iw(2938, 5909, 2938, 6810); // berging | entree
iw(3562, 4745, 3562, 5819, [['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet → entree
const plafond = { ceiling: LO };
room('A', 9500, 1800, 'woonkeuken', 'Woonkamer/keuken', plafond);
room('A', 1500, 1800, 'slaapkamer', 'Slaapkamer', plafond);
room('A', 4300, 2200, 'badkamer', '', plafond);
room('B', 4800, 600, 'hal', 'Hal', plafond);
room('B', 2900, 1800, 'toilet', '', plafond);
room('B', 4700, 2700, 'hal', 'Entree', plafond);
room('B', 1200, 2400, 'berging', 'Berging', plafond);
const zB = (z) => z - D;
fx('kledingkast', 'A', 1690, 490, 0, { w: 3000 });
fx('bed', 'A', 1240, 2770, 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('douche', 'A', 4585, 640, 90, { w: 900, d: 1390 }, { glas: 'rechts', glasLengte: 700 });
fx('wastafel', 'A', 5050, 1920, 90, { w: 800 });
fx('toilet', 'A', 3790, 3085, 180, { w: 1000 });
fx('keuken', 'A', 5670, 1810, 270, { w: 3240 }, { front: 'antraciet', hogeKast: 'links', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.45 });
fx('keuken', 'A', 7025, 490, 0, { w: 2110 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.5, kookplaat: false });
fx('eettafel', 'A', 10230, 1730, 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
fx('bank', 'B', 9820, zB(5365), 90, { w: 2500, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
fx('fauteuil', 'B', 8420, zB(3910), 330);
fx('salontafel', 'B', 8180, zB(5180), 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('tvmeubel', 'B', 6735, zB(5815), 270, { w: 1500, d: 400 });
fx('kledingkast', 'B', 6145, zB(5777), 90, { w: 2060 });
fx('toilet', 'B', 2638, zB(5312), 270, { w: 1000 });
fx('boiler', 'B', 1810, zB(4970), 0);
fx('wasmachine', 'B', 2500, zB(6510), 180);
computeParts(st, { skip3D: true });
return st;
}
// Model Maas (examples/ontwerp_maas.pdf): vierkant 9,2 × 9,2 m onder één flauw zadeldak (nok west–oost op de
// naad). Twee units van 4,6 m diep: noord 6,7 m breed (oost uitgelijnd), zuid 9,2 m. De overkapping van het
// woningdak maakt van de noordwesthoek een inpandige veranda (2,5 × 4,6 m) op eiken palen. Maten buitenwerks vanaf
// de noordwesthoek van de woning; unit A begint 2,5 m oostelijker (X = interieur-coördinaten).
function createMaas() {
const st = newModelState('Maas');
const S = st.settings, LO = 2800, HI = 3500, D = 4600, V = 2500;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel' });
S.cladding = 'verticaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: 300, fasciaHeight: 300 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
const A = createUnitEntry('A', null, { width: 9200 - V, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI }, cover: true });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 800, 1500, 600, 'right', 970, K('raam')],
['raam', 800, 1500, 600, 'right', 2715, K('raam')],
['raam', 800, 1100, 1000, 'right', 4815, K('raam')]]);
// Westgevel onder de veranda: openslaande deuren met een vast zijlicht aan beide kanten (samen 3000 × 2100).
ops(A.unit.walls.links, [
['raam', 600, 2100, 0, 'left', 770, K('raam')],
['deur', 900, 2100, 0, 'left', 1370, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'left', 2270, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['raam', 600, 2100, 0, 'left', 3170, K('raam')]]);
ops(B.unit.walls.voor, [['raam', 1100, 1500, 600, 'right', 315, K('raam')]]);
ops(B.unit.walls.links, [['raam', 800, 1500, 600, 'left', 200, K('raam')]]);
ops(B.unit.walls.rechts, [['raam', 800, 1100, 1000, 'right', 210, K('raam')]]);
ops(B.unit.walls.achter, [
['raam', 500, 700, 1400, 'left', 3525, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 5675, K('deur', { hinge: 'links', swing: 'buiten' })],
['raam', 500, 1500, 600, 'left', 8375, K('raam')]]);
ensureSeamWalls(st);
const seam = st.interiorWalls.find(w => w.seam);
if (seam) {
const tA = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.links));
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: 2115, y: 0, anchor: 'left', offset: Math.round(5790 - V - tA),
fill: binnen({ hinge: 'rechts', swing: 'binnen' }) }];
}
const DH = 2115, X = (x) => x - V;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(X(3250), 4700, X(3250), 9010, [ // slaapkamer | hal, toilet, badkamer
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', 980, 2130, 'left', 2780, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(X(3295), 5740, X(5620), 5740, [['deur', 930, DH, 'left', 665, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal | toilet, schacht
iw(X(5075), 5785, X(5075), 6865); // toilet | schacht
iw(X(3295), 6910, X(5530), 6910); // toilet, schacht | badkamer
iw(X(5575), 5785, X(5575), 9010); // schacht, badkamer | entree
iw(X(6865), 4700, X(6865), 9010, [ // entree | logeerkamer, berging
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })],
['deur', 930, DH, 'left', 3185, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
iw(X(6910), 6915, X(9010), 6915); // logeerkamer | berging
const plafond = { ceiling: LO };
room('A', 2500, 2000, 'woonkeuken', 'Woonkamer/keuken');
room('B', 1500, 2000, 'slaapkamer', 'Slaapkamer', plafond);
room('B', 4100, 1700, 'toilet');
room('B', 4000, 3300, 'badkamer');
room('B', 6200, 2500, 'hal', 'Entree', plafond);
room('B', 7900, 1100, 'slaapkamer', 'Logeerkamer', plafond);
room('B', 7900, 3300, 'berging', 'Berging', plafond);
const zB = (z) => z - D;
fx('keuken', 'A', X(7620), 490, 0, { w: 1580 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.58, kookplaat: false });
fx('keuken', 'A', X(8710), 2290, 90, { w: 4200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.43 });
fx('eettafel', 'A', X(5860), 1090, 0, { w: 1000, d: 1000 }, { vorm: 'ovaal', stoelen: 4 });
fx('bank', 'A', X(4020), 3770, 180, { w: 2550, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
fx('salontafel', 'A', X(4200), 2710, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'A', X(3480), 1460, 330);
fx('bed', 'B', 1240, zB(6520), 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('kledingkast', 'B', 1700, zB(8710), 180, { w: 3000 });
fx('toilet', 'B', 3600, zB(6325), 270, { w: 1000 });
fx('wastafel', 'B', 3760, zB(7205), 0, { w: 800 });
fx('toilet', 'B', 3760, zB(8700), 180, { w: 1000 });
fx('douche', 'B', 5090, zB(8265), 180, { w: 900, d: 1500 }, { glas: 'rechts', glasLengte: 700 });
fx('bed', 'B', 7960, zB(6430), 90, { w: 900, d: 2100 }, { nachtkastjes: false });
fx('wasmachine', 'B', 7280, zB(7260), 0);
fx('boiler', 'B', 8700, zB(7260), 0);
computeParts(st, { skip3D: true });
return st;
}
// Model Rijn (examples/ontwerp_rijn.pdf): L-vorm 13 × 10 m, hoofdvolume 13 × 6,54 m (twee units van 3,27 m) met
// een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die
// op de naad de nok vormen (een woningdak zou ook de vleugel overspannen); de vleugel heeft een plat dak op
// goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand.
// Steenstrips rondom, in de oostgevel een donkere strook waar de schoorsteen staat. Maten buitenwerks vanaf de
// noordwesthoek, zoals de tekening (de steenstrips komen daar in de configurator buiten de units nog bij).
function createRijn() {
const st = newModelState('Rijn');
const S = st.settings, LO = 2800, HI = 3500, D = 3270, BRICK = 'R752', SCHOORSTEEN = 'R777';
Object.assign(S.facade, { type: 'steen', stone: BRICK });
Object.assign(S.trasraam, { enabled: true, height: 500, finish: 'R669' });
Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 350 });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
const A = createUnitEntry('A', null, { width: 13000, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 4200, depth: 3460, height: LO, windowOn: [] });
st.units.push(A, B, C);
UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'achter', low: LO, high: HI });
UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: LO, high: HI });
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
ops(A.unit.walls.voor, [
['raam', 500, 1400, 700, 'right', 1045, K('raam')],
['raam', 500, 1400, 700, 'right', 3630, K('raam')],
['raam', 1200, 500, 1600, 'right', 4760, K('raam')],
['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]);
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', 785, K('raam')]]);
ops(B.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 730, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 1630, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
ops(B.unit.walls.links, [['raam', 1400, 1100, 1000, 'left', 465, K('raam')]]);
ops(B.unit.walls.achter, [
['raam', 1100, 2100, 0, 'left', 4680, K('raam')],
['raam', 1800, 2100, 0, 'left', 10490, K('raam')]]);
ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, K('raam')]]);
ops(C.unit.walls.rechts, [['voordeur', 1100, 2100, 0, 'right', 70, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]);
// Schoorsteen midden in de oostgevel (z 2870–3670). u loopt van buiten gezien van links (zuid) naar rechts.
A.facade.walls.rechts = { type: 'steen', stone: SCHOORSTEEN };
A.unit.walls.rechts.splits = [{ u: 400, facade: { type: 'steen', stone: BRICK } }];
B.unit.walls.rechts.splits = [{ u: 2870, facade: { type: 'steen', stone: SCHOORSTEEN } }];
const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
iw(190, 3266, 6513, 3266, [['deur', 930, DH, 'left', 2800, binnen({ hinge: 'links', swing: 'buiten' })]], 160); // slaapkamer, badkamer | keuken (langs de nok)
iw(4380, 190, 4380, 3186, [['deur', 980, 2130, 'left', 1136, // slaapkamer | badkamer
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
iw(6466, 190, 6466, 3186); // badkamer | woonkamer
iw(5800, 1135, 6421, 1135); // muurtje naast de douche
iw(2762, 3346, 2762, 6440, [['deur', 930, DH, 'left', 104, binnen({ hinge: 'links', swing: 'buiten' })]]); // berging | keuken
iw(1880, 6640, 1880, 7655, [['deur', 930, DH, 'left', 40, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | entree
iw(190, 7700, 3107, 7700, [['deur', 930, DH, 'left', 1820, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet, entree | logeerkamer
iw(3062, 7745, 3062, 8335); // kapstoknis entree
iw(3107, 8290, 4010, 8290); // kapstoknis | kast logeerkamer
ensureSeamWalls(st, 'C');
const seam = st.interiorWalls.find(w => w.seam);
if (seam) {
const tC = WallModel.thicknessOf(WallModel.configFromState(C.unit.walls.links));
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: Math.round(2915 - tC),
fill: binnen({ hinge: 'links', swing: 'buiten' }) }];
}
const plafond = { ceiling: LO };
room('A', 9500, 1500, 'woonkeuken', 'Woonkamer/keuken');
room('A', 2600, 1700, 'slaapkamer', 'Slaapkamer', plafond);
room('A', 5400, 2000, 'badkamer', '', plafond);
room('B', 1400, 1500, 'berging', 'Berging', plafond);
room('C', 1000, 500, 'toilet');
room('C', 2400, 500, 'hal', 'Entree');
room('C', 1500, 2300, 'slaapkamer', 'Logeerkamer');
const zB = (z) => z - D, zC = (z) => z - 2 * D;
fx('kledingkast', 'A', 493, 1688, 270, { w: 2990 });
fx('bed', 'A', 2615, 1240, 0, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('douche', 'A', 5725, 640, 0, { w: 1400, d: 900 }, { glas: 'links', glasLengte: 900 });
fx('wastafel', 'A', 6191, 2000, 90, { w: 800 });
fx('toilet', 'A', 4998, 2680, 180);
fx('bank', 'A', 11195, 800, 0, { w: 3000, d: 1000 }, { chaise: 'rechts', chaiseLengte: 2100 });
fx('fauteuil', 'A', 8880, 1720, 315);
fx('salontafel', 'A', 10410, 2280, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('keuken', 'B', 4945, zB(4350), 90, { w: 2000 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.15, kookplaat: true, kookplaatPlek: 0.85 });
fx('kookeiland', 'B', 5545, zB(4350), 270, { w: 2000, d: 600 }, { front: 'antraciet', kookplaat: false });
fx('keuken', 'B', 3110, zB(5080), 270, { w: 1200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false });
fx('eettafel', 'B', 10010, zB(4990), 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
fx('wasmachine', 'B', 620, zB(6140), 180);
fx('wasmachine', 'B', 1450, zB(6140), 180); // droger
fx('boiler', 'B', 2350, zB(6215), 180);
fx('toilet', 'C', 535, zC(7150), 270);
fx('bed', 'C', 650, zC(8745), 0, { w: 900, d: 2000 }, { nachtkastjes: false });
fx('kledingkast', 'C', 3703, zC(9072), 90, { w: 1450 });
computeParts(st, { skip3D: true });
return st;
}
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering },
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel }
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
};
function createDefaultState(template) {
@@ -598,7 +885,9 @@ window.HouseModel = (() => {
ws.materials = Object.assign({}, ws.materials, facadeMaterials(state, facadeFor(state, u, side)));
});
if (u.roof.followFloor) {
const e = cover[u.id] || { l: 0, r: 0, f: 0, b: 0 };
// Overkapping van het woningdak plus de eigen verlenging (veranda) van deze unit.
const c = cover[u.id] || {}, x = u.roof.extend || {};
const e = Object.fromEntries(['l', 'r', 'f', 'b'].map(k => [k, (c[k] || 0) + (x[k] || 0)]));
u.roof.deck.floor.width = u.unit.floor.floor.width + e.l + e.r;
u.roof.deck.floor.depth = u.unit.floor.floor.depth + e.f + e.b;
u.roof.offsetX = (e.r - e.l) / 2;
@@ -610,6 +899,11 @@ window.HouseModel = (() => {
applyRoofJoins(state);
}
const EXTEND_MAX = 4000;
function normalizeExtend(e) {
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
}
const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form);
const ROOF_HEIGHT_KEYS = ['heightAll', 'heightLeft', 'heightRight', 'heightMid', 'topPts'];
@@ -1338,6 +1632,10 @@ window.HouseModel = (() => {
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
const sandwich = state.settings.sandwich && units.length
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
@@ -1350,7 +1648,7 @@ window.HouseModel = (() => {
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
if (fixturesGroup) fixturesGroup.visible = shows('afwerking');
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, rooms, roomsGroup, fixtures, fixturesGroup };
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, rooms, roomsGroup, fixtures, fixturesGroup };
}
function allWallPieces(parts) {
@@ -1489,6 +1787,7 @@ window.HouseModel = (() => {
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
}));
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
if (parts.posts.count) weights.push({ label: 'Palen overkapping', element: 'PAAL', kg: parts.posts.kg });
const isolatie = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
@@ -1568,6 +1867,10 @@ window.HouseModel = (() => {
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
}),
posts: parts.posts.count ? {
count: parts.posts.count, size: parts.posts.size, wood: (PostModel.WOODS[parts.posts.wood] || {}).label || '',
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg
} : null,
roofEdge: parts.roofEdge ? {
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
fasciaHeight: parts.roofEdge.fasciaHeight,
@@ -1659,6 +1962,7 @@ window.HouseModel = (() => {
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer';
u.roof.extend = normalizeExtend(u.roof.extend);
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
u.facade = { walls: (u.facade && u.facade.walls) || {} };
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
@@ -1672,7 +1976,7 @@ window.HouseModel = (() => {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions,
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();
+8
View File
@@ -135,6 +135,10 @@ window.HousePdf = (() => {
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
}
doc.setLineDashPattern([], 0);
doc.setDrawColor(...BLACK);
doc.setLineWidth(0.2);
doc.setFillColor(160, 124, 84);
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107);
@@ -586,6 +590,10 @@ window.HousePdf = (() => {
rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
if (totals.posts) {
const pp = totals.posts;
rows.push(['Palen overkapping', `${pp.count} stuks ${pp.wood.toLowerCase()} ${pp.size}x${pp.size} mm, ${fmt(pp.lengthM, 2)} m, ${fmt(pp.m3, 3)} m3, op paalvoeten`]);
}
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
}
+44 -4
View File
@@ -327,6 +327,7 @@ function mountHouseEditor(container, callbacks) {
const roofMaterials = RoofModel.createMaterials();
const lightingMaterials = LightingModel.createMaterials();
const sandwichMaterials = SandwichModel.createMaterials();
const postMaterials = PostModel.createMaterials();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
@@ -975,6 +976,12 @@ function mountHouseEditor(container, callbacks) {
ctx.beginPath(); ctx.arc(q.x, q.y, 3, 0, Math.PI * 2); ctx.fill();
});
}
ctx.setLineDash([]);
ctx.fillStyle = '#a07c54'; ctx.strokeStyle = '#e8d3b0'; ctx.lineWidth = 1;
parts.posts.posts.forEach(pp => {
const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale;
ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz);
});
ctx.restore();
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
@@ -1238,6 +1245,12 @@ function mountHouseEditor(container, callbacks) {
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
{ label: 'Palen overkapping', html:
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
(r.posts.enabled ? sel('hs-posts-wood', 'Hout', Object.fromEntries(Object.entries(PostModel.WOODS).map(([k, w]) => [k, w.label])), r.posts.wood) +
UI.range('hs-posts-size', 'Maat', r.posts.size, { min: 100, max: 300, step: 10, hint: 'Vierkant, mm' }) +
num('hs-posts-span', 'Max. h.o.h.', r.posts.maxSpan, { step: 100, hint: 'Grootste afstand tussen palen (of paal en gevel)' }) +
num('hs-posts-min', 'Min. uitkraging', r.posts.minExt, { step: 100, hint: 'Kleinere overstekken krijgen geen palen' }) : '') },
{ label: 'Daken per unit', html: `<div data-live="roof-list" class="rb-cols"></div>` });
return groups;
},
@@ -1252,6 +1265,11 @@ function mountHouseEditor(container, callbacks) {
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
UI.onSel('hs-posts-wood', v => { r.posts.wood = v; }, rerender);
UI.onRange('hs-posts-size', v => { if (v > 0) r.posts.size = v; }, after);
UI.onNum('hs-posts-span', v => { if (v >= 1000) r.posts.maxSpan = v; }, after, pos);
UI.onNum('hs-posts-min', v => { if (v >= 400) r.posts.minExt = v; }, after, pos);
const list = () => {
const el = body.querySelector('[data-live="roof-list"]');
if (!el || !parts) return;
@@ -1393,7 +1411,8 @@ function mountHouseEditor(container, callbacks) {
groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) +
num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) });
groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) +
num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) });
num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) +
sel('he-plate', 'Kleur', Object.fromEntries(Object.entries(ElektraModel.PLATE_COLORS).map(([k, c]) => [k, c.label])), e.plateColor || 'wit', { hint: 'Schakelaars, wcd\'s en vloerdozen' }) });
groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) =>
check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') });
const nManual = Object.keys(e.place).length;
@@ -1423,6 +1442,7 @@ function mountHouseEditor(container, callbacks) {
UI.onNum('he-sw-h', v => { e.switchHeight = v; }, after, pos);
UI.onNum('he-wcd-h', v => { e.wcdHeight = v; }, after, pos);
UI.onNum('he-wcd-n', v => { e.wcdPerUnit = Math.max(0, Math.round(v)); }, after);
UI.onSel('he-plate', v => { e.plateColor = v; }, rerender);
UI.onNum('he-slack', v => { e.slack = v; }, after);
UI.onNum('he-res', v => { e.reserve = v; }, after);
UI.onNum('he-mk-res', v => { e.meterkastReserve = v; }, after);
@@ -1860,6 +1880,9 @@ function mountHouseEditor(container, callbacks) {
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf'].includes(shapeNow)
? toggle('hp-steel-ridge', 'Stalen nokbalk', u.roof.steelRidge !== false, { hint: pu && pu.roof.unitRoof.ridgeBeam ? `${pu.roof.unitRoof.ridgeBeam.profile.name}, ${Math.round(pu.roof.unitRoof.ridgeBeam.steelKg)} kg; uit = verwijderen` : 'Uit = verwijderd' }) : '') +
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
if (u.roof.followFloor) groups.push({ label: 'Overkapping', html:
['f', 'r', 'b', 'l'].map(k => num('hp-ext-' + k, { f: 'Noord', r: 'Oost', b: 'Zuid', l: 'West' }[k], (u.roof.extend || {})[k] || 0,
{ hint: k === 'f' ? `Dak verlengen voorbij de gevel (veranda), max. ${HouseModel.EXTEND_MAX} mm; palen volgen vanzelf` : '' })).join('') });
if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html:
num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) +
num('hp-roof-ox', 'Versch. oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Versch. zuid', u.roof.offsetZ || 0) });
@@ -1897,6 +1920,9 @@ function mountHouseEditor(container, callbacks) {
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
}, after));
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
@@ -2124,6 +2150,12 @@ function mountHouseEditor(container, callbacks) {
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
}
if (totals.posts) {
const pp = totals.posts;
cards.push(UI.specCard('Overkapping', 'columns', [
[`${pp.wood} palen ${pp.size}×${pp.size} mm`, pp.count, 'st.'],
['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']]));
}
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const sw = totals.saw, cs = cost.saw;
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
@@ -2166,7 +2198,7 @@ function mountHouseEditor(container, callbacks) {
const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking');
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
@@ -2200,6 +2232,7 @@ function mountHouseEditor(container, callbacks) {
houseGroup.add(u.roof.group);
});
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
}
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
@@ -2255,7 +2288,7 @@ function mountHouseEditor(container, callbacks) {
renderNav(parts);
}
const SIGN = { ahead: 4000, aside: 1800, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
const SIGN = { ahead: 4000, aside: 1800, clear: 1200, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
let signPos = null;
let signMaterial = null;
const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 });
@@ -2284,7 +2317,14 @@ function mountHouseEditor(container, callbacks) {
function buildLogoSign(groundY) {
const { d, n } = frontDoor();
const t = { x: n.z, z: -n.x };
signPos = { x: d.x + n.x * SIGN.ahead + t.x * SIGN.aside, z: d.z + n.z * SIGN.ahead + t.z * SIGN.aside };
// Voordeur in een binnenhoek: opzij kan het bord onder een andere unit vallen, dan verder opzij of de andere kant op.
const at = (a) => ({ x: d.x + n.x * SIGN.ahead + t.x * a, z: d.z + n.z * SIGN.ahead + t.z * a });
const free = (q) => !parts.rects.some(r => !r.error && q.x > r.x0 - SIGN.clear && q.x < r.x1 + SIGN.clear && q.z > r.z0 - SIGN.clear && q.z < r.z1 + SIGN.clear);
signPos = at(SIGN.aside);
for (let a = SIGN.aside; a <= 30000; a += 500) {
const q = [at(a), at(-a)].find(free);
if (q) { signPos = q; break; }
}
const toM = 0.001, plateH = SIGN.plateW / 3;
const g = new THREE.Group();
const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat);
+41 -15
View File
@@ -39,14 +39,16 @@ window.LightingModel = (() => {
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
}
function enforceMinSpacing(positions, min) {
// Te dicht bij elkaar: samen naar het midden, tenzij daar een opening zit (dan vervalt de tweede).
function enforceMinSpacing(positions, min, blockedAt) {
let pts = positions.slice().sort((a, b) => a - b);
let changed = true;
while (changed && pts.length > 1) {
changed = false;
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1] - pts[i] < min - 0.5) {
pts.splice(i, 2, (pts[i] + pts[i + 1]) / 2);
const mid = (pts[i] + pts[i + 1]) / 2;
pts.splice(i, 2, blockedAt && blockedAt(mid) ? pts[i] : mid);
changed = true;
break;
}
@@ -55,6 +57,27 @@ window.LightingModel = (() => {
return pts;
}
// Naast openingen: op openingOffset van elke opening; is de tussenruimte daarvoor te klein, dan één lamp in
// het midden ervan (of geen, als die tegen de kozijnen zou hangen). Posities in u (mm vanaf de linkerkant).
function besideOpenings(cfg, s) {
const W = cfg.wallWidth, off = s.openingOffset, clear = cfg.studWidth + 150;
const ops = cfg.openings.map(op => [op.x, op.x + op.w]).sort((p, q) => p[0] - q[0]);
const out = [];
ops.forEach(([x0, x1], i) => {
const next = ops[i + 1];
if (i === 0) { const u = Math.max(x0 - off, s.endOffset); if (x0 - u >= clear) out.push(u); }
if (next) {
const gap = next[0] - x1;
if (gap >= 2 * off + 1000) out.push(x1 + off, next[0] - off);
else if (gap >= 2 * clear) out.push((x1 + next[0]) / 2);
} else {
const u = Math.min(x1 + off, W - s.endOffset);
if (u - x1 >= clear) out.push(u);
}
});
return out;
}
function freeIntervals(lo, hi, blocked) {
const free = [];
let cursor = lo;
@@ -113,7 +136,7 @@ window.LightingModel = (() => {
};
if (s.mode === 'gevel') {
runs.forEach(run => {
const blocked = [];
const blocked = [], openings = [];
const pos = [];
// Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
segs.filter(o => !run.segs.includes(o)).forEach(o => {
@@ -126,26 +149,29 @@ window.LightingModel = (() => {
const cfg = sg.piece.config, sW = cfg.studWidth;
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
if (sg.override === 'openingen') {
blocked.push([sg.s0, sg.s1]);
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return;
}
const own = [];
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
blocked.push([Math.min(a, b), Math.max(a, b)]);
own.push([Math.min(a, b), Math.max(a, b)]);
});
openings.push(...own);
if (sg.override === 'openingen') {
blocked.push([sg.s0, sg.s1]);
besideOpenings(cfg, s).forEach(u => pos.push(uToS(sg, u)));
return;
}
blocked.push(...own);
});
// Automatisch: per vrij wandstuk gelijk verdeeld, midden in gelijke delen van ca. lampMax (één lamp
// midden tussen twee ramen), niet tegen de kozijnen of de hoek aan.
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return;
const first = a + s.endOffset, last = b - s.endOffset;
pos.push(first);
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
pos.push(last);
const k = Math.max(1, Math.round((b - a) / s.lampMax));
for (let i = 0; i < k; i++) pos.push(a + (b - a) * (i + 0.5) / k);
});
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
const inOpening = (v) => openings.some(([a, b]) => v > a && v < b);
enforceMinSpacing(pos, s.lampMin, inOpening).filter(v => !inOpening(v)).forEach(v => addLamp(run, v));
});
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
}
+139
View File
@@ -0,0 +1,139 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Palen onder een overkapping: steekt een dak minstens minExt buiten de vloeren uit (veranda), dan komen er
// palen onder de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel
// uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft.
window.PostModel = (() => {
const EPS = 0.5;
const SHOE_MM = 100; // verzinkte paalvoet tussen maaiveld en paal
const WOODS = {
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
};
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000 };
function normalizeSettings(settings) {
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
s.enabled = s.enabled !== false;
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
s.minExt = num(s.minExt, DEFAULT_SETTINGS.minExt, 400, 5000);
return s;
}
// units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut).
function compute(settings, units, groundY) {
const s = normalizeSettings(settings);
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0 };
if (!s.enabled || !units.length) return out;
const floors = units.map(u => u.rect);
const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m);
const underRoof = (x, z) => units.some(u => { const r = u.roof.rect; return x > r.x0 && x < r.x1 && z > r.z0 && z < r.z1; });
// Vrije stukken dakrand: buitenrand van de daken samen, recht tegenover een gevel op minstens minExt (loodrecht
// op de rand gemeten); de zijkanten van een veranda krijgen alleen de hoekpalen. In een binnenhoek (veranda
// tussen twee units) loopt het stuk door tot de andere gevel en rust daar op.
const runs = [];
units.forEach(u => {
const R = u.roof.rect;
[
{ axis: 'z', at: R.x1, n: 1, s0: R.z0, s1: R.z1 }, { axis: 'z', at: R.x0, n: -1, s0: R.z0, s1: R.z1 },
{ axis: 'x', at: R.z1, n: 1, s0: R.x0, s1: R.x1 }, { axis: 'x', at: R.z0, n: -1, s0: R.x0, s1: R.x1 }
].forEach(e => {
const pt = (t, d) => (e.axis === 'z' ? { x: e.at - e.n * d, z: t } : { x: t, z: e.at - e.n * d });
const facing = (p) => Math.min(...floors.map(f => {
const [lo, hi, c0, c1, v, c] = e.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1, p.z, p.x] : [f.x0, f.x1, f.z0, f.z1, p.x, p.z];
if (!(v > lo + EPS && v < hi - EPS)) return Infinity;
const d = e.n > 0 ? c - c1 : c0 - c;
return d > -EPS ? d : Infinity;
}));
const free = (t) => {
const p = pt(t, 0), o = pt(t, -1), d = facing(p);
return !underRoof(o.x, o.z) && d >= s.minExt - EPS && d < Infinity;
};
const cuts = [e.s0, e.s1].concat(floors.flatMap(f => (e.axis === 'z' ? [f.z0, f.z1] : [f.x0, f.x1]))
.filter(t => t > e.s0 + EPS && t < e.s1 - EPS)).sort((a, b) => a - b);
for (let i = 0; i < cuts.length - 1; i++) {
const a = cuts[i], b = cuts[i + 1];
if (b - a < EPS || !free((a + b) / 2)) continue;
const last = runs[runs.length - 1];
if (last && last.axis === e.axis && last.n === e.n && Math.abs(last.at - e.at) < EPS && Math.abs(last.b - a) < EPS) last.b = b;
else runs.push({ axis: e.axis, at: e.at, n: e.n, a, b });
}
});
});
// Aansluitende stukken van verschillende units (bijv. twee units met dezelfde veranda) samenvoegen.
runs.sort((p, q) => p.axis.localeCompare(q.axis) || p.n - q.n || Math.round(p.at) - Math.round(q.at) || p.a - q.a);
const merged = [];
runs.forEach(r => {
const m = merged.find(o => o.axis === r.axis && o.n === r.n && Math.abs(o.at - r.at) < EPS && Math.abs(o.b - r.a) < EPS);
if (m) m.b = r.b; else merged.push(Object.assign({}, r));
});
const half = s.size / 2;
const pts = [];
merged.forEach(r => {
const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half });
// Een eind dat op of tegen een vloer uitkomt rust op die gevel.
const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
const fa = endFree(r.a), fb = endFree(r.b);
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
if (pb - pa < EPS) return;
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
for (let k = 0; k <= n; k++) {
if ((k === 0 && !fa) || (k === n && !fb)) continue;
pts.push(pos(pa + (pb - pa) * k / n));
}
});
pts.forEach(p => {
if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return;
const roof = units.map(u => u.roof).find(rf => p.x > rf.rect.x0 - EPS && p.x < rf.rect.x1 + EPS && p.z > rf.rect.z0 - EPS && p.z < rf.rect.z1 + EPS);
if (!roof) return;
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1))));
if (!(top > groundY + SHOE_MM)) return;
out.posts.push({ x: Math.round(p.x), z: Math.round(p.z), y0: groundY, y1: Math.round(top), size: s.size });
});
out.count = out.posts.length;
out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
out.m3 = out.lengthMM * s.size * s.size / 1e9;
out.kg = out.m3 * WOODS[s.wood].kgM3;
return out;
}
function createMaterials() {
return {
wood: Object.fromEntries(Object.entries(WOODS).map(([k, w]) => [k, new THREE.MeshStandardMaterial({ color: w.hex, roughness: 0.78 })])),
shoe: new THREE.MeshStandardMaterial({ color: 0x9ba0a4, roughness: 0.45, metalness: 0.6 })
};
}
function build3D(data, materials) {
const toM = 0.001, group = new THREE.Group();
if (!data || !data.posts.length) return group;
const m = materials || createMaterials();
const woodMat = m.wood[data.wood] || m.wood.eiken;
data.posts.forEach(p => {
const h = p.y1 - p.y0 - SHOE_MM;
const post = new THREE.Mesh(new THREE.BoxGeometry(p.size * toM, h * toM, p.size * toM), woodMat);
post.position.set(p.x * toM, (p.y0 + SHOE_MM + h / 2) * toM, p.z * toM);
group.add(post);
const plate = new THREE.Mesh(new THREE.BoxGeometry((p.size + 40) * toM, 10 * toM, (p.size + 40) * toM), m.shoe);
plate.position.set(p.x * toM, (p.y0 + 5) * toM, p.z * toM);
group.add(plate);
const stem = new THREE.Mesh(new THREE.BoxGeometry(p.size * 0.35 * toM, (SHOE_MM - 10) * toM, p.size * 0.35 * toM), m.shoe);
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
group.add(stem);
});
return group;
}
return { WOODS, SHOE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
})();
+2 -1
View File
@@ -92,7 +92,7 @@ window.RoofModel = (() => {
const deck = clone(floorState);
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
deck.openings = [];
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck };
return { followFloor: true, offsetX: 0, offsetZ: 0, extend: { f: 0, r: 0, b: 0, l: 0 }, fascia: true, covering: 'dakleer', deck };
}
function thickness(calcData, covering) {
@@ -1074,6 +1074,7 @@ window.RoofModel = (() => {
// v0.6.2: mansarde vervallen, wordt een zadeldak met dezelfde nok.
if (form && form.type === 'mansarde') { form.type = 'zadel'; delete form.kneeH; delete form.kneeIn; delete form.kneeRatio; }
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
s.posts = PostModel.normalizeSettings(s.posts);
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
return s;
}