3 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 adb3c74f85 v0.7.0: exports voor CAD, BIM en renders (Bestand › Exporteren, los van Opslaan/Ctrl+S): CAD (DXF R12, plattegrond met wanden/sparingen/gevelbekleding/kozijnen met draaicirkel, ruimtes, inrichting, dakrand, elektra en echte maatvoering, plus vier gevels als lijntekening met verborgen lijnen weg; lagen PL-/AZ-, 1:1 mm, afgestemd op 1:50; in CAD op te slaan als DWG), BIM (IFC4 met alleen de bouwconstructie: stijlen, regels, vloer- en dakbalken, stalen nokbalk, palen, spaanplaat/OSB/cementplaat en elektra per leiding, gegroepeerd per unit en element met Pset_Tectum en kleur per soort) en Render (OBJ + MTL + texturen in een ZIP voor Blender, volledige afgewerkte woning, kleuren lineair, PBR); schoorsteen tegen een buitenwand (ChimneyModel: sparing in wand en dak met raveelwerk, steenstrip/stuc, verslepen langs de gevel); kozijnen koppelen met een eigen schakelaar; kozijnindeling (deurblad, zij-/bovenlicht, schuifbladen) gedeeld tussen 3D, plattegrond en aanzicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:00:32 +02:00
MachielandClaude Opus 5.5 86fcd57b96 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>
2026-10-11 14:26:06 +02:00
MachielandClaude Opus 5.5 b137a458d4 v0.6.2: dakaansluiting L/T (vleugel met nokdak loopt het hoofddak in met kilkepers, of de kop wordt een schild met hoekkeper; eigen dakdeel en PDF-blad per unit, ook in de kosten), model IJssel (L-vorm met zadeldaken in riet), unit max 4,7 m breed; mansarde vervallen (oude ontwerpen worden een zadeldak met dezelfde nok); gevelbekleding volgt voorbij het wandeinde de daklijn (geen blok meer boven een steil dakvlak) en bij een bolle daklijn per punt de bovenkant van het dak; gevellampen standaard min. 500 mm h.o.h. (geen samengevoegde lamp meer boven een smal raam)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:19:43 +02:00
28 changed files with 3525 additions and 370 deletions
+112 -11
View File
@@ -22,17 +22,25 @@ index.html app-shell: ribbon, splashscreen
css/app.css alle stijl
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen,
labels, camerageheugen, weergavefilter, skybox, licentie, logo
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen, weergavefilter,
skybox, licentie, logo
js/wall/ WallModel (logica) + wand-PDF + wand-editor
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen)
js/house/chimney-model.js ChimneyModel: schoorsteen tegen een buitenwand (hol, sparing in de wand zoals een raam,
ook over een naad, zonder hout erin; sparing in het dak met raveelwerk (stijlen, balken, kreupele
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
buiten steenstrip/stuc, binnen als de wanden of eigen; in de plattegrond
te verslepen langs de buitengevel)
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
js/sandwich/ SandwichModel: sandwichpanelen rondom
js/room/ RoomModel: ruimtes (verfemmer), functie, netto m², afwerking, vals plafond
js/interior/ FixtureModel: inrichting (sanitair, keuken, kasten, meubels, boiler)
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
js/house/ HouseModel (woning) + woning-PDF + woning-editor
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
js/app.js menubalk, versienummer, actieve editor
json/defaults/ referentie-JSON's (niet runtime ingeladen)
@@ -85,7 +93,11 @@ assets/ logo, texturen, skybox
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
met zijlichten = twee deuren naast elkaar. Schuifdeur: 1 of 2 bladen
met zijlichten = twee deuren naast elkaar. Koppelen: een opening die tegen
zijn buur aan staat (zelfde onderkant en hoogte, samen max. 2850 mm) vormt
daarmee één sparing zonder stijlen, dagkanten, latten of kozijnstijlen
ertussen; twee draaideuren worden stolp (bladen tegen elkaar), anders één
gedeelde tussenstijl. Schuifdeur: 1 of 2 bladen
(uit elkaar), in de wand of op de wand aan een rail (binnen- of
buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
@@ -97,26 +109,44 @@ 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).
Een dubbel lessenaardak (twee units, elk hoog aan hun gezamenlijke naad,
op de mm gelijke goot, nok en hoogte op de naad: samen een zadeldak)
krijgt net als een zadeldak een stalen nokbalk onder de naad; één
lessenaar of twee die zo niet aansluiten niet. Staat er een schoorsteen
op de nok, dan stopt elke nokbalk vóór de schoorsteen.
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
@@ -214,6 +244,52 @@ assets/ logo, texturen, skybox
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
vloeren, wandstukken, binnenwanden, totalen).
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF), ook in de ribbon
Woning › Uitvoer; los van Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
CAD-pakket (AutoCAD, BricsCAD, DraftSight, LibreCAD, …); DWG maakt het
CAD-pakket zelf met Opslaan als (echte DWG schrijven vergt een GPL- of
commerciële bibliotheek). Modelruimte 1:1 in mm: plattegrond met noord
boven en de zuidwesthoek van de units op 0,0, eronder de vier gevels in een
2×2-raster (maaiveld als basis). Plattegrond: wanden per stuk als gesloten
polylijn, onderbroken bij sparingen die onder het snijvlak (1500 mm) beginnen
(hogere gestreept), stijlzone en gevelbekleding als eigen lagen, kozijnen
(raam: kader + glaslijn; deur: stijlen, blad en draaicirkel als boog;
schuifdeur: bladen, pijlen, rail), ruimtegrenzen als gesloten polylijn met
naam/m²/plafond, inrichting, dakrand, palen, schoorsteen, verlichting,
elektra, naden en elementcodes. Gevels: dezelfde vlakken als de
PDF-aanzichten (`HousePdf.viewProjections`) als lijntekening met verborgen
lijnen weg, kozijnen met kader, blad, zij-/bovenlicht en glas
(`KozijnModel.viewLines`). Maatvoering als echte DIMENSION (maatstijl
TECTUM): ketting per buitenwandstuk langs de sparingen, totaal per stuk en
van de woning, hoogtes maaiveld/peil/top per gevel. Lagen `PL-*`
(plattegrond), `AZ-*` (aanzichten), `MAATVOERING`, `TEKST`; tekst,
maatvoering en lijntypes afgestemd op 1:50 (`$LTSCALE`/`DIMSCALE` 50).
- **BIM-export (IFC):** Bestand › Exporteren › BIM (IFC), ook in Woning ›
Uitvoer. IFC4 met alleen de bouwconstructie: hout (stijlen/regels als
IfcMember, vloerbalken als IfcBeam, dakbalken als IfcMember), stalen
nokbalk, palen, platen (spaanplaat, OSB, cementplaat als IfcPlate) en
elektra (buis per leiding, inbouw-/lasdozen, meterkast); geen
gevelafwerking, latwerk, isolatie, kozijnen, dakbedekking of inrichting.
Geometrie uit de 3D-opbouw zelf (`HouseIfc`: fase *elektra* voor alle balken
en leidingen, fase *beplating* voor de platen; herkend aan het materiaal),
als driehoeksnet. Opbouw: unit › element (A-W1, A-V1, A-D) › onderdelen;
binnenwanden en elektra los in de bouwlaag. Per onderdeel `Pset_Tectum`
(element, onderdeel, afmeting, lengte, materiaal). Kleur per soort (op het
vlak en het materiaal): stijl lichthout, regel oranje, vloerbalk bruin,
dakbalk roodbruin, paal donkerbruin, stalen nokbalk staalblauw, spaanplaat
groen, OSB geel, cementplaat grijs; elektra in de app-kleuren
(`HouseIfc` COLORS). X = oost, Y = noord,
Z = 0 op peil; zuidwesthoek van de units = 0,0 (zelfde als de DXF).
- **Render-export (OBJ):** Bestand › Exporteren › Render (OBJ), ook in Woning ›
Uitvoer. De volledige afgewerkte woning (laatste bouwfase, dak, dakrand,
lampen, ruimtes, inrichting; los van de gekozen fase, doorsnede en
weergave-opties; zonder logobord, grond en lucht) als ZIP met `.obj`, `.mtl`
en `textures/*.png` (rabat, steen, riet, tegels). Meters, Y omhoog (de
standaard OBJ-import van Blender zet dat goed: noord = +Y), oorsprong op
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de
UV's (`ObjWriter`).
## Kosten en prijzen (CSV)
@@ -260,6 +336,16 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
## Bekende beperkingen
- DXF: geen arceringen en geen lijndiktes (R12); kleuren per laag (ACI), dus
pendiktes via de plotstijl. Geen doorsnede en geen dakaanzicht. Eenheid
staat niet in het bestand: in CAD op millimeters zetten (UNITS) als het
pakket erom vraagt.
- OBJ: geen lichtbronnen (lampen alleen als gloeiend materiaal), geen
reliëf-/normaalkaarten; glas als doorzichtig materiaal, in Blender eventueel
op Glass BSDF zetten.
- IFC: onderdelen zijn driehoeksnetten (niet parametrisch); geen
verbindingsmiddelen, isolatie of bekabeling (alleen buizen).
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
@@ -275,7 +361,22 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
`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. De standaardmodellen staan in
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. Model **Waal**
(`HouseModel.createWaal`, naar `examples/ontwerp_waal.pdf`): 12,85 × 7 m,
flauw zadeldak dat aan de oostkop 1,5 m doorloopt als veranda op eiken
palen (Overkapping per unit). Model **Maas** (`HouseModel.createMaas`,
naar `examples/ontwerp_maas.pdf`): vierkant 9,2 × 9,2 m, flauw zadeldak
over de hele woning; de overkapping maakt van de noordwesthoek een
inpandige veranda (2,5 × 4,6 m) op eiken palen. Model **Rijn**
(`HouseModel.createRijn`, naar `examples/ontwerp_rijn.pdf`): L-vorm
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad
de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
apart toilet in de vleugel. 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
+8
View File
@@ -91,12 +91,17 @@
<script src="js/core/skybox.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/core/pdf-sheet.js"></script>
<script src="js/core/dxf-writer.js"></script>
<script src="js/core/ifc-writer.js"></script>
<script src="js/core/zip-writer.js"></script>
<script src="js/core/obj-writer.js"></script>
<script src="js/wall/kozijn-model.js"></script>
<script src="js/wall/wall-model.js"></script>
<script src="js/wall/wall-pdf.js"></script>
<script src="js/floor/floor-model.js"></script>
<script src="js/floor/floor-pdf.js"></script>
<script src="js/roof/roof-model.js"></script>
<script src="js/roof/post-model.js"></script>
<script src="js/lighting/lighting-model.js"></script>
<script src="js/elektra/elektra-model.js"></script>
<script src="js/sandwich/sandwich-model.js"></script>
@@ -105,6 +110,7 @@
<script src="js/unit/unit-model.js"></script>
<script src="js/room/room-model.js"></script>
<script src="js/interior/fixture-model.js"></script>
<script src="js/house/chimney-model.js"></script>
<script src="js/house/house-model.js"></script>
<script src="js/cost/cost-defaults.js"></script>
<script src="js/cost/cost-csv.js"></script>
@@ -112,6 +118,8 @@
<script src="js/cost/cost-settings.js"></script>
<script src="js/elektra/elektra-pdf.js"></script>
<script src="js/house/house-pdf.js"></script>
<script src="js/house/house-dxf.js"></script>
<script src="js/house/house-ifc.js"></script>
<script src="js/house/house.js"></script>
<script src="js/app.js"></script>
</body>
+5 -1
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.6.1';
const APP_VERSION = '0.7.0';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
@@ -57,6 +57,10 @@ function appRibbonTabs() {
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
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: 'Exporteren', html:
big('CAD (DXF)', 'vector-pen', `data-app="exportDxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG" ${disabledUnless('exportDxf')}`) +
big('BIM (IFC)', 'boxes', `data-app="exportIfc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting" ${disabledUnless('exportIfc')}`) +
big('Render (OBJ)', 'badge-3d', `data-app="exportObj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender" ${disabledUnless('exportObj')}`) },
{ label: 'Snel nieuw', html:
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"') +
+284
View File
@@ -0,0 +1,284 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// DXF R12 (AC1009): het formaat dat elk CAD-pakket opent; DWG maakt het CAD-pakket zelf (Opslaan als).
// Eenheid mm, tekst in ANSI_1252. Maten zijn echte DIMENSION-entiteiten (met blok), dus aanpasbaar in CAD.
window.DxfWriter = (() => {
const CP1252 = { 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e,
0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f };
const LTYPES = {
CONTINUOUS: { desc: 'Solid line', dashes: [] },
STREEP: { desc: '__ __ __', dashes: [4, -2] },
STIP: { desc: '. . . .', dashes: [0.8, -0.8] },
STREEPSTIP: { desc: '__ . __ .', dashes: [6, -1.5, 0.5, -1.5] }
};
const num = (v) => {
const r = Math.round(v * 1000) / 1000;
return Object.is(r, -0) || !isFinite(r) ? '0' : String(r);
};
const deg = (rad) => ((rad * 180 / Math.PI) % 360 + 360) % 360;
// Een groepswaarde mag geen regeleinde bevatten.
const clean = (s) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ');
function create(opts) {
const o = opts || {};
const scale = o.scale || 50; // plotschaal: tekst/maatvoering/lijntypes in papier-mm × scale
const layers = new Map([['0', { color: 7, ltype: 'CONTINUOUS' }]]);
const entities = [];
const blocks = [];
let dimCount = 0;
const ext = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
const grow = (x, y) => {
if (x < ext.x0) ext.x0 = x; if (x > ext.x1) ext.x1 = x;
if (y < ext.y0) ext.y0 = y; if (y > ext.y1) ext.y1 = y;
};
function layer(name, color, ltype) {
if (!layers.has(name)) layers.set(name, { color: color || 7, ltype: LTYPES[ltype] ? ltype : 'CONTINUOUS' });
return name;
}
function common(out, lay, eo) {
out.push(8, lay || '0');
if (eo && eo.ltype && LTYPES[eo.ltype]) out.push(6, eo.ltype);
if (eo && eo.color !== undefined && eo.color !== null) out.push(62, eo.color);
}
function emit(target, type, lay, eo, body) {
const out = [0, type];
common(out, lay, eo);
target.push(out.concat(body));
}
function line(a, b, lay, eo, target) {
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.01) return;
emit(target || entities, 'LINE', lay, eo, [10, num(a.x), 20, num(a.y), 30, 0, 11, num(b.x), 21, num(b.y), 31, 0]);
if (!target) { grow(a.x, a.y); grow(b.x, b.y); }
}
function polyline(pts, closed, lay, eo, target) {
const p = pts.filter((q, i) => i === 0 || Math.hypot(q.x - pts[i - 1].x, q.y - pts[i - 1].y) > 0.01);
if (closed && p.length > 2 && Math.hypot(p[0].x - p[p.length - 1].x, p[0].y - p[p.length - 1].y) < 0.01) p.pop();
if (p.length < 2) return;
if (p.length === 2 && !closed) { line(p[0], p[1], lay, eo, target); return; }
const t = target || entities;
emit(t, 'POLYLINE', lay, eo, [66, 1, 10, 0, 20, 0, 30, 0, 70, closed ? 1 : 0]);
p.forEach(q => { emit(t, 'VERTEX', lay, null, [10, num(q.x), 20, num(q.y), 30, 0]); if (!target) grow(q.x, q.y); });
emit(t, 'SEQEND', lay, null, []);
}
function circle(c, r, lay, eo, target) {
emit(target || entities, 'CIRCLE', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r)]);
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
}
// Hoeken in radialen, tegen de klok in van a0 naar a1.
function arc(c, r, a0, a1, lay, eo, target) {
emit(target || entities, 'ARC', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r), 50, num(deg(a0)), 51, num(deg(a1))]);
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
}
// h = teksthoogte in model-mm; align: left|center|right; rot in graden.
function text(p, h, str, lay, to, target) {
const s = clean(str);
if (!s) return;
const t = to || {};
const hj = t.align === 'center' ? 1 : t.align === 'right' ? 2 : 0;
const body = [10, num(p.x), 20, num(p.y), 30, 0, 40, num(h), 1, s];
if (t.rot) body.push(50, num(t.rot));
body.push(7, 'STANDARD');
if (hj) body.push(72, hj, 11, num(p.x), 21, num(p.y), 31, 0);
emit(target || entities, 'TEXT', lay, t, body);
if (!target) { grow(p.x - s.length * h * 0.35, p.y); grow(p.x + s.length * h * 0.35, p.y + h); }
}
// Lineaire maat tussen p1 en p2, maatlijn door 'at' (evenwijdig aan p1-p2 tenzij dir gegeven).
const DIM = { txt: 2.5, exo: 1.5, exe: 1.5, tsz: 1.25, gap: 0.8, dle: 1.5 };
function dim(p1, p2, at, lay, dopts) {
const d = dopts || {};
let ux = p2.x - p1.x, uy = p2.y - p1.y;
if (d.dir) { ux = d.dir.x; uy = d.dir.y; }
const ul = Math.hypot(ux, uy);
if (ul < 1e-9) return;
ux /= ul; uy /= ul;
const nx = -uy, ny = ux;
const off = (at.x - p1.x) * nx + (at.y - p1.y) * ny;
const s1 = (p1.x - at.x) * ux + (p1.y - at.y) * uy, s2 = (p2.x - at.x) * ux + (p2.y - at.y) * uy;
const measured = Math.abs(s2 - s1);
if (measured < 1) return;
const q1 = { x: at.x + ux * s1, y: at.y + uy * s1 }, q2 = { x: at.x + ux * s2, y: at.y + uy * s2 };
const sg = Math.sign(s2 - s1) || 1, k = scale;
const name = `*D${++dimCount}`;
const be = [];
const BB = { color: 0 };
// Maatlijn voorbij de streepjes, hulplijnen met afstand tot het object.
line({ x: q1.x - ux * sg * DIM.dle * k, y: q1.y - uy * sg * DIM.dle * k }, { x: q2.x + ux * sg * DIM.dle * k, y: q2.y + uy * sg * DIM.dle * k }, '0', BB, be);
[[p1, q1], [p2, q2]].forEach(([p, q]) => {
const side = Math.sign(off) || 1;
const a = { x: p.x + nx * side * DIM.exo * k, y: p.y + ny * side * DIM.exo * k };
const b = { x: q.x + nx * side * DIM.exe * k, y: q.y + ny * side * DIM.exe * k };
if (Math.abs(off) > DIM.exo * k) line(a, b, '0', BB, be);
const t = DIM.tsz * k * Math.SQRT1_2;
line({ x: q.x - (ux + nx) * t, y: q.y - (uy + ny) * t }, { x: q.x + (ux + nx) * t, y: q.y + (uy + ny) * t }, '0', BB, be);
});
let ang = Math.atan2(uy, ux) * 180 / Math.PI;
// Tekst leesbaar: van onder of van rechts.
let tnx = nx, tny = ny;
if (ang > 90.01 || ang <= -89.99) { ang += 180; tnx = -nx; tny = -ny; }
ang = (ang + 360) % 360;
const mid = { x: (q1.x + q2.x) / 2, y: (q1.y + q2.y) / 2 };
const label = d.text !== undefined ? d.text : String(Math.round(measured));
const tp = { x: mid.x + tnx * DIM.gap * k, y: mid.y + tny * DIM.gap * k };
text(tp, DIM.txt * k, label, '0', { align: 'center', rot: ang, color: 0 }, be);
blocks.push({ name, entities: be });
const tm = { x: mid.x + tnx * (DIM.gap + DIM.txt / 2) * k, y: mid.y + tny * (DIM.gap + DIM.txt / 2) * k };
const body = [2, name, 3, 'TECTUM', 10, num(q2.x), 20, num(q2.y), 30, 0, 11, num(tm.x), 21, num(tm.y), 31, 0, 70, 0];
if (d.text !== undefined) body.push(1, clean(d.text));
body.push(13, num(p1.x), 23, num(p1.y), 33, 0, 14, num(p2.x), 24, num(p2.y), 34, 0, 50, num(deg(Math.atan2(uy, ux))));
emit(entities, 'DIMENSION', lay, null, body);
[p1, p2, q1, q2, tm].forEach(p => grow(p.x, p.y));
}
function header() {
const has = isFinite(ext.x0);
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
const v = [
['$ACADVER', 1, 'AC1009'], ['$DWGCODEPAGE', 3, 'ANSI_1252'],
['$INSBASE', 10, 0, 20, 0, 30, 0],
['$EXTMIN', 10, num(e.x0), 20, num(e.y0), 30, 0], ['$EXTMAX', 10, num(e.x1), 20, num(e.y1), 30, 0],
['$LIMMIN', 10, num(e.x0), 20, num(e.y0)], ['$LIMMAX', 10, num(e.x1), 20, num(e.y1)],
['$LTSCALE', 40, scale], ['$TEXTSIZE', 40, 2.5 * scale], ['$TEXTSTYLE', 7, 'STANDARD'], ['$CLAYER', 8, '0'],
['$LUNITS', 70, 2], ['$LUPREC', 70, 0], ['$AUNITS', 70, 0], ['$AUPREC', 70, 0],
['$DIMSTYLE', 2, 'TECTUM'], ['$DIMSCALE', 40, scale], ['$DIMTXT', 40, DIM.txt], ['$DIMTSZ', 40, DIM.tsz], ['$DIMASZ', 40, 2.5],
['$DIMEXO', 40, DIM.exo], ['$DIMEXE', 40, DIM.exe], ['$DIMDLE', 40, DIM.dle], ['$DIMGAP', 40, DIM.gap],
['$DIMTAD', 70, 1], ['$DIMTIH', 70, 0], ['$DIMTOH', 70, 0], ['$DIMZIN', 70, 0]
];
const out = [0, 'SECTION', 2, 'HEADER'];
v.forEach(([name, ...rest]) => out.push(9, name, ...rest));
out.push(0, 'ENDSEC');
return out;
}
function table(name, rows) {
const out = [0, 'TABLE', 2, name, 70, rows.length];
rows.forEach(r => out.push(...r));
out.push(0, 'ENDTAB');
return out;
}
function tables() {
const has = isFinite(ext.x0);
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
const w = Math.max(1000, e.x1 - e.x0), h = Math.max(1000, e.y1 - e.y0);
const vport = [0, 'VPORT', 2, '*ACTIVE', 70, 0, 10, 0, 20, 0, 11, 1, 21, 1,
12, num((e.x0 + e.x1) / 2), 22, num((e.y0 + e.y1) / 2), 13, 0, 23, 0, 14, 10, 24, 10, 15, 100, 25, 100,
16, 0, 26, 0, 36, 1, 17, 0, 27, 0, 37, 0, 40, num(Math.max(h, w / 1.6) * 1.08), 41, 1.6, 42, 50, 43, 0, 44, 0,
50, 0, 51, 0, 71, 0, 72, 100, 73, 1, 74, 3, 75, 0, 76, 0, 77, 0, 78, 0];
const ltypes = Object.entries(LTYPES).map(([name, lt]) => {
const total = lt.dashes.reduce((a, d) => a + Math.abs(d), 0);
const r = [0, 'LTYPE', 2, name, 70, 0, 3, lt.desc, 72, 65, 73, lt.dashes.length, 40, num(total)];
lt.dashes.forEach(d => r.push(49, num(d)));
return r;
});
const lay = [...layers.entries()].map(([name, l]) => [0, 'LAYER', 2, name, 70, 0, 62, l.color, 6, l.ltype]);
const style = [[0, 'STYLE', 2, 'STANDARD', 70, 0, 40, 0, 41, 1, 50, 0, 71, 0, 42, 2.5 * scale, 3, 'arial.ttf', 4, '']];
const dimstyle = [[0, 'DIMSTYLE', 2, 'TECTUM', 70, 0, 3, '', 4, '', 5, '', 6, '', 7, '',
40, scale, 41, 2.5, 42, DIM.exo, 43, 7, 44, DIM.exe, 45, 0, 46, DIM.dle, 47, 0, 48, 0,
140, DIM.txt, 141, 0, 142, DIM.tsz, 143, 25.4, 144, 1, 145, 0, 146, 1, 147, DIM.gap,
71, 0, 72, 0, 73, 0, 74, 0, 75, 0, 76, 0, 77, 1, 78, 0,
170, 0, 171, 2, 172, 1, 173, 0, 174, 0, 175, 0, 176, 0, 177, 0, 178, 0]];
return [0, 'SECTION', 2, 'TABLES',
...table('VPORT', [vport]), ...table('LTYPE', ltypes), ...table('LAYER', lay), ...table('STYLE', style),
...table('VIEW', []), ...table('UCS', []), ...table('APPID', [[0, 'APPID', 2, 'ACAD', 70, 0]]), ...table('DIMSTYLE', dimstyle),
0, 'ENDSEC'];
}
function toString() {
const out = [...header(), ...tables(), 0, 'SECTION', 2, 'BLOCKS'];
blocks.forEach(b => {
out.push(0, 'BLOCK', 8, '0', 2, b.name, 70, 1, 10, 0, 20, 0, 30, 0, 3, b.name);
b.entities.forEach(en => out.push(...en));
out.push(0, 'ENDBLK', 8, '0');
});
out.push(0, 'ENDSEC', 0, 'SECTION', 2, 'ENTITIES');
entities.forEach(en => out.push(...en));
out.push(0, 'ENDSEC', 0, 'EOF');
const lines = [];
for (let i = 0; i < out.length; i += 2) lines.push(String(out[i]).padStart(3, ' '), String(out[i + 1]));
return lines.join('\r\n') + '\r\n';
}
function toBytes() {
const s = toString();
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
bytes[i] = c < 0x80 || (c >= 0xa0 && c <= 0xff) ? c : (CP1252[c] || 0x3f);
}
return bytes;
}
function save(filename) {
const blob = new Blob([toBytes()], { type: 'application/dxf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return { scale, layer, line, polyline, circle, arc, text, dim, toString, toBytes, save, extents: () => Object.assign({}, ext) };
}
// jsPDF-achtige tekenlaag voor bestaande PDF-tekencode (papier-mm, y omlaag) op schaal 1:scale in een laag.
// Kleur zwart = BYLAYER, anders de dichtstbijzijnde ACI-kleur; vullingen worden alleen omtrek.
const ACI = [[1, 255, 0, 0], [2, 255, 255, 0], [3, 0, 255, 0], [4, 0, 255, 255], [5, 0, 0, 255], [6, 255, 0, 255],
[8, 128, 128, 128], [9, 192, 192, 192], [30, 255, 127, 0], [40, 255, 191, 0], [32, 165, 82, 0], [94, 0, 127, 0], [12, 165, 0, 0]];
function aciOf(r, g, b) {
if (r + g + b < 60) return null;
let best = null, bd = Infinity;
ACI.forEach(([i, R, G, B]) => { const d = (r - R) ** 2 + (g - G) ** 2 + (b - B) ** 2; if (d < bd) { bd = d; best = i; } });
return best;
}
function pdfAdapter(dxf, lay, map) {
const k = dxf.scale;
const P = map || ((x, y) => ({ x: x * k, y: -y * k }));
let draw = null, txt = null, fontPt = 10, dashed = false;
const eo = (c) => ({ color: c, ltype: dashed ? 'STIP' : undefined });
const ptToMm = 0.3528 * 0.72;
const doc = {
setDrawColor(r, g, b) { draw = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
setTextColor(r, g, b) { txt = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
setFillColor() {}, setLineWidth() {}, setFont() {},
setFontSize(pt) { fontPt = pt; },
setLineDashPattern(p) { dashed = !!(p && p.length); },
getTextWidth(s) { return String(s).length * fontPt * 0.3528 * 0.5; },
line(x1, y1, x2, y2) { dxf.line(P(x1, y1), P(x2, y2), lay, eo(draw)); },
rect(x, y, w, h) { dxf.polyline([P(x, y), P(x + w, y), P(x + w, y + h), P(x, y + h)], true, lay, eo(draw)); },
circle(x, y, r) { const c = P(x, y); dxf.circle(c, r * k, lay, eo(draw)); },
triangle(x1, y1, x2, y2, x3, y3) { dxf.polyline([P(x1, y1), P(x2, y2), P(x3, y3)], true, lay, eo(draw)); },
lines(segs, x, y, sc, style, closed) {
const pts = [P(x, y)];
let cx = x, cy = y;
segs.forEach(([dx, dy]) => { cx += dx; cy += dy; pts.push(P(cx, cy)); });
dxf.polyline(pts, !!closed, lay, eo(draw));
},
text(str, x, y, to) {
const t = to || {};
dxf.text(P(x, y), fontPt * ptToMm * k, Array.isArray(str) ? str.join(' ') : str, lay, { align: t.align, rot: t.angle || 0, color: txt });
}
};
return doc;
}
return { create, pdfAdapter, LTYPES };
})();
+187
View File
@@ -0,0 +1,187 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// IFC4 (STEP, ISO 10303-21) met driehoeksnetten (IfcTriangulatedFaceSet), eenheid mm, Z omhoog.
// Ruimtelijke opbouw: project > terrein > gebouw > bouwlaag; elementen eronder of in een samenstelling.
window.IfcWriter = (() => {
const B64 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$';
function guid() {
const b = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b);
else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
let out = B64[b[0] >> 6] + B64[b[0] & 63];
for (let i = 1; i < 16; i += 3) {
const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
}
return out;
}
// Tekst: ' verdubbelen, \ escapen, alles buiten ASCII als \X2\hhhh\X0\.
function str(s) {
if (s === null || s === undefined) return '$';
let out = '', ext = '';
const flush = () => { if (ext) { out += '\\X2\\' + ext + '\\X0\\'; ext = ''; } };
for (const ch of String(s)) {
const c = ch.codePointAt(0);
if (c >= 32 && c < 127) {
flush();
out += ch === "'" ? "''" : ch === '\\' ? '\\\\' : ch;
} else if (c >= 127 && c <= 0xffff) ext += c.toString(16).toUpperCase().padStart(4, '0');
else if (c > 0xffff) ext += '003F';
}
flush();
return `'${out}'`;
}
function real(v) {
const r = Math.round(v * 100) / 100;
const s = Object.is(r, -0) || !isFinite(r) ? '0' : String(r);
return /[.eE]/.test(s) ? s : s + '.';
}
const list = (a) => `(${a.join(',')})`;
const ref = (n) => `#${n}`;
function create(opts) {
const o = opts || {};
const lines = [];
let next = 1;
const add = (type, args) => { const id = next++; lines.push(`#${id}=${type}(${args.join(',')});`); return id; };
const mm = add('IFCSIUNIT', ['*', '.LENGTHUNIT.', '.MILLI.', '.METRE.']);
const m2 = add('IFCSIUNIT', ['*', '.AREAUNIT.', '$', '.SQUARE_METRE.']);
const m3 = add('IFCSIUNIT', ['*', '.VOLUMEUNIT.', '$', '.CUBIC_METRE.']);
const rad = add('IFCSIUNIT', ['*', '.PLANEANGLEUNIT.', '$', '.RADIAN.']);
const units = add('IFCUNITASSIGNMENT', [list([mm, m2, m3, rad].map(ref))]);
const origin = add('IFCCARTESIANPOINT', [list(['0.', '0.', '0.'])]);
const axis = add('IFCAXIS2PLACEMENT3D', [ref(origin), '$', '$']);
const ctx = add('IFCGEOMETRICREPRESENTATIONCONTEXT', ['$', str('Model'), '3', '1.E-05', ref(axis), '$']);
const body = add('IFCGEOMETRICREPRESENTATIONSUBCONTEXT', [str('Body'), str('Model'), '*', '*', '*', '*', ref(ctx), '$', '.MODEL_VIEW.', '$']);
const project = add('IFCPROJECT', [str(guid()), '$', str(o.project || 'Woning'), str(o.description || null), '$', '$', '$', list([ref(ctx)]), ref(units)]);
const sitePl = add('IFCLOCALPLACEMENT', ['$', ref(axis)]);
const site = add('IFCSITE', [str(guid()), '$', str('Terrein'), '$', '$', ref(sitePl), '$', '$', '.ELEMENT.', '$', '$', '$', '$', '$']);
const bldPl = add('IFCLOCALPLACEMENT', [ref(sitePl), ref(axis)]);
const a = o.address || {};
const addr = a.lines || a.town ? add('IFCPOSTALADDRESS', ['$', '$', '$', '$', a.lines ? list([str(a.lines)]) : '$', '$', str(a.town || null), '$', '$', str('Nederland')]) : null;
const building = add('IFCBUILDING', [str(guid()), '$', str(o.building || 'Woning'), '$', '$', ref(bldPl), '$', '$', '.ELEMENT.', '$', '$', addr ? ref(addr) : '$']);
const stPl = add('IFCLOCALPLACEMENT', [ref(bldPl), ref(axis)]);
const storey = add('IFCBUILDINGSTOREY', [str(guid()), '$', str(o.storey || 'Begane grond'), '$', '$', ref(stPl), '$', '$', '.ELEMENT.', '0.']);
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(project), list([ref(site)])]);
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(site), list([ref(building)])]);
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(building), list([ref(storey)])]);
// Kleur [r, g, b] (0..1) als oppervlaktestijl, één per kleur.
const styles = new Map();
function style(rgb) {
const k = rgb.map(c => Math.round(c * 1000)).join(',');
if (!styles.has(k)) {
const col = add('IFCCOLOURRGB', ['$', ...rgb.map(real)]);
const shading = add('IFCSURFACESTYLESHADING', [ref(col), '0.']);
styles.set(k, add('IFCSURFACESTYLE', ['$', '.BOTH.', list([ref(shading)])]));
}
return styles.get(k);
}
const contained = [];
const materials = new Map(); // naam -> { id, els: [] }
const psets = new Map(); // sleutel -> { props, els: [] }
// Driehoeken als vlakke lijst [x,y,z, x,y,z, x,y,z, ...] in mm; punten worden samengevoegd.
function shape(tri, rgb) {
const idx = new Map(), pts = [], faces = [];
const key = (i) => `${Math.round(tri[i] * 10)},${Math.round(tri[i + 1] * 10)},${Math.round(tri[i + 2] * 10)}`;
const vid = (i) => {
const k = key(i);
if (!idx.has(k)) { idx.set(k, pts.length + 1); pts.push(`(${real(tri[i])},${real(tri[i + 1])},${real(tri[i + 2])})`); }
return idx.get(k);
};
for (let i = 0; i + 8 < tri.length; i += 9) {
const f = [vid(i), vid(i + 3), vid(i + 6)];
if (f[0] !== f[1] && f[1] !== f[2] && f[0] !== f[2]) faces.push(`(${f.join(',')})`);
}
if (!faces.length) return null;
const pl = add('IFCCARTESIANPOINTLIST3D', [`(${pts.join(',')})`]);
const fs = add('IFCTRIANGULATEDFACESET', [ref(pl), '$', '$', `(${faces.join(',')})`, '$']);
if (rgb) add('IFCSTYLEDITEM', [ref(fs), list([ref(style(rgb))]), '$']);
const rep = add('IFCSHAPEREPRESENTATION', [ref(body), str('Body'), str('Tessellation'), list([ref(fs)])]);
return add('IFCPRODUCTDEFINITIONSHAPE', ['$', '$', list([ref(rep)])]);
}
// el: { type: 'IFCMEMBER', predefined: 'STUD', name, tag, tri, color: [r, g, b], material, props: {naam: waarde}, objectType }
function element(el) {
const rep = el.tri ? shape(el.tri, el.color) : null;
if (el.tri && !rep) return null;
const pl = add('IFCLOCALPLACEMENT', [ref(stPl), ref(axis)]);
const args = [str(guid()), '$', str(el.name), str(el.description || null), str(el.objectType || null), ref(pl), rep ? ref(rep) : '$', str(el.tag || null)];
if (el.type === 'IFCELEMENTASSEMBLY') args.push('.FACTORY.', `.${el.predefined || 'NOTDEFINED'}.`);
else args.push(`.${el.predefined || 'NOTDEFINED'}.`);
const id = add(el.type, args);
if (el.material) {
if (!materials.has(el.material)) {
const id = add('IFCMATERIAL', [str(el.material), '$', '$']);
// Ook aan het materiaal, voor viewers die op materiaal kleuren.
if (el.color) {
const si = add('IFCSTYLEDITEM', ['$', list([ref(style(el.color))]), '$']);
const sr = add('IFCSTYLEDREPRESENTATION', [ref(ctx), str('Style'), str('Material'), list([ref(si)])]);
add('IFCMATERIALDEFINITIONREPRESENTATION', ['$', '$', list([ref(sr)]), ref(id)]);
}
materials.set(el.material, { id, els: [] });
}
materials.get(el.material).els.push(id);
}
if (el.props) {
const k = JSON.stringify(el.props);
if (!psets.has(k)) psets.set(k, { props: el.props, els: [] });
psets.get(k).els.push(id);
}
return id;
}
function contain(id) { if (id) contained.push(id); }
function aggregate(parent, children) {
const c = children.filter(Boolean);
if (parent && c.length) add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(parent), list(c.map(ref))]);
}
const value = (v) => typeof v === 'number' ? `IFCLENGTHMEASURE(${real(v)})` : `IFCLABEL(${str(v)})`;
function toString(filename) {
if (contained.length) add('IFCRELCONTAINEDINSPATIALSTRUCTURE', [str(guid()), '$', '$', '$', list(contained.map(ref)), ref(storey)]);
materials.forEach(m => add('IFCRELASSOCIATESMATERIAL', [str(guid()), '$', '$', '$', list(m.els.map(ref)), ref(m.id)]));
psets.forEach(p => {
const props = Object.entries(p.props).filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([n, v]) => add('IFCPROPERTYSINGLEVALUE', [str(n), '$', value(v), '$']));
if (!props.length) return;
const ps = add('IFCPROPERTYSET', [str(guid()), '$', str(o.psetName || 'Pset_Tectum'), '$', list(props.map(ref))]);
add('IFCRELDEFINESBYPROPERTIES', [str(guid()), '$', '$', '$', list(p.els.map(ref)), ref(ps)]);
});
contained.length = 0; materials.clear(); psets.clear();
const now = new Date().toISOString().slice(0, 19);
return [
'ISO-10303-21;', 'HEADER;',
"FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');",
`FILE_NAME(${str(filename || 'woning.ifc')},${str(now)},(${str(o.author || '')}),(${str(o.organization || '')}),${str(o.app || 'Tectum')},${str(o.app || 'Tectum')},'');`,
"FILE_SCHEMA(('IFC4'));", 'ENDSEC;', 'DATA;', ...lines, 'ENDSEC;', 'END-ISO-10303-21;', ''
].join('\n');
}
function save(filename) {
const blob = new Blob([toString(filename)], { type: 'application/x-step' });
const url = URL.createObjectURL(blob);
const el = document.createElement('a');
el.href = url;
el.download = filename;
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
URL.revokeObjectURL(url);
}
return { element, contain, aggregate, toString, save, count: () => next - 1 };
}
return { create, guid };
})();
+133
View File
@@ -0,0 +1,133 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Wavefront OBJ + MTL + texturen (PNG) uit three.js-objecten, in meters, Y omhoog (Blender: standaard import).
// Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, de MTL verwijst naar textures/*.png.
// PBR via de MTL-uitbreiding (Pr ruwheid, Pm metaal, d doorzichtigheid, Ke gloed), die Blender inleest.
window.ObjWriter = (() => {
const f4 = (v) => { const r = Math.round(v * 10000) / 10000; return Object.is(r, -0) ? '0' : String(r); };
const f3 = (v) => { const r = Math.round(v * 1000) / 1000; return Object.is(r, -0) ? '0' : String(r); };
const safe = (s) => String(s || '').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 40);
function pngBytes(image) {
try {
const w = image.naturalWidth || image.videoWidth || image.width, h = image.naturalHeight || image.videoHeight || image.height;
if (!w || !h) return null;
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(image, 0, 0);
const bin = atob(c.toDataURL('image/png').split(',')[1]);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
} catch (e) { return null; }
}
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; name: basisnaam van de bestanden.
function build(objects, opts) {
const o = opts || {};
const name = o.name || 'woning', origin = o.origin || { x: 0, y: 0, z: 0 };
const enc = new TextEncoder();
const obj = [`# Tectum${o.app ? ' ' + o.app : ''}\n# meters, Y omhoog\nmtllib ${name}.mtl\n`], chunks = [];
const mats = new Map(), textures = new Map(), files = [];
let vBase = 1, tBase = 1, nBase = 1, meshNr = 0, triCount = 0;
const flush = () => { if (obj.length > 2000) { chunks.push(enc.encode(obj.join(''))); obj.length = 0; } };
function textureFile(tex) {
if (!tex || !tex.image) return null;
const key = tex.image;
if (!textures.has(key)) {
const bytes = pngBytes(tex.image);
const file = bytes ? `textures/tex_${textures.size + 1}.png` : null;
if (bytes) files.push({ name: file, data: bytes });
textures.set(key, file);
}
return textures.get(key);
}
function matName(m) {
if (!mats.has(m)) mats.set(m, { name: `m${mats.size + 1}_${safe(m.name || m.type)}`, mat: m, map: textureFile(m.map) });
return mats.get(m);
}
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
// Eerst de hele boom bijwerken: een mesh alleen gebruikt de (verouderde) matrix van zijn groep.
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
const g = mesh.geometry, pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
nm.getNormalMatrix(mesh.matrixWorld);
const flip = mesh.matrixWorld.determinant() < 0;
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
const out = [`o ${safe(mesh.parent && mesh.parent.name) || 'deel'}_${++meshNr}\n`];
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld);
out.push(`v ${f4(v.x - origin.x)} ${f4(v.y - origin.y)} ${f4(v.z - origin.z)}\n`);
}
if (nrm) for (let i = 0; i < nrm.count; i++) {
v.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize();
out.push(`vn ${f3(v.x)} ${f3(v.y)} ${f3(v.z)}\n`);
}
let tCount = 0;
groups.forEach(gr => {
const m = list[gr.materialIndex] || list[0];
if (!m || m.visible === false) return;
const mi = matName(m);
out.push(`usemtl ${mi.name}\n`);
const map = mi.map && m.map ? m.map : null;
if (map) map.updateMatrix();
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) {
let c = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
if (flip) c = [c[0], c[2], c[1]];
const corner = c.map(ci => {
let s = `${vBase + ci}`;
if (uvA && map) {
uv.fromBufferAttribute(uvA, ci).applyMatrix3(map.matrix);
out.push(`vt ${f4(uv.x)} ${f4(uv.y)}\n`);
s += `/${tBase + tCount++}`;
} else if (nrm) s += '/';
if (nrm) s += `/${nBase + ci}`;
return s;
});
out.push(`f ${corner.join(' ')}\n`);
triCount++;
}
});
vBase += pos.count; tBase += tCount; if (nrm) nBase += nrm.count;
obj.push(out.join(''));
flush();
}));
objects.forEach(visit);
chunks.push(enc.encode(obj.join('')));
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); Blender rekent lineair: sRGB -> lineair.
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
const col = (c) => c ? `${f3(lin(c.r))} ${f3(lin(c.g))} ${f3(lin(c.b))}` : '0.6 0.6 0.6';
const mtl = [`# Tectum materialen\n`];
mats.forEach(({ name: n, mat: m, map }) => {
mtl.push(`\nnewmtl ${n}\n`);
mtl.push(`Kd ${col(m.color)}\n`);
mtl.push('Ks 0.5 0.5 0.5\n');
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
mtl.push(`Ke ${f3(lin(m.emissive.r) * k)} ${f3(lin(m.emissive.g) * k)} ${f3(lin(m.emissive.b) * k)}\n`);
}
if (m.roughness !== undefined) mtl.push(`Pr ${f3(m.roughness)}\n`);
if (m.metalness !== undefined) mtl.push(`Pm ${f3(m.metalness)}\n`);
const d = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
mtl.push(`d ${f3(d)}\nillum 2\n`);
if (map) mtl.push(`map_Kd ${map}\n`);
});
return {
files: [{ name: `${name}.obj`, data: chunks }, { name: `${name}.mtl`, data: enc.encode(mtl.join('')) }].concat(files),
stats: { meshes: meshNr, triangles: triCount, materials: mats.size, textures: files.length }
};
}
return { build };
})();
+70
View File
@@ -0,0 +1,70 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ZIP zonder compressie (stored): meerdere bestanden in één download.
window.ZipWriter = (() => {
const CRC = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC[n] = c >>> 0;
}
function crc32(parts) {
let c = 0xffffffff;
parts.forEach(b => { for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 0xff] ^ (c >>> 8); });
return (c ^ 0xffffffff) >>> 0;
}
// files: [{ name, data: Uint8Array | Uint8Array[] }]
function build(files) {
const enc = new TextEncoder();
const out = [], central = [];
let offset = 0;
const d = new Date();
const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
files.forEach(f => {
const parts = Array.isArray(f.data) ? f.data : [f.data];
const size = parts.reduce((a, b) => a + b.length, 0);
const name = enc.encode(f.name), crc = crc32(parts);
const head = (sig, extra) => {
const h = new DataView(new ArrayBuffer(extra ? 46 : 30));
let p = 0;
const u32 = (v) => { h.setUint32(p, v, true); p += 4; }, u16 = (v) => { h.setUint16(p, v, true); p += 2; };
u32(sig);
if (extra) u16(20);
u16(20); u16(0x0800); u16(0); u16(time); u16(date); u32(crc); u32(size); u32(size); u16(name.length); u16(0);
if (extra) { u16(0); u16(0); u16(0); u32(0); u32(offset); }
return new Uint8Array(h.buffer);
};
const local = head(0x04034b50, false);
central.push(head(0x02014b50, true), name);
out.push(local, name, ...parts);
offset += local.length + name.length + size;
});
const cdSize = central.reduce((a, b) => a + b.length, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true);
end.setUint16(8, files.length, true); end.setUint16(10, files.length, true);
end.setUint32(12, cdSize, true); end.setUint32(16, offset, true);
return out.concat(central, [new Uint8Array(end.buffer)]);
}
function save(files, filename) {
const blob = new Blob(build(files), { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return { build, save };
})();
+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 }
}
},
+12 -7
View File
@@ -167,7 +167,7 @@ window.CostModel = (() => {
const opKey = p.seam ? `iw${p.id}` : null;
if (!opKey || !seenOpenings.has(opKey)) {
if (opKey) seenOpenings.add(opKey);
(p.config.openings || []).forEach(op => {
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
const fill = KozijnModel.normalize(op.fill);
if (fill && fill.type === 'schuifdeur') {
add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`,
@@ -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);
@@ -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);
+18 -7
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) => {
@@ -758,7 +767,7 @@ window.ElektraModel = (() => {
const shift = (p) => ({
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
});
el.runs.forEach(r => {
el.runs.forEach((r, ri) => {
const c = cableInfo(r.type);
if (!c.conduit) return;
const rad = c.conduit / 2 * toM;
@@ -770,11 +779,13 @@ window.ElektraModel = (() => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
m.userData.run = ri;
group.add(m);
});
pts.slice(1, -1).forEach(p => {
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
j.position.set(p.x * toM, p.y * toM, p.z * toM);
j.userData.run = ri;
group.add(j);
});
});
@@ -864,5 +875,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 };
})();
+130 -34
View File
@@ -102,6 +102,101 @@ window.FloorModel = (() => {
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
}
// Rechthoekige sparingen (schoorsteen door het dak): x t.o.v. het midden (-width/2..width/2), y 0..depth.
// Raveelwerk zodat de plaat overal op hout eindigt: raveelstijlen over de volle lengte langs de zijkanten (tenzij de
// sparing tot de rand loopt), raveelbalken dwars ervoor en erna tussen die stijlen, kreupele balken daartussen tot
// de raveelbalk. Een gewone balk die een raveelstijl zou raken vervalt; de randbalk is onderbroken.
function normalizeCuts(cuts, width, depth) {
return (cuts || []).map(c => ({
x0: Math.max(-width / 2, Math.min(c.x0, c.x1)), x1: Math.min(width / 2, Math.max(c.x0, c.x1)),
y0: Math.max(0, Math.min(c.y0, c.y1)), y1: Math.min(depth, Math.max(c.y0, c.y1))
})).filter(c => c.x1 - c.x0 > 1 && c.y1 - c.y0 > 1);
}
function joistLayout(calcData) {
const { joists, joistW, depth, width } = calcData, rims = calcData.rims || {}, cuts = calcData.cuts || [];
const half = width / 2, ya = rims.front ? joistW : 0, yb = depth - (rims.back ? joistW : 0);
const frames = cuts.map(c => ({
c,
sides: [c.x0 > -half + joistW ? c.x0 - joistW / 2 : null, c.x1 < half - joistW ? c.x1 + joistW / 2 : null].filter(x => x !== null),
head: c.y0 > ya + joistW, tail: c.y1 < yb - joistW
}));
const overlaps = (x, a, b) => x + joistW / 2 > a + 0.5 && x - joistW / 2 < b - 0.5;
const pieces = [];
joists.forEach(x => {
if (frames.some(f => f.sides.some(sx => overlaps(x, sx - joistW / 2, sx + joistW / 2)))) return;
let segs = [[ya, yb]];
frames.filter(f => overlaps(x, f.c.x0, f.c.x1)).forEach(f => {
const a0 = f.head ? f.c.y0 - joistW : f.c.y0, b0 = f.tail ? f.c.y1 + joistW : f.c.y1;
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, a0)], [Math.max(a, b0), b]]).filter(([a, b]) => b - a > 1);
});
pieces.push({ x, segs });
});
const trimmers = [];
frames.forEach(f => {
f.sides.forEach(sx => { if (!pieces.some(p => overlaps(p.x, sx - joistW / 2, sx + joistW / 2))) pieces.push({ x: sx, segs: [[ya, yb]], frame: true }); });
const x0 = f.c.x0 > -half + joistW ? f.c.x0 : -half, x1 = f.c.x1 < half - joistW ? f.c.x1 : half;
if (f.head) trimmers.push({ x0, x1, y0: f.c.y0 - joistW, y1: f.c.y0 });
if (f.tail) trimmers.push({ x0, x1, y0: f.c.y1, y1: f.c.y1 + joistW });
});
pieces.sort((a, b) => a.x - b.x);
// Randbalken (dwars, voor en achter): onderbroken waar een sparing tot in de rand loopt.
const rimRuns = [['front', 0, joistW], ['back', depth - joistW, depth]].filter(([k]) => rims[k]).map(([k, y0, y1]) => {
let segs = [[-half, half]];
cuts.filter(c => c.y1 > y0 + 0.5 && c.y0 < y1 - 0.5).forEach(c => {
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, c.x0)], [Math.max(a, c.x1), b]]).filter(([a, b]) => b - a > 1);
});
return { key: k, y0, y1, segs };
});
return { pieces, trimmers, rimRuns };
}
// Bovenvlak (plaat, afwerking) als vormen in x/(y - depth/2), met de doorvoeren als gaten.
function surfaceShapes(calcData, resolvedOpenings, k) {
const s = k || 1, W = calcData.width, D = calcData.depth, cuts = calcData.cuts || [];
const pts = (loop) => loop.map(p => new THREE.Vector2(p.x * s, (p.z - D / 2) * s));
let regions;
if (!cuts.length) regions = [{ outer: [{ x: -W / 2, z: 0 }, { x: W / 2, z: 0 }, { x: W / 2, z: D }, { x: -W / 2, z: D }], holes: [] }];
else {
const xs = [...new Set([-W / 2, W / 2, ...cuts.flatMap(c => [c.x0, c.x1])])].sort((a, b) => a - b);
const ys = [...new Set([0, D, ...cuts.flatMap(c => [c.y0, c.y1])])].sort((a, b) => a - b);
const cells = [];
for (let i = 0; i < xs.length - 1; i++) for (let j = 0; j < ys.length - 1; j++) {
const xm = (xs[i] + xs[i + 1]) / 2, ym = (ys[j] + ys[j + 1]) / 2;
if (!cuts.some(c => xm > c.x0 && xm < c.x1 && ym > c.y0 && ym < c.y1)) cells.push({ x0: xs[i], x1: xs[i + 1], z0: ys[j], z1: ys[j + 1] });
}
const loops = rectUnionLoops(cells);
const inside = (pt, poly) => {
let c = false;
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
if ((poly[a].z > pt.z) !== (poly[b].z > pt.z) && pt.x < (poly[b].x - poly[a].x) * (pt.z - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
}
return c;
};
const sign = Math.sign(loops[0].area);
regions = loops.filter(l => Math.sign(l.area) === sign).map(l => ({ outer: l.pts, holes: [] }));
loops.filter(l => Math.sign(l.area) !== sign).forEach(l => {
const owner = regions.find(r => inside(l.pts[0], r.outer));
if (owner) owner.holes.push(l.pts);
});
}
return regions.map(rg => {
const shape = new THREE.Shape(pts(rg.outer));
rg.holes.forEach(h => shape.holes.push(new THREE.Path(pts(h))));
resolvedOpenings.filter(o => !o.removed && o.r > 0 && inside2(o, rg.outer)).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * s, (o.y - D / 2) * s, o.r * s, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
return shape;
});
function inside2(o, poly) {
let c = false;
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
if ((poly[a].z > o.y) !== (poly[b].z > o.y) && o.x < (poly[b].x - poly[a].x) * (o.y - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
}
return c;
}
}
function topHeight(calcData) {
return calcData.joistH + calcData.underlayThick;
}
@@ -115,7 +210,12 @@ window.FloorModel = (() => {
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
const joistLenM = (calcData.joistLen || depth) * toM;
const joistWoodM3 = joists.length * joistWM * joistHM * joistLenM + rimCount * joistWM * joistHM * widthM;
const cuts = calcData.cuts || [];
const lay = cuts.length ? joistLayout(calcData) : null;
const joistMM = lay ? lay.pieces.reduce((t, p) => t + p.segs.reduce((u, [a, b]) => u + b - a, 0), 0) + lay.trimmers.reduce((t, tr) => t + tr.x1 - tr.x0, 0)
: joists.length * (calcData.joistLen || depth);
const rimMM = lay ? lay.rimRuns.reduce((t, r) => t + r.segs.reduce((u, [a, b]) => u + b - a, 0), 0) : rimCount * width;
const joistWoodM3 = joistMM * toM * joistWM * joistHM + rimMM * toM * joistWM * joistHM;
const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
const grossAreaM2 = widthM * depthM;
@@ -125,6 +225,7 @@ window.FloorModel = (() => {
holesAreaM2 += Math.PI * rM * rM;
holesVoidM3 += Math.PI * rM * rM * joistHM;
});
cuts.forEach(c => { holesAreaM2 += (c.x1 - c.x0) * (c.y1 - c.y0) * toM * toM; });
const osbAreaM2 = Math.max(0, grossAreaM2 - holesAreaM2);
const osbVolM3 = osbAreaM2 * underlayM;
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
@@ -154,6 +255,7 @@ window.FloorModel = (() => {
function computeFromState(state, rims) {
const calcData = computeFrame(state.floor, rims);
calcData.cuts = normalizeCuts(state.cuts, calcData.width, calcData.depth);
const resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW);
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
return { calcData, resolvedOpenings, chassisData };
@@ -240,7 +342,22 @@ window.FloorModel = (() => {
const rims = calcData.rims || {};
const joistLenM = (calcData.joistLen || depth) * toM;
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
joists.forEach((xMM, i) => {
if ((calcData.cuts || []).length) {
const lay = joistLayout(calcData);
const box = (x0, x1, y0, y1) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry((x1 - x0) * toM, joistHM, (y1 - y0) * toM), woodMaterial);
mesh.position.set((x0 + x1) / 2 * toM, joistHM / 2, ((y0 + y1) / 2 - depth / 2) * toM);
mesh.castShadow = true;
floorGroup3D.add(mesh);
};
const outer = [joists[0], joists[joists.length - 1]];
lay.pieces.forEach(p => {
// Plaat zichtbaar: alleen de buitenste balken en het raveelwerk (zoals zonder sparing).
if (opts.outerJoistsOnly && !outer.includes(p.x) && !p.frame && p.segs.length === 1 && p.segs[0][1] - p.segs[0][0] > depth - 3 * joistW) return;
p.segs.forEach(([a, b]) => box(p.x - joistW / 2, p.x + joistW / 2, a, b));
});
lay.trimmers.forEach(t => box(t.x0, t.x1, t.y0, t.y1));
} else joists.forEach((xMM, i) => {
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
const mesh = new THREE.Mesh(geo, woodMaterial);
@@ -248,7 +365,14 @@ window.FloorModel = (() => {
mesh.castShadow = true;
floorGroup3D.add(mesh);
});
[['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
if ((calcData.cuts || []).length) {
joistLayout(calcData).rimRuns.forEach(r => r.segs.forEach(([a, b]) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry((b - a) * toM, joistHM, joistWM), woodMaterial);
mesh.position.set((a + b) / 2 * toM, joistHM / 2, ((r.y0 + r.y1) / 2 - depth / 2) * toM);
mesh.castShadow = true;
floorGroup3D.add(mesh);
}));
} else [['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(widthM, joistHM, joistWM), woodMaterial);
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
mesh.castShadow = true;
@@ -257,24 +381,7 @@ window.FloorModel = (() => {
// Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
const osbShape = new THREE.Shape();
osbShape.moveTo(-widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, depthM / 2);
osbShape.lineTo(-widthM / 2, depthM / 2);
osbShape.closePath();
resolvedOpenings.filter(o => !o.removed).forEach(o => {
const cx = o.x * toM;
const cz = o.y * toM - depthM / 2;
const rM = o.r * toM;
if (rM <= 0) return;
const hole = new THREE.Path();
hole.absarc(cx, cz, rM, 0, Math.PI * 2, false);
osbShape.holes.push(hole);
});
const osbGeo = new THREE.ExtrudeGeometry(osbShape, { steps: 1, depth: underlayM, bevelEnabled: false });
const osbGeo = new THREE.ExtrudeGeometry(surfaceShapes(calcData, resolvedOpenings, toM), { steps: 1, depth: underlayM, bevelEnabled: false });
const osbMesh = new THREE.Mesh(osbGeo, osbMaterial);
osbMesh.rotation.x = Math.PI / 2;
osbMesh.position.set(0, joistHM + underlayM, 0);
@@ -304,18 +411,7 @@ window.FloorModel = (() => {
const fin = opts.finish && finishMaterial(opts.finish.key);
if (fin) {
const shape = new THREE.Shape();
shape.moveTo(-widthM / 2, -depthM / 2);
shape.lineTo(widthM / 2, -depthM / 2);
shape.lineTo(widthM / 2, depthM / 2);
shape.lineTo(-widthM / 2, depthM / 2);
shape.closePath();
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * toM, o.y * toM - depthM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
const geo = new THREE.ShapeGeometry(shape);
const geo = new THREE.ShapeGeometry(surfaceShapes(calcData, resolvedOpenings, toM));
const ox = opts.finish.originX || 0, oz = opts.finish.originZ || 0;
const pos = geo.attributes.position, uv = geo.attributes.uv;
// Na het kantelen (-90° om X) wordt vorm-y wereld -z.
@@ -353,6 +449,6 @@ window.FloorModel = (() => {
return {
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D
computeSpecs, computeFromState, createMaterials, build3D, surfaceShapes, joistLayout
};
})();
+322
View File
@@ -0,0 +1,322 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Schoorsteen tegen een buitenwand: hol, binnen vlak met de wand, buiten (iets) voor de gevel. Hij maakt in elke
// buitenwand in hetzelfde vlak die hij raakt een sparing zoals een raam (ook over een naad tussen units heen) en
// onderbreekt de dakrand; aan de voorkant steekt hij minstens tot voorbij de dakoverstek uit.
// Lokaal assenstelsel per schoorsteen: t langs de wand (wereld-x of -z), s loodrecht erop, 0 = buitenkant regelwerk
// van de unit (rand van de vloer), positief naar buiten.
window.ChimneyModel = (() => {
const EPS = 0.5;
const SHELL_MM = 100; // wand rond het rookkanaal
const CAP_MM = 60, CAP_OVER_MM = 30;
const FLUE_SHOW_MM = 500; // zichtbare diepte van het rookkanaal onder de kap
const INSET_MM = 2; // sparingen in wand, dak en dakrand net binnen de schoorsteen (geen z-fighting)
const BEAR_MM = -4; // nokbalk stopt net vóór de binnenkant van de schoorsteen
const MIN_CUT_MM = 100;
const CORNER_CLEAR_MM = 200; // slepen: zoveel vrij van een buitenhoek (gevelbekleding van de haakse wand)
const INSIDE_TYPES = { wand: 'Zoals de wanden', stuc: 'Stucwerk', steen: 'Steenstrip' };
const OUTSIDE_TYPES = { steen: 'Steenstrip', stuc: 'Stucwerk' };
const DEFAULTS = { w: 800, out: 180, above: 600, inside: { type: 'wand', stone: 'R777', stuc: 'wit' }, outside: { type: 'steen', stone: 'R777', stuc: 'wit' } };
const SIDES = {
voor: { axis: 'x', n: { x: 0, z: -1 } }, achter: { axis: 'x', n: { x: 0, z: 1 } },
links: { axis: 'z', n: { x: -1, z: 0 } }, rechts: { axis: 'z', n: { x: 1, z: 0 } }
};
const skinOf = (finish) => finish.type === 'steen' ? WallModel.STONE.STRIP_MM : finish.type === 'stuc' ? WallModel.STUC.THICK_MM : 0;
function normalizeFinish(f, types, def) {
const o = Object.assign({}, def, f || {});
if (!types[o.type]) o.type = def.type;
if (!WallModel.isStoneFinish(o.stone)) o.stone = def.stone;
if (!WallModel.isStucColor(o.stuc)) o.stuc = def.stuc;
return { type: o.type, stone: o.stone, stuc: o.stuc };
}
function normalize(list, unitIds) {
const used = new Set();
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
return (Array.isArray(list) ? list : []).filter(c => c && unitIds.includes(c.unitId) && SIDES[c.side]).map(c => {
let id = Number(c.id) > 0 ? Math.round(c.id) : 1;
while (used.has(id)) id++;
used.add(id);
return {
id, unitId: c.unitId, side: c.side, u: num(c.u, 0, -20000, 40000),
w: num(c.w, DEFAULTS.w, 400, 2000), out: num(c.out, DEFAULTS.out, 0, 1000), above: num(c.above, DEFAULTS.above, 0, 3000),
inside: normalizeFinish(c.inside, INSIDE_TYPES, DEFAULTS.inside),
outside: normalizeFinish(c.outside, OUTSIDE_TYPES, DEFAULTS.outside)
};
});
}
function create(list, unitId, side, u) {
const id = (list || []).reduce((m, c) => Math.max(m, c.id || 0), 0) + 1;
return normalize([{ id, unitId, side, u }], [unitId])[0];
}
// Vlak van een zijde van een unit; u = hart vanaf de linker vloerhoek, van buitenaf gezien.
const planeOf = (r, side) => ({ voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 })[side];
// opts: { wallThickness(unitId, side), faceDepth(unitId, side) (gevelbekleding), overhang } voor de voetafdruk
// (sIn..sFront), die het dak al nodig heeft voordat de gevels gebouwd zijn.
function place(list, rects, opts) {
const o = opts || {};
return list.map(c => {
const r = rects.find(q => q.id === c.unitId);
if (!r || r.error) return null;
const S = SIDES[c.side];
const t = { voor: r.x1 - c.u, achter: r.x0 + c.u, links: r.z0 + c.u, rechts: r.z1 - c.u }[c.side];
const T = o.wallThickness ? o.wallThickness(c.unitId, c.side) : 260, face = o.faceDepth ? o.faceDepth(c.unitId, c.side) : 0;
return Object.assign({}, c, {
axis: S.axis, n: S.n, plane: planeOf(r, c.side), t0: t - c.w / 2, t1: t + c.w / 2,
sIn: -T, sFront: Math.max(face + c.out, (o.overhang || 0) + 20)
});
}).filter(Boolean);
}
// Sparingen in het dak van een unit (plattegrondmaten t.o.v. het unitmidden, zoals RoofModel.computeUnitRoof):
// de hele voetafdruk binnen het dak; een nokbalk stopt ervoor (BEAR_MM).
function roofCuts(placed, rect, roofRect) {
const out = [];
placed.forEach(c => {
const S = SIDES[c.side];
const k = S.axis === 'z' ? S.n.x : S.n.z, sA = c.plane + k * (c.sIn + INSET_MM), sB = c.plane + k * c.sFront;
const w = S.axis === 'z'
? { x0: Math.min(sA, sB), x1: Math.max(sA, sB), z0: c.t0 + INSET_MM, z1: c.t1 - INSET_MM }
: { x0: c.t0 + INSET_MM, x1: c.t1 - INSET_MM, z0: Math.min(sA, sB), z1: Math.max(sA, sB) };
const x0 = Math.max(w.x0, roofRect.x0), x1 = Math.min(w.x1, roofRect.x1), z0 = Math.max(w.z0, roofRect.z0), z1 = Math.min(w.z1, roofRect.z1);
if (x1 - x0 < 1 || z1 - z0 < 1) return;
out.push({ x0: x0 - rect.cx, x1: x1 - rect.cx, z0: z0 - rect.cz, z1: z1 - rect.cz, bear: BEAR_MM, chimney: c.id });
});
return out;
}
// Ruwe sparingen voor één unitwand (wl uit UnitModel.computeLayout; rect en rects in wereld-mm).
// Loopt de schoorsteen over het wandeinde heen en gaat de wand daar over in die van een andere unit in hetzelfde
// vlak (naad), dan loopt de sparing tot het einde door: geen hoekstijlen in de schoorsteen.
function wallCuts(placed, rect, wl, studWidth, rects) {
const S = SIDES[wl.side], sw = studWidth, plane = planeOf(rect, wl.side);
const dir = UnitModel.WALL_SIDES.find(x => x.key === wl.side).dir;
const mid = S.axis === 'x' ? wl.cx : wl.cz, c = S.axis === 'x' ? rect.cx : rect.cz;
const s0 = mid - wl.width / 2, s1 = mid + wl.width / 2;
const toU = (t) => { const p = t - c; return dir > 0 ? p - s0 : s1 - p; };
const toT = (u) => c + (dir > 0 ? u + s0 : s1 - u);
const range = (r) => S.axis === 'x' ? [r.x0, r.x1] : [r.z0, r.z1];
const seamEnd = (u) => {
const t = toT(u);
return (rects || []).some(r => r !== rect && r.id !== rect.id && !r.error && Math.abs(planeOf(r, wl.side) - plane) < EPS &&
range(r)[0] <= t + EPS && range(r)[1] >= t - EPS);
};
const edge = sw * WallModel.CORNER_STUDS;
const out = [];
placed.filter(ch => ch.side === wl.side && Math.abs(ch.plane - plane) < EPS).forEach(ch => {
const ua = toU(ch.t0 + INSET_MM), ub = toU(ch.t1 - INSET_MM);
const atStart = Math.min(ua, ub) < edge && Math.max(toU(ch.t0), toU(ch.t1)) > 0 && seamEnd(0);
const atEnd = Math.max(ua, ub) > wl.width - edge && Math.min(toU(ch.t0), toU(ch.t1)) < wl.width && seamEnd(wl.width);
const a = atStart ? 0 : Math.max(edge, Math.min(ua, ub)), b = atEnd ? wl.width : Math.min(wl.width - edge, Math.max(ua, ub));
if (b - a < MIN_CUT_MM) return;
out.push({ id: 1000 + ch.id, type: 'schoorsteen', w: Math.round(b - a), h: 99999, y: 0, anchor: 'left', offset: Math.round(a), fill: null,
chimney: { id: ch.id, inside: ch.inside.type, atStart, atEnd } });
});
return out;
}
// units: parts.units (met walls/pieces en roof), roofEdge: RoofModel.computeEdge (krijgt gaps), groundY absoluut.
function resolve(placed, units, roofEdge, groundY, overhang) {
return placed.map(ch => {
const S = SIDES[ch.side];
const toW = (t, s) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, z: t } : { x: t, z: ch.plane + S.n.z * s };
const sOf = (p) => S.axis === 'z' ? (p.x - ch.plane) * S.n.x : (p.z - ch.plane) * S.n.z;
const tOf = (p) => S.axis === 'z' ? p.z : p.x;
let T = 0, face = 0, floorTop = null;
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
if (p.baseY || !p.place.normal || p.place.normal.x !== S.n.x || p.place.normal.y !== S.n.z) return;
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
if (Math.abs(sOf(m)) > 2) return;
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, 0), b = UnitModel.localToWorld(p.place, p.place.width / 2, 0);
if (Math.min(ch.t1, Math.max(tOf(a), tOf(b))) - Math.max(ch.t0, Math.min(tOf(a), tOf(b))) < EPS) return;
T = Math.max(T, p.place.thickness);
face = Math.max(face, WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc));
if (floorTop === null) floorTop = p.place.floorTop;
})));
if (floorTop === null) {
const u0 = units.find(u => u.id === ch.unitId);
floorTop = u0 ? u0.layout.floorTop : 0;
T = T || (u0 && u0.walls.find(w => w.side === ch.side) || { thickness: 260 }).thickness;
}
// De dakrand onderbreken en de voorkant tot voorbij de overstek.
let roofOut = 0;
if (roofEdge) roofEdge.segments.forEach(sg => {
if (Math.abs(sg.normal.x - S.n.x) > 0.01 || Math.abs(sg.normal.z - S.n.z) > 0.01) return;
if (Math.abs(sOf(sg.a)) > Math.max(overhang, 50) + face + 5) return;
const ta = tOf(sg.outerA), tb = tOf(sg.outerB);
if (Math.min(ch.t1, Math.max(ta, tb)) - Math.max(ch.t0, Math.min(ta, tb)) < EPS) return;
sg.gaps = (sg.gaps || []).concat([[ch.t0 + INSET_MM, ch.t1 - INSET_MM]]);
roofOut = Math.max(roofOut, sOf(sg.outerA) + 20);
});
const sIn = -T, sFace = face, sFront = Math.max(face + ch.out, roofOut);
const roofs = units.map(u => u.roof);
const roofAt = (p) => roofs.filter(r => p.x > r.rect.x0 - EPS && p.x < r.rect.x1 + EPS && p.z > r.rect.z0 - EPS && p.z < r.rect.z1 + EPS);
const ridgeTs = roofs.flatMap(r => r.ridges.filter(rd => rd.axis === (S.axis === 'x' ? 'x' : 'z') && rd.value > ch.t0 && rd.value < ch.t1).map(rd => rd.value));
// Ook de randen van de daken: twee lessenaars die op een naad de nok vormen hebben geen eigen nok.
const edgeTs = roofs.flatMap(r => (S.axis === 'x' ? [r.rect.x0, r.rect.x1] : [r.rect.z0, r.rect.z1])).filter(t => t > ch.t0 + 1 && t < ch.t1 - 1);
const ts = [...new Set([ch.t0, ch.t1, ...ridgeTs, ...edgeTs].map(Math.round))].sort((a, b) => a - b);
let roofTop = -Infinity;
ts.forEach(t => [sIn, 0, sFront].forEach(s => roofAt(toW(t, s)).forEach(r => { roofTop = Math.max(roofTop, r.topAt(toW(t, s).x, toW(t, s).z)); })));
// Binnenkant tot de onderkant van het dak (vlak achter de wand gemeten).
const insideTop = ts.map(t => {
const p = toW(t, sIn - 1), r = roofAt(p);
return { t, y: r.length ? Math.min(...r.map(q => q.ceilingAt(p.x, p.z))) : floorTop + 2600 };
});
const under = Math.max(...insideTop.map(q => q.y));
if (!isFinite(roofTop)) roofTop = under + 300;
const yTop = Math.round(roofTop + ch.above);
const flue = {
t0: ch.t0 + SHELL_MM, t1: Math.max(ch.t0 + SHELL_MM + MIN_CUT_MM, ch.t1 - SHELL_MM),
s0: sIn + SHELL_MM, s1: Math.max(sIn + SHELL_MM + MIN_CUT_MM, sFront - SHELL_MM)
};
const corners = (q) => [toW(q.t0, q.s0), toW(q.t1, q.s0), toW(q.t1, q.s1), toW(q.t0, q.s1)];
return Object.assign({}, ch, {
T, sIn, sFace, sFront, yGround: groundY, yFloor: floorTop, yTop, yUnder: under, insideTop, flue,
footprint: corners({ t0: ch.t0, t1: ch.t1, s0: sIn, s1: sFront }), flueFootprint: corners(flue),
protrudes: Math.round(sFront - sFace), height: yTop - groundY
});
});
}
// Ligt een punt (wereld-mm) in of vlak voor een schoorsteen?
function covers(list, x, z, margin) {
const m = margin || 0;
return list.some(c => {
const S = SIDES[c.side];
const t = S.axis === 'z' ? z : x, s = S.axis === 'z' ? (x - c.plane) * S.n.x : (z - c.plane) * S.n.z;
return t > c.t0 - m && t < c.t1 + m && s > c.sIn - m && s < c.sFront + m;
});
}
function hit(list, x, z, margin) {
return list.find(c => covers([c], x, z, margin)) || null;
}
// Slepen: dichtstbijzijnde stuk buitengevel (omtrek van de units samen), hart van de schoorsteen erlangs zodat hij
// er helemaal tegen staat; ook over een naad tussen units heen. Geeft { unitId, side, u, t, plane, n } of null.
function snapToFacade(rects, x, z, w) {
const rs = rects.filter(r => !r.error);
if (!rs.length) return null;
const loop = rectUnionLoops(rs)[0];
if (!loop) return null;
const pts = loop.pts, n = pts.length;
let best = null;
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n], nrm = rectilinearNormal(a, b);
const side = Object.keys(SIDES).find(k => Math.abs(SIDES[k].n.x - nrm.x) < 1e-6 && Math.abs(SIDES[k].n.z - nrm.z) < 1e-6);
if (!side) continue;
const axis = SIDES[side].axis, lo = Math.min(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x), hi = Math.max(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x);
if (hi - lo < w + 2 * CORNER_CLEAR_MM) continue;
const plane = axis === 'z' ? a.x : a.z, along = axis === 'z' ? z : x, across = axis === 'z' ? x : z;
const t = Math.max(lo + w / 2 + CORNER_CLEAR_MM, Math.min(hi - w / 2 - CORNER_CLEAR_MM, along));
const d = Math.hypot(along - t, across - plane);
if (best && d >= best.d) continue;
const owner = rs.find(r => Math.abs(planeOf(r, side) - plane) < EPS && (axis === 'z' ? r.z0 <= t + EPS && r.z1 >= t - EPS : r.x0 <= t + EPS && r.x1 >= t - EPS));
if (!owner) continue;
const u = { voor: owner.x1 - t, achter: t - owner.x0, links: t - owner.z0, rechts: owner.z1 - t }[side];
best = { d, unitId: owner.id, side, u: Math.round(u), t, plane, n: SIDES[side].n };
}
return best;
}
function createMaterials() {
return {
cap: new THREE.MeshStandardMaterial({ color: 0x77736d, roughness: 0.85 }),
flue: new THREE.MeshStandardMaterial({ color: 0x1b1a19, roughness: 1, side: THREE.DoubleSide }),
plain: new THREE.MeshStandardMaterial({ color: 0xf2efe9, roughness: 0.9 })
};
}
const finishMaterial = (f, fallback) => f.type === 'steen' ? WallModel.stoneMaterial(f.stone) : f.type === 'stuc' ? WallModel.stucMaterial(f.stuc) : fallback;
// opts: { materials, wallMaterial (binnenkant 'wand': hetzelfde materiaal als de wanden) }
function build3D(list, opts) {
const o = opts || {}, m = o.materials || createMaterials(), toM = 0.001;
const group = new THREE.Group();
list.forEach(c => {
const S = SIDES[c.side];
const W = (t, s) => S.axis === 'z' ? { x: c.plane + S.n.x * s, z: t } : { x: t, z: c.plane + S.n.z * s };
const buckets = new Map();
const add = (mat, tri) => { if (!buckets.has(mat)) buckets.set(mat, { pos: [], uv: [] }); const b = buckets.get(mat); tri.forEach(v => { b.pos.push(v.x * toM, v.y * toM, v.z * toM); b.uv.push(v.u, v.v); }); };
// Verticaal vlak tussen p0 en p1 (wereld), buitennormaal nrm; hoogtes per kant. UV in meters (steen/stuc).
const quad = (mat, p0, p1, y0a, y1a, y0b, y1b, nrm) => {
const right = { x: nrm.z, z: -nrm.x };
if ((p1.x - p0.x) * right.x + (p1.z - p0.z) * right.z < 0) { [p0, p1] = [p1, p0]; [y0a, y0b] = [y0b, y0a]; [y1a, y1b] = [y1b, y1a]; }
const v = (p, y) => ({ x: p.x, y, z: p.z, u: (p.x * right.x + p.z * right.z) * toM, v: (y - c.yFloor) * toM });
const BL = v(p0, y0a), BR = v(p1, y0b), TR = v(p1, y1b), TL = v(p0, y1a);
add(mat, [BL, BR, TR]); add(mat, [BL, TR, TL]);
};
const nOut = S.n, nIn = { x: -S.n.x, z: -S.n.z };
const tDir = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 }, tNeg = { x: -tDir.x, z: -tDir.z };
const outMat = finishMaterial(c.outside, m.plain);
const yG = c.yGround, yT = c.yTop;
quad(outMat, W(c.t0, c.sFront), W(c.t1, c.sFront), yG, yT, yG, yT, nOut);
quad(outMat, W(c.t0, c.sIn), W(c.t0, c.sFront), yG, yT, yG, yT, tNeg);
quad(outMat, W(c.t1, c.sIn), W(c.t1, c.sFront), yG, yT, yG, yT, tDir);
quad(outMat, W(c.t0, c.sIn), W(c.t1, c.sIn), c.yUnder, yT, c.yUnder, yT, nIn); // achterkant boven het dak
// Binnenkant: van de vloer tot de onderkant van het dak.
const k = skinOf(c.inside), inMat = finishMaterial(c.inside, o.wallMaterial || m.plain), sI = c.sIn - k;
for (let i = 0; i < c.insideTop.length - 1; i++) {
const a = c.insideTop[i], b = c.insideTop[i + 1];
quad(inMat, W(a.t, sI), W(b.t, sI), c.yFloor, a.y, c.yFloor, b.y, nIn);
}
if (k > 0) {
const first = c.insideTop[0], last = c.insideTop[c.insideTop.length - 1];
quad(inMat, W(c.t0, sI), W(c.t0, c.sIn), c.yFloor, first.y, c.yFloor, first.y, tNeg);
quad(inMat, W(c.t1, sI), W(c.t1, c.sIn), c.yFloor, last.y, c.yFloor, last.y, tDir);
}
// Rookkanaal: zwarte binnenwanden en bodem tot FLUE_SHOW_MM onder de kap.
const f = c.flue, yF = yT - Math.min(FLUE_SHOW_MM, c.above + 100);
quad(m.flue, W(f.t0, f.s0), W(f.t1, f.s0), yF, yT, yF, yT, nOut);
quad(m.flue, W(f.t0, f.s1), W(f.t1, f.s1), yF, yT, yF, yT, nIn);
quad(m.flue, W(f.t0, f.s0), W(f.t0, f.s1), yF, yT, yF, yT, tDir);
quad(m.flue, W(f.t1, f.s0), W(f.t1, f.s1), yF, yT, yF, yT, tNeg);
const fp = [W(f.t0, f.s0), W(f.t1, f.s0), W(f.t1, f.s1), W(f.t0, f.s1)].map(p => ({ x: p.x, y: yF, z: p.z, u: 0, v: 0 }));
add(m.flue, [fp[0], fp[1], fp[2]]); add(m.flue, [fp[0], fp[2], fp[3]]);
buckets.forEach((b, mat) => {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(b.uv, 2));
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = mat !== m.flue;
mesh.receiveShadow = true;
group.add(mesh);
});
// Afdekplaat rond het rookkanaal (vier stroken).
const capY = (yT + CAP_MM / 2) * toM;
const strips = [
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, c.sIn - CAP_OVER_MM, f.s0],
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, f.s1, c.sFront + CAP_OVER_MM],
[c.t0 - CAP_OVER_MM, f.t0, f.s0, f.s1],
[f.t1, c.t1 + CAP_OVER_MM, f.s0, f.s1]
];
strips.forEach(([ta, tb, sa, sb]) => {
if (tb - ta < 1 || sb - sa < 1) return;
const p = W((ta + tb) / 2, (sa + sb) / 2);
const [dx, dz] = S.axis === 'z' ? [sb - sa, tb - ta] : [tb - ta, sb - sa];
const box = new THREE.Mesh(new THREE.BoxGeometry(dx * toM, CAP_MM * toM, dz * toM), m.cap);
box.position.set(p.x * toM, capY, p.z * toM);
box.castShadow = box.receiveShadow = true;
group.add(box);
});
});
return group;
}
return { SHELL_MM, INSIDE_TYPES, OUTSIDE_TYPES, DEFAULTS, SIDES, normalize, create, place, wallCuts, roofCuts, resolve, covers, hit, snapToFacade, createMaterials, build3D };
})();
+392
View File
@@ -0,0 +1,392 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// CAD-export van de woning (DXF): plattegrond en vier gevels in modelruimte, 1:1 in mm, met lagen per onderdeel.
// Plattegrond: noord boven, zuidwesthoek van de units = 0,0. Gevels in een 2×2-raster eronder, maaiveld als basis.
// Tekst, maatvoering en lijntypes zijn afgestemd op plotten op 1:50.
window.HouseDxf = (() => {
const SCALE = 50;
const CUT = 1500; // snijvlak plattegrond boven de vloer: openingen die lager beginnen zijn doorgesneden
const LAYERS = {
'PL-WAND-BUITEN': [7], 'PL-WAND-OPBOUW': [8], 'PL-GEVELBEKLEDING': [32], 'PL-WAND-BINNEN': [7],
'PL-KOZIJNEN': [4], 'PL-DRAAIRICHTING': [151], 'PL-SPARING-BOVEN': [4, 'STREEP'],
'PL-RUIMTE-GRENS': [3], 'PL-RUIMTE-TEKST': [7], 'PL-INRICHTING': [8], 'PL-DAKRAND': [8, 'STREEP'],
'PL-PALEN': [32], 'PL-SCHOORSTEEN': [8], 'PL-VERLICHTING': [2], 'PL-ELEKTRA': [6], 'PL-NADEN': [30, 'STREEPSTIP'],
'PL-ELEMENTCODES': [8],
'AZ-GEVEL': [7], 'AZ-TRASRAAM': [7], 'AZ-KOZIJNEN': [4], 'AZ-DAK': [7], 'AZ-DAKRAND': [8], 'AZ-SCHOORSTEEN': [8], 'AZ-MAAIVELD': [7],
'MAATVOERING': [5], 'TEKST': [7]
};
const FACE_LAYERS = { wand: 'AZ-GEVEL', opening: 'AZ-GEVEL', trasraam: 'AZ-TRASRAAM', dak: 'AZ-DAK', dakrand: 'AZ-DAKRAND', schoorsteen: 'AZ-SCHOORSTEEN' };
const mm = (paper) => paper * SCALE;
// Intervallen [a, b]: samenvoegen en aftrekken.
function mergeIv(list) {
const s = list.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
const out = [];
s.forEach(([a, b]) => { const l = out[out.length - 1]; if (l && a <= l[1] + 1e-9) l[1] = Math.max(l[1], b); else out.push([a, b]); });
return out;
}
function subtractIv(from, cuts) {
let out = from.map(iv => iv.slice());
mergeIv(cuts).forEach(([c0, c1]) => {
out = out.flatMap(([a, b]) => {
if (c1 <= a || c0 >= b) return [[a, b]];
const r = [];
if (c0 > a) r.push([a, c0]);
if (c1 < b) r.push([c1, b]);
return r;
});
});
return out;
}
// Randen van een ruimte (RoomModel: as x/z, waarde v, s0..s1) tot gesloten lussen.
function roomLoops(edges) {
const key = (p) => Math.round(p.x) + ',' + Math.round(p.z);
const segs = edges.map(e => e.axis === 'x' ? [{ x: e.v, z: e.s0 }, { x: e.v, z: e.s1 }] : [{ x: e.s0, z: e.v }, { x: e.s1, z: e.v }]);
const at = new Map();
segs.forEach((s, i) => s.forEach(p => { const k = key(p); if (!at.has(k)) at.set(k, []); at.get(k).push(i); }));
const used = new Set(), out = [];
segs.forEach((s, i) => {
if (used.has(i)) return;
used.add(i);
const loop = [s[0], s[1]];
let cur = s[1];
while (key(cur) !== key(loop[0])) {
const j = (at.get(key(cur)) || []).find(n => !used.has(n));
if (j === undefined) break;
used.add(j);
cur = key(segs[j][0]) === key(cur) ? segs[j][1] : segs[j][0];
loop.push(cur);
}
const closed = key(loop[loop.length - 1]) === key(loop[0]);
if (closed) loop.pop();
const n = loop.length;
const pts = loop.filter((p, k) => {
if (!closed && (k === 0 || k === n - 1)) return true;
const a = loop[(k - 1 + n) % n], b = loop[(k + 1) % n];
return !((Math.abs(a.x - p.x) < 0.5 && Math.abs(b.x - p.x) < 0.5) || (Math.abs(a.z - p.z) < 0.5 && Math.abs(b.z - p.z) < 0.5));
});
out.push({ pts, closed });
});
return out;
}
// Cirkelboog door het eerste, middelste en laatste punt van een polylijn (draaicirkel van een deur).
function arcThrough(pts) {
const a = pts[0], b = pts[Math.floor(pts.length / 2)], c = pts[pts.length - 1];
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
if (Math.abs(d) < 1e-9) return null;
const s = (p) => p.x * p.x + p.y * p.y;
const ctr = { x: (s(a) * (b.y - c.y) + s(b) * (c.y - a.y) + s(c) * (a.y - b.y)) / d, y: (s(a) * (c.x - b.x) + s(b) * (a.x - c.x) + s(c) * (b.x - a.x)) / d };
const ang = (p) => Math.atan2(p.y - ctr.y, p.x - ctr.x);
const ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0;
return { c: ctr, r: Math.hypot(a.x - ctr.x, a.y - ctr.y), a0: ccw ? ang(a) : ang(c), a1: ccw ? ang(c) : ang(a) };
}
// Tekstrichting langs een wand, altijd leesbaar (van onder of van rechts).
const readable = (deg) => { let a = ((deg % 360) + 360) % 360; if (a > 90.01 && a <= 270.01) a -= 180; return a; };
function drawPlan(dxf, parts, T) {
const W2 = (place, lx, lz) => T(UnitModel.localToWorld(place, lx, lz));
const ext = new Set();
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.add(p))));
const P = KozijnModel.PROFILE;
HouseModel.allWallPieces(parts).forEach(p => {
const pl = p.place;
if (pl.baseY) return;
const isExt = ext.has(p);
const W = pl.width, zi = -pl.thickness / 2, zo = pl.thickness / 2;
const ops = (p.config.openings || []).filter(op => op.type !== 'schoorsteen');
const cutOps = ops.filter(op => op.y < CUT);
const holes = mergeIv(cutOps.map(op => [Math.max(-W / 2, -W / 2 + op.x), Math.min(W / 2, -W / 2 + op.x + op.w)]));
const solid = subtractIv([[-W / 2, W / 2]], holes);
const lay = isExt ? 'PL-WAND-BUITEN' : 'PL-WAND-BINNEN';
const box = (a, b, z0, z1, layer, eo) => dxf.polyline([W2(pl, a, z0), W2(pl, b, z0), W2(pl, b, z1), W2(pl, a, z1)], true, layer, eo);
solid.forEach(([a, b]) => box(a, b, zi, zo, lay));
const c = isExt ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0;
if (isExt) {
const sh = p.config.sheathingThick || 0;
if (sh > 0) solid.forEach(([a, b]) => [zi + sh, zo - sh].forEach(z => dxf.line(W2(pl, a, z), W2(pl, b, z), 'PL-WAND-OPBOUW')));
if (c > 0) {
// Hoek: buitenhoek loopt de bekleding door over de buurwand, binnenhoek stopt ze ervoor (skinExt < 0).
const e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : (e.left || 0)), xr = W / 2 + (e.right > 0 ? e.right + c : (e.right || 0));
subtractIv([[xl, xr]], holes).forEach(([a, b]) => box(a, b, zo, zo + c, 'PL-GEVELBEKLEDING'));
}
}
cutOps.forEach(op => {
const f = KozijnModel.normalize(op.fill);
if (!f) return;
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
const kz1 = zo, kz0 = exterior ? Math.max(zi, zo - P.depth) : zi;
const a = x0 + P.gap, b = x1 - P.gap;
if (f.type === 'raam') {
box(a, b, kz0, kz1, 'PL-KOZIJNEN');
[a + P.kader, b - P.kader].forEach(x => dxf.line(W2(pl, x, kz0), W2(pl, x, kz1), 'PL-KOZIJNEN'));
dxf.line(W2(pl, a + P.kader, kz1 - 50), W2(pl, b - P.kader, kz1 - 50), 'PL-KOZIJNEN');
return;
}
box(a, a + P.kader, kz0, kz1, 'PL-KOZIJNEN');
box(b - P.kader, b, kz0, kz1, 'PL-KOZIJNEN');
KozijnModel.planSymbol(op, op.fill, { zIn: zi, zOut: zo }).forEach(sh => {
const pts = sh.pts.map(q => W2(pl, x0 + q.x, q.z));
if (sh.kind === 'arc') {
const arc = arcThrough(pts);
if (arc) dxf.arc(arc.c, arc.r, arc.a0, arc.a1, 'PL-DRAAIRICHTING');
} else if (sh.kind === 'rail') dxf.polyline(pts, !!sh.closed, 'PL-DRAAIRICHTING', { ltype: 'STIP' });
else dxf.polyline(pts, !!sh.closed, sh.kind === 'arrow' ? 'PL-DRAAIRICHTING' : 'PL-KOZIJNEN');
});
});
// Sparingen boven het snijvlak (bovenlichten, hoge ramen) gestreept, tenzij ze boven een doorgesneden opening liggen.
ops.filter(op => op.y >= CUT).forEach(op => {
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
if (holes.some(([a, b]) => x0 >= a - 1 && x1 <= b + 1)) return;
box(x0, x1, zi, zo, 'PL-SPARING-BOVEN');
});
// Elementcode binnen langs de wand, verticaal gecentreerd op 2,5 mm (papier) van het binnenvlak.
const rot = readable(Math.atan2(W2(pl, 1, 0).y - W2(pl, 0, 0).y, W2(pl, 1, 0).x - W2(pl, 0, 0).x) * 180 / Math.PI) * Math.PI / 180;
const mid = W2(pl, 0, zi - mm(2.5)), th = mm(2);
dxf.text({ x: mid.x + Math.sin(rot) * th / 2, y: mid.y - Math.cos(rot) * th / 2 }, th, p.element, 'PL-ELEMENTCODES', { align: 'center', rot: rot * 180 / Math.PI });
if (isExt) {
const xs = [-W / 2, W / 2];
holes.forEach(([a, b]) => xs.push(a, b));
const ticks = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((m, n) => m - n);
const zf = zo + c, row1 = zf + 700, row2 = row1 + 450;
for (let i = 0; i < ticks.length - 1; i++) {
dxf.dim(W2(pl, ticks[i], zf), W2(pl, ticks[i + 1], zf), W2(pl, (ticks[i] + ticks[i + 1]) / 2, row1), 'MAATVOERING');
}
if (ticks.length > 2) dxf.dim(W2(pl, -W / 2, zf), W2(pl, W / 2, zf), W2(pl, 0, row2), 'MAATVOERING');
}
});
const R = parts.rooms;
R.rooms.concat(R.free).forEach(r => {
roomLoops(r.edges || []).forEach(lp => dxf.polyline(lp.pts.map(T), lp.closed, 'PL-RUIMTE-GRENS'));
const named = !!r.name;
const lines = [[named ? r.name : 'Ruimte (geen functie)', 3.5], [`${r.areaM2.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m²`, 2.5]];
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 2]);
const c = T(r.label), step = mm(4.5);
lines.forEach(([t, size], i) => dxf.text({ x: c.x, y: c.y + ((lines.length - 1) / 2 - i) * step - mm(size) / 2 }, mm(size), t, 'PL-RUIMTE-TEKST', { align: 'center' }));
});
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
const eo = sh.kind === 'dash' ? { ltype: 'STREEP' } : null;
if (sh.circle) dxf.circle(T(sh.circle), sh.circle.r, 'PL-INRICHTING', eo);
else if (sh.text) { const q = T(sh.at); dxf.text({ x: q.x, y: q.y - mm(0.7) }, mm(1.4), sh.text, 'PL-INRICHTING', { align: 'center' }); }
else dxf.polyline(sh.pts.map(T), !!sh.closed, 'PL-INRICHTING', eo);
}));
if (parts.roofEdge) {
parts.roofEdge.segments.forEach(sg => dxf.line(T(sg.outerA), T(sg.outerB), 'PL-DAKRAND'));
parts.roofEdge.spots.forEach(sp => dxf.circle(T(sp), mm(0.8), 'PL-VERLICHTING'));
} else {
parts.units.forEach(u => {
const r = u.roof.rect;
dxf.polyline([{ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }].map(T), true, 'PL-DAKRAND');
});
}
parts.posts.posts.forEach(pp => {
const s = pp.size / 2;
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
});
(parts.chimneys || []).forEach(ch => {
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
});
parts.lighting.lamps.forEach(l => {
const q = T({ x: l.x + l.normal.x * 80, z: l.z + l.normal.z * 80 }), s = mm(0.9);
dxf.polyline([{ x: q.x - s, y: q.y - s }, { x: q.x + s, y: q.y - s }, { x: q.x + s, y: q.y + s }, { x: q.x - s, y: q.y + s }], true, 'PL-VERLICHTING');
});
const o = T({ x: 0, z: 0 });
ElektraPdf.drawPlan(DxfWriter.pdfAdapter(dxf, 'PL-ELEKTRA'), parts.elektra, (x) => (x + o.x) / SCALE, (z) => (z - o.y) / SCALE);
parts.seams.forEach(s => dxf.line(T({ x: s.x0, z: s.z0 }), T({ x: s.x1, z: s.z1 }), 'PL-NADEN'));
parts.rects.forEach(r => {
const q = T({ x: r.x0 + 450, z: r.z0 + 450 });
dxf.text({ x: q.x, y: q.y - mm(8) }, mm(8), r.id, 'PL-ELEMENTCODES');
});
// Totaalmaten en noordpijl buiten alles.
const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const e = dxf.extents();
dxf.dim(T({ x: bx0, z: bz0 }), T({ x: bx1, z: bz0 }), { x: 0, y: e.y1 + 700 }, 'MAATVOERING');
dxf.dim(T({ x: bx0, z: bz1 }), T({ x: bx0, z: bz0 }), { x: e.x0 - 700, y: 0 }, 'MAATVOERING');
const na = { x: e.x1 + mm(10), y: e.y1 - mm(4) }, ns = mm(4);
dxf.polyline([{ x: na.x, y: na.y + ns }, { x: na.x - ns * 0.4, y: na.y - ns * 0.6 }, { x: na.x, y: na.y - ns * 0.3 }, { x: na.x + ns * 0.4, y: na.y - ns * 0.6 }], true, 'TEKST');
dxf.text({ x: na.x, y: na.y - ns * 0.6 - mm(5) }, mm(3.5), 'N', 'TEKST', { align: 'center' });
}
function simplifyPoly(pts) {
let p = pts.filter((q, i) => { const r = pts[(i + 1) % pts.length]; return Math.hypot(r.x - q.x, r.y - q.y) > 0.5; });
let changed = true;
while (changed && p.length > 3) {
changed = false;
for (let i = 0; i < p.length; i++) {
const a = p[(i - 1 + p.length) % p.length], b = p[i], c = p[(i + 1) % p.length];
const len = Math.hypot(c.x - a.x, c.y - a.y);
if (len > 0 && Math.abs((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / len < 0.5) { p.splice(i, 1); changed = true; break; }
}
}
return p;
}
const insidePoly = (q, poly) => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
};
const distSeg = (q, a, b) => {
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
const t = l2 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0;
return Math.hypot(q.x - a.x - t * dx, q.y - a.y - t * dy);
};
// Echt binnen (niet op de rand): lijnen die samenvallen met de rand van een vlak ervoor blijven zichtbaar.
const strictlyInside = (q, poly) => insidePoly(q, poly) && poly.every((a, i) => distSeg(q, a, poly[(i + 1) % poly.length]) > 0.5);
// Delen [t0, t1] van segment a-b die door poly worden afgedekt.
function coveredBy(a, b, poly) {
const ts = [0, 1], dx = b.x - a.x, dy = b.y - a.y;
poly.forEach((c, i) => {
const d = poly[(i + 1) % poly.length], ex = d.x - c.x, ey = d.y - c.y;
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) return;
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / den, s = ((c.x - a.x) * dy - (c.y - a.y) * dx) / den;
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t);
});
ts.sort((m, n) => m - n);
const out = [];
for (let i = 0; i < ts.length - 1; i++) {
const t0 = ts[i], t1 = ts[i + 1];
if (t1 - t0 < 1e-7) continue;
const tm = (t0 + t1) / 2;
if (!strictlyInside({ x: a.x + dx * tm, y: a.y + dy * tm }, poly)) continue;
const l = out[out.length - 1];
if (l && Math.abs(l[1] - t0) < 1e-7) l[1] = t1; else out.push([t0, t1]);
}
return out;
}
// Verborgen lijnen weg: vlakken van achter naar voor, elke rand wordt afgedekt door de vlakken die later komen.
function hiddenLines(faces) {
const boxOf = (pts) => ({ x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)) });
const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
faces.forEach(f => { f.box = boxOf(f.poly); });
const out = [];
faces.forEach((f, i) => {
const segs = [];
const ring = (pts, layer) => pts.forEach((a, k) => segs.push({ a, b: pts[(k + 1) % pts.length], layer }));
ring(f.poly, f.layer);
f.detail.forEach(pl => ring(pl, 'AZ-KOZIJNEN'));
segs.forEach(sg => {
const len = Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y);
if (len < 1) return;
const sb = boxOf([sg.a, sg.b]);
sb.x0 -= 1; sb.y0 -= 1; sb.x1 += 1; sb.y1 += 1;
let vis = [[0, 1]];
for (let j = i + 1; j < faces.length && vis.length; j++) {
if (!hit(sb, faces[j].box)) continue;
const cov = coveredBy(sg.a, sg.b, faces[j].poly);
if (cov.length) vis = subtractIv(vis, cov);
}
const at = (t) => ({ x: sg.a.x + (sg.b.x - sg.a.x) * t, y: sg.a.y + (sg.b.y - sg.a.y) * t });
vis.forEach(([t0, t1]) => { if ((t1 - t0) * len > 1) out.push({ a: at(t0), b: at(t1), layer: sg.layer }); });
});
});
return out;
}
// Samenvallende en in elkaars verlengde liggende stukken per laag samenvoegen (gedeelde randen maar één keer).
function mergeCollinear(segs) {
const groups = new Map();
segs.forEach(s => {
let dx = s.b.x - s.a.x, dy = s.b.y - s.a.y;
const len = Math.hypot(dx, dy);
dx /= len; dy /= len;
if (dx < -1e-9 || (Math.abs(dx) < 1e-9 && dy < 0)) { dx = -dx; dy = -dy; }
const off = -dy * s.a.x + dx * s.a.y;
const key = `${s.layer}|${Math.round(Math.atan2(dy, dx) * 2000)}|${Math.round(off)}`;
if (!groups.has(key)) groups.set(key, { dx, dy, off, layer: s.layer, iv: [] });
const g = groups.get(key);
const ta = s.a.x * dx + s.a.y * dy, tb = s.b.x * dx + s.b.y * dy;
g.iv.push([Math.min(ta, tb), Math.max(ta, tb)]);
});
const out = [];
groups.forEach(g => mergeIv(g.iv).forEach(([t0, t1]) => {
const p = (t) => ({ x: g.dx * t - g.dy * g.off, y: g.dy * t + g.dx * g.off });
out.push({ a: p(t0), b: p(t1), layer: g.layer });
}));
return out;
}
function drawViews(dxf, parts, state, planExt) {
const { groundY, views } = HousePdf.viewProjections(parts, state);
if (!views.length) return;
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
const maxW = Math.max(...views.map(w => w.u1 - w.u0)), maxH = Math.max(...views.map(w => w.v1 - groundY));
const gapX = 5000, cellH = maxH + 4500;
views.forEach((w, i) => {
const col = i % 2, row = Math.floor(i / 2);
const ox = planExt.x0 + col * (maxW + gapX) - w.u0;
const base = planExt.y0 - 3500 - maxH - row * cellH;
const P = (u, v) => ({ x: ox + u, y: base + (v - groundY) });
const faces = w.list.map(l => ({
poly: simplifyPoly(l.pts.map(p => ({ x: p.u, y: p.v }))),
layer: FACE_LAYERS[l.f.kind] || 'AZ-GEVEL',
detail: (l.f.detail || []).map(pl => simplifyPoly(pl.map(p => ({ x: w.v.u(p), y: p.y }))))
})).filter(f => f.poly.length >= 3);
mergeCollinear(hiddenLines(faces)).forEach(s => dxf.line(P(s.a.x, s.a.y), P(s.b.x, s.b.y), s.layer));
dxf.line(P(w.u0 - 1000, groundY), P(w.u1 + 1000, groundY), 'AZ-MAAIVELD');
const left = P(w.u0, groundY), xd = left.x - 900;
if (floorTop - groundY > 1) dxf.dim(P(w.u0, groundY), P(w.u0, floorTop), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
dxf.dim(P(w.u0, floorTop), P(w.u0, w.v1), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
const peil = P(w.u0, floorTop);
dxf.line({ x: xd - mm(10), y: peil.y }, { x: xd - mm(2), y: peil.y }, 'MAATVOERING');
dxf.text({ x: xd - mm(10), y: peil.y + mm(1) }, mm(2), 'peil', 'MAATVOERING');
dxf.dim(P(w.u0, groundY), P(w.u1, groundY), { x: 0, y: base - 700 }, 'MAATVOERING');
dxf.text(P((w.u0 + w.u1) / 2, groundY - 1300 - mm(5)), mm(5), w.v.title.toUpperCase(), 'TEKST', { align: 'center' });
});
}
function generate(state) {
const parts = HouseModel.computeParts(state, { skip3D: true });
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const dxf = DxfWriter.create({ scale: SCALE });
Object.entries(LAYERS).forEach(([name, [color, ltype]]) => dxf.layer(name, color, ltype));
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const T = (p) => ({ x: p.x - bx0, y: bz1 - p.z });
drawPlan(dxf, parts, T);
const planExt = dxf.extents();
dxf.text({ x: planExt.x0, y: planExt.y0 - mm(12) }, mm(5), 'PLATTEGROND', 'TEKST');
drawViews(dxf, parts, state, planExt);
const pr = state.project || {};
const version = typeof APP_VERSION !== 'undefined' ? ` ${APP_VERSION}` : '';
const info = [
pr.naam, [pr.straat, pr.nummer].filter(Boolean).join(' '), pr.plaats,
`Tectum${version}, ${new Date().toLocaleDateString('nl-NL')}`,
'Maten in mm, peil = bovenkant vloer; tekst en maatvoering voor 1:50.'
].filter(Boolean);
info.forEach((t, i) => dxf.text({ x: planExt.x1 + mm(30), y: planExt.y1 - mm(5) - i * mm(4.5) }, mm(i === 0 && pr.naam ? 3.5 : 2.5), t, 'TEKST'));
return dxf;
}
return { generate, LAYERS };
})();
+186
View File
@@ -0,0 +1,186 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// BIM-export van de bouwconstructie (IFC4): balken, platen en elektra, geen afwerking of inrichting.
// De geometrie komt uit de 3D-opbouw (dezelfde als in de app), in twee fasen: 'elektra' (alle balken en
// leidingen, nog zonder platen, dus niets weggelaten) en 'beplating' (alleen de platen).
// Assen: X = oost, Y = noord, Z = omhoog; 0,0 = zuidwesthoek van de units, Z = 0 op peil (bovenkant vloer).
// Opbouw: unit (samenstelling) > element A-W1 / A-V / A-D (samenstelling) > onderdelen; binnenwanden los.
window.HouseIfc = (() => {
const WOOD = 'Hout', ELEKTRA_KIND = { kast: 1, doos: 1, buis: 1 };
// Productiekleuren per soort (elektra houdt de kleur uit de app).
const COLORS = {
Stijl: [0.93, 0.74, 0.42], Regel: [0.85, 0.48, 0.18], Vloerbalk: [0.62, 0.40, 0.20], Dakbalk: [0.78, 0.30, 0.22],
Paal: [0.45, 0.28, 0.15], 'Stalen nokbalk': [0.35, 0.42, 0.55],
Spaanplaat: [0.40, 0.68, 0.42], OSB: [0.95, 0.86, 0.50], Cementplaat: [0.62, 0.64, 0.68]
};
const rgbOf = (mesh) => { const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return m && m.color ? [m.color.r, m.color.g, m.color.b] : null; };
function materialsSet() {
return {
wall: WallModel.createMaterials(), floor: FloorModel.createMaterials(), roof: RoofModel.createMaterials(),
elektra: ElektraModel.createMaterials(), post: PostModel.createMaterials()
};
}
// Materiaal -> soort onderdeel (alles wat hier niet in staat, gaat niet mee).
function classifier(M) {
const map = new Map();
map.set(M.wall.woodMaterial, { kind: 'hout', where: 'wand' });
map.set(M.floor.woodMaterial, { kind: 'hout', where: 'vloer' });
map.set(M.roof.woodMaterial, { kind: 'hout', where: 'dak' });
map.set(M.roof.steelMaterial, { kind: 'staal' });
map.set(M.wall.spaanplaatMaterial, { kind: 'plaat', label: 'Spaanplaat' });
map.set(M.wall.cementplaatMaterial, { kind: 'plaat', label: 'Cementplaat' });
map.set(M.floor.osbMaterial, { kind: 'plaat', label: 'OSB' });
map.set(M.roof.osbMaterial, { kind: 'plaat', label: 'OSB' });
Object.entries(M.elektra.conduit).forEach(([type, m]) => map.set(m, { kind: 'buis', type }));
map.set(M.elektra.boxMaterial, { kind: 'doos' });
map.set(M.elektra.kastMaterial, { kind: 'kast' });
map.set(M.elektra.deurMaterial, { kind: 'kast' });
Object.values(M.post.wood).forEach(m => map.set(m, { kind: 'paal' }));
return (mesh) => {
const m0 = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
return m0 ? map.get((m0.userData && m0.userData.base) || m0) || null : null;
};
}
function build(state, stage) {
const M = materialsSet();
const parts = HouseModel.computeParts(state, {
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
stage, lampenVisible: false
});
HouseModel.placeGroups(parts);
return { parts, classify: classifier(M) };
}
function generate(state) {
const frame = build(state, BuildStages.INDEX.elektra);
const sheet = build(state, BuildStages.INDEX.beplating);
const parts = frame.parts;
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const peil = Math.max(...parts.units.map(u => u.layout.floorTop));
const v = new THREE.Vector3(), ax = new THREE.Vector3(), box = new THREE.Box3(), size = new THREE.Vector3();
function triangles(mesh, out) {
const g = mesh.geometry, pos = g.attributes.position, idx = g.index;
const n = idx ? idx.count : pos.count;
for (let i = 0; i < n; i++) {
v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(mesh.matrixWorld);
out.push(v.x * 1000 - bx0, bz1 - v.z * 1000, v.y * 1000 - peil);
}
return out;
}
// Maat in de eigen assen (afgeschuinde sporen houden zo lengte en dikte), oplopend in mm.
function dims(mesh) {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
box.copy(mesh.geometry.boundingBox).getSize(size);
const s = [size.x * Math.abs(mesh.scale.x), size.y * Math.abs(mesh.scale.y), size.z * Math.abs(mesh.scale.z)].map(d => Math.round(d * 1000));
const longest = s.indexOf(Math.max(...s));
ax.set(longest === 0 ? 1 : 0, longest === 1 ? 1 : 0, longest === 2 ? 1 : 0).transformDirection(mesh.matrixWorld);
return { sorted: s.slice().sort((a, b) => a - b), vertical: Math.abs(ax.y) > 0.7 };
}
const label = (d) => d.sorted.join(' × ');
const groups = new Map(); // sleutel -> { name, tag, objectType, unit, members: [] }
const loose = [];
const merged = new Map(); // leidingen per run, meterkast
const groupOf = (key, info) => { if (!groups.has(key)) groups.set(key, Object.assign({ members: [] }, info)); return groups.get(key); };
function member(mesh, c, tag) {
const d = dims(mesh);
if (d.sorted[0] < 1) return null; // vlakken zonder dikte (kopse afdekking)
const tri = triangles(mesh, []);
const props = (name, material) => ({ Element: tag || null, Onderdeel: name, Afmeting: label(d), Lengte: d.sorted[2], Materiaal: material });
if (c.kind === 'plaat') {
const name = `${c.label} ${d.sorted[0]} mm`;
return { type: 'IFCPLATE', predefined: 'SHEET', name, material: c.label, tri, color: COLORS[c.label], props: props(name, c.label) };
}
const el = (type, predefined, name, material) => ({ type, predefined, name, material, tri, color: COLORS[name], props: props(name, material) });
if (c.kind === 'staal') return el('IFCBEAM', 'BEAM', 'Stalen nokbalk', 'Staal');
if (c.kind === 'paal') return el('IFCCOLUMN', 'COLUMN', 'Paal', WOOD);
if (c.where === 'vloer') return el('IFCBEAM', 'JOIST', 'Vloerbalk', WOOD);
if (c.where === 'dak') return el('IFCMEMBER', 'RAFTER', 'Dakbalk', WOOD);
return d.vertical ? el('IFCMEMBER', 'STUD', 'Stijl', WOOD) : el('IFCMEMBER', 'PLATE', 'Regel', WOOD);
}
function collect(src, group, plates, target) {
if (!group) return;
group.updateMatrixWorld(true);
group.traverseVisible(o => {
if (!o.isMesh) return;
const c = src.classify(o);
if (!c || (c.kind === 'plaat') !== plates) return;
if (ELEKTRA_KIND[c.kind]) {
if (c.kind === 'doos') {
loose.push({ type: 'IFCJUNCTIONBOX', predefined: 'POWER', name: 'Inbouwdoos', material: 'Kunststof', tri: triangles(o, []), color: rgbOf(o), props: { Onderdeel: 'Inbouwdoos', Materiaal: 'Kunststof' } });
return;
}
const key = c.kind === 'kast' ? 'kast' : `buis:${o.userData.run}`;
if (!merged.has(key)) {
merged.set(key, c.kind === 'kast'
? { type: 'IFCELECTRICDISTRIBUTIONBOARD', predefined: 'DISTRIBUTIONBOARD', name: 'Meterkast', material: 'Kunststof', tri: [], color: rgbOf(o), props: { Onderdeel: 'Meterkast' } }
: { type: 'IFCCABLECARRIERSEGMENT', predefined: 'CONDUITSEGMENT', name: `Installatiebuis ${c.type}`, material: `PVC ${c.type}`, tri: [], len: 0, color: rgbOf(o), props: { Onderdeel: `Installatiebuis ${c.type}`, Materiaal: 'PVC' } });
}
const e = merged.get(key);
triangles(o, e.tri);
if (e.len !== undefined && o.geometry.type === 'CylinderGeometry') e.len += o.geometry.parameters.height * 1000;
return;
}
const m = member(o, c, target && target.tag);
if (m) (target ? target.members : loose).push(m);
});
}
[[frame, false], [sheet, true]].forEach(([src, plates]) => {
const P = src.parts;
P.units.forEach(u => {
collect(src, u.floorGroup, plates, groupOf(u.floorElement, { name: `Unit ${u.id} vloer`, tag: u.floorElement, objectType: 'Vloerelement', unit: u.id }));
u.walls.forEach(w => w.pieces.forEach(p => {
collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Wandelement', unit: u.id }));
}));
collect(src, u.roof.group, plates, groupOf(`${u.id}-D`, { name: `Unit ${u.id} dak`, tag: `${u.id}-D`, objectType: 'Dakelement', unit: u.id }));
});
P.interior.forEach(p => collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Binnenwand', unit: null })));
collect(src, P.postsGroup, plates, null);
collect(src, P.elektraGroup, plates, null);
});
merged.forEach(e => {
if (e.len) e.props.Lengte = Math.round(e.len);
delete e.len;
loose.push(e);
});
const pr = state.project || {};
const ifc = IfcWriter.create({
project: pr.naam || 'Woning', building: pr.naam || 'Woning', author: pr.tekenaar || '',
app: `Tectum${typeof APP_VERSION !== 'undefined' ? ' ' + APP_VERSION : ''}`,
address: { lines: [pr.straat, pr.nummer].filter(Boolean).join(' '), town: pr.plaats || '' }
});
const unitParts = new Map(parts.units.map(u => [u.id, []]));
groups.forEach(g => {
const ids = g.members.map(m => ifc.element(m)).filter(Boolean);
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: g.name, tag: g.tag, objectType: g.objectType, props: { Element: g.tag } });
ifc.aggregate(asm, ids);
if (g.unit && unitParts.has(g.unit)) unitParts.get(g.unit).push(asm);
else ifc.contain(asm);
});
unitParts.forEach((ids, id) => {
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: `Unit ${id}`, tag: id, objectType: 'Unit' });
ifc.aggregate(asm, ids);
ifc.contain(asm);
});
loose.forEach(e => ifc.contain(ifc.element(e)));
return ifc;
}
return { generate };
})();
+628 -24
View File
@@ -74,7 +74,7 @@ window.HouseModel = (() => {
}
function newModelState(naam) {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [], chimneys: [] };
st.project.naam = naam || '';
return st;
}
@@ -123,14 +123,14 @@ window.HouseModel = (() => {
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) => {
const wall = interiorWallDefaults(T, H);
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) =>
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }));
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) => {
@@ -294,10 +294,374 @@ window.HouseModel = (() => {
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 = '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: 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;
}
// 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 schoorsteen (ChimneyModel) over de naad. 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')]]);
// De oostwand van A begint na de noordwand: offset vanaf het wandeinde.
const tN = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', Math.round(785 - tN), 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 800 mm breed midden in de oostgevel, hart op de naad (z 3270), 180 mm voor de gevel.
st.chimneys.push(Object.assign(ChimneyModel.create(st.chimneys, 'B', 'rechts', 3270), {
w: 800, out: 180, above: 600, outside: { type: 'steen', stone: SCHOORSTEEN, stuc: 'wit' }
}));
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 }
wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering },
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) {
@@ -523,7 +887,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;
@@ -532,6 +898,12 @@ 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 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);
@@ -607,7 +979,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) => {
@@ -636,16 +1007,168 @@ 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);
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;
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;
}
// Dubbel lessenaardak = zadeldak: twee units met elk een lessenaar dwars op hun naad, allebei hoog aan de naad, met
// op de mm gelijke goot, nok en hoogte op de naad. Dan een stalen nokbalk onder de naad, net als bij een zadeldak.
// Eén lessenaar, of twee die niet zo op elkaar aansluiten, krijgen er geen. Een schoorsteen op de nok maakt een
// sparing in het dak; de balk stopt dan vóór de schoorsteen (RoofModel.computeRidgeBeam).
function doubleLessenaarPairs(state, rects, cuts) {
const out = {};
const info = state.units.map((u, i) => {
const r = rects[i];
if (r.error || u.roof.steelRidge === false) return null;
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const sh = RoofModel.computeShape(u.unit, layout);
if (sh.type !== 'lessenaar' || sh.profile.length !== 2) return null;
const c = sh.axis === 'x' ? r.cx : r.cz;
const [A, B] = sh.profile.map(q => ({ p: q.p + c, h: q.h })).sort((a, b) => a.p - b.p);
return { u, r, axis: sh.axis, A, B, floorTop: layout.floorTop, slope: (B.h - A.h) / (B.p - A.p) };
});
const at = (L, p) => L.A.h + (p - L.A.p) * L.slope;
info.forEach(M => info.forEach(N => {
if (!M || !N || M === N || M.axis !== N.axis || out[M.u.id] || out[N.u.id]) return;
const ax = M.axis, lo = (r) => ax === 'x' ? r.x0 : r.z0, hi = (r) => ax === 'x' ? r.x1 : r.z1;
const seam = hi(M.r);
if (Math.abs(seam - lo(N.r)) > EPS || !(M.slope > 0 && N.slope < 0) || Math.abs(M.floorTop - N.floorTop) > EPS) return;
if (Math.abs(M.A.h - N.B.h) > EPS || Math.abs(M.B.h - N.A.h) > EPS || Math.abs(at(M, seam) - at(N, seam)) > EPS) return;
const qlo = (r) => ax === 'x' ? r.z0 : r.x0, qhi = (r) => ax === 'x' ? r.z1 : r.x1;
const q0 = Math.max(qlo(M.r), qlo(N.r)), q1 = Math.min(qhi(M.r), qhi(N.r));
if (q1 - q0 < 100) return;
out[M.u.id] = {
axis: ax, a0: lo(M.r), a1: hi(N.r), q0, q1, span: [q0, q1],
profile: [{ p: M.A.p, h: M.A.h }, { p: seam, h: at(M, seam) }, { p: N.B.p, h: N.B.h }]
};
}));
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;
});
}
function setHouseRoof(state, on) {
const hr = state.settings.roof.house;
if (!!on === houseRoofOn(state)) return;
@@ -718,18 +1241,30 @@ window.HouseModel = (() => {
const { cuts, seams } = computeSeams(rects, wallThickness);
const houseShape = houseRoofShape(state, rects);
const coverExt = coverExtensions(state, rects);
const joins = roofJoins(state, rects);
const chimneysPlaced = ChimneyModel.place(ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id)), rects, {
wallThickness,
faceDepth: (id, side) => {
const fc = facadeFor(state, state.units.find(u => u.id === id), side), m = facadeMaterials(state, fc);
return WallModel.claddingOffset(m.exterior, m.stone, m.stuc);
},
overhang: RoofModel.normalizeSettings(state.settings.roof).overhang
});
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
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) };
}
function wallOf(u, rect, layout, wl, raised) {
const ws = u.unit.walls[wl.side];
const ws0 = u.unit.walls[wl.side];
const chimneyCuts = ChimneyModel.wallCuts(chimneysPlaced, rect, wl, Math.round(ws0.wall.studWidth || WallModel.STANDARD_BEAM.width), rects);
const ws = chimneyCuts.length ? Object.assign({}, ws0, { openings: (ws0.openings || []).concat(chimneyCuts) }) : ws0;
const wp = UnitModel.wallPieces(wl, ws, raised);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
@@ -776,9 +1311,23 @@ 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 roofCuts = ChimneyModel.roofCuts(chimneysPlaced, rect,
{ x0: rect.cx + roofRect.x0, x1: rect.cx + roofRect.x1, z0: rect.cz + roofRect.z0, z1: rect.cz + roofRect.z1 });
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims, roofCuts);
const dl = !join && ridgePairs[u.id];
if (dl) {
const c = dl.axis === 'x' ? rect.cx : rect.cz, cq = dl.axis === 'x' ? rect.cz : rect.cx;
const zadel = { type: 'zadel', axis: dl.axis, profile: dl.profile.map(q => ({ p: q.p - c, h: q.h })) };
const both = dl.axis === 'x' ? { x0: dl.a0 - c, x1: dl.a1 - c, z0: dl.q0 - cq, z1: dl.q1 - cq } : { x0: dl.q0 - cq, x1: dl.q1 - cq, z0: dl.a0 - c, z1: dl.a1 - c };
RoofModel.sharedRidgeBeam(u.roof, ur, zadel, both, dl.span.map(q => q - cq), roofCuts);
}
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}`;
@@ -984,7 +1533,7 @@ window.HouseModel = (() => {
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
// Gespiegeld: de schuine bovenzijde klapt om. Het kozijn (fill) staat alleen in het eerste deel.
const flip = ua > ub;
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
return { id: op.id, type: op.type, koppel: op.koppel, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
@@ -1003,11 +1552,32 @@ 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));
for (let x = -half + 250; x < half; x += 250) xs.add(x);
const flues = cfg.openings.filter(op => op.type === 'schoorsteen').map(op => [op.x - half, op.x + op.w - half]);
flues.forEach(([a, b]) => { xs.add(a); xs.add(b); });
const pts = [...xs].sort((a, b) => a - b).map(x => {
if (flues.some(([a, b]) => x > a + 0.5 && x < b - 0.5)) return { x, o: 0, i: 0 }; // geen hout in de schoorsteen
const top = p.place.floorTop + tl.at(x);
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
@@ -1041,6 +1611,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).
@@ -1097,7 +1668,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;
@@ -1107,7 +1678,10 @@ window.HouseModel = (() => {
Object.assign(p, built);
});
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const chimneys = ChimneyModel.resolve(chimneysPlaced, units, roofEdge, groundY, roofEdge ? roofEdge.overhang : 0);
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
}) : null;
@@ -1120,6 +1694,13 @@ window.HouseModel = (() => {
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
}) : null;
if (chimneysGroup) chimneysGroup.visible = shows('gevel');
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(), {
@@ -1132,7 +1713,21 @@ 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, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
}
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
function placeGroups(parts) {
const toM = 0.001;
parts.units.forEach(u => {
if (u.floorGroup) u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
if (u.roof.group) u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
});
allWallPieces(parts).forEach(p => {
if (!p.group) return;
p.group.rotation.y = p.place.rotY;
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
});
}
function allWallPieces(parts) {
@@ -1271,6 +1866,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,
@@ -1350,6 +1946,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,
@@ -1390,6 +1990,7 @@ window.HouseModel = (() => {
});
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
if (design.chimneys !== undefined && !Array.isArray(design.chimneys)) throw new Error('veld "chimneys" moet een lijst zijn');
}
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
@@ -1403,6 +2004,7 @@ window.HouseModel = (() => {
state.interiorWalls = state.interiorWalls || [];
state.rooms = RoomModel.normalize(state.rooms, state.units.map(u => u.id));
state.fixtures = FixtureModel.normalize(state.fixtures, state.units.map(u => u.id));
state.chimneys = ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id));
const def = createDefaultSettings();
state.settings = state.settings || {};
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
@@ -1437,9 +2039,11 @@ 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';
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]) => {
@@ -1453,7 +2057,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,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();
+61 -21
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' });
@@ -86,7 +87,7 @@ window.HousePdf = (() => {
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
p.config.openings.forEach(op => {
p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
poly(c, 'FD');
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
@@ -134,6 +135,16 @@ 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'));
(parts.chimneys || []).forEach(ch => {
doc.setFillColor(150, 150, 150);
poly(ch.footprint, 'FD');
doc.setFillColor(30, 30, 30);
poly(ch.flueFootprint, 'F');
});
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107);
@@ -235,10 +246,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;
};
@@ -247,21 +258,23 @@ window.HousePdf = (() => {
const n = { x: p.place.normal.x, z: p.place.normal.y };
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
const tr = p.trasraam;
if (tr && tr.top > y0) {
const W = p.place.width;
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
}
(p.config.openings || []).forEach(op => {
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return;
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
const detail = op.fill ? KozijnModel.viewLines(op, op.fill, exterior).map(pl => pl.map(q => toWorld(p, x0 + q.x, op.y + q.y, zOut))) : [];
faces.push({ kind: 'opening', pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true, detail });
});
};
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
@@ -278,11 +291,11 @@ window.HousePdf = (() => {
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
const len = Math.hypot(nx, ny, nz) || 1;
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
faces.push({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
};
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 };
}));
});
@@ -311,9 +324,20 @@ window.HousePdf = (() => {
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
}
faces.push({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
});
}
(parts.chimneys || []).forEach(ch => {
const S = ChimneyModel.SIDES[ch.side];
const W = (t, s, y) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, y, z: t } : { x: t, y, z: ch.plane + S.n.z * s };
const color = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
const tN = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 };
const side = (a, b, ya, n) => faces.push({ kind: 'schoorsteen', pts: [W(a.t, a.s, ya), W(b.t, b.s, ya), W(b.t, b.s, ch.yTop), W(a.t, a.s, ch.yTop)], n, color, bias: 4 });
side({ t: ch.t0, s: ch.sFront }, { t: ch.t1, s: ch.sFront }, ch.yGround, S.n);
side({ t: ch.t0, s: ch.sIn }, { t: ch.t0, s: ch.sFront }, ch.yGround, { x: -tN.x, z: -tN.z });
side({ t: ch.t1, s: ch.sIn }, { t: ch.t1, s: ch.sFront }, ch.yGround, tN);
side({ t: ch.t0, s: ch.sIn }, { t: ch.t1, s: ch.sIn }, ch.yUnder, { x: -S.n.x, z: -S.n.z });
});
return faces;
}
@@ -412,8 +436,8 @@ window.HousePdf = (() => {
});
}
function drawViewsSheet(doc, parts, state, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
// Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor.
function viewProjections(parts, state) {
const faces = viewFaces(parts, state);
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
const views = VIEWS.map(v => {
@@ -424,6 +448,12 @@ window.HousePdf = (() => {
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
}).filter(x => x.list.length);
return { groundY, views };
}
function drawViewsSheet(doc, parts, state, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
const { groundY, views } = viewProjections(parts, state);
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
@@ -457,7 +487,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 +504,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 +536,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);
@@ -579,6 +615,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'));
}
@@ -763,5 +803,5 @@ window.HousePdf = (() => {
return doc;
}
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
})();
+219 -44
View File
@@ -327,6 +327,8 @@ function mountHouseEditor(container, callbacks) {
const roofMaterials = RoofModel.createMaterials();
const lightingMaterials = LightingModel.createMaterials();
const sandwichMaterials = SandwichModel.createMaterials();
const postMaterials = PostModel.createMaterials();
const chimneyMaterials = ChimneyModel.createMaterials();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
@@ -461,9 +463,17 @@ function mountHouseEditor(container, callbacks) {
}
const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key);
// Klik op een schoorsteen: de wand waar hij aan hangt (daar staat het schoorsteenpaneel).
function chimneyWallHit(ch) {
const u = parts.units.find(q => q.id === ch.unitId), w = u && u.walls.find(q => q.side === ch.side);
const pc = w && w.pieces.find(p => !p.baseY);
return pc ? { type: 'wall', unit: u.id, side: ch.side, element: pc.element } : null;
}
function hitTest(x, z) {
const eh = elektraHit(x, z);
if (eh) return { type: 'elektra', id: eh.key };
const ch = ChimneyModel.hit(parts.chimneys || [], x, z, 0);
if (ch && chimneyWallHit(ch)) return chimneyWallHit(ch);
const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4));
if (iw) return { type: 'iw', id: iw.id };
for (const u of parts.units) {
@@ -561,6 +571,7 @@ function mountHouseEditor(container, callbacks) {
let roomHover = null;
let fxHover = null;
let fxDrag = null;
let chDrag = null;
function setTool(t) {
tool = t;
@@ -680,6 +691,38 @@ function mountHouseEditor(container, callbacks) {
refresh();
}
window.addEventListener('mouseup', onFixtureDrop);
// Schoorsteen slepen: alleen langs de buitengevel.
canvas.addEventListener('mousedown', (e) => {
if (tool !== 'select' || !parts || e.button !== 0 || elDrag || fxDrag) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0);
if (ch) chDrag = { id: ch.id, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
});
canvas.addEventListener('mousemove', (e) => {
if (!chDrag) return;
if (!chDrag.moved && Math.hypot(e.clientX - chDrag.sx, e.clientY - chDrag.sy) < 4) return;
chDrag.moved = true;
canvas.style.cursor = 'grabbing';
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = (parts.chimneys || []).find(c => c.id === chDrag.id);
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
drawPlan();
});
window.addEventListener('mouseup', () => {
if (!chDrag) return;
const d = chDrag;
chDrag = null;
canvas.style.cursor = 'default';
if (!d.moved || !d.snap) return;
suppressClick = true;
const cs = (state.chimneys || []).find(c => c.id === d.id);
if (cs) Object.assign(cs, { unitId: d.snap.unitId, side: d.snap.side, u: d.snap.u });
const u = parts.units.find(q => q.id === d.snap.unitId), wl = u && u.walls.find(q => q.side === d.snap.side), pc = wl && wl.pieces.find(p => !p.baseY);
if (pc) selection = { type: 'wall', unit: u.id, side: d.snap.side, element: pc.element };
refresh();
});
const newFixtureItem = (cx, cz, rot) => {
const T = FixtureModel.TYPES[fixtureType];
return { type: fixtureType, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}), rot: rot || 0, cx, cz };
@@ -843,7 +886,7 @@ function mountHouseEditor(container, callbacks) {
ctx.fill();
ctx.strokeStyle = '#8a6836'; ctx.lineWidth = 1;
ctx.stroke();
p.config.openings.forEach(op => {
p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w));
ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff');
ctx.fill();
@@ -975,6 +1018,28 @@ 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);
});
(parts.chimneys || []).forEach(ch => {
const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); };
const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null;
if (sn) {
// Voorbeeld tijdens het slepen: dezelfde diepte, op de nieuwe plek.
const S = ChimneyModel.SIDES[sn.side], W = (t, s) => S.axis === 'z' ? { x: sn.plane + S.n.x * s, z: t } : { x: t, z: sn.plane + S.n.z * s };
path([W(sn.t - ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sFront), W(sn.t - ch.w / 2, ch.sFront)]);
ctx.fillStyle = 'rgba(77, 166, 255, 0.45)'; ctx.fill(); ctx.strokeStyle = '#4da6ff'; ctx.stroke();
return;
}
path(ch.footprint);
ctx.fillStyle = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
ctx.fill(); ctx.strokeStyle = '#2b2b2b'; ctx.stroke();
path(ch.flueFootprint);
ctx.fillStyle = '#1b1a19'; ctx.fill();
});
ctx.restore();
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
@@ -1123,6 +1188,9 @@ function mountHouseEditor(container, callbacks) {
if (a === 'add-unit') addUnit();
else if (a === 'project') openProjectProperties();
else if (a === 'save') exportDesign();
else if (a === 'dxf') exportDxf();
else if (a === 'ifc') exportIfc();
else if (a === 'obj') exportObj();
else if (a === 'costs') CostSettings.open();
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
@@ -1228,7 +1296,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 en van een dubbel lessenaardak (gelijke goot en nok, samen een zadel); 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.') });
@@ -1238,6 +1306,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,11 +1326,17 @@ 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;
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('');
@@ -1392,7 +1472,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;
@@ -1422,6 +1503,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);
@@ -1466,7 +1548,10 @@ function mountHouseEditor(container, callbacks) {
key: 'rapport', label: 'Rapport', order: 60,
groups: () => [
{ label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') +
big('CAD (DXF)', 'vector-pen', 'data-ho-act="dxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG"') +
big('BIM (IFC)', 'boxes', 'data-ho-act="ifc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting"') +
big('Render (OBJ)', 'badge-3d', 'data-ho-act="obj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender"') },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') }
],
wire: (body) => { live = []; wireButtons(body); }
@@ -1754,10 +1839,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 = [
@@ -1768,12 +1849,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;
@@ -1789,13 +1866,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;
@@ -1806,13 +1876,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;
@@ -1832,8 +1898,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 };
@@ -1877,9 +1941,12 @@ 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', 'lessenaar'].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) });
@@ -1917,6 +1984,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);
@@ -1925,6 +1995,52 @@ function mountHouseEditor(container, callbacks) {
};
}
// Schoorstenen in het vlak van deze wand (ook als ze aan een andere unit hangen, bijv. over een naad).
function chimneysOnWall(pu, side) {
const r = pu.rect, plane = { voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 }[side];
const ids = (parts.chimneys || []).filter(c => c.side === side && Math.abs(c.plane - plane) < 1).map(c => c.id);
return (state.chimneys || []).filter(c => ids.includes(c.id));
}
function chimneyGroup(pu, wl, piece, e0) {
const list = chimneysOnWall(pu, wl.side);
const fin = (pre, label, f, types) => sel(pre, label, types, f.type) +
(f.type === 'steen' ? pair(sel(pre + '-stone', 'Steen', stoneOptions, f.stone), thumb(WallModel.stoneThumbUrl(f.stone, 64), f.stone)) : '') +
(f.type === 'stuc' ? pair(sel(pre + '-stuc', 'Kleur', stucColors, f.stuc), swatchOf(WallModel.stucHex(f.stuc))) : '');
const html = list.map(c => {
const r = (parts.chimneys || []).find(x => x.id === c.id);
const id = 'hw-ch' + c.id;
return kv('Schoorsteen', `${c.id} (unit ${c.unitId})`) +
num(id + '-u', 'Positie', c.u, { step: 10, hint: `Hart vanaf de linker vloerhoek van unit ${c.unitId}, van buitenaf gezien` }) +
num(id + '-w', 'Breedte', c.w, { step: 10, hint: 'Buitenwerks; de wand krijgt een sparing zoals een raam' }) +
num(id + '-out', 'Voor de gevel', c.out, { step: 10, hint: r ? `Minstens tot voorbij de dakoverstek: nu ${r.protrudes} mm voor de gevel` : '' }) +
num(id + '-above', 'Boven het dak', c.above, { step: 50, hint: r ? `Bovenkant ${r.yTop - r.yFloor} mm boven de vloer (hoogste dakpunt + dit)` : '' }) +
fin(id + '-out-type', 'Buiten', c.outside, ChimneyModel.OUTSIDE_TYPES) +
fin(id + '-in-type', 'Binnen', c.inside, ChimneyModel.INSIDE_TYPES) +
small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'rb-danger');
}).join('');
return { label: 'Schoorsteen', html: html + small('Schoorsteen toevoegen', 'plus-lg', `data-hw-ch="add" data-u="${Math.round(e0 + (piece.u0 + piece.u1) / 2)}"`) };
}
function wireChimneys(body, pu, wl) {
chimneysOnWall(pu, wl.side).forEach(c => {
const id = 'hw-ch' + c.id;
UI.onNum(id + '-u', v => { c.u = Math.round(v); }, after);
UI.onNum(id + '-w', v => { if (v >= 400) c.w = Math.round(v); }, after);
UI.onNum(id + '-out', v => { if (v >= 0) c.out = Math.round(v); }, after);
UI.onNum(id + '-above', v => { if (v >= 0) c.above = Math.round(v); }, after);
[['out', c.outside], ['in', c.inside]].forEach(([k, f]) => {
UI.onSel(`${id}-${k}-type`, v => { f.type = v; }, rerender);
UI.onSel(`${id}-${k}-type-stone`, v => { f.stone = v; }, rerender);
UI.onSel(`${id}-${k}-type-stuc`, v => { f.stuc = v; }, rerender);
});
});
body.querySelectorAll('[data-hw-ch]').forEach(b => b.addEventListener('click', () => {
state.chimneys = state.chimneys || [];
if (b.dataset.hwCh === 'add') state.chimneys.push(ChimneyModel.create(state.chimneys, pu.id, wl.side, Number(b.dataset.u)));
else state.chimneys = state.chimneys.filter(c => c.id !== Number(b.dataset.ch));
refresh();
}));
}
function wallTab() {
const pu = parts.units.find(x => x.id === selection.unit);
const wl = pu && pu.walls.find(x => x.side === selection.side);
@@ -1964,6 +2080,7 @@ function mountHouseEditor(container, callbacks) {
(own.facade && own.facade.type === 'steen' ? sel('hw-piece-stone', 'Steen', stoneOptions, own.facade.stone || fc.stone) : '') +
(own.facade && own.facade.type === 'stuc' ? sel('hw-piece-stuc', 'Kleur', stucColors, own.facade.stuc || fc.stuc) : '') +
small('Splitsing weg', 'x-lg', 'data-hw="unsplit"') : '') },
chimneyGroup(pu, wl, piece, e0),
{ label: 'Gevellampen', html: lt.mode === 'gevel'
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto',
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) +
@@ -1978,6 +2095,7 @@ function mountHouseEditor(container, callbacks) {
wireButtons(body);
const fc = HouseModel.facadeFor(state, su, wl.side);
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
wireChimneys(body, pu, wl);
UI.onSel('hw-facade', v => {
if (v === 'woning') delete su.facade.walls[wl.side];
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : { type: v };
@@ -2144,6 +2262,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', [
@@ -2186,23 +2310,19 @@ 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, chimneyMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
houseGroup = new THREE.Group();
planCaps = null;
const toM = 0.001;
parts.units.forEach(u => {
u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
houseGroup.add(u.floorGroup);
});
HouseModel.placeGroups(parts);
parts.units.forEach(u => houseGroup.add(u.floorGroup));
// Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
const section = view3D.section;
const clip = section ? [cutPlane] : [];
HouseModel.allWallPieces(parts).forEach(p => {
p.group.rotation.y = p.place.rotY;
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
p.group.traverse(o => {
if (!o.material) return;
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => {
@@ -2215,11 +2335,10 @@ function mountHouseEditor(container, callbacks) {
});
placeSection();
if (view3D.dak && !section) {
parts.units.forEach(u => {
u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
houseGroup.add(u.roof.group);
});
parts.units.forEach(u => houseGroup.add(u.roof.group));
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
if (parts.chimneysGroup) houseGroup.add(parts.chimneysGroup);
}
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
@@ -2275,7 +2394,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 });
@@ -2304,7 +2423,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);
@@ -2324,12 +2450,13 @@ function mountHouseEditor(container, callbacks) {
}
const WALK_RADIUS = 250;
const isWalkDoor = (op) => op.type === 'deur' || op.type === 'voordeur' ||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50;
const isWalkDoor = (op) => op.type !== 'schoorsteen' && (op.type === 'deur' || op.type === 'voordeur' ||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50);
function wallBlocked(xm, zm) {
if (!parts) return false;
const x = xm * 1000, z = zm * 1000;
if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true;
if ((parts.chimneys || []).length && ChimneyModel.covers(parts.chimneys, x, z, WALK_RADIUS)) return true;
return HouseModel.allWallPieces(parts).some(p => {
if (p.place.baseY) return false; // opbouw boven een lager dak
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
@@ -2614,6 +2741,51 @@ function mountHouseEditor(container, callbacks) {
return filename;
}
function exportAs(ext, title, run, done) {
flushActivePart();
const naam = String((state.project && state.project.naam) || '').replace(/[\\/:*?"<>|]+/g, '-').trim();
const suggested = (naam || 'woning') + '-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt(`Bestandsnaam voor de ${title}:`, suggested);
if (filename === null) { setIoStatus('Export geannuleerd.', ''); return null; }
filename = filename.trim() || suggested;
if (!new RegExp(`\\.${ext}$`, 'i').test(filename)) filename += '.' + ext;
try {
const res = run(filename);
setIoStatus(`Geëxporteerd als "${filename}" (${typeof done === 'function' ? done(res) : done}).`, 'ok');
} catch (err) {
setIoStatus(`${ext.toUpperCase()}-export mislukt: ${err.message}`, 'err');
}
return filename;
}
const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG');
const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra');
// Render-export: de volledige afgewerkte woning, los van de gekozen bouwfase, doorsnede en weergave-opties.
// Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong.
const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen, ZIP)', (filename) => {
const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials();
if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
const full = HouseModel.computeParts(state, {
wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(),
sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(),
elektraMaterials: ElektraModel.createMaterials(), stage: BuildStages.FINAL, lampenVisible: true, elektraVisible: true
});
HouseModel.placeGroups(full);
if (full.roomsGroup && full.roomsGroup.userData.ceilings) full.roomsGroup.userData.ceilings.visible = true;
const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group),
[full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean);
const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1));
const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1));
const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
const out = ObjWriter.build(roots, {
name: filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning', app: APP_VERSION,
origin: { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 }
});
ZipWriter.save(out.files, filename);
return out.stats;
}, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen; uitpakken en in Blender importeren`);
function triggerImport() {
document.getElementById('h-file-import-json').click();
}
@@ -2659,6 +2831,9 @@ function mountHouseEditor(container, callbacks) {
activeEditor = null;
},
exportDesign,
exportDxf,
exportIfc,
exportObj,
triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'),
toggleFullscreen3D: forward('toggleFullscreen3D'),
+42 -16
View File
@@ -10,7 +10,7 @@ window.LightingModel = (() => {
mode: 'spots',
direction: 'both',
lampHeight: 1950,
lampMin: 1500,
lampMin: 500,
lampMax: 3000,
minWallWidth: 2000,
endOffset: 300,
@@ -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 };
})();
+310 -86
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) {
@@ -125,18 +125,8 @@ window.RoofModel = (() => {
deck.position.y = deckH;
group.add(deck);
const wM = calcData.width * toM, dM = calcData.depth * toM;
const shape = new THREE.Shape();
shape.moveTo(-wM / 2, -dM / 2);
shape.lineTo(wM / 2, -dM / 2);
shape.lineTo(wM / 2, dM / 2);
shape.lineTo(-wM / 2, dM / 2);
shape.closePath();
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
const dM = calcData.depth * toM;
const shape = FloorModel.surfaceShapes(calcData, resolvedOpenings, toM);
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
const bT = BESCHOT_MM * toM;
@@ -212,7 +202,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 +257,11 @@ 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).
// cuts: sparingen (schoorsteen) in plattegrondmaten; ligt er een op de nok, dan stopt de balk bear mm erin
// (negatief: ervoor).
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span, cuts) {
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 +276,13 @@ 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]); }
(cuts || []).forEach(c => {
const [pa, pb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1], [qa, qb] = alongX ? [c.z0, c.z1] : [c.x0, c.x1];
if (R.p < pa - 100 || R.p > pb + 100 || qb < q0 || qa > q1) return;
if ((qa + qb) / 2 > (q0 + q1) / 2) q1 = Math.min(q1, qa + (c.bear || 0));
else q0 = Math.max(q0, qb - (c.bear || 0));
});
const L = q1 - q0;
if (L < 100) return null;
// q in N/mm = kN/m.
@@ -348,7 +348,9 @@ window.RoofModel = (() => {
return out;
}
function computeUnitRoof(roofState, shape, rect, rims) {
// cuts: rechthoekige sparingen (schoorsteen) in plattegrondmaten van de unit: { x0, x1, z0, z1, bear }.
function computeUnitRoof(roofState, shape, rect, rims, cutRects) {
const roofCuts = cutRects || [];
const alongX = shape.axis === 'x';
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
@@ -370,10 +372,18 @@ window.RoofModel = (() => {
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
let data;
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) {
const deckCuts = roofCuts.map(c => {
const [qa, qb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1];
const a = Math.max(qa, p0), b = Math.min(qb, p1);
if (b - a < 1) return null;
const [x0, x1] = alongX ? [zc - c.z1, zc - c.z0] : [c.x0 - xc, c.x1 - xc];
return { x0, x1, y0: (a - p0) / cos, y1: (b - p0) / cos };
}).filter(Boolean);
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5 && !deckCuts.length) {
data = flat;
} else {
const deck = clone(roofState.deck);
deck.cuts = deckCuts;
deck.floor.width = Math.round(alongX ? D : W);
deck.floor.depth = Math.round(slopeLen);
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
@@ -393,7 +403,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) => {
@@ -417,12 +427,8 @@ window.RoofModel = (() => {
topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
}
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;
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6), null, roofCuts);
addRidgeSpecs(specs, ridgeBeam);
return {
shape, covering, planes: outPlanes, specs, ridgeBeam,
undersideAt, topAt, cap, rect, alongX,
@@ -431,6 +437,200 @@ window.RoofModel = (() => {
};
}
// Stalen nok onder de naad van een dubbel lessenaardak (HouseModel): shape is het samengestelde zadel en rect
// de twee units samen, beide in de lokale maten van deze unit; de balk hoort bij deze unit (3D, kosten).
function sharedRidgeBeam(roofState, unitRoof, shape, rect, span, cuts) {
const pl = unitRoof.planes[0];
if (!pl || !pl.data) return null;
const r = unitRoof.rect, kgM2 = unitRoof.specs.totalKg / Math.max(0.1, (r.x1 - r.x0) * (r.z1 - r.z0) / 1e6);
const rb = computeRidgeBeam(roofState, shape, rect, shape.axis === 'x', pl.data.calcData, kgM2, span, cuts);
if (!rb) return null;
rb.alongX = shape.axis === 'x';
rb.shared = true;
unitRoof.ridgeBeam = rb;
addRidgeSpecs(unitRoof.specs, rb);
return rb;
}
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 +665,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 +688,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 +701,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 +771,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 +794,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 +861,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);
@@ -855,6 +1069,12 @@ window.RoofModel = (() => {
const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value;
polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3);
});
// Schoorsteen: de rand stopt ertegen (gaps langs de rand, in wereld-mm).
const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5;
(sg.gaps || []).forEach(([g0, g1]) => {
const t = (p) => alongZ ? p.z : p.x;
polys = polys.flatMap(pl => [clipPoly(pl, p => g0 - t(p)), clipPoly(pl, p => t(p) - g1)]).filter(pl => pl.length >= 3);
});
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
polys.forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
@@ -879,7 +1099,11 @@ 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 };
s.posts = PostModel.normalizeSettings(s.posts);
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
return s;
}
@@ -887,7 +1111,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, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings
};
})();
+2 -1
View File
@@ -200,7 +200,8 @@ window.RoomModel = (() => {
const la = Math.max(-W / 2, Math.min(toLx(e.s0), toLx(e.s1))), lb = Math.min(W / 2, Math.max(toLx(e.s0), toLx(e.s1)));
if (lb - la < 1) return { quads: [], area: 0 };
const hiAt = (lx) => Math.min(tl.at(lx), H);
const ops = (cfg.openings || []).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) }));
// Schoorsteen met binnenkant 'zoals de wanden': de afwerking van de ruimte loopt eroverheen.
const ops = (cfg.openings || []).filter(op => !(op.chimney && op.chimney.inside === 'wand')).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) }));
const bs = new Set([la, lb]);
tl.pts.forEach(pt => bs.add(pt.x));
for (let i = 0; i < tl.pts.length - 1; i++) {
+14 -21
View File
@@ -200,7 +200,7 @@ window.UnitModel = (() => {
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, chimney: op.chimney, koppel: op.koppel, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
}, topsIn(a, b, 0));
@@ -223,7 +223,7 @@ window.UnitModel = (() => {
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, chimney: op.chimney, koppel: op.koppel, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
}, topsIn(a, b, base));
@@ -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 };
})();
+114 -43
View File
@@ -92,6 +92,11 @@ window.KozijnModel = (() => {
});
return out;
}
// >= 0 onder de bovenrand q[3]-q[2] van een vierhoek.
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
@@ -134,6 +139,11 @@ window.KozijnModel = (() => {
g.add(mesh);
};
// Gekoppelde zijde (op.joinL/joinR, zie WallModel.resolveOpenings): geen speling en geen kaderstijl;
// 'stijl' = de helft van een tussenstijl die het kozijn ernaast afmaakt, 'stolp' = niets (bladen tegen elkaar).
const sideGap = (join) => join ? 0 : P.gap;
const sideKader = (join) => join === 'stolp' ? 0 : join ? P.tussen / 2 : P.kader;
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
function build3D(op, fill, place) {
const f = normalize(fill);
@@ -143,15 +153,12 @@ window.KozijnModel = (() => {
const depth = Math.min(P.depth, place.depth || P.depth);
const z1 = place.zOut, z0 = z1 - depth;
const hR = op.hR !== undefined ? op.hR : op.h;
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]);
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
slab(g, outer, [inner], z0, z1, mIn, mOut);
let pane = inner;
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
@@ -171,8 +178,8 @@ window.KozijnModel = (() => {
const sill = place.exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
const u = [...(kL ? [frameOuter[0], inner[0]] : []), inner[3], inner[2], ...(kR ? [inner[1], frameOuter[1]] : []), frameOuter[2], frameOuter[3]];
slab(g, u, null, z0, z1, mIn, mOut);
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
@@ -187,12 +194,9 @@ window.KozijnModel = (() => {
return g;
}
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const lowTop = Math.min(topAt(ix0), topAt(ix1));
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
const ly1 = transom ? iy0 + P.leafH : Infinity;
const hingeR = f.hinge === 'rechts';
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
const { side, leafR, la, lb, transom, ly1 } = d;
if (side) {
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
bar(region(t0, t0 + P.tussen, iy0, ly1));
@@ -204,20 +208,10 @@ window.KozijnModel = (() => {
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
const leaf = clipPoly(rect(la + 3, lb - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
if (leaf.length < 3) return g;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
const L = doorLeaf(f, d, iy0, sill, top, topAt);
if (!L) return g;
const { leaf, lx0, lxR, lb0, lt, lw, holes } = L;
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
let holes = null;
const lw = lxR - lx0;
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
}
const outward = f.swing === 'buiten';
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
const pivot = new THREE.Group(), lg = new THREE.Group();
@@ -245,28 +239,64 @@ window.KozijnModel = (() => {
}
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
function buildSlideLeaves(g, f, o) {
// Deurblad en zij-/bovenlicht binnen de kaderopening ix0..ix1 vanaf iy0 (lowTop = laagste bovenkant).
function doorSplit(f, ix0, ix1, iy0, lowTop) {
const leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
return { side, leafR, la, lb, transom, ly1: transom ? iy0 + P.leafH : Infinity };
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer) met het glas erin.
function doorLeaf(f, d, iy0, sill, top, topAt) {
const leaf = clipPoly(rect(d.la + 3, d.lb - 3, iy0 + (sill ? 3 : 10), Math.min(d.ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
if (leaf.length < 3) return null;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), d.ly1) - 3;
const lw = lxR - lx0;
let holes = null;
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = f.hinge === 'rechts' ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)];
}
return { leaf, lx0, lxR, lb0, lt, lw, holes };
}
function slideLeaves(f, ix0, ix1, iy0, sill, topAt) {
const n = f.leaves, opbouw = f.mount === 'opbouw';
const ov = opbouw ? SLIDE.overlap : -3;
const xa = ix0 - ov, xb = ix1 + ov;
const yb = iy0 + (sill ? 3 : 10);
const yt = Math.min(topAt(ix0), topAt(ix1)) + (opbouw ? SLIDE.overlap : -3);
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
const leaves = dirs.map((dir, i) => ({
dir, x0: xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1: xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0)
}));
const pane = (l) => f.glas && l.x1 - l.x0 > 2 * SLIDE.frame + 100 ? rect(l.x0 + SLIDE.frame, l.x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame) : null;
return { n, opbouw, xa, xb, yb, yt, lw, dirs, leaves, pane };
}
function buildSlideLeaves(g, f, o) {
const S = slideLeaves(f, o.ix0, o.ix1, o.iy0, o.sill, o.topAt);
const { n, opbouw, xa, xb, yb, yt, lw, dirs } = S;
const out = f.side === 'buiten';
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
const ov = opbouw ? SLIDE.overlap : -3;
const xa = o.ix0 - ov, xb = o.ix1 + ov;
const yb = o.iy0 + (o.sill ? 3 : 10);
const yt = Math.min(o.topAt(o.ix0), o.topAt(o.ix1)) + (opbouw ? SLIDE.overlap : -3);
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [lz0, lz1] = opbouw
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
dirs.forEach((dir, i) => {
const x0 = xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1 = xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0);
S.leaves.forEach(l => {
const { dir, x0, x1 } = l;
const lg = new THREE.Group();
g.add(lg);
const pts = rect(x0, x1, yb, yt);
if (f.glas && x1 - x0 > 2 * SLIDE.frame + 100) {
const pane = rect(x0 + SLIDE.frame, x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame);
const pane = S.pane(l);
if (pane) {
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
} else {
@@ -291,12 +321,11 @@ window.KozijnModel = (() => {
const f = normalize(fill);
if (!f || f.type === 'raam') return [];
const zIn = faces.zIn, zOut = faces.zOut;
const ix0 = P.gap + P.kader, ix1 = op.w - P.gap - P.kader;
const ix0 = sideGap(op.joinL) + sideKader(op.joinL), ix1 = op.w - sideGap(op.joinR) - sideKader(op.joinR);
const out = [];
if (f.type === 'deur') {
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const hingeR = f.hinge === 'rechts';
const { la, lb } = doorSplit(f, ix0, ix1, 0, 0);
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 1 : -1;
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
@@ -326,5 +355,47 @@ window.KozijnModel = (() => {
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, colorMaterial: colorMat };
// Aanzicht van buiten, lokaal als build3D (x vanaf links, y vanaf de onderkant van de opening): gesloten omtrekken.
function viewLines(op, fill, exterior) {
const f = normalize(fill);
if (!f) return [];
const hR = op.hR !== undefined ? op.hR : op.h;
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
const out = [outer];
const add = (pts) => { if (pts && pts.length > 2 && areaOf(pts) > 1) out.push(pts); };
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
add(inner);
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) add(insetPoly(inner, [0, 0, P.rooster, 0]));
return out;
}
const sill = exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
if (sill) add(frameOuter);
add(inner);
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
const top = below(inner);
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
if (f.type === 'schuifdeur') {
const S = slideLeaves(f, ix0, ix1, iy0, sill, topAt);
if (!S.opbouw || f.side === 'buiten') S.leaves.forEach(l => { add(region(l.x0, l.x1, S.yb, S.yt)); add(S.pane(l)); });
return out;
}
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
if (d.side) {
const t0 = d.leafR ? d.la - P.tussen : d.lb;
add(region(t0, t0 + P.tussen, iy0, d.ly1));
const [g0, g1] = d.leafR ? [ix0, d.la - P.tussen] : [d.lb + P.tussen, ix1];
add(region(g0, g1, iy0, iy0 + P.kader));
}
if (d.transom) add(region(ix0, ix1, d.ly1, d.ly1 + P.tussen));
const L = doorLeaf(f, d, iy0, sill, top, topAt);
if (L) { add(L.leaf); (L.holes || []).forEach(add); }
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, viewLines, colorMaterial: colorMat };
})();
+118 -44
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) => [
@@ -309,20 +320,26 @@ window.WallModel = (() => {
const edgeMargin = studWidth * CORNER_STUDS;
let resolved = rawOpenings.map(raw => {
const w = Math.min(Math.max(400, raw.w), maxOpW);
// Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 400, raw.w), maxOpW);
let x;
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
if (raw.chimney) o.chimney = raw.chimney;
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
return o;
});
resolved.sort((a, b) => a.x - b.x);
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
resolved.forEach(o => {
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin));
o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
});
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
@@ -336,22 +353,42 @@ window.WallModel = (() => {
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
const FAR = 'staat niet tegen de opening ernaast aan';
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
resolved.forEach((a, i) => {
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
let why = null;
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
if (why) { blame(a, b, why); return; }
rightOf.set(a, b);
leftOf.set(b, a);
});
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
for (let i = 1; i < resolved.length; i++) {
const cur = resolved[i];
const cur = resolved[i], partner = leftOf.get(cur);
if (partner) cur.x = partner.x + partner.w;
for (let j = 0; j < i; j++) {
const prev = resolved[j];
if (stacked(prev, cur)) continue;
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur));
cur.x = Math.max(cur.x, prev.x + prev.w + gap(prev, cur));
}
}
const rightLimit = wallWidth - edgeMargin;
for (let i = resolved.length - 1; i >= 0; i--) {
const cur = resolved[i];
let maxX = rightLimit - cur.w;
let maxX = wallWidth - mR(cur) - cur.w;
for (let j = i + 1; j < resolved.length; j++) {
const next = resolved[j];
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w);
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gap(cur, next) - cur.w);
}
if (cur.x > maxX) cur.x = maxX;
}
@@ -359,22 +396,34 @@ window.WallModel = (() => {
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
resolved.forEach(o => {
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
// Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = hL;
o.hR = hR;
});
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
const swing = (o) => !!o.fill && o.fill.type === 'deur';
rightOf.forEach((b, a) => {
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
});
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
resolved.forEach(o => {
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5);
o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 0.5);
});
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i], b = resolved[j];
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
if (!stacked(a, b, true) && r.x < l.x + l.w + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
}
}
@@ -453,10 +502,14 @@ window.WallModel = (() => {
});
const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
ops.forEach(op => {
const x0 = startX + op.x, x1 = x0 + op.w;
cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' });
if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' });
if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
else cands.push({ x: x1, kind: 'kreupel', seam: true });
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
});
const cols = [];
@@ -478,16 +531,17 @@ window.WallModel = (() => {
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
const segs = [];
cands.forEach(c => {
cands.filter(inWall).forEach(c => {
const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => {
if (c.kind === 'kreupel') {
if (c.kind === 'kreupel' && !c.seam) {
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
@@ -524,16 +578,20 @@ window.WallModel = (() => {
const roofSafetyMargin = 10;
const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - sw;
const hx1 = -halfW + op.x + op.w + sw;
const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
// Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
const m = op.type === 'schoorsteen' ? 0 : sw;
// Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
const hx0 = -halfW + op.x - mL;
const hx1 = -halfW + op.x + op.w + mR;
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
const clampTop = (y, x) => {
const ceil = topYAt(x) - roofSafetyMargin;
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
};
const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r };
const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
});
}
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
@@ -593,7 +651,7 @@ window.WallModel = (() => {
const fixed = [lo, hi];
holes.forEach(h => {
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); });
[!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
});
fixed.sort((a, b) => a - b);
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
@@ -769,16 +827,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).
@@ -789,7 +841,8 @@ window.WallModel = (() => {
yMin: op.y * toM,
yMax: (op.y + op.h) * toM,
yMaxR: (op.y + op.hR) * toM,
wM: op.w * toM
wM: op.w * toM,
chimney: op.type === 'schoorsteen'
}));
const floorCuts = opsM
@@ -820,7 +873,21 @@ window.WallModel = (() => {
horizBeamCount++;
}
topPlateOutlines(tl).forEach(pts => {
// Bovenregel onderbroken waar een schoorsteen doorloopt.
const clipX = (pts, x, keepBelow) => {
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
pts.forEach((p, k) => {
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
if (fp >= 0) out.push(p);
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
});
return out;
};
let plates = topPlateOutlines(tl);
opsM.filter(op => op.chimney).forEach(op => {
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
});
plates.forEach(pts => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++;
});
@@ -858,6 +925,7 @@ window.WallModel = (() => {
});
opsM.forEach(op => {
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax);
@@ -940,7 +1008,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 +1286,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)));
@@ -1277,7 +1347,7 @@ window.WallModel = (() => {
const holes = sheathingHoles(CONFIG);
CONFIG.openings.forEach((op, i) => {
const h = holes[i];
if (!h) return;
if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
const hTop = (x) => holeTopAt(h, x);
@@ -1288,24 +1358,28 @@ window.WallModel = (() => {
].filter(pts => area(pts) > 1);
const fill = KozijnModel.normalize(op.fill);
const isWindow = op.y >= CONFIG.plateThick;
// Gekoppelde zijde: geen dagkant; vensterbank en waterslag lopen door tot de naad.
const over = (d) => [op.joinL ? x0 : x0 - d, op.joinR ? x1 : x1 + d];
if (finishIn) {
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
if (zB !== null) {
const boards = [
[{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
[{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
!op.joinL && [{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
!op.joinR && [{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
];
].filter(Boolean);
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
if (isWindow) prism([{ x: x0 - 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 + 5 }, { x: x0 - 30, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
const [sa, sb] = over(30);
if (isWindow) prism([{ x: sa, y: y0 - SILL + 5 }, { x: sb, y: y0 - SILL + 5 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
}
}
if (finishOut) {
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
if (isWindow && fill) prism([{ x: x0 - 20, y: y0 - 25 }, { x: x1 + 20, y: y0 - 25 }, { x: x1 + 20, y: y0 + 5 }, { x: x0 - 20, y: y0 + 5 }],
const [sa, sb] = over(20);
if (isWindow && fill) prism([{ x: sa, y: y0 - 25 }, { x: sb, y: y0 - 25 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }],
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
}
});
@@ -1324,7 +1398,7 @@ window.WallModel = (() => {
// Dubbelzijdig: de schuine regel heeft geen vaste oriëntatie.
const wedgeMats = new WeakMap();
function wedgeMaterial(wood) {
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; wedgeMats.set(wood, m); }
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; m.userData.base = wood; wedgeMats.set(wood, m); }
return wedgeMats.get(wood);
}
@@ -1546,7 +1620,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,
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs
};
+11 -8
View File
@@ -424,7 +424,7 @@ function mountWallEditor(container, callbacks) {
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') +
pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar, bladen naar het midden, scharnieren buiten') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar en gekoppeld, bladen naar het midden, scharnieren buiten') +
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
@@ -499,6 +499,10 @@ function mountWallEditor(container, callbacks) {
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
<div class="control-group" title="Tegen de opening ernaast aan: één sparing zonder stijlen, dagkanten of kozijnstijlen ertussen. Twee draaideuren worden openslaand (bladen tegen elkaar), anders één tussenstijl. Onderkant en hoogte gelijk aan de buur; samen max. 2850 mm.">
<label>Koppelen</label>
<div><label class="ui-switch"><input type="checkbox" data-field="koppel" data-id="${op.id}" ${op.koppel ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div>
</div>
@@ -543,6 +547,8 @@ function mountWallEditor(container, callbacks) {
fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value;
if (key === 'leaves' || key === 'mount') renderOpeningsList();
} else if (field === 'koppel') {
if (e.target.checked) op.koppel = true; else delete op.koppel;
} else if (field === 'sloped') {
if (e.target.checked) op.hR = op.h; else delete op.hR;
renderOpeningsList();
@@ -730,12 +736,9 @@ function mountWallEditor(container, callbacks) {
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
if (!card || !warnEl) return;
card.classList.toggle('opening-warn', !!o.overflowing);
if (o.overflowing) {
warnEl.style.display = 'block';
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
} else {
warnEl.style.display = 'none';
}
const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : '';
warnEl.style.display = msg ? 'block' : 'none';
warnEl.textContent = msg;
});
}
@@ -1380,7 +1383,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,
+1
View File
@@ -40,6 +40,7 @@ dak.daktrimMontage;Montage daktrim;s/m;300
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
1 code omschrijving eenheid waarde
40 dak.nokPlaatsen Stalen nokbalk plaatsen, stellen en vastzetten s 2700
41 dak.nokVulhoutMontage Vulhout in de holling van de nokbalk aanbrengen (beide zijden) s/m 240
42 dak.nokPersoneel Personeel bij plaatsen nokbalk (i.p.v. de bezetting) man 3
43 dak.paalMontage Paal plaatsen: stellen, aftoppen, vastzetten aan het dak s/st. 2700
44 vloer.afwerkingMontage Montage vloerafwerking s/m² 1200
45 vloer.sandwichMontage Montage per sandwichpaneel s 600
46 afwerking.valsPlafondMontage Montage vals plafond s/m² 1800
+2
View File
@@ -21,6 +21,8 @@ dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
dak.daktrimM;Daktrim;€/m;12;;;
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
1 code omschrijving eenheid prijs leverancier artikelnummer bijgewerkt
21 dak.daktrimM Daktrim €/m 12
22 dak.staalKg Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond) €/kg 2,9
23 dak.nokOplegging Oplegging nokbalk (stalen schoen + bouten), 2 per balk €/st. 45
24 dak.paalHoutM3 Paalhout overkapping (eiken, geschaafd) €/m³ 2400
25 dak.paalvoet Paalvoet verzinkt, verstelbaar (incl. ankers) €/st. 40
26 vloer.afwerkingM2 Vloerafwerking €/m² 35
27 vloer.sandwichM2 Sandwichpaneel €/m² 35
28 afwerking.valsPlafondM2 Vals plafond (regelwerk + gipsplaat, afgewerkt) €/m² 38