2 Commits
15 changed files with 744 additions and 232 deletions
+33 -9
View File
@@ -97,26 +97,39 @@ assets/ logo, texturen, skybox
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
"Geschilderd" aan staat; anders kale OSB.
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek).
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met
lessenaar, zadel of wolfsdak, met schuiven en hellingshoek).
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij een
wolfsdak afgeknot op het wolfseind, met
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van
de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn
daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
daklijn daar precies op aan. Het wolfseind schaalt mee met goot en
nok (verhouding). De PDF heeft voor een wolfsdak een eigen blad met het
bovenste deel (bovenaanzicht houtwerk + stuklijst).
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
bovenkant vloer tot onderkant dak), unit max 4,7 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een
wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
De daklijn van een wand hoort bij de wand zonder naden: loopt een wand
door een naad langer door (bijv. de kopgevel naast een gekoppelde unit),
dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`).
- **Dakaansluiting (L/T):** staat een unit met nokdak (zadel of
wolfsdak) met zijn nok haaks op de langszijde van een buur met nokdak
(vleugel tegen hoofdvolume), dan loopt de vleugel het hoofddak in tot
zijn nok, met kilkepers in de binnenhoeken (bij gelijke goot, nok en
overspanning onder 45°); het
wolfseind van de vleugel aan de naad vervalt. Ligt de vleugel gelijk met
een kop van het hoofdvolume, dan wordt die kop een schild in de vlakken
van de vleugel (hoekkeper, de goot loopt door; de kopgevel staat dan op
goothoogte, een wolfseind daar vervalt). Een vleugel die in een wolfseind
van het hoofdvolume zou steken sluit niet aan. Elke unit houdt zijn eigen
dakdeel (sporen, kreupele sporen, kepers, knik- en nokbalken; stalen
nokbalk alleen over de eigen nok) met een eigen PDF-blad
(`HouseModel.roofJoins`, `RoofModel.computeJoinedRoof`). Niet bij dak
over de hele woning; dakramen in zo'n dakdeel doen nog niet mee.
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
terras naast een kortere module), met een afgewerkte onderplaat in de
@@ -271,8 +284,19 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
stucwerk wit met verticaal hout aan de slaapkamerkant, binnenwanden,
dubbele schuifdeur tussen woonkamer en hal, en complete inrichting.
Langer maken: beide units breder (alles is vanaf de westgevel gemaat).
Ook via Bestand › Woning Amstel. De L-woning (8 × 3,6 m + 4,5 × 3,6 m)
blijft beschikbaar als "Woning L-vorm".
Model **Wetering** (`HouseModel.createWetering`, naar
`examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en
2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
badkamer, slaapkamer met kledingkast. Model **IJssel**
(`HouseModel.createIJssel`, naar `examples/ontwerp_ijssel.pdf`): L-vorm
14 × 4,6 m met een vleugel van 4,6 × 3 m, twee units met zadeldak en
riet die met een kil en een hoekkeper op elkaar aansluiten, stucwerk wit,
openslaande deuren in de westgevel. De standaardmodellen staan in
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
beschikbaar als "Woning L-vorm".
- Netto m² per ruimte telt de hele vloer, ook onder een laag schuin dak
(geen NEN 2580-correctie voor hoogte < 1,5 m).
- Units kunnen niet gedraaid worden.
+1
View File
@@ -428,6 +428,7 @@ body { display: flex; flex-direction: column; }
color: var(--nav-link-color); cursor: pointer; width: 110px;
}
.new-option svg { width: 40px; height: 40px; }
.new-option .bi { font-size: 36px; line-height: 40px; }
.new-option span { font-size: 13px; }
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.new-option.disabled { opacity: .4; cursor: not-allowed; }
-4
View File
@@ -51,10 +51,6 @@
<div id="new-modal">
<h2>Nieuw</h2>
<div class="new-options">
<button class="new-option" data-kind="woning" data-template="amstel">
<svg viewBox="0 0 48 48"><rect x="6" y="10" width="36" height="14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M6 24 L6 38 L38 38 L38 24" fill="none" stroke="currentColor" stroke-width="2"/><rect x="38" y="24" width="4" height="14" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/><line x1="22" y1="24" x2="22" y2="38" stroke="currentColor" stroke-width="1"/></svg>
<span>Woning Amstel</span>
</button>
<button class="new-option" data-kind="woning" data-template="single">
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
<span>Woning</span>
+4 -2
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.6.0';
const APP_VERSION = '0.6.2';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
@@ -58,7 +58,7 @@ function appRibbonTabs() {
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
{ label: 'Snel nieuw', html:
small('Woning Amstel', 'house-heart', 'data-app="new-kind" data-kind="woning" data-template="amstel"') +
Object.entries(HouseModel.MODELS).map(([key, m]) => small(m.label, m.icon, `data-app="new-kind" data-kind="woning" data-template="${key}"`)).join('') +
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
@@ -213,6 +213,8 @@ function wireAbout() {
function wireNewModal() {
const overlay = document.getElementById('new-overlay');
overlay.querySelector('.new-options').insertAdjacentHTML('afterbegin', Object.entries(HouseModel.MODELS).map(([key, m]) =>
`<button class="new-option" data-kind="woning" data-template="${key}"><i class="bi bi-${m.icon}"></i><span>${m.label}</span></button>`).join(''));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => {
+8 -9
View File
@@ -206,13 +206,13 @@ window.CostModel = (() => {
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
const rs = u.roof.specs, D = S.dak;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 0);
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] ? u.roof.planes[0].data.calcData : cap), 0);
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
if (cap) {
const n = cap.beams.length;
add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true);
const n = cap.beams.length, join = cap.kind === 'join';
add(rf, 'Hout', 'Zagen', `${sawLabel}: ${join ? 'dak (sporen, kepers)' : 'wolfseinden'} ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
addJoints(rf, n * V.verbindingenPerVloerbalk, join ? 'dakbalken' : 'wolfseindbalken', true);
}
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
@@ -324,11 +324,10 @@ window.CostModel = (() => {
const m = f.w / 1000, crew = IN.personeel;
const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew);
if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage);
else if (f.cost === 'kookeiland') {
perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
if (f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
} else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
else if (f.cost === 'kookeiland') perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew);
if ((f.cost === 'keuken' || f.cost === 'kookeiland') && f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
});
const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 });
+342 -43
View File
@@ -73,12 +73,17 @@ window.HouseModel = (() => {
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
}
function newModelState(naam) {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
st.project.naam = naam || '';
return st;
}
// TIJDELIJK (testen): nieuwe woning = voorbeeldontwerp (json/premade/vlinderwoning.json); false = één unit.
const TEST_DEFAULT = true;
function createVlinderwoning() {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
st.project.naam = 'Vlinderwoning';
const st = newModelState('Vlinderwoning');
st.settings.trasraam.finish = 'K490';
const A = createUnitEntry('A', null, { width: 10000, depth: 4000, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10000, depth: 3600, windowOn: [] });
@@ -112,10 +117,35 @@ window.HouseModel = (() => {
return st;
}
// Bouwstenen voor de standaardmodellen. Gevelopeningen: [type, w, h, y, anchor, offset, fill];
// binnenwanden: [type, w, h, anchor, offset, fill] vanaf de vloer.
function modelKit(st, H, T) {
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {});
const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {});
const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); };
const iw = (x1, z1, x2, z2, openings, t) => {
const wall = interiorWallDefaults(t || T, H);
wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill }));
st.interiorWalls.push({ id: st.interiorWalls.length + 1, x1, z1, x2, z2, wall });
};
// zaadpunt t.o.v. de noordwesthoek van de unit
const room = (unitId, x, z, type, name, extra) =>
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }, extra || {}));
// Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost).
// anchor 'oost': x opgeven vanaf de westgevel, het meubel schuift mee met de oostgevel.
const fx = (type, unitId, x, z, rot, size, opts) => {
const f = FixtureModel.create(st, type, unitId, x, z, rot);
if (size && size.anchor === 'oost') f.x = Math.round(st.units.find(u => u.id === unitId).unit.floor.floor.width - x);
Object.assign(f, size || {});
Object.assign(f.opts, opts || {});
st.fixtures.push(f);
};
return { K, binnen, ops, iw, room, fx };
}
// Model Amstel (examples/ontwerp_amstel.pdf). Alles vanaf de westgevel gemaat, zodat de woning in de lengte kan groeien.
function createAmstel() {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
st.project.naam = 'Amstel';
const st = newModelState('Amstel');
const S = st.settings, H = 2800;
Object.assign(S.facade, { type: 'stuc', stucColor: 'wit', rabatColor: 'naturel' });
S.cladding = 'verticaal';
@@ -131,9 +161,7 @@ window.HouseModel = (() => {
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'plat', low: H, high: H }, cover: true });
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {});
const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {});
const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); };
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90);
// Noordgevel: 'right' = vanaf de westhoek.
ops(A.unit.walls.voor, [
['deur', 1474, 2650, 0, 'right', 150, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
@@ -152,13 +180,6 @@ window.HouseModel = (() => {
B.facade.walls.rechts = { type: 'rabat' };
B.lighting.walls.achter = 'openingen';
const T = 90;
let iwId = 0;
const iw = (x1, z1, x2, z2, openings) => {
const wall = interiorWallDefaults(T, H);
wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill }));
st.interiorWalls.push({ id: ++iwId, x1, z1, x2, z2, wall });
};
const D = 2115;
iw(2555, 3700, 2555, 4800, [['deur', 850, D, 'left', 80, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | logeerkamer
iw(1980, 4845, 3070, 4845); // boven boilerkast en garderobe
@@ -180,25 +201,14 @@ window.HouseModel = (() => {
fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true, out: 'zwart', in: 'zwart' }) }];
}
// zaadpunt t.o.v. de noordwesthoek van de unit
const room = (id, unitId, x, z, type, name) => st.rooms.push(Object.assign(RoomModel.applyType({ id, unitId, x, z, name: '' }, type), { name }));
room(1, 'A', 5600, 1200, 'woonkeuken', 'Woonkamer/keuken');
room(2, 'B', 1000, 2000, 'slaapkamer', 'Logeerkamer');
room(3, 'B', 3600, 2600, 'hal', 'Entree');
room(4, 'B', 2480, 2800, 'toilet', '');
room(5, 'B', 5300, 2700, 'badkamer', '');
room(6, 'B', 8300, 1800, 'slaapkamer', 'Slaapkamer');
room(7, 'B', 2245, 1650, 'techniek', 'Boilerkast');
room('A', 5600, 1200, 'woonkeuken', 'Woonkamer/keuken');
room('B', 1000, 2000, 'slaapkamer', 'Logeerkamer');
room('B', 3600, 2600, 'hal', 'Entree');
room('B', 2480, 2800, 'toilet');
room('B', 5300, 2700, 'badkamer');
room('B', 8300, 1800, 'slaapkamer', 'Slaapkamer');
room('B', 2245, 1650, 'techniek', 'Boilerkast');
// Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost).
const xA = 11240, xB = 10600;
const fx = (type, unitId, x, z, rot, size, opts) => {
const f = FixtureModel.create(st, type, unitId, x, z, rot);
if (size && size.anchor === 'oost') f.x = Math.round((unitId === 'A' ? xA : xB) - x);
Object.assign(f, size || {});
Object.assign(f.opts, opts || {});
st.fixtures.push(f);
};
fx('keuken', 'A', 4746, 556, 0, { w: 2660 }, { front: 'antraciet', hogeKast: 'links', spoelbak: 0.49 });
fx('kookeiland', 'A', 4746, 2000, 0, { w: 2660, d: 600 }, { front: 'antraciet', kookplaat: true });
fx('eettafel', 'A', 1600, 1820, 90, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
@@ -220,10 +230,155 @@ window.HouseModel = (() => {
return st;
}
// Model Wetering (examples/ontwerp_wetering.pdf): 10,5 × 4,8 m, één rechthoek zonder naadwand (open woonkamer
// over de volle diepte). 4,8 m is te diep voor één unit, dus twee units (2,6 + 2,2 m). Alles vanaf de westgevel en
// de noordgevel gemaat (buitenwerks); de entreegevel met openslaande deuren en voordeur is het zuiden.
function createWetering() {
const st = newModelState('Wetering');
const S = st.settings, H = 2800, W = 10500, DA = 2600, DB = 2200;
Object.assign(S.facade, { type: 'rabat', rabatColor: 'grijs' });
S.cladding = 'verticaal';
S.trasraam.enabled = false;
S.roof.fasciaColor = 'zwart';
S.lighting.mode = 'gevel';
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: DA, height: H, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
st.units.push(A, B);
setHouseRoof(st, true);
Object.assign(S.roof.house, { form: { type: 'plat', low: H, high: H } });
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 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, 700, 1400, 'right', 5670, K('raam')]]);
ops(A.unit.walls.links, [['raam', 700, 2100, 0, 'left', 300, K('raam')], ['raam', 700, 2100, 0, 'left', 1500, K('raam')]]);
ops(A.unit.walls.rechts, [['raam', 500, 1400, 700, 'right', 270, K('raam')]]);
ops(B.unit.walls.rechts, [['raam', 500, 1400, 700, 'right', 540, K('raam')]]);
ops(B.unit.walls.achter, [
['deur', 824, 2100, 0, 'left', 1200, K('deur', { hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 824, 2100, 0, 'left', 2176, K('deur', { hinge: 'rechts', swing: 'buiten', volglas: true })],
['voordeur', 1100, 2100, 0, 'left', 5350, K('deur', { hinge: 'rechts', swing: 'buiten', volglas: true })]]);
B.lighting.walls.achter = 'openingen';
const D = 2115;
iw(4675, 190, 4675, 2210); // keuken | badkamer
iw(4630, 2255, 7120, 2255, [['deur', 930, D, 'left', 1390, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // badkamer | entree
iw(7165, 190, 7165, 4610, [ // slaapkamer
['deur', 980, 2130, 'left', 360, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'rechts' })],
['deur', 930, D, 'left', 2277, binnen({ hinge: 'links', swing: 'buiten' })]]);
iw(6455, 3765, 7120, 3765); // kapstoknis entree
// Geen naadwand: woonkamer en slaapkamer lopen over beide units door (Unit › Naadwand toevoegen zet hem terug).
room('A', 2000, 1500, 'woonkeuken', 'Woonkamer/keuken');
room('A', 5900, 1500, 'badkamer');
room('A', 8700, 1500, 'slaapkamer', 'Slaapkamer');
const zB = (z) => z - DA;
fx('keuken', 'A', 4330, 1245, 90, { w: 2110 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.85, kookplaat: true, kookplaatPlek: 0.2 });
fx('keuken', 'A', 4630, 2600, 0, { w: 1200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false });
fx('boiler', 'A', 5515, 2525, 0);
fx('eettafel', 'A', 1620, 1160, 0, { w: 1000, d: 1000 }, { vorm: 'ovaal', stoelen: 4 });
fx('bank', 'B', 615, zB(3640), 270, { w: 1600, d: 850 }, { chaise: 'geen' });
fx('salontafel', 'B', 1770, zB(3510), 0, { w: 600, d: 600 }, { vorm: 'rond' });
fx('fauteuil', 'B', 2760, zB(4060), 150);
fx('douche', 'A', 5245, 640, 270, { w: 900, d: 1050 }, { glas: 'links', glasLengte: 800 });
fx('toilet', 'A', 5065, 1760, 270, { w: 900 });
fx('wastafel', 'A', 6150, 420, 0, { w: 600 }, { spiegel: false });
fx('bed', 'A', 9260, 2050, 90, { w: 1600, d: 2100, anchor: 'oost' }, { nachtkastjes: false });
fx('kledingkast', 'B', 8760, zB(4310), 180, { w: 3100 });
computeParts(st, { skip3D: true });
return st;
}
// Model IJssel (examples/ontwerp_ijssel.pdf): L-vorm 14 × 4,6 m met een vleugel van 4,6 × 3 m aan de zuidoostkant,
// samen 14 × 7,6 m. Twee units: hoofdvolume met zadeldak west–oost, vleugel met zadeldak noord–zuid. Gelijke
// goot, nok en overspanning, dus de daken sluiten onder 45° aan (dakaansluiting, zie roofJoins): kil in de
// binnenhoek en, omdat de vleugel gelijk ligt met de oostgevel, een hoekkeper (de oostkop wordt een schild).
// Geen naadwanden; de wand tussen hal/badkamer en slaapkamer is een gewone binnenwand. Binnenwanden volgen het
// dak nog niet, dus alle ruimtes krijgen een vlak plafond op wandhoogte. Alles buitenwerks vanaf de noordwesthoek.
function createIJssel() {
const st = newModelState('IJssel');
const S = st.settings, LO = 3000, HI = 4600, H = 2900, D = 4600, ROOF = 'riet_001';
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.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: 14000, depth: D, height: LO, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
st.units.push(A, B);
UnitModel.applyRoofForm(A.unit, { type: 'zadel', ridge: 'x', low: LO, high: HI });
UnitModel.applyRoofForm(B.unit, { type: 'zadel', ridge: 'z', low: LO, high: HI });
[A, B].forEach(u => { u.roof.covering = ROOF; });
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 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', 1500, 500, 1600, 'right', 5445, K('raam')], ['raam', 500, 700, 1400, 'right', 9950, K('raam')]]);
ops(A.unit.walls.links, [
['deur', 1424, 2100, 0, 'left', 610, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 1424, 2100, 0, 'left', 2376, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]);
ops(A.unit.walls.rechts, [['raam', 1400, 1100, 1000, 'right', 608, K('raam')], ['raam', 1200, 500, 1600, 'right', 3101, K('raam')]]);
ops(A.unit.walls.achter, [
['raam', 1800, 2800, 0, 'left', 3215, K('raam')],
['voordeur', 1100, 2100, 0, 'left', 8240, K('deur', { hinge: 'rechts', swing: 'buiten', glas: true })]]);
ops(B.unit.walls.achter, [['raam', 500, 1400, 700, 'left', 410, K('raam')], ['raam', 500, 1400, 700, 'right', 1100, K('raam')]]);
A.lighting.walls.achter = 'openingen';
const DH = 2115;
iw(9580, 190, 9580, 3220, [], 160); // keuken | toilet, hal
iw(8060, 3265, 9660, 3265); // woonkamer | hal
iw(8105, 3310, 8105, 4410, [['deur', 930, DH, 'left', 86, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // hal → woonkamer
iw(9660, 1633, 10760, 1633, [['deur', 930, DH, 'left', 85, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet | hal
iw(10805, 190, 10805, 4410, [['deur', 930, DH, 'left', 1725, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // hal | berging, badkamer
iw(10850, 2965, 13810, 2965); // berging | badkamer
iw(9590, 4455, 13810, 4455, [ // hal, badkamer | slaapkamer
['deur', 930, DH, 'left', 115, binnen({ hinge: 'links', swing: 'buiten' })],
['deur', 980, DH, 'left', 2350, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
const plafond = { ceiling: H };
room('A', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken', plafond);
room('A', 10210, 900, 'toilet');
room('A', 12300, 1500, 'berging', 'Berging', plafond);
room('A', 9000, 3800, 'hal', 'Hal', plafond);
room('A', 12300, 3700, 'badkamer');
room('B', 2000, 1400, 'slaapkamer', 'Slaapkamer', plafond);
const xB = (x) => x - 9400, zB = (z) => z - D;
fx('bank', 'A', 2030, 690, 0, { w: 3600, d: 1000 }, { chaise: 'links', chaiseLengte: 1700 });
fx('salontafel', 'A', 2410, 1980, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'A', 3120, 2950, 150);
fx('tvmeubel', 'A', 1060, 4210, 180, { w: 1200 });
fx('eettafel', 'A', 6100, 1115, 0, { w: 2000, d: 1000 }, { vorm: 'ovaal', stoelen: 6 });
fx('keuken', 'A', 9200, 1705, 90, { w: 3030 }, { front: 'antraciet', hogeKast: 'rechts', spoelbak: 0.16, kookplaat: true, kookplaatPlek: 0.8 });
fx('toilet', 'A', 10210, 535, 0, { w: 1100 });
fx('boiler', 'A', 11040, 415, 0);
fx('douche', 'A', 11300, 3710, 0, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 1400 });
fx('wastafel', 'A', 12300, 3240, 0, { w: 800 });
fx('toilet', 'A', 13355, 3355, 0, { w: 900 });
fx('bed', 'B', xB(11400), zB(6360), 180, { w: 1600, d: 2100 }, { nachtkastjes: false });
fx('kledingkast', 'B', xB(13510), zB(5955), 90, { w: 2910, anchor: 'oost' });
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 }
};
function createDefaultState(template) {
if (template === 'amstel') return createAmstel();
if (MODELS[template]) return MODELS[template].create();
if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning();
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
const state = newModelState();
state.units.push(createUnitEntry('A', null, template === 'L' ? { width: 8000, depth: 3600 } : { width: 6000, depth: 3000 }));
if (template === 'L') {
state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 4500, depth: 3600, windowOn: [] }));
@@ -452,6 +607,7 @@ window.HouseModel = (() => {
});
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
applyHouseRoof(state);
applyRoofJoins(state);
}
const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form);
@@ -527,7 +683,6 @@ window.HouseModel = (() => {
const shape = houseRoofShape(state, rects);
if (!shape) return;
const form = state.settings.roof.house.form, ln = shape.line;
if (form.type === 'mansarde' && ln.length === 5) Object.assign(form, { kneeIn: Math.round(ln[1].u), kneeH: Math.round(ln[1].h) });
if (form.type === 'wolf' && ln.length === 4) form.cut = Math.round(ln[1].h);
const planes = RoofModel.shapeLines(shape);
state.units.forEach((u, i) => {
@@ -556,13 +711,131 @@ window.HouseModel = (() => {
const a = pts[k], b = pts[k + 2];
return q.h > a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h) + 0.5;
});
const wall = u.unit.walls[wl.side].wall;
const hL = Math.round(pts[0].h), hR = Math.round(pts[pts.length - 1].h);
setWallTop(u.unit.walls[wl.side].wall, W, pts[0].h, pts[pts.length - 1].h, inner);
});
});
}
function setWallTop(wall, W, h0, h1, inner) {
const hL = Math.round(h0), hR = Math.round(h1);
const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5;
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null });
if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) }));
else delete wall.topPts;
}
// Dakaansluiting (L/T): een vleugel met nokdak (zadel, wolfsdak), nok haaks op de naad, tegen de
// langszijde van een hoofdvolume met nokdak loopt het hoofddak in tot zijn nok (kilkepers). Het wolfseind van de
// vleugel aan de naad vervalt. Ligt de vleugel gelijk met een kop van het hoofdvolume, dan wordt die kop een
// schild in de vlakken van de vleugel (hoekkeper, goot loopt door; een wolfseind daar vervalt); hooguit één kop,
// anders heeft het hoofdvolume geen kopgevel meer om zijn eigen dakvorm uit af te leiden. Een vleugel die in een
// wolfseind van het hoofdvolume zou steken (niet gelijk met de kop) sluit niet aan.
// Vlakken in wereldmaten; h = min(verre vlakken, wolfseinden, schildvlakken, max(min(nabije vlakken), vleugels)),
// zie RoofModel.computeJoinedRoof.
const JOIN_TYPES = ['zadel', 'wolf'];
function roofJoins(state, rects) {
const out = {};
if (houseRoofOn(state)) return out;
const rs = rects || unitRects(state);
if (rs.some(r => r.error) || overlappingPairs(rs).length) return out;
const at = (pl, [x, z]) => pl.a + pl.bx * x + pl.bz * z;
const info = state.units.map((u, i) => {
const r = rs[i], shape = RoofModel.computeShape(u.unit, UnitModel.computeLayout(u.unit, {}));
const planes = [];
if (!JOIN_TYPES.includes(shape.type)) return { u, r, shape, planes };
const sx = shape.axis === 'x';
const s = shape.type === 'wolf' && shape.hip
? Object.assign({}, shape, { hip: Object.assign({}, shape.hip, sx ? { q0: -r.D / 2, q1: r.D / 2 } : { q0: -r.W / 2, q1: r.W / 2 }) })
: shape;
const corners = [[r.x0, r.z0], [r.x1, r.z0], [r.x0, r.z1], [r.x1, r.z1]];
RoofModel.shapeLines(s).forEach(l => {
const [bx, bz] = sx ? [l.bp, l.bq] : [l.bq, l.bp];
const pl = { a: l.a - bx * r.cx - bz * r.cz, bx, bz };
// Wolfsdak: onder- en bovenstuk liggen (op afronding na) in één vlak.
if (!planes.some(q => corners.every(c => Math.abs(at(q, c) - at(pl, c)) < 2))) planes.push(pl);
});
return { u, r, shape, planes };
});
const groups = new Map(), role = {};
info.forEach(M => info.forEach(W => {
if (M === W || !M.planes.length || !W.planes.length || M.shape.axis === W.shape.axis) return;
if (role[M.u.id] === 'vleugel' || role[W.u.id]) return;
const seamX = M.shape.axis === 'z'; // naad evenwijdig aan de nok van M
const [m0, m1, w0, w1] = seamX ? [M.r.x0, M.r.x1, W.r.x0, W.r.x1] : [M.r.z0, M.r.z1, W.r.z0, W.r.z1];
const [a0, a1, b0, b1] = seamX ? [M.r.z0, M.r.z1, W.r.z0, W.r.z1] : [M.r.x0, M.r.x1, W.r.x0, W.r.x1];
const seam = Math.abs(a1 - b0) < EPS ? a1 : Math.abs(b1 - a0) < EPS ? a0 : null;
if (seam === null || w0 < m0 - EPS || w1 > m1 + EPS || (w0 < m0 + EPS && w1 > m1 - EPS)) return;
const xz = (t) => seamX ? [t, seam] : [seam, t];
const dir = seam === a1 ? 1 : -1; // vleugel aan de +kant (dwars op de naad) van M
const across = (pl) => seamX ? pl.bz : pl.bx, along = (pl) => seamX ? pl.bx : pl.bz;
const g = groups.get(M) || { M, seamX, wings: [], flush: null, drop: [] };
const end = Math.abs(w1 - m1) < EPS ? 1 : Math.abs(w0 - m0) < EPS ? 0 : -1;
const toEnd = (pl) => end ? along(pl) < 0 : along(pl) > 0; // daalt naar die kop
const flush = end >= 0 && !g.flush ? {
planes: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 && toEnd(pl)),
drop: M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && toEnd(pl)),
side: seamX ? (end ? 'rechts' : 'links') : (end ? 'achter' : 'voor')
} : null;
const wingTop = Math.max(...W.shape.profile.map(q => q.h));
const hips = M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && !g.drop.includes(pl) && !(flush && flush.drop.includes(pl)));
if (hips.some(pl => Math.min(at(pl, xz(w0)), at(pl, xz(w1))) < wingTop - 1)) return;
if (flush) { g.flush = flush; g.drop.push(...flush.drop); }
g.wings.push({ W, sign: -dir, roof: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 || across(pl) * dir < 0) });
groups.set(M, g);
role[M.u.id] = 'hoofd';
role[W.u.id] = 'vleugel';
}));
groups.forEach(g => {
const planes = [], idx = (pl) => planes.includes(pl) ? planes.indexOf(pl) : planes.push(pl) - 1;
const cross = (pl) => g.seamX ? pl.bz : pl.bx, terms = [];
[-1, 1].forEach(sg => {
const side = g.M.planes.filter(pl => Math.abs(cross(pl)) > 1e-9 && Math.sign(cross(pl)) === sg);
const ws = g.wings.filter(w => w.sign === sg);
if (!ws.length) side.forEach(pl => terms.push(idx(pl)));
else terms.push(['max', ['min', ...side.map(idx)], ...ws.map(w => ['min', ...w.roof.map(idx)])]);
});
g.M.planes.filter(pl => Math.abs(cross(pl)) <= 1e-9 && !g.drop.includes(pl)).forEach(pl => terms.push(idx(pl)));
if (g.flush) g.flush.planes.forEach(pl => terms.push(idx(pl)));
const expr = ['min', ...terms];
[g.M, ...g.wings.map(w => w.W)].forEach(X => { out[X.u.id] = { planes, expr, flush: X === g.M && g.flush ? g.flush.side : null }; });
});
return out;
}
// De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug.
function applyRoofJoins(state) {
const rects = unitRects(state);
const joins = roofJoins(state, rects);
state.units.forEach((u, i) => {
const join = joins[u.id], side = join && join.flush;
if (Object.entries(u.unit.walls).some(([k, ws]) => ws.wall.joinTop && k !== side)) {
Object.values(u.unit.walls).forEach(ws => { delete ws.wall.joinTop; });
UnitModel.applyRoofForm(u.unit, UnitModel.roofForm(u.unit));
}
if (!side) return;
const r = rects[i];
const wl = UnitModel.computeLayout(u.unit, {}).walls.find(w => w.side === side);
const s = UnitModel.WALL_SIDES.find(x => x.key === side), alongX = s.axis === 'x';
const start = (alongX ? wl.cx + r.cx : wl.cz + r.cz) - s.dir * wl.width / 2;
const perp = alongX ? wl.cz + r.cz : wl.cx + r.cx, out = alongX ? wl.normal.y : wl.normal.x;
const faces = [perp + out * wl.thickness / 2, perp - out * wl.thickness / 2];
const pos = (x, f) => alongX ? { x: start + s.dir * x, z: f } : { x: f, z: start + s.dir * x };
const W = wl.width, xs = [0, W];
// Knikken alleen waar twee vlakken langs een wandvlak even hoog zijn.
faces.forEach(f => join.planes.forEach((p1, j) => join.planes.slice(j + 1).forEach(p2 => {
const h = (pl, x) => { const q = pos(x, f); return pl.a + pl.bx * q.x + pl.bz * q.z; };
const d0 = h(p1, 0) - h(p2, 0), d1 = h(p1, W) - h(p2, W);
if (Math.abs(d1 - d0) > 1e-9) { const x = -d0 / (d1 - d0) * W; if (x > 1 && x < W - 1) xs.push(x); }
})));
const hAt = (x) => Math.min(...faces.map(f => { const q = pos(x, f); return RoofModel.joinValue(join, q); }));
const pts = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((a, b) => a - b).map(x => ({ x, h: hAt(x) }));
const inner = pts.slice(1, -1).filter((q, k) => {
const a = pts[k], b = pts[k + 2];
return Math.abs(q.h - (a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h))) > 0.5;
});
const wall = u.unit.walls[side].wall;
setWallTop(wall, W, pts[0].h, pts[pts.length - 1].h, inner);
wall.joinTop = true;
});
}
@@ -638,11 +911,12 @@ window.HouseModel = (() => {
const { cuts, seams } = computeSeams(rects, wallThickness);
const houseShape = houseRoofShape(state, rects);
const coverExt = coverExtensions(state, rects);
const joins = roofJoins(state, rects);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc) {
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc,
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
@@ -696,9 +970,14 @@ window.HouseModel = (() => {
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
const roofW = deckFrame.width, roofD = deckFrame.depth;
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
const roofRect = { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 };
const join = joins[u.id] && {
planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })),
expr: joins[u.id].expr
};
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims);
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type];
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', wolf: 'wolfsdak' }[shape.type];
const multi = ur.planes.length > 1;
ur.planes.forEach((pl, i) => {
pl.element = `${u.id}-D${i + 1}`;
@@ -923,6 +1202,24 @@ window.HouseModel = (() => {
});
}
// Buitenlagen tot de bovenkant van het dak, per punt: een steil vlak (wolfseind) is verticaal dikker.
function skinTopLine(p, roof) {
const cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg), zF = p.place.thickness / 2;
const xs = new Set([0, W].concat((cfg.wallTop || []).map(q => q.x)));
for (let x = 100; x < W; x += 100) xs.add(x);
const pts = [...xs].sort((a, b) => a - b).map(x => {
const top = tl.at(x - W / 2), w = UnitModel.localToWorld(p.place, x - W / 2, zF), wallTop = p.place.floorTop + top;
return { x, h: top + Math.max(0, roof.topAt(w.x, w.z) - Math.min(wallTop, roof.ceilingAt(w.x, w.z))) };
});
const L = pts[0], R = pts[pts.length - 1];
const inner = WallModel.wallTopPoints({ topPts: pts.slice(1, -1).map(q => ({ f: q.x / W, h: q.h })) }, W, Math.round(L.h), Math.round(R.h));
const line = [L, ...inner, R];
const at = (x) => { let i = 0; while (i < line.length - 2 && x > line[i + 1].x) i++; const a = line[i], b = line[i + 1]; return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); };
// Alleen een bolle daklijn: een kil of sprong (binnenhoek L-vorm) zou de lijn erboven trekken; dan vast skinAbove.
if (pts.some(q => at(q.x) > q.h + 5)) return null;
return { hL: Math.round(L.h), hR: Math.round(R.h), pts: inner };
}
function topWedge(p, roof) {
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
const xs = new Set([-half, half].concat(tl.kinks));
@@ -961,6 +1258,7 @@ window.HouseModel = (() => {
p.skinAbove = covered !== null
? Math.max(0, Math.round(covered - 3 - wallTop))
: Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
p.skinTop = covered !== null ? null : skinTopLine(p, u.roof);
p.underRoof = covered !== null;
p.config.topWedge = topWedge(p, u.roof);
// Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop).
@@ -1017,7 +1315,7 @@ window.HouseModel = (() => {
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc);
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
if (built.group && p.shiftZ) {
const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001;
@@ -1357,6 +1655,7 @@ window.HouseModel = (() => {
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
state.units.forEach(u => {
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
UnitModel.dropMansarde(u.unit);
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';
@@ -1371,7 +1670,7 @@ window.HouseModel = (() => {
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
+18 -11
View File
@@ -32,7 +32,8 @@ window.HousePdf = (() => {
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: cap.kind === 'join' ? `Unit ${u.id} dak (aansluiting)` : `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
});
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
@@ -235,10 +236,10 @@ window.HousePdf = (() => {
const W = p.place.width, T = p.place.thickness;
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
const tl = WallModel.topLine(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop));
const top = (x) => Math.min(tl.at(x), tl.at(Math.max(-W / 2, Math.min(W / 2, x))));
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y })))
.concat([{ x: xl, y: top(xl) }]);
return loc;
};
@@ -282,7 +283,7 @@ window.HousePdf = (() => {
};
RoofModel.capRegions(r.unitRoof).forEach(rg => {
pushRoof(rg.poly.map(pt => {
const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x);
const ax = r.unitRoof.alongX, x = u.rect.cx + (ax ? pt.x : pt.z), z = u.rect.cz + (ax ? pt.z : pt.x);
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
}));
});
@@ -457,7 +458,7 @@ window.HousePdf = (() => {
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' };
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', kilkeper: 'Kilkeper', nokbalk: 'Nokbalk', knikbalk: 'Knikbalk', kopbalk: 'Kopbalk' };
function drawCapSheet(doc, u, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
const ur = u.roof.unitRoof, c = ur.cap;
@@ -474,7 +475,8 @@ window.HousePdf = (() => {
};
doc.setTextColor(...BLACK);
doc.setFontSize(11);
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
const join = c.kind === 'join';
doc.text(join ? 'Bovenaanzicht dak (aansluiting: kil- en hoekkepers, kreupele sporen)' : 'Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
@@ -505,17 +507,22 @@ window.HousePdf = (() => {
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
const hip = c.regions.find(rg => rg.key === 'h1');
const facts = [
const facts = (join ? [
['Goot (onderkant dak)', `${Math.round(c.eave)} mm`],
['Nok (onderkant dak)', `${Math.round(c.ridgeH)} mm`],
['Helling dakvlakken', `${deg(c.slope)}°`]
] : [
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
['Helling dakvlakken', `${deg(c.slope)}°`],
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-']
]).concat([
['Balken', `${c.joistW} x ${c.joistH} mm`],
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
];
]);
doc.setTextColor(...BLACK);
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: join ? 'Dak met aansluiting' : 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 });
doc.setFontSize(7);
doc.setTextColor(...GREY);
+9 -27
View File
@@ -1228,7 +1228,7 @@ function mountHouseEditor(container, callbacks) {
toggle('hs-roof-house', 'Over hele woning', hr.enabled, { hint: 'Eén dakvorm over alle units (bijv. een grote wolfskap); uit = dak per unit. De daken per unit blijven bewaard.' }) +
(houseRoofUi ? sel('hs-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(state.units[0].roof.covering, shapeType), { hint: 'Voor alle units (per unit aan te passen)' }) : '') +
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel-, mansarde- en wolfsdaken (alle units); uit = verwijderen' }) }
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken (alle units); uit = verwijderen' }) }
];
if (houseRoofUi) groups.push(...houseRoofUi.groups());
else groups.push({ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') });
@@ -1256,7 +1256,8 @@ function mountHouseEditor(container, callbacks) {
const el = body.querySelector('[data-live="roof-list"]');
if (!el || !parts) return;
el.innerHTML = parts.units.map(u => {
const pl = u.roof.planes[0], deg = Math.abs(pl ? pl.theta : 0) * 180 / Math.PI;
const pl = u.roof.planes[0], cap = u.roof.unitRoof.cap;
const deg = (pl ? Math.abs(pl.theta) : cap ? Math.atan(cap.slope) : 0) * 180 / Math.PI;
const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || '';
return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`);
}).join('');
@@ -1522,11 +1523,13 @@ function mountHouseEditor(container, callbacks) {
}
const FX_OPT_UI = {
front: { label: 'Front', kind: 'sel', options: () => Object.fromEntries(Object.entries(FixtureModel.FRONTS).map(([k, f]) => [k, f.label])) },
spoelbak: { label: 'Spoelbak', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
spoel: { label: 'Spoelbak', kind: 'toggle' },
spoelbak: { label: 'Plek spoelbak', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
hogeKast: { label: 'Hoge kast', kind: 'sel', options: () => ({ geen: 'Geen', links: 'Links', rechts: 'Rechts' }), hint: 'Inbouwkoelkast + oven, 600 mm' },
vaatwasser: { label: 'Vaatwasser', kind: 'toggle' },
bovenkasten: { label: 'Bovenkasten', kind: 'toggle' },
kookplaat: { label: 'Kookplaat', kind: 'toggle', hint: 'Inductie, 4 zones' },
kookplaatPlek: { label: 'Plek kookplaat', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
glas: { label: 'Glaswand', kind: 'sel', options: () => ({ links: 'Links', rechts: 'Rechts', geen: 'Geen' }), hint: 'Van voren gezien' },
glasLengte: { label: 'Lengte glas', kind: 'num', step: 50 },
spiegel: { label: 'Spiegel', kind: 'toggle' },
@@ -1752,10 +1755,6 @@ function mountHouseEditor(container, callbacks) {
const LIM = UnitModel.LIMITS;
const runOf = () => UnitModel.hasRidge(form.type) ? o.span() / 2 : o.span();
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
const mansardeDeg = () => {
const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn);
return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI);
};
const f = (k) => `${id}-roof-${k}`;
const groups = () => {
const g = [
@@ -1766,12 +1765,8 @@ function mountHouseEditor(container, callbacks) {
];
if (sloped()) g.push({ label: 'Helling', html:
UI.range(f('high'), UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
(form.type === 'mansarde' ? '' : UI.range(f('deg'), 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' })) +
UI.range(f('deg'), 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' }) +
(o.gable ? `<div data-live="roof-gable">${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}</div>` : '') });
if (form.type === 'mansarde') g.push({ label: 'Mansarde', html:
UI.range(f('knee-h'), 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) +
UI.range(f('knee-in'), 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) +
`<div data-live="roof-mansarde">${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}</div>` });
if (form.type === 'wolf') g.push({ label: 'Wolfsdak', html:
UI.range(f('cut'), 'Wolfseind', form.cut, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte waarop de kopgevels stoppen; daarboven driehoekige wolfseinden met de dakhelling tot de nok' }) });
return g;
@@ -1787,13 +1782,6 @@ function mountHouseEditor(container, callbacks) {
if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700;
if (v === 'lessenaar' && !form.dir) form.dir = 'voor';
if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z';
if (v === 'mansarde') {
const run = runOf();
form.kneeIn = Math.round(run * 0.35);
form.kneeH = Math.round(form.low + form.kneeIn * 2.1);
form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5)));
form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low));
}
if (v === 'wolf') {
if (form.high - form.low < 1000) form.high = form.low + 1500;
form.cutRatio = 0.6;
@@ -1804,13 +1792,9 @@ function mountHouseEditor(container, callbacks) {
UI.onSel(f('ridge'), v => { form.ridge = v; apply(); }, rerender);
const syncDeg = () => {
const now = o.readBack();
if (now && now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
if (now && now.type === form.type) ['cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
UI.setRange(f('deg'), Math.round(degOf() * 10) / 10);
UI.setRange(f('knee-h'), form.kneeH);
UI.setRange(f('knee-in'), form.kneeIn);
UI.setRange(f('cut'), form.cut);
const md = body.querySelector('[data-live="roof-mansarde"]');
if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '));
};
UI.onRange(f('low'), v => {
if (!(v >= 500)) return;
@@ -1830,8 +1814,6 @@ function mountHouseEditor(container, callbacks) {
apply(); syncDeg();
}, after);
const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low)));
UI.onRange(f('knee-h'), v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after);
UI.onRange(f('knee-in'), v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after);
UI.onRange(f('cut'), v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
};
return { groups, wire };
@@ -1875,7 +1857,7 @@ function mountHouseEditor(container, callbacks) {
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }),
thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) +
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) +
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'mansarde', 'wolf'].includes(shapeNow)
(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: 'Dakmaat', html:
+22 -14
View File
@@ -162,8 +162,8 @@ window.FixtureModel = (() => {
},
keuken: {
label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken',
opts: { front: 'antraciet', spoelbak: 0.6, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad en spoelbak (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
opts: { front: 'antraciet', spoel: true, spoelbak: 0.6, kookplaat: false, kookplaatPlek: 0.2, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad, spoelbak en eventueel kookplaat (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
build(f, g) { kitchenRun(g, f, false); },
plan(f) { return kitchenPlan(f, false); }
},
@@ -386,25 +386,32 @@ window.FixtureModel = (() => {
boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2);
}
if (!island) {
// Spoelbak + kraan
if (f.opts.spoel !== false) {
const sx = spoelX(f, K);
boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70);
boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85);
ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300);
boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280);
}
if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350);
} else if (f.opts.kookplaat) {
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80);
boxAt(g, MAT.kookplaat(), -hw / 2, hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), z.x, z.z, z.r, z.r, top + 4, top + 5));
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
boxAt(g, MAT.kookplaat(), hx - hw / 2, hx + hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), hx + z.x, z.z, z.r, z.r, top + 4, top + 5));
}
}
const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]]
.map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) }));
function spoelX(f, K) {
const t = Math.max(0, Math.min(1, f.opts.spoelbak === undefined ? 0.6 : f.opts.spoelbak));
function plekX(K, v, def) {
const t = Math.max(0, Math.min(1, v === undefined ? def : v));
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
}
const spoelX = (f, K) => plekX(K, f.opts.spoelbak, 0.6);
// Kookeiland: kookplaat in het midden; keukenblok: op kookplaatPlek.
const hobOf = (f, K, island) => ({
hx: island ? 0 : plekX(K, f.opts.kookplaatPlek, 0.2), hw: Math.min(780, K.x1 - K.x0 - 100), hd: Math.min(520, f.d - 80)
});
function kitchenPlan(f, island) {
const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = [];
out.push(rectP(K.x0, K.x1, zb, zf));
@@ -413,13 +420,14 @@ window.FixtureModel = (() => {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600;
out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin'));
}
if (!island) {
if (!island && f.opts.spoel !== false) {
const sx = spoelX(f, K);
out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin'));
} else if (f.opts.kookplaat) {
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80);
out.push(rectP(-hw / 2, hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(z.x, z.z, z.r, 'thin')));
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
out.push(rectP(hx - hw / 2, hx + hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(hx + z.x, z.z, z.r, 'thin')));
}
return out;
}
+1 -1
View File
@@ -10,7 +10,7 @@ window.LightingModel = (() => {
mode: 'spots',
direction: 'both',
lampHeight: 1950,
lampMin: 1500,
lampMin: 500,
lampMax: 3000,
minWallWidth: 2000,
endOffset: 300,
+264 -70
View File
@@ -212,7 +212,7 @@ window.RoofModel = (() => {
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
}
}
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel';
const type = profile.length === 2 ? 'lessenaar' : 'zadel';
return { type, axis, profile };
}
@@ -267,8 +267,9 @@ window.RoofModel = (() => {
finishKNm2: 0.3, // afwerking/installaties onder het dak
gammaG: 1.2, gammaQ: 1.5, deflection: 250
};
const ridgeTypes = ['zadel', 'mansarde', 'wolf'];
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) {
const ridgeTypes = ['zadel', 'wolf'];
// span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper).
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span) {
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
const pr = shape.profile, inner = pr.slice(1, -1);
if (!inner.length) return null;
@@ -283,6 +284,7 @@ window.RoofModel = (() => {
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
}
if (span) { q0 = Math.max(q0, span[0]); q1 = Math.min(q1, span[1]); }
const L = q1 - q0;
if (L < 100) return null;
// q in N/mm = kN/m.
@@ -393,7 +395,7 @@ window.RoofModel = (() => {
});
});
// Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
// Knik in de daklijn: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
// bovenkant op het snijpunt van de bovenvlakken.
planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
planes.slice(0, -1).forEach((pi, i) => {
@@ -418,11 +420,7 @@ window.RoofModel = (() => {
}
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6));
Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
addRidgeSpecs(specs, ridgeBeam);
return {
shape, covering, planes: outPlanes, specs, ridgeBeam,
undersideAt, topAt, cap, rect, alongX,
@@ -431,6 +429,185 @@ window.RoofModel = (() => {
};
}
function addRidgeSpecs(specs, ridgeBeam) {
Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
}
// Dakaansluiting (HouseModel.roofJoins): vlakken h = a + bx·x + bz·z, samengevoegd volgens expr
// (vlaknummer of ['min' | 'max', ...]). extra(k): ophoging per vlak (bovenkant dak).
const planeAt = (pl, p) => pl.a + pl.bx * p.x + pl.bz * p.z;
function joinValue(join, p, extra) {
const at = (e) => Array.isArray(e) ? Math[e[0]](...e.slice(1).map(at)) : planeAt(join.planes[e], p) + (extra ? extra(e) : 0);
return at(join.expr);
}
function convexHull(pts) {
const sorted = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
const half = (list) => list.reduce((h, q) => {
while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 0.01 * Math.hypot(q.x - h[h.length - 2].x, q.z - h[h.length - 2].z)) h.pop();
h.push(q);
return h;
}, []);
const lo = half(sorted), up = half(sorted.slice().reverse());
const out = lo.slice(0, -1).concat(up.slice(0, -1));
return out.filter((p, k) => { const q = out[(k + 1) % out.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; });
}
// Dak van één unit in een dakaansluiting (join in unit-lokale maten): per vlak de convexe stukken binnen het dek,
// sporen haaks op de goot (kreupel tegen kil- en hoekkepers), kepers en nokken op de grenzen tussen de vlakken.
// Alles als houtwerk zoals het bovenste deel van een wolfsdak (cap); de eigen nok wordt de stalen nokbalk.
function computeJoinedRoof(roofState, join, rect, shape) {
const fp = roofState.deck.floor;
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
const covering = coveringFor(roofState.covering, shape.type);
const cov = COVERINGS[covering] || COVERINGS.dakleer;
const pls = join.planes.map((pl, k) => {
const slope = Math.hypot(pl.bx, pl.bz), cos = 1 / Math.sqrt(1 + slope * slope);
const tV = underlay / cos, jV = joistH / cos, bV = BESCHOT_MM / cos, covV = cov.thickMM / cos;
const under = (p) => planeAt(pl, p);
return { key: k, pl, slope, cos, tV, jV, bV, covV, vThick: tV + jV + bV + covV, under, f: under, along: Math.abs(pl.bx) >= Math.abs(pl.bz) ? 'p' : 'q' };
});
const activeAt = (p) => {
const h = joinValue(join, p);
return pls.reduce((a, b) => Math.abs(b.under(p) - h) < Math.abs(a.under(p) - h) ? b : a);
};
const box = [{ x: rect.x0, z: rect.z0 }, { x: rect.x1, z: rect.z0 }, { x: rect.x1, z: rect.z1 }, { x: rect.x0, z: rect.z1 }];
const clipBox = (poly) => [(p) => p.x - rect.x0, (p) => rect.x1 - p.x, (p) => p.z - rect.z0, (p) => rect.z1 - p.z]
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
const pairs = [];
pls.forEach((A, i) => pls.slice(i + 1).forEach(B => {
if (Math.hypot(A.pl.bx - B.pl.bx, A.pl.bz - B.pl.bz) > 1e-9) pairs.push({ A, B, f: (p) => A.under(p) - B.under(p) });
}));
// Afgeronde wandhoogtes maken bijna samenvallende lijnen: splinters (gemiddeld < 0,5 mm breed) vallen weg.
const perim = (poly) => poly.reduce((t, p, k) => { const q = poly[(k + 1) % poly.length]; return t + Math.hypot(q.x - p.x, q.z - p.z); }, 0);
const sliver = (poly) => quadArea(poly) < Math.max(100, 0.25 * perim(poly));
let cells = [box];
pairs.forEach(({ f }) => {
cells = cells.flatMap(c => [clipPoly(c, f), clipPoly(c, p => -f(p))]).filter(c => c.length >= 3 && !sliver(c));
});
const centroid = (poly) => ({ x: poly.reduce((t, p) => t + p.x, 0) / poly.length, z: poly.reduce((t, p) => t + p.z, 0) / poly.length });
const regions = [];
pls.forEach(pl => {
const list = cells.filter(c => activeAt(centroid(c)) === pl).map(convexHull);
// Aangrenzende cellen samenvoegen zolang het geheel convex blijft.
let merged = true;
while (merged) {
merged = false;
for (let i = 0; i < list.length && !merged; i++) {
for (let j = i + 1; j < list.length && !merged; j++) {
const h = convexHull(list[i].concat(list[j]));
if (quadArea(h) - quadArea(list[i]) - quadArea(list[j]) < 0.5 * perim(h)) { list[i] = h; list.splice(j, 1); merged = true; }
}
}
}
list.filter(poly => poly.length >= 3).forEach(poly => regions.push(Object.assign({}, pl, { poly })));
});
// Grenzen tussen twee vlakken: bol = nok (waterpas) of hoekkeper, hol = kilkeper.
const members = [];
pairs.forEach(({ A, B, f }) => {
const gx = A.pl.bx - B.pl.bx, gz = A.pl.bz - B.pl.bz, g = Math.hypot(gx, gz), c0 = f({ x: 0, z: 0 });
const o = { x: -c0 * gx / (g * g), z: -c0 * gz / (g * g) }, d = { x: -gz / g, z: gx / g }, n = { x: gx / g, z: gz / g };
let t0 = -Infinity, t1 = Infinity;
[['x', rect.x0, rect.x1], ['z', rect.z0, rect.z1]].forEach(([k, lo, hi]) => {
if (Math.abs(d[k]) < 1e-9) { if (o[k] < lo || o[k] > hi) t1 = -Infinity; return; }
const a = (lo - o[k]) / d[k], b = (hi - o[k]) / d[k];
t0 = Math.max(t0, Math.min(a, b));
t1 = Math.min(t1, Math.max(a, b));
});
if (!(t1 - t0 >= 100)) return;
const pt = (t) => ({ x: o.x + d.x * t, z: o.z + d.z * t });
const ts = [t0, t1];
pairs.forEach(q => {
const dn = q.f(pt(1)) - q.f(pt(0));
if (Math.abs(dn) < 1e-9) return;
const t = -q.f(pt(0)) / dn;
if (t > t0 && t < t1) ts.push(t);
});
ts.sort((a, b) => a - b);
const segs = [];
let cur = null;
ts.slice(0, -1).forEach((ta, k) => {
const tb = ts[k + 1];
if (tb - ta < 1) return;
const m = pt((ta + tb) / 2), off = (s) => ({ x: m.x + n.x * s, z: m.z + n.z * s });
const s1 = activeAt(off(2)), s2 = activeAt(off(-2));
if (Math.abs(joinValue(join, m) - A.under(m)) > 0.5 || !((s1 === A && s2 === B) || (s1 === B && s2 === A))) { cur = null; return; }
const onA = off(s1 === A ? 2 : -2), convex = B.under(onA) > A.under(onA);
if (cur && cur.convex === convex) { cur.tb = tb; return; }
cur = { ta, tb, convex };
segs.push(cur);
});
// Waterpas en bol: nok als de vlakken van elkaar af aflopen, anders een knik.
const ridge = A.pl.bx * B.pl.bx + A.pl.bz * B.pl.bz < 0;
segs.forEach(sg => {
const P = pt(sg.ta), Q = pt(sg.tb), level = Math.abs(A.under(P) - A.under(Q)) < 1;
members.push({ kind: !sg.convex ? 'kilkeper' : !level ? 'hoekkeper' : ridge ? 'nokbalk' : 'knikbalk', A: P, B: Q, rg: A });
});
});
const alongX = shape.axis === 'x';
const own = members.find(m => m.kind === 'nokbalk' && (alongX ? Math.abs(m.A.x - m.B.x) < 1 : Math.abs(m.A.z - m.B.z) < 1));
const steel = own && roofState.steelRidge !== false;
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
members.forEach(m => { if (!(steel && m === own)) addBeam(m.kind, m.rg, m.A, m.B, [], true); });
pls.forEach(pl => {
const polys = regions.filter(rg => rg.key === pl.key).map(rg => rg.poly);
if (!polys.length) return;
const fixed = pl.along === 'p' ? 'z' : 'x';
const [lo, hi] = fixed === 'z' ? [rect.z0, rect.z1] : [rect.x0, rect.x1];
joistSpread(fp, hi - lo).map(v => lo + v).forEach(v => {
const spans = polys.map(poly => cutLine(poly, fixed, v)).filter(Boolean).sort((a, b) => a[0] - b[0]);
const runs = [];
spans.forEach(sp => {
const last = runs[runs.length - 1];
if (last && sp[0] <= last[1] + 1) last[1] = Math.max(last[1], sp[1]);
else runs.push(sp.slice());
});
runs.forEach(span => {
const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t };
const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2);
const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid));
addBeam('spoor', pl, A, B, clips);
});
});
});
const ownBeams = clipBeams(beams, clipBox);
const capSpecs = regionSpecs(ownBeams, regions, joistW, joistH, underlay, covering);
const kgM2 = capSpecs.totalKg / Math.max(0.1, (rect.x1 - rect.x0) * (rect.z1 - rect.z0) / 1e6);
const q = own ? (alongX ? [own.A.z, own.B.z] : [own.A.x, own.B.x]) : null;
// Wolfsdak: de nok loopt hier tot de aansluiting, de inkorting van de wolfseinden volgt uit span.
const ridgeShape = shape.type === 'wolf' ? Object.assign({}, shape, { type: 'zadel' }) : shape;
const ridgeBeam = steel ? computeRidgeBeam(roofState, ridgeShape, rect, alongX, { underlayThick: underlay, joistH }, kgM2, [Math.min(...q), Math.max(...q)]) : null;
if (ridgeBeam) ridgeBeam.alongX = alongX;
const specs = Object.assign({}, capSpecs);
addRidgeSpecs(specs, ridgeBeam);
const heights = regions.flatMap(rg => rg.poly.map(p => rg.under(p)));
const maxTop = Math.max(...regions.map(rg => Math.max(...rg.poly.map(p => rg.under(p) + rg.vThick))));
const cap = {
kind: 'join', pA: rect.x0, pB: rect.x1, q0: rect.z0, q1: rect.z1, eave: Math.min(...heights), ridgeH: Math.max(...heights),
slope: Math.max(...regions.map(rg => rg.slope)), joistW, joistH, regions, beams: ownBeams, specs: capSpecs, maxTop
};
// Knikpunten van de daklijn op de randen (nok, knik, wolfseind, kepers): daar knikt ook het boeideel.
const ridges = [];
members.forEach(m => [m.A, m.B].forEach(p => {
const onX = Math.abs(p.x - rect.x0) < 1 || Math.abs(p.x - rect.x1) < 1, onZ = Math.abs(p.z - rect.z0) < 1 || Math.abs(p.z - rect.z1) < 1;
const r = onX && !onZ ? { axis: 'z', value: p.z } : onZ && !onX ? { axis: 'x', value: p.x } : null;
if (r && !ridges.some(q => q.axis === r.axis && Math.abs(q.value - r.value) < 1)) ridges.push(r);
}));
return {
shape, covering, planes: [], specs, ridgeBeam, cap, rect, alongX: true, ridges, maxTop,
undersideAt: (x, z) => joinValue(join, { x, z }),
topAt: (x, z) => joinValue(join, { x, z }, k => pls[k].vThick)
};
}
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
function computeCap(roofState, shape, rect, alongX, covering) {
const H = (p) => profileHeight(shape.profile, p);
@@ -465,53 +642,7 @@ window.RoofModel = (() => {
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
if (!clipped.some(rg => rg.along === 'q')) return null;
const cutLine = (poly, fixed, v) => {
const ts = [];
poly.forEach((a, k) => {
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
ts.push(a[o] + (b[o] - a[o]) * t);
});
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
};
const hw = joistW / 2;
const rectAround = (A, B) => {
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
};
const awayFrom = (m, from) => {
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
const sgn = d(from) >= 0 ? 1 : -1;
return (p) => sgn * d(p) - hw;
};
const distToSeg = (p, m) => {
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
};
const beams = [];
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
let poly = rectAround(A, B);
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
const yAt = lineHeight
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
: (p) => rg.under(p) + rg.tV;
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null;
const bm = {
kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
@@ -534,8 +665,7 @@ window.RoofModel = (() => {
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
}
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2);
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
const qs = joistSpread(fp, q1 - q0).map(v => q0 + v), ps = joistSpread(fp, pB - pA).map(v => pA + v);
regions.forEach(rg => {
const fixed = rg.along === 'p' ? 'z' : 'x';
(rg.along === 'p' ? qs : ps).forEach(v => {
@@ -548,8 +678,69 @@ window.RoofModel = (() => {
});
});
const ownBeams = beams.map(bm => {
const poly = clipBox(bm.poly);
const ownBeams = clipBeams(beams, clipBox);
const specs = regionSpecs(ownBeams, clipped, joistW, joistH, underlay, covering);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
}
// Houtwerk op dakvlakken met een eigen omtrek (wolfseinden, dakaansluiting); balken als rechthoek in bovenaanzicht.
function cutLine(poly, fixed, v) {
const ts = [];
poly.forEach((a, k) => {
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
ts.push(a[o] + (b[o] - a[o]) * t);
});
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
}
function distToSeg(p, m) {
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
}
function joistSpread(fp, len) {
return FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: fp.joistWidth || 70, joistHeight: fp.joistHeight || 220, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: fp.underlayThick || 22 })
.joists.map(x => x + len / 2);
}
function beamTools(joistW) {
const hw = joistW / 2, beams = [];
const rectAround = (A, B) => {
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
};
const awayFrom = (m, from) => {
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
const sgn = d(from) >= 0 ? 1 : -1;
return (p) => sgn * d(p) - hw;
};
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
let poly = rectAround(A, B);
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
const yAt = lineHeight
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
: (p) => rg.under(p) + rg.tV;
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null;
const bm = {
kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
return { hw, beams, awayFrom, addBeam };
}
function clipBeams(beams, clip) {
return beams.map(bm => {
const poly = clip(bm.poly);
if (poly.length < 3 || quadArea(poly) < 100) return null;
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
@@ -557,20 +748,19 @@ window.RoofModel = (() => {
if (plan < 50) return null;
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
}).filter(Boolean);
}
function regionSpecs(beams, regions, joistW, joistH, underlay, covering) {
const W = joistW * 1e-3, Hh = joistH * 1e-3;
const woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
const areaM2 = clipped.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3;
const specs = Object.assign({
return Object.assign({
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3,
voidM3: Math.max(0, areaM2 * Hh - woodM3),
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
}, cs);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
}
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
@@ -581,7 +771,8 @@ window.RoofModel = (() => {
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
if (unitRoof.ridgeBeam) {
const r = unitRoof.rect;
group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, unitRoof.alongX, opts.materials));
const rb = unitRoof.ridgeBeam;
group.add(buildRidgeBeam3D(rb, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, rb.alongX !== undefined ? rb.alongX : unitRoof.alongX, opts.materials));
}
unitRoof.planes.forEach(pl => {
const g = new THREE.Group();
@@ -647,7 +838,7 @@ window.RoofModel = (() => {
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0;
const up = T.reduce((t, a, k) => { const b = T[(k + 1) % n]; return t + a[0] * b[2] - b[0] * a[2]; }, 0) < 0;
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
const topCount = pos.length / 3;
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
@@ -879,7 +1070,10 @@ window.RoofModel = (() => {
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
const h = s.house || {};
s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover };
const form = h.form ? Object.assign({}, h.form) : null;
// 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 };
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
return s;
}
@@ -887,7 +1081,7 @@ window.RoofModel = (() => {
return {
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions,
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, joinValue, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings
};
})();
+12 -19
View File
@@ -306,8 +306,8 @@ window.UnitModel = (() => {
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
}
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
// cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert.
const LIMITS = { ridge: 5000, eave: 3000, short: 4700, long: 14000 };
function clampSize(floor, keep) {
floor.width = Math.min(LIMITS.long, floor.width);
floor.depth = Math.min(LIMITS.long, floor.depth);
@@ -317,9 +317,9 @@ window.UnitModel = (() => {
}
return floor;
}
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' };
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', wolf: 'Wolfsdak' };
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf';
const hasRidge = (type) => type === 'zadel' || type === 'wolf';
function gableOf(form) {
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
@@ -333,10 +333,8 @@ window.UnitModel = (() => {
const full = fullTopLine(w, gableSpan(unitState, side));
const F = full[full.length - 1].u, inner = full.slice(1, -1);
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
if (inner.length === 3) {
const high = Math.round(inner[1].h);
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
}
// v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok.
if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) };
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
const high = Math.round(L.h + slope * F / 2);
@@ -351,6 +349,10 @@ window.UnitModel = (() => {
const h = Math.max(...Object.values(W).map(wallTopOf));
return { type: 'plat', low: h, high: h };
}
function dropMansarde(unitState) {
const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null;
if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f);
}
function applyRoofForm(unitState, form) {
const side = form.type !== 'plat' ? gableOf(form) : 'achter';
const g = gableSpan(unitState, side);
@@ -365,16 +367,7 @@ window.UnitModel = (() => {
const half = F / 2;
let line = [{ u: 0, h: low }, { u: F, h: low }];
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
else if (sloped && form.type === 'mansarde') {
// Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
const minK = low + (high - low) * kIn / half + 20;
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
line = kH > minK - 1 && high - kH >= 20
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped && form.type === 'wolf') {
else if (sloped && form.type === 'wolf') {
const minR = (eMin + 50) / half;
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
@@ -401,5 +394,5 @@ window.UnitModel = (() => {
}
}
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, dropMansarde, applyRoofForm, formLine, validateState };
})();
+19 -12
View File
@@ -269,6 +269,17 @@ window.WallModel = (() => {
};
}
function raiseTop(CONFIG, d) {
return d ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d }))
}) : CONFIG;
}
// Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove.
function skinConfig(CONFIG, skinAbove, skinTop) {
return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove);
}
function topPlateOutlines(tl) {
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
return tl.segs.map((sg, i) => [
@@ -769,16 +780,10 @@ window.WallModel = (() => {
const startXM = -halfW;
const topYAt = tl.at;
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove }))
}) : CONFIG;
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop);
const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM);
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove }))
}) : CONFIG;
const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove);
const underPts = tl.underPts;
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
@@ -940,7 +945,9 @@ window.WallModel = (() => {
uv.needsUpdate = true;
return geo;
};
const topClampedOut = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x)));
// Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit.
const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x))));
const topClampedOut = beyondEnd(tlOut);
const layerShapes = (a, b, y0, yCap) => {
if (b - a <= edgeEps) return [];
const capped = yCap === Infinity;
@@ -1216,7 +1223,7 @@ window.WallModel = (() => {
const rExt = extM(rabatDepthMM);
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x)));
const rTop = beyondEnd(tlBat);
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
@@ -1546,7 +1553,7 @@ window.WallModel = (() => {
return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+1 -1
View File
@@ -1380,7 +1380,7 @@ function mountWallEditor(container, callbacks) {
const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => {
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
// Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen.
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
input.addEventListener('input', updateAll);
});
+1 -1
View File
@@ -48,7 +48,7 @@
"mode": "spots",
"direction": "both",
"lampHeight": 1950,
"lampMin": 1500,
"lampMin": 500,
"lampMax": 3000,
"minWallWidth": 2000,
"endOffset": 300,