diff --git a/README.md b/README.md index 609a20a..6bc0a45 100644 --- a/README.md +++ b/README.md @@ -22,18 +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) @@ -86,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 @@ -105,6 +116,11 @@ assets/ logo, texturen, skybox 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. Het wolfseind schaalt mee met goot en @@ -228,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) @@ -274,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" @@ -303,7 +375,7 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026 (`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 - donkere schoorsteenstrook in de oostgevel, kookeiland, logeerkamer en + 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 diff --git a/index.html b/index.html index 988c781..ad58dad 100644 --- a/index.html +++ b/index.html @@ -91,6 +91,10 @@ + + + + @@ -106,6 +110,7 @@ + @@ -113,6 +118,8 @@ + + diff --git a/js/app.js b/js/app.js index 2255d6b..15f043e 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.6.3'; +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"') + diff --git a/js/core/dxf-writer.js b/js/core/dxf-writer.js new file mode 100644 index 0000000..59a3927 --- /dev/null +++ b/js/core/dxf-writer.js @@ -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 }; +})(); diff --git a/js/core/ifc-writer.js b/js/core/ifc-writer.js new file mode 100644 index 0000000..77c7f27 --- /dev/null +++ b/js/core/ifc-writer.js @@ -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 }; +})(); diff --git a/js/core/obj-writer.js b/js/core/obj-writer.js new file mode 100644 index 0000000..f4d3416 --- /dev/null +++ b/js/core/obj-writer.js @@ -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 }; +})(); diff --git a/js/core/zip-writer.js b/js/core/zip-writer.js new file mode 100644 index 0000000..7d47477 --- /dev/null +++ b/js/core/zip-writer.js @@ -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 }; +})(); diff --git a/js/cost/cost-model.js b/js/cost/cost-model.js index d948155..7fcbf0b 100644 --- a/js/cost/cost-model.js +++ b/js/cost/cost-model.js @@ -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'})`, diff --git a/js/elektra/elektra-model.js b/js/elektra/elektra-model.js index ca09e7f..05b33e3 100644 --- a/js/elektra/elektra-model.js +++ b/js/elektra/elektra-model.js @@ -767,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; @@ -779,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); }); }); diff --git a/js/floor/floor-model.js b/js/floor/floor-model.js index 2490fd8..3eb7cb9 100644 --- a/js/floor/floor-model.js +++ b/js/floor/floor-model.js @@ -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 }; })(); diff --git a/js/house/chimney-model.js b/js/house/chimney-model.js new file mode 100644 index 0000000..5c5bad2 --- /dev/null +++ b/js/house/chimney-model.js @@ -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 }; +})(); diff --git a/js/house/house-dxf.js b/js/house/house-dxf.js new file mode 100644 index 0000000..6f9fcb7 --- /dev/null +++ b/js/house/house-dxf.js @@ -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 }; +})(); diff --git a/js/house/house-ifc.js b/js/house/house-ifc.js new file mode 100644 index 0000000..e999abb --- /dev/null +++ b/js/house/house-ifc.js @@ -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 }; +})(); diff --git a/js/house/house-model.js b/js/house/house-model.js index 5c98cf2..0510230 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -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; } @@ -556,7 +556,7 @@ window.HouseModel = (() => { // een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die // op de naad de nok vormen (een woningdak zou ook de vleugel overspannen); de vleugel heeft een plat dak op // goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand. - // Steenstrips rondom, in de oostgevel een donkere strook waar de schoorsteen staat. Maten buitenwerks vanaf de + // 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'); @@ -582,7 +582,9 @@ window.HouseModel = (() => { ['raam', 500, 1400, 700, 'right', 3630, K('raam')], ['raam', 1200, 500, 1600, 'right', 4760, K('raam')], ['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]); - ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', 785, K('raam')]]); + // 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 })]]); @@ -593,10 +595,10 @@ window.HouseModel = (() => { ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, K('raam')]]); ops(C.unit.walls.rechts, [['voordeur', 1100, 2100, 0, 'right', 70, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]); ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]); - // Schoorsteen midden in de oostgevel (z 2870–3670). u loopt van buiten gezien van links (zuid) naar rechts. - A.facade.walls.rechts = { type: 'steen', stone: SCHOORSTEEN }; - A.unit.walls.rechts.splits = [{ u: 400, facade: { type: 'steen', stone: BRICK } }]; - B.unit.walls.rechts.splits = [{ u: 2870, facade: { type: 'steen', stone: SCHOORSTEEN } }]; + // 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. @@ -1096,6 +1098,40 @@ window.HouseModel = (() => { 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); @@ -1206,6 +1242,15 @@ window.HouseModel = (() => { 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, skinTop) { if (!build) return { group: null, stats: null, specs: null }; @@ -1217,7 +1262,9 @@ window.HouseModel = (() => { } 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); @@ -1269,7 +1316,16 @@ window.HouseModel = (() => { planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })), expr: joins[u.id].expr }; - const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims); + const 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', wolf: 'wolfsdak' }[shape.type]; const multi = ur.planes.length > 1; @@ -1477,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); } @@ -1518,7 +1574,10 @@ window.HouseModel = (() => { 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)) }; @@ -1619,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; @@ -1632,9 +1694,12 @@ window.HouseModel = (() => { const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null; if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra'); - const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0; const posts = PostModel.compute(state.settings.roof.posts, units, groundY); const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null; + const 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; @@ -1648,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, posts, postsGroup, 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) { @@ -1911,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. @@ -1924,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 || {}); @@ -1977,6 +2058,6 @@ window.HouseModel = (() => { clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls, houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX, - computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize + computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize }; })(); diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index d03fc32..7a13143 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -87,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)); @@ -139,6 +139,12 @@ window.HousePdf = (() => { 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); @@ -252,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)))); @@ -283,7 +291,7 @@ 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 => { @@ -316,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; } @@ -417,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 => { @@ -429,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; @@ -778,5 +803,5 @@ window.HousePdf = (() => { return doc; } - return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS }; + return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS }; })(); diff --git a/js/house/house.js b/js/house/house.js index 3f410be..4feac08 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -328,6 +328,7 @@ function mountHouseEditor(container, callbacks) { 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); @@ -462,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) { @@ -562,6 +571,7 @@ function mountHouseEditor(container, callbacks) { let roomHover = null; let fxHover = null; let fxDrag = null; + let chDrag = null; function setTool(t) { tool = t; @@ -681,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 }; @@ -844,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(); @@ -982,6 +1024,22 @@ function mountHouseEditor(container, callbacks) { 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; @@ -1130,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); @@ -1235,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- 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.') }); @@ -1487,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); } @@ -1877,7 +1941,7 @@ function mountHouseEditor(container, callbacks) { pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }), thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) + toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) + - (pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', '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: @@ -1931,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); @@ -1970,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.` }) + @@ -1984,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 }; @@ -2198,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, postMaterials, 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 => { @@ -2227,12 +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); @@ -2344,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); @@ -2634,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(); } @@ -2679,6 +2831,9 @@ function mountHouseEditor(container, callbacks) { activeEditor = null; }, exportDesign, + exportDxf, + exportIfc, + exportObj, triggerImport, mirrorHorizontal: forward('mirrorHorizontal'), toggleFullscreen3D: forward('toggleFullscreen3D'), diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index f824292..f513869 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -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; @@ -269,7 +259,9 @@ window.RoofModel = (() => { }; const ridgeTypes = ['zadel', 'wolf']; // span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper). - function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span) { + // 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; @@ -285,6 +277,12 @@ window.RoofModel = (() => { 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. @@ -350,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; @@ -372,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 => ({ @@ -419,7 +427,7 @@ 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)); + 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, @@ -429,6 +437,21 @@ 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, @@ -1046,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); @@ -1082,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, computeJoinedRoof, joinValue, buildUnit3D, capRegions, + computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions, computeEdge, buildEdge3D, normalizeSettings }; })(); diff --git a/js/room/room-model.js b/js/room/room-model.js index 2f7a077..e44630b 100644 --- a/js/room/room-model.js +++ b/js/room/room-model.js @@ -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++) { diff --git a/js/unit/unit-model.js b/js/unit/unit-model.js index 511f9a2..a53f314 100644 --- a/js/unit/unit-model.js +++ b/js/unit/unit-model.js @@ -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)); diff --git a/js/wall/kozijn-model.js b/js/wall/kozijn-model.js index 7c4c8f5..a9d02a4 100644 --- a/js/wall/kozijn-model.js +++ b/js/wall/kozijn-model.js @@ -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 }; })(); diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index 34ff34e..d3a32cb 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -320,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. @@ -347,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; } @@ -370,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; } } } @@ -464,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 = []; @@ -489,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; @@ -535,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; @@ -604,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); @@ -794,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 @@ -825,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++; }); @@ -863,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); @@ -1284,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); @@ -1295,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); } }); @@ -1331,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); } @@ -1553,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, skinConfig, 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 }; diff --git a/js/wall/wall.js b/js/wall/wall.js index 3709a2d..c984aac 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -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) {
+
+ +
+
${isSloped(op) ? `` : ''} @@ -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; }); }