/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // wall/wall-model.js — ENIGE bron van waarheid voor de wand-logica: // ankerresolutie van openingen, regelwerk (stijlen), 3D-opbouw en de // specificatie-berekening. Geen DOM. Wordt gebruikt door: // - wall/wall.js (de losse wand-editor) // - unit/unit.js (unit = vloer + 4 wanden) // Wijzigingen aan de wand-logica horen dus HIER, zodat ze automatisch // voor zowel de losse wand als de unit gelden. // // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): // { wall: {width, heightAll, heightLeft, heightRight, heightMid?, topPts?, studWidth, ...}, // materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset,fill?}] } // fill = vulling van de opening (kozijn, zie wall/kozijn-model.js). // // Plain script (geen ES-module), moet na core/geometry.js geladen worden. // ============================================================ window.WallModel = (() => { // Referentiedichtheden — hier hardcoded aanpasbaar (geen UI-velden meer, // zoals afgesproken). Spaanplaat (binnenzijde) en cement-gebonden platen // (optie voor de buitenzijde) gebruiken hetzelfde kg/m3 x volume-patroon // als voorheen "OSB"; vochtdoek (andere buitenzijde-optie) blijft een // kg/m2-membraan. const DENSITY_WOOD_KGM3 = 450; const DENSITY_SPAANPLAAT_KGM3 = 650; const DENSITY_CEMENTPLAAT_KGM3 = 1250; const DENSITY_DOEK_KGM2 = 1.5; // Klein, bewust niet-nul luchtgaatje tussen plaat en houtwerk (zie // build3D). Ook gebruikt voor de totale wanddikte (thicknessOf). const SKIN_GAP_MM = 0.5; // Standaard muurbalk: 38 x 235 mm (dikte x diepte), ingekocht in deze // handelslengtes. Het zaagplan (planPurchase) kiest per staaf de lengte // met zo min mogelijk snijverlies. const STANDARD_BEAM = { width: 38, depth: 235 }; const STOCK_LENGTHS_MM = [3600, 4200, 4800, 5400]; // Hoeken: elke wandkop bestaat uit 2 stijlen tegen elkaar (38 + 38 mm). const CORNER_STUDS = 2; // Latwerk op het vochtdoek voor de gevelbekleding (WEO/Novalit): kruislat // van 2 lagen, latten 20 (dik) x 30 (breed) mm, handelslengte 4000 mm, // 400 mm h.o.h. in beide richtingen. De bovenste laag staat haaks op de // richting van de gevelbekleding (materials.cladding). Alleen bij // vochtdoek; cement-gebonden platen zijn voor steenstrips. const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 }; const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' }; const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit) // Rabat (gevelbekleding op de kruislat): planken van BOARD_MM werkende // breedte in de richting van materials.cladding, in een kleur uit // RABAT_COLORS (woning-instelling). In 3D één blok per wandstuk, van het // doek tot de voorkant van de planken (latwerk + plank): zo zijn hoeken, // dagkanten en onderrand dicht en vervallen doek en latten erachter. De // planken zijn een textuur in gevel-coördinaten (lopen door over naden). const RABAT = { BOARD_MM: 150, TILE_BOARDS: 8 }; const RABAT_COLORS = { naturel: { label: 'Naturel', hex: '#a0805f' }, antraciet: { label: 'Antraciet', hex: '#3c4045' }, zwart: { label: 'Zwart', hex: '#1f2022' }, grijs: { label: 'Grijs', hex: '#8b8f93' }, wit: { label: 'Gebroken wit', hex: '#e6e3dc' }, groen: { label: 'Mosgroen', hex: '#55604c' } }; const rabatHex = (key) => (RABAT_COLORS[key] || RABAT_COLORS.naturel).hex; // Eén materiaal per kleur + richting (gedeeld). Textuur: TILE_BOARDS // planken, elk met een lichte bovenkant en een schaduwlijn onderaan // (overlap); kleine kleurverschillen per plank. const rabatMaterials = {}; function rabatMaterial(colorKey, direction) { const key = (RABAT_COLORS[colorKey] ? colorKey : 'naturel') + '|' + (direction === 'verticaal' ? 'v' : 'h'); if (rabatMaterials[key]) return rabatMaterials[key]; const hex = rabatHex(colorKey); let mat; const canvas = typeof document !== 'undefined' && document.createElement('canvas'); const ctx = canvas && canvas.getContext && canvas.getContext('2d'); if (!ctx) { mat = new THREE.MeshStandardMaterial({ color: hex, roughness: 0.8 }); } else { const B = 32, N = RABAT.TILE_BOARDS, vert = direction === 'verticaal'; canvas.width = vert ? B * N : 16; canvas.height = vert ? 16 : B * N; const base = new THREE.Color(hex); const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString(); // rect(a, b) = band over de lengte van de plank, van a tot b (px dwars). const rect = (a, b, color) => { ctx.fillStyle = color; if (vert) ctx.fillRect(a, 0, b - a, canvas.height); else ctx.fillRect(0, a, canvas.width, b - a); }; for (let i = 0; i < N; i++) { const a = i * B, f = 0.96 + ((i * 37) % 9) / 100; // 0,96 .. 1,04 rect(a, a + B, shade(f)); rect(a, a + 2, shade(f * 1.12)); // bovenkant (licht) rect(a + B - 4, a + B, shade(f * 0.55)); // schaduw van de overlap } const tex = new THREE.CanvasTexture(canvas); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; const tileM = RABAT.BOARD_MM * N / 1000; if (vert) tex.repeat.set(1 / tileM, 1); else tex.repeat.set(1, 1 / tileM); tex.anisotropy = 8; mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.8 }); } rabatMaterials[key] = mat; return mat; } // Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek // de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de // positie van gevellampen. // Bij cementplaat met steen (stone): de steenstrip. function claddingOffset(buitenMateriaal, stone) { if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM; return buitenMateriaal === 'cementplaat' && stone ? STONE.THICK_MM : 0; } // Trasraam: strook cement-gebonden plaat (montage-achterkant voor de // plint-afwerking) onderaan de gevel, vóór het vochtdoek; het latwerk // begint erboven. // De hoogte telt vanaf Y=0 van de vloer = het beloopbare vlak (bovenkant // vloer), en dat is ook de onderkant van de wand: in wand-coördinaten // loopt de strook dus van 0 tot de hoogte. // Geeft null als hij uit staat. Alleen van toepassing op vochtdoek-wanden // (cement-gebonden buitenzijde is al plaat). const TRASRAAM_DEFAULT_MM = 350; // finish: 'cementplaat' of een steencode uit TRASRAAM_STONES (steenstrip // op een isolatieblok, zie STONE). function trasraamFor(heightMM, finish) { if (!(heightMM > 0)) return null; return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' }; } // Steen (trasraam en steengevel): isolatieblok met steenstrip ervoor, // samen THICK_MM dik (vóór het doek resp. de cementplaat). Texturen: assets/textures/buitenmuur/ // " - Witte voeg.jpg", 2062 x 1125 px op 2 mm/px (steen + voeg // ~62 mm per laag), naadloos herhalend. Voorvertoning in thumb// (.png). // JOINT_MM: hart van de onderste voeg boven de onderrand van de textuur // (gemeten: 1-2 px); COURSE_MM: steen + voeg (31 px). De bovenrand van de // textuur snijdt door een steen (1125 px is geen veelvoud van 31). const STONE = { THICK_MM: 110, STRIP_MM: 20, TILE_W_MM: 4124, TILE_H_MM: 2250, JOINT_MM: 3, COURSE_MM: 62, DIR: 'assets/textures/buitenmuur/' }; const TRASRAAM_STONES = ('K490 K518 K560 K662 K706 K739 K752 K764 K773 K911P K911 K914 K915 K919 K923P K923 K941P K941 K947 K948 K960 K991 ' + 'R100 R116 R140 R200 R206 R214 R216 R220 R227 R228 R240 R268 R286 R287 R303 R307 R332 R335 R343 R356 R381 R382 R384 R385 R386 R391 ' + 'R400 R401 R435 R436 R440 R480 R483 R484 R487 R488 R489 R500 R510 R518 R535 R540 R550 R555 R560 R561 R563 R564 R565 R572DF14_64 R572DF ' + 'R580 R581 R582 R658 R659 R663 R664 R665 R669 R680 R682 R684 R685 R686 R687 R688 R689 R690 R691 R692 R-693 R694 R695 R696 R697 R698 ' + 'R700 R714 R715 R717 R719 R720 R721 R725 R726 R727 R729 R730 R731 R732 R733 R734 R735 R736 R737 R738 R739 R740 R742 R743 R744 R745X737 ' + 'R746 R747 R748 R748X764X749 R749 R750 R751 R751X759 R752 R754 R756 R757 R758 R759 R760 R761 R762 R763 R764 R764X756 R766 R767 R768 ' + 'R769 R770 R771 R772 R773 R775 R777 R788 R800 R835 R840 R911P R911 R913 R916 R917 R918 R921 R923P R931 R932 R941P R941 R942 R943 R944 ' + 'R960DF14_70 R960DF R961 R970 R980 R985 R991').split(' '); // Gemiddelde kleur per steen (uit de textuur berekend), voor vlakke // weergaven zoals de aanzichten in de PDF. const STONE_COLORS = { 'K490': '#b67d60', 'K518': '#666562', 'K560': '#937464', 'K662': '#877770', 'K706': '#615d58', 'K739': '#796f65', 'K752': '#8e6d5f', 'K764': '#a1927d', 'K773': '#9f8c76', 'K911': '#e7ddc9', 'K911P': '#d6c8b6', 'K914': '#d4c0a5', 'K915': '#c5a37b', 'K919': '#cbb498', 'K923': '#c6b3a4', 'K923P': '#beaea1', 'K941': '#d2c5b2', 'K941P': '#dcd2c0', 'K947': '#c9bca8', 'K948': '#b9a896', 'K960': '#b4a495', 'K991': '#9e7b69', 'R-693': '#675e5a', 'R100': '#eee4c7', 'R116': '#e5ddc5', 'R140': '#eae3cc', 'R200': '#edc484', 'R206': '#e0b48b', 'R214': '#b88e74', 'R216': '#e0bc8c', 'R220': '#e29a72', 'R227': '#d49069', 'R228': '#c48869', 'R240': '#e5c587', 'R268': '#ca956e', 'R286': '#b98d66', 'R287': '#d1a477', 'R303': '#a47562', 'R307': '#966b59', 'R332': '#ae8171', 'R335': '#a97968', 'R343': '#a07662', 'R356': '#a47f77', 'R381': '#9c7c6f', 'R382': '#8e7769', 'R384': '#706260', 'R385': '#8a7972', 'R386': '#897f7e', 'R391': '#8b6a64', 'R400': '#b67c63', 'R401': '#a97360', 'R435': '#ad7463', 'R436': '#9f7565', 'R440': '#b57860', 'R480': '#d58a65', 'R483': '#c08973', 'R484': '#a78d7d', 'R487': '#c47f61', 'R488': '#bd8267', 'R489': '#a87967', 'R500': '#7d6c5e', 'R510': '#5b5855', 'R518': '#655d5a', 'R535': '#977365', 'R540': '#7e685a', 'R550': '#8b796d', 'R555': '#9a7668', 'R560': '#8c6f63', 'R561': '#7c6762', 'R563': '#876965', 'R564': '#7c7168', 'R565': '#736a66', 'R572DF': '#8b6657', 'R572DF14_64': '#886355', 'R580': '#907265', 'R581': '#837571', 'R582': '#96705e', 'R658': '#a47e65', 'R659': '#8a6d5a', 'R663': '#8b7163', 'R664': '#8a6a5c', 'R665': '#ca996c', 'R669': '#716257', 'R680': '#b7a28d', 'R682': '#c3af99', 'R684': '#c29263', 'R685': '#926e5d', 'R686': '#b57f5e', 'R687': '#b37f60', 'R688': '#d4ae82', 'R689': '#a67961', 'R690': '#b69477', 'R691': '#e1d3b6', 'R692': '#e1cda3', 'R694': '#aa6c56', 'R695': '#cfa77e', 'R696': '#dbb897', 'R697': '#75685e', 'R698': '#a47760', 'R700': '#66615d', 'R714': '#90695b', 'R715': '#a16f5b', 'R717': '#706e6e', 'R719': '#a58272', 'R720': '#998d8d', 'R721': '#a6948b', 'R725': '#a77963', 'R726': '#987c67', 'R727': '#8e6f5c', 'R729': '#a97260', 'R730': '#e7d3b5', 'R731': '#c68b66', 'R732': '#e4cca6', 'R733': '#eee9ce', 'R734': '#c39c7e', 'R735': '#8c8982', 'R736': '#5f5b54', 'R737': '#7c7973', 'R738': '#7d7771', 'R739': '#81756c', 'R740': '#5e595a', 'R742': '#e7d0ac', 'R743': '#a47f6e', 'R744': '#b38672', 'R745X737': '#9e8d78', 'R746': '#9a7d6f', 'R747': '#88776b', 'R748': '#8b7667', 'R748X764X749': '#a08a77', 'R749': '#92826f', 'R750': '#a88374', 'R751': '#b87a63', 'R751X759': '#766b63', 'R752': '#946c58', 'R754': '#a97e6f', 'R756': '#d2b48c', 'R757': '#eee8cc', 'R758': '#cb9978', 'R759': '#c78365', 'R760': '#baa595', 'R761': '#746f6b', 'R762': '#e9cfa9', 'R763': '#ebe1c6', 'R764': '#bea994', 'R764X756': '#cfb695', 'R766': '#ddb991', 'R767': '#b38469', 'R768': '#b58774', 'R769': '#966e5d', 'R770': '#836e60', 'R771': '#c1b2a3', 'R772': '#d7c8b5', 'R773': '#9d8b77', 'R775': '#917e6e', 'R777': '#6e6b6a', 'R788': '#a27c6c', 'R800': '#aba394', 'R835': '#c2b4a2', 'R840': '#c3b9a7', 'R911': '#e7deca', 'R911P': '#d6c8b6', 'R913': '#d3c0a7', 'R916': '#dfc9a6', 'R917': '#dcaf7e', 'R918': '#caa174', 'R921': '#cbad9a', 'R923P': '#beaea1', 'R931': '#9f8773', 'R932': '#a8917e', 'R941': '#c0b3a1', 'R941P': '#c2b6aa', 'R942': '#d3ccc1', 'R943': '#beb2a4', 'R944': '#afa295', 'R960DF': '#bab1a4', 'R960DF14_70': '#bbb2a5', 'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274' }; const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish); const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'); const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'); // Eén materiaal per steen (gedeeld, pas geladen als hij gebruikt wordt). // UV's van de steenblokken zijn in meters, dus repeat = 1/tegel. const stoneMaterials = {}; function stoneMaterial(code) { if (stoneMaterials[code]) return stoneMaterials[code]; const mat = new THREE.MeshStandardMaterial({ color: 0xb0a090, roughness: 0.9 }); if (typeof document !== 'undefined' && THREE.TextureLoader) { new THREE.TextureLoader().load(stoneTextureUrl(code), (tex) => { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(1000 / STONE.TILE_W_MM, 1000 / STONE.TILE_H_MM); tex.anisotropy = 8; mat.map = tex; mat.color.set(0xffffff); mat.needsUpdate = true; }); } stoneMaterials[code] = mat; return mat; } // Hoekdoorloop van de buitenlagen. Een wand die tussen twee doorlopende // wanden past (unit-hoek) moet met zijn buitenlagen ook de kopse kant // (hoekstijlen) van die doorlopende wand bedekken: skinExt = { left, // right } = dikte van die wand (mm, 0 = vrij uiteinde). Elke laag loopt // daarbovenop nog door tot zijn eigen buitenvlak, zodat de hoek dicht is. // depth = afstand van het buitenvlak van de laag tot het wandvlak (mm). // Negatief (binnenhoek): het stuk loopt zoveel de naad in, binnen de wand // van de buur-unit. Geen doorloop; de buitenlagen krijgen daar een // uitsparing (build3D: trim, tot opts.trimTop). function cornerExt(skinExt, depth) { const e = skinExt || {}; const one = (v) => v > 0 ? v + depth : 0; return { left: one(e.left), right: one(e.right) }; } // Onderkant van vochtdoek en latwerk (wand-coördinaten, mm). function skinStartY(trasraam) { return trasraam ? Math.max(0, trasraam.top) : 0; } // Openingtypes met hun standaardmaat (mm). 'y' = hoogte onderkant. const OPENING_TYPES = { raam: { label: 'Raam', w: 500, h: 1900, y: 250 }, deur: { label: 'Deur', w: 900, h: 2150, y: 0 }, voordeur: { label: 'Voordeur', w: 1150, h: 2150, y: 0 } }; // Deuren krijgen aan weerszijden een tweede stijl direct tegen de // koningsstijl (stevigheid, computeFrame). const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur'; // Schuine bovenzijde (zoals de wand zelf: schuin bovenin mag, onderin // niet): h = hoogte links, hR = hoogte rechts. Ruwe openingen hebben hR // alleen als hij (bewust) schuin is; opgeloste openingen altijd. // x is lokaal (0..wandbreedte), zoals op.x. const hRightOf = (op) => op.hR !== undefined ? op.hR : op.h; const openingTopAt = (op, x) => op.y + op.h + (x - op.x) / op.w * (hRightOf(op) - op.h); const openingTopMax = (op) => op.y + Math.max(op.h, hRightOf(op)); const openingArea = (op) => op.w * (op.h + hRightOf(op)) / 2; // Stuk [x0, x1] van een vlak met waterpas onderkant y0 en schuine // bovenkant (yl links, yr rechts) op hoogte y, of null. function slopedSpanAtY(x0, x1, y0, yl, yr, y) { if (y <= y0 || y >= Math.max(yl, yr)) return null; if (y <= Math.min(yl, yr)) return [x0, x1]; const xc = x0 + (y - yl) / (yr - yl) * (x1 - x0); return yr > yl ? [xc, x1] : [x0, xc]; } const openingSpanAtY = (op, y) => slopedSpanAtY(op.x, op.x + op.w, op.y, op.y + op.h, op.y + hRightOf(op), y); // Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor // binnenwanden (aan beide zijden spaanplaat). // 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het // deel van de andere unit aan staat (HouseModel, naadwanden). const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' }; // Bovenrand van de wand (daklijn): één recht stuk van links naar rechts, // of via tussenpunten (CONFIG.wallTop: nok, of de knikken van een // mansarde-/wolfsdak) meerdere stukken. De daklijn is altijd bol // (wallTopPoints), dus de bovenrand is overal het MINIMUM van de // (doorgetrokken) stukken — dat // houdt at/under/rangeAbove simpel en maakt "laagste punt boven een // opening" gelijk aan het laagste van de twee uiteinden. // x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters). // at(x) bovenkant (daklijn) // under(x) onderkant bovenregel(s); knee = hun snijpunt (verstek) // rangeAbove(y) [x0, x1] waar de daklijn boven y ligt (of null) // crossings(y) x-posities waar de daklijn y kruist (knikpunten) // underPts(a, b) onderkant bovenregel van a naar b, incl. knie function topLine(CONFIG, k) { k = k || 1; const halfW = CONFIG.wallWidth / 2 * k; const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k }; const inner = (CONFIG.wallTop || []).map(q => ({ x: (q.x - CONFIG.wallWidth / 2) * k, y: q.h * k })); const pts = [L, ...inner, R]; const segs = []; for (let i = 0; i < pts.length - 1; i++) { const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x); segs.push({ a, b, slope, vo: CONFIG.plateThick * k * Math.sqrt(1 + slope * slope) }); } const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope; const at = (x) => Math.min(...segs.map(sg => lineY(sg, x))); const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo)); // Knieën: snijpunt van de onderkanten van twee opeenvolgende bovenregels. const knees = segs.slice(0, -1).map((s0, i) => { const s1 = segs[i + 1], ds = s0.slope - s1.slope; // s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1 const x = Math.abs(ds) > 1e-12 ? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds : s0.b.x; return { x, y: under(x) }; }); const rangeAbove = (y) => { let x0 = -halfW, x1 = halfW; segs.forEach(sg => { if (Math.abs(sg.slope) < 1e-12) { if (sg.a.y <= y) x1 = x0 - 1; return; } const xc = sg.a.x + (y - sg.a.y) / sg.slope; if (sg.slope > 0) x0 = Math.max(x0, xc); else x1 = Math.min(x1, xc); }); return x1 > x0 ? [x0, x1] : null; }; const crossings = (y) => segs.filter(sg => Math.abs(sg.slope) > 1e-12) .map(sg => ({ sg, x: sg.a.x + (y - sg.a.y) / sg.slope })) .filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x); const underPts = (xa, xb) => { const out = [{ x: xa, y: under(xa) }]; knees.filter(kn => kn.x > xa && kn.x < xb).forEach(kn => out.push({ x: kn.x, y: kn.y })); out.push({ x: xb, y: under(xb) }); return out; }; // peak: hoogste tussenpunt (nok), voor maatvoering. const peak = inner.length ? inner.reduce((m, q) => q.y > m.y ? q : m) : null; return { pts, segs, peak, knees, knee: knees[0] || null, at, under, rangeAbove, crossings, underPts, maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y), kinks: inner.map(q => q.x) }; } // Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één // parallellogram, of per stuk van de daklijn een bovenregel; die sluiten // in verstek op elkaar aan (knik boven, knie onder). function topPlateOutlines(tl) { const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n]; return tl.segs.map((sg, i) => [ sg.a, sg.b, i < n - 1 ? tl.knees[i] : { x: R.x, y: R.y - sg.vo }, i > 0 ? tl.knees[i - 1] : { x: L.x, y: L.y - sg.vo } ]); } // Tussenpunten van de daklijn (mm vanaf links) uit de wand-parameters: // topPts [{ f (0..1 van de breedte), h }] (mansarde, wolfsdak), of het // middenpunt heightMid op peakX (standaard midden). Alleen punten die // de daklijn bol houden (boven de koorde tussen hun buren); de bovenrand // is dan overal het minimum van de doorgetrokken stukken (topLine). function wallTopPoints(params, wallWidth, hLeft, hRight) { let raw = []; if (Array.isArray(params.topPts) && params.topPts.length) { raw = params.topPts.map(q => ({ x: q.f * wallWidth, h: Math.round(q.h) })); } else if (params.heightMid > 0) { const px = params.peakX !== undefined ? params.peakX : wallWidth / 2; raw = [{ x: px, h: Math.round(Math.max(params.heightMid, hLeft, hRight)) }]; } let pts = [{ x: 0, h: hLeft }, ...raw.filter(q => q.x > 1 && q.x < wallWidth - 1).sort((a, b) => a.x - b.x), { x: wallWidth, h: hRight }]; for (let changed = true; changed;) { changed = false; for (let i = 1; i < pts.length - 1; i++) { const a = pts[i - 1], b = pts[i + 1], q = pts[i]; const chord = a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h); if (q.x - a.x < 1 || b.x - q.x < 1 || q.h <= chord + 0.5) { pts.splice(i, 1); changed = true; break; } } } return pts.slice(1, -1); } // Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst // individueel gepositioneerd op basis van zijn anker, dan van links naar // rechts gesorteerd en tegen elkaar aan geduwd als er overlap is, en tot // slot terug binnen de wand getrokken als de laatste opening over de // rechter rand heen zou steken. Als openingen simpelweg niet allemaal // passen bij de huidige wandbreedte, wordt dat expliciet gemeld i.p.v. // stil verkeerd te tekenen. // // Breedte/positie (x) worden EERST volledig opgelost; pas daarna wordt per // opening de hoogte/Y geklemd tegen de daklijn — en wel de daklijn ZOALS // DIE OP DE EIGEN (definitieve) POSITIE van die opening loopt, niet tegen // het laagste punt van de hele wand. Bij een hellende wand mag een opening // dicht bij de hoge kant dus hoger/groter zijn dan een opening dicht bij // de lage kant. // roofAt(xLocal): daklijn, xLocal vanaf de linker wandrand (0..wallWidth). function resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) { // 2850mm is de vaste referentie-bovengrens (bijv. max. sparingbreedte in // dit constructiesysteem); daarnaast blijft de wand zelf ook een harde // grens (er moet nog stijlruimte overblijven). const maxOpW = Math.min(2850, Math.max(400, wallWidth - (studWidth * (2 * CORNER_STUDS + 2)))); // Openingen blijven vrij van de dubbele hoekstijl; de koningsstijl mag // tegen de binnenste hoekstijl aan vallen. const edgeMargin = studWidth * CORNER_STUDS; // Fase 1: breedte + x-positie (ankers), zonder hoogte/Y. let resolved = rawOpenings.map(raw => { const w = Math.min(Math.max(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 }; }); resolved.sort((a, b) => a.x - b.x); // Individuele randclamp (marge voor een stijl aan weerszijden van de wand) resolved.forEach(o => { o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin)); }); // Openingen die elkaar in de hoogte raken (incl. boven-/onderdorpel en // 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar // (bijv. een raam boven een deur), dan mogen ze in x overlappen. // Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd. // (Schuine bovenzijde: de hoogste kant telt.) const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth]; const stacked = (a, b, useFinal) => { const [a0, a1] = useFinal ? vBand(a, Math.max(a.h, a.hR), a.y) : vBand(a, Math.max(400, a.rawH, a.rawHR), a.rawY); const [b0, b1] = useFinal ? vBand(b, Math.max(b.h, b.hR), b.y) : vBand(b, Math.max(400, b.rawH, b.rawHR), b.rawY); return a1 <= b0 + 0.5 || b1 <= a0 + 0.5; }; // Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing // rond elke opening steekt aan weerszijden al 1x stijldikte uit (om de // koningsstijl zichtbaar te laten), dus bij minder afstand zouden hun // uitsparingen elkaar kunnen overlappen. Naast een deur 1x extra voor // de dubbele stijl. const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0)); // Links-naar-rechts: duw botsende openingen uit elkaar. for (let i = 1; i < resolved.length; i++) { const cur = resolved[i]; 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)); } } // Rechts-naar-links: wat over de rechterrand steekt terugtrekken, en de // botsende openingen links ervan mee. const rightLimit = wallWidth - edgeMargin; for (let i = resolved.length - 1; i >= 0; i--) { const cur = resolved[i]; let maxX = rightLimit - 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 (cur.x > maxX) cur.x = maxX; } // Fase 2: hoogte (h links, hR rechts) en verticale positie (y), per // opening geklemd tegen de daklijn zoals die loopt op de EIGEN (nu // definitieve) x-positie van die opening — per kant: de linkerhoogte // tegen de daklijn boven de linkerrand, de rechterhoogte tegen die // boven de rechterrand. Zo kan een schuine bovenzijde het dak volgen. // Daartussen blijft de bovenkant onder het dak: de daklijn is lineair // of heeft een nok als hoogste punt (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)); 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; }); // Detecteer of het na dit alles nog steeds niet past (te veel/te brede // openingen voor deze wandbreedte, of boven elkaar zonder ruimte // ertussen) zodat de UI dit kan tonen. resolved.forEach(o => { o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 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; } } } return resolved; } // Zet ruwe (DOM- of JSON-)parameters om naar de opgeloste CONFIG waar de // rest van de logica mee rekent (afronding + fallback-waarden, en de // openingen al geresolved op hun anker). function buildConfig(params, rawOpenings) { const wallWidth = Math.round(params.width || 3000); const hLeft = Math.round(params.heightLeft || 2400); const hRight = Math.round(params.heightRight || 2900); const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width); const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width); // Tussenpunten van de daklijn (nok; knikken bij mansarde/wolfsdak), // zie wallTopPoints. Een wandstuk uit een unit krijgt ze op zijn eigen // positie (peakX / topPts). const wallTop = wallTopPoints(params, wallWidth, hLeft, hRight); const tp = [{ x: 0, h: hLeft }, ...wallTop, { x: wallWidth, h: hRight }]; const roofAt = (x) => { let i = 0; while (i < tp.length - 2 && x > tp[i + 1].x) i++; const a = tp[i], b = tp[i + 1]; return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); }; return { wallWidth, wallHeightLeft: hLeft, wallHeightRight: hRight, wallTop, studWidth, studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth), plateThick, plateDepth: Math.round(params.plateDepth || STANDARD_BEAM.depth), sheathingThick: Math.round(params.sheathingThick || 10), minSpacing: Math.round(params.minSpacing || 240), maxSpacing: Math.round(params.maxSpacing || 480), openings: resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) }; } function configFromState(state) { return buildConfig(state.wall, state.openings || []); } // Totale wanddikte (mm): regelwerk + beplating aan beide zijden (bij // buitenMateriaal 'geen' alleen aan de binnenzijde). Gebruikt door de unit // om wanden met hun buitenkant op de vloerrand te zetten. function thicknessOf(config, buitenMateriaal) { const sides = buitenMateriaal === 'geen' ? 1 : 2; return Math.max(config.studDepth, config.plateDepth) + sides * (config.sheathingThick + SKIN_GAP_MM); } // Regelwerk: volledige stijlen (incl. koningsstijlen) + kreupele stijlen. function computeFrame(CONFIG) { const startX = -CONFIG.wallWidth / 2; const top = topLine(CONFIG); const slope = top.segs[0].slope; // Met nok: de steilste helling (beide kanten lopen dan op naar de nok). const angleDeg = top.segs.length > 1 ? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI)) : Math.round(Math.atan(slope) * (180 / Math.PI)); // Dubbele hoekstijlen: de buitenste staat op de wandrand, de tweede er // direct tegenaan. De regelmatige verdeling begint bij de binnenste. const cornerInset = (CORNER_STUDS - 1) * CONFIG.studWidth; const outerLeftStudX = startX + CONFIG.studWidth / 2; const outerRightStudX = startX + CONFIG.wallWidth - CONFIG.studWidth / 2; const wallLeftStudX = outerLeftStudX + cornerInset; const wallRightStudX = outerRightStudX - cornerInset; const cornerStuds = []; for (let i = 0; i < CORNER_STUDS; i++) { cornerStuds.push(outerLeftStudX + i * CONFIG.studWidth, outerRightStudX - i * CONFIG.studWidth); } // Stijlen als stukken { x, y0, y1, kind } (mm; y1 null = tot de // daklijn). Eerst kandidaat-posities, daarna wordt elke kandidaat // opgeknipt door de openingen (incl. boven-/onderdorpel) die hij kruist. // Zo kunnen openingen ook boven elkaar staan (raam boven een deur): de // stijlen ertussen lopen van dorpel tot dorpel. // hoek dubbele hoekstijl // koning koningsstijl direct naast een opening // dubbel tweede stijl direct tegen de koningsstijl van een deur // regel regelmatige verdeling in de vrije zones // kreupel boven/onder een opening, binnen de openingbreedte const sw = CONFIG.studWidth; const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings // Bovendorpel volgt een schuine bovenzijde (loodrechte dikte sw): // topAt(x) = bovenkant dorpel, y1 = het hoogste punt daarvan. const bands = ops.map(op => { const x0 = startX + op.x, s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s); return { op, x0, x1: x0 + op.w, y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + Math.max(op.h, op.hR) + vo, topAt: (x) => op.y + op.h + (x - x0) * s + vo }; }); const cands = []; cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' })); 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' }); }); // Kolommen: openingen (incl. hun stijlen) die elkaar in x overlappen. // Tussen de kolommen en de hoekstijlen: regelmatige verdeling. const cols = []; ops.map(op => { const extra = (isDoor(op) ? 2 : 1) * sw; return [startX + op.x - extra + sw / 2, startX + op.x + op.w + extra - sw / 2]; // hartlijnen buitenste stijlen }).sort((p, q) => p[0] - q[0]).forEach(r => { const last = cols[cols.length - 1]; if (last && r[0] < last[1] + sw) last[1] = Math.max(last[1], r[1]); else cols.push(r.slice()); }); const edges = [wallLeftStudX]; cols.forEach(c => edges.push(c[0], c[1])); edges.push(wallRightStudX); for (let i = 0; i < edges.length; i += 2) { const a = edges[i], b = edges[i + 1]; if (b - a > sw) calculateSegmentPositions(b - a, CONFIG.minSpacing, CONFIG.maxSpacing).forEach(p => cands.push({ x: a + p, kind: 'regel' })); } ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing) .forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op }))); // Knippen op de openingen. 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 => { 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); // cur = onderkant van het volgende stuk [links, rechts]: schuin als // het op een schuine bovendorpel staat (dan ook y0l/y0r; y0 = laagste). let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null; const curMax = () => Math.max(cur[0], cur[1]); const push = (y1, above) => { if (c.kind === 'kreupel') { // Alleen direct boven of onder de eigen opening; onder de opening // niet als hij op een andere opening uitkomt (dat stuk vult de // kreupele stijl van de onderste opening al). const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner; if (!onTop && !(underneath && !below)) return; } const sg = { x, y0: Math.min(cur[0], cur[1]), y1, kind: c.kind }; if (Math.abs(cur[0] - cur[1]) > 0.05) { sg.y0l = cur[0]; sg.y0r = cur[1]; } segs.push(sg); }; hit.forEach(bd => { if (bd.y0 > curMax() + 1) push(bd.y0, bd); const t = [bd.topAt(x - sw / 2), bd.topAt(x + sw / 2)]; if (Math.max(t[0], t[1]) > curMax()) { cur = t; below = bd; } }); if (roofMin(x) > curMax() + 1) push(null, null); }); // Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint. const studs = []; segs.sort((p, q) => PRIO[p.kind] - PRIO[q.kind]).forEach(sg => { const top1 = sg.y1 === null ? Infinity : sg.y1; const clash = studs.some(o => Math.abs(o.x - sg.x) < sw - 0.5 && Math.min(top1, o.y1 === null ? Infinity : o.y1) - Math.max(sg.y0, o.y0) > 1); if (!clash) studs.push(sg); }); studs.sort((p, q) => p.x - q.x || p.y0 - q.y0); // Volle stijlen (onderregel tot daklijn), o.a. voor de maatvoering. const fullStuds = [...new Set(studs.filter(sg => sg.y1 === null && sg.y0 <= CONFIG.plateThick).map(sg => sg.x))].sort((a, b) => a - b); return { studs, fullStuds, slope, angleDeg }; } // Uitsparing in de beplating/het vochtdoek per opening (mm; x t.o.v. het // wandmidden, y vanaf de onderkant): de opening + 1x stijldikte marge // links/rechts/boven, zodat de koningsstijlen en bovendorpel zichtbaar // blijven. De bovenkant wordt defensief tegen de lokale daklijn geklemd — // bij een sterk hellende wand met een opening dicht bij de lage kant kan // de marge anders lokaal boven het dak uitkomen (kapotte uitsparing). // Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen). // Schuine bovenzijde: hy1 = bovenkant links (bij hx0), hy1r = rechts (bij // hx1), langs de bovenkant van de schuine bovendorpel; per kant geklemd // (de daklijn is bol, dus de lijn ertussen blijft eronder). function sheathingHoles(CONFIG) { const halfW = CONFIG.wallWidth / 2; const topYAt = topLine(CONFIG).at; 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); 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 holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1; const holeTopAt = (h, x) => h.hy1 + (x - h.hx0) / (h.hx1 - h.hx0) * (holeTopR(h) - h.hy1); const holeRect = (h) => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1, y1r: holeTopR(h) }); // Stukken van een horizontale lijn op hoogte y (mm) die OP de beplating // liggen: binnen de wand-omtrek (incl. schuine top) en buiten de // uitsparingen. Geeft [[x0, x1], ...] terug (mm, t.o.v. het wandmidden). function skinSegmentsAtY(CONFIG, y, extraHoles) { if (y <= 0) return []; // Deel van de breedte waar de daklijn boven y ligt. const range = topLine(CONFIG).rangeAbove(y); if (!range) return []; let segments = [range]; sheathingHoles(CONFIG).concat(extraHoles || []).forEach(h => { const span = slopedSpanAtY(h.hx0, h.hx1, h.hy0, h.hy1, holeTopR(h), y); if (!span) return; const [hx0, hx1] = span; segments = segments.flatMap(([a, b]) => { if (hx1 <= a || hx0 >= b) return [[a, b]]; const out = []; if (hx0 > a) out.push([a, hx0]); if (hx1 < b) out.push([hx1, b]); return out; }); }); return segments; } // Kruislat (pure berekening, mm, x t.o.v. het wandmidden). Latten worden // uitgespaard rond de openingen (zelfde uitsparing als het vochtdoek, // sheathingHoles) en afgekapt op de (schuine) bovenrand. // Geeft { top, under, layers: { horizontaal: [...], verticaal: [...] }, // lengths: [...], stock: {count, wasteMM}, totalLengthMM }. // facade (optioneel, woning): { u0, extStart, isEnd } — plaats van dit // wandstuk in de doorlopende gevel (zie HouseModel.assignFacades): u0 = // afstand (mm, wandvlak) van het gevelbegin (incl. hoekdoorloop) tot de // linkerrand van dit stuk (gebruikt door de horizontale latten via gridY0). // Verticale latten: per wandstuk (de modules worden los vervoerd, de // bekleding eindigt op de rand van het stuk) een lat op beide randen en // langs beide zijden van elke opening (de bekleding moet daar op een lat // eindigen); daartussen gelijk verdeeld, h.o.h. max. BATTEN.spacing // (krimpend als het vak kleiner is, minimaal de twee randlatten). // facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het // horizontale latten-raster van de hele gevel begint. Een wandstuk dat // hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster. // skinFrom (optioneel, mm): buitenafwerking pas vanaf deze hoogte (het // blootliggende deel van een naadwand boven het dak van de andere unit). // extraHoles (optioneel, mm, zoals sheathingHoles): extra uitsparingen // (binnenhoek-doorloop, zie build3D). function computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom, extraHoles) { const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right; const halfW = CONFIG.wallWidth / 2; const tl = topLine(CONFIG), topYAt = tl.at; const holes = sheathingHoles(CONFIG).concat(extraHoles || []); const w = BATTEN.width, sp = BATTEN.spacing; const top = (cladding === 'verticaal') ? 'horizontaal' : 'verticaal'; // Horizontale latten: vanaf de onderkant elke 400 mm (hartmaat). const horizontaal = []; let yStart = y0 + w / 2; if (facade && facade.gridY0 !== undefined) { const g = facade.gridY0 + w / 2; yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp; } for (let y = yStart; y < tl.maxY - w / 2; y += sp) { // Schuine bovenrand: afkappen op de bovenkant van de lat (y + w/2), // anders steekt het uiteinde tot w/2 boven de daklijn (door het dak). const cap = tl.rangeAbove(y + w / 2); if (!cap) continue; skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => { x0 = Math.max(x0, cap[0]); x1 = Math.min(x1, cap[1]); if (x0 <= -halfW + 0.5) x0 = xL; if (x1 >= halfW - 0.5) x1 = xR; if (x1 - x0 > w) horizontaal.push({ x0, x1, y }); }); } // Verticale latten: vaste posities (randen, zijden van openingen), // daartussen gelijk verdeeld met h.o.h. <= sp. const lo = xL + w / 2, hi = xR - w / 2; const fixed = [lo, hi]; holes.forEach(h => { if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return; // valt buiten de bekleding [h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (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); // niet dubbel op elkaar const xs = []; anchors.forEach((x, i) => { xs.push(x); const next = anchors[i + 1]; if (next === undefined) return; const n = Math.ceil((next - x) / sp - 1e-9); for (let k = 1; k < n; k++) xs.push(x + (next - x) * k / n); }); const verticaal = []; xs.forEach(x => { const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2)); let segs = [[y0, yTop]]; holes.filter(h => x + w / 2 > h.hx0 && x - w / 2 < h.hx1).forEach(h => { // Schuine bovenkant: de hoogste waarde over de latbreedte. const hy1 = Math.max(holeTopAt(h, Math.max(h.hx0, x - w / 2)), holeTopAt(h, Math.min(h.hx1, x + w / 2))); segs = segs.flatMap(([a, b]) => { if (hy1 <= a || h.hy0 >= b) return [[a, b]]; const out = []; if (h.hy0 > a) out.push([a, h.hy0]); if (hy1 < b) out.push([hy1, b]); return out; }); }); segs.filter(([a, b]) => b - a > w).forEach(([y0, y1]) => verticaal.push({ x, y0, y1 })); }); const lengths = horizontaal.map(b => b.x1 - b.x0).concat(verticaal.map(b => b.y1 - b.y0)); const bars = lengths.length ? planStockCutting(lengths, [BATTEN.length]) : []; const sum = summarizeStockPlan(bars); return { top, under: top === 'verticaal' ? 'horizontaal' : 'verticaal', layers: { horizontaal, verticaal }, lengths, stock: { count: bars.length, wasteMM: sum.wasteMM }, totalLengthMM: lengths.reduce((a, b) => a + b, 0) }; } // Vochtdoek-bedrukking zoals op de echte doeken: het logo als medium-grijs // watermerk (geen kleur) onder 45°, 1,5m breed. De doekbanen hebben // ExtrudeGeometry-UV's in meters (vormcoördinaten), dus repeat = 1/tegel. // Eén gedeelde textuur; zonder DOM (node-tests) valt hij weg. const DOEK_COLOR = '#444444'; const WATERMARK = { color: '#8c8c8c', logoWidthM: 1.5, tileM: 2.0, px: 512 }; let doekTexture = null; function getDoekTexture() { if (doekTexture !== null) return doekTexture || null; doekTexture = false; if (typeof document === 'undefined' || !window.TECTUM_LOGO) return null; const canvas = document.createElement('canvas'); canvas.width = canvas.height = WATERMARK.px; const ctx = canvas.getContext && canvas.getContext('2d'); if (!ctx) return null; ctx.fillStyle = DOEK_COLOR; ctx.fillRect(0, 0, WATERMARK.px, WATERMARK.px); const tex = new THREE.CanvasTexture(canvas); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(1 / WATERMARK.tileM, 1 / WATERMARK.tileM); tex.anisotropy = 8; const img = new Image(); img.onload = () => { // Logo-silhouet egaal grijs maken (source-in), dan 45° gedraaid midden // in de tegel tekenen. const pxPerM = WATERMARK.px / WATERMARK.tileM; const w = WATERMARK.logoWidthM * pxPerM, h = w / window.TECTUM_LOGO.aspect; const tint = document.createElement('canvas'); tint.width = Math.ceil(w); tint.height = Math.ceil(h); const tctx = tint.getContext('2d'); tctx.drawImage(img, 0, 0, tint.width, tint.height); tctx.globalCompositeOperation = 'source-in'; tctx.fillStyle = WATERMARK.color; tctx.fillRect(0, 0, tint.width, tint.height); ctx.save(); ctx.translate(WATERMARK.px / 2, WATERMARK.px / 2); ctx.rotate(-Math.PI / 4); // oplopend van links naar rechts ctx.drawImage(tint, -w / 2, -h / 2, w, h); ctx.restore(); tex.needsUpdate = true; }; img.src = window.TECTUM_LOGO.png; doekTexture = tex; return tex; } function createMaterials() { const doekMap = getDoekTexture(); return { woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }), spaanplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x1e4620, roughness: 0.6, side: THREE.DoubleSide }), doekMaterial: doekMap ? new THREE.MeshStandardMaterial({ color: 0xffffff, map: doekMap, roughness: 0.7, side: THREE.DoubleSide }) : new THREE.MeshStandardMaterial({ color: DOEK_COLOR, roughness: 0.7, side: THREE.DoubleSide }), cementplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x808285, roughness: 0.8, side: THREE.DoubleSide }), isolatieMaterial: new THREE.MeshStandardMaterial({ color: 0xf4f4f2, roughness: 0.95 }), battenMaterial: new THREE.MeshStandardMaterial({ color: 0xd9b77e, roughness: 0.7 }) }; } // Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte // gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid) // en geeft daarnaast de volume-boekhouding terug voor de specificaties. // opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, stage, rabatColor } // trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null. // skinExt: { left, right } in mm (zie cornerExt) of null. // facade: { u0, extStart, isEnd } (zie computeBattens) of null. // stage: bouwfase (BuildStages, standaard afgewerkt). Lagen van een latere // fase en lagen die in deze fase volledig ingesloten zijn worden niet // gebouwd; de stats rekenen altijd met alle lagen. function build3D(CONFIG, calcData, opts) { const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials; const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek'; const stage = BuildStages.clamp(opts.stage); const show = (key) => BuildStages.shows(stage, key); const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating'); const buitenVisible = opts.buitenVisible !== false; const cladding = opts.cladding || 'horizontaal'; // Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone). const trasraam = buitenMateriaal === 'vochtdoek' || (buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone)) ? (opts.trasraam || null) : null; const skinExt = opts.skinExt || null; const facade = opts.facade || null; const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0; // buitenafwerking vanaf (mm), zie computeBattens const painted = !!opts.painted; // spaanplaat geschilderd: zonder plaatnaden // stone: steencode (TRASRAAM_STONES) voor steenstrips op een cementplaat-gevel. const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null; // spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand // geheel in één unit: onder het dak van de andere unit is het een binnenwand). const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek'; // Zichtbare buitenlagen in deze fase. Bij rabat (vochtdoek-gevel) dekt // het rabatblok doek en latten volledig af (zie RABAT). const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel'); const doekOn = buitenVisible && show('doek') && !rabatOn; const battensOn = buitenVisible && show('latwerk') && !rabatOn; // Regelwerk ingesloten: binnen spaanplaat, buiten een plaat of het doek // (naadzijde 'geen': het deel van de andere unit staat ertegen). Dan // alleen de stukken die in de dagkant van een opening of aan de rand // te zien zijn (hoek-, koningsstijlen, regels, dorpels). const outerCovered = buitenMateriaal === 'geen' || (buitenVisible && show(buitenMateriaal === 'vochtdoek' ? 'doek' : 'beplating')); const frameEnclosed = opts.cull !== false && spaanplaatVisible && outerCovered; const HIDDEN_STUDS = { regel: true, kreupel: true, dubbel: true }; // Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over // naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving). const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0; // Verticaal: een hoger beginnend stuk (facade.yOff, mm boven het // loopvlak van de gevel) schuift het watermerk mee omhoog. const uvShiftYM = facade && facade.yOff ? facade.yOff * 0.001 : 0; const shiftUV = (geo) => { if (!uvShiftM && !uvShiftYM) return geo; const uv = geo.attributes.uv; for (let k = 0; k < uv.count; k++) { uv.setX(k, uv.getX(k) + uvShiftM); uv.setY(k, uv.getY(k) + uvShiftYM); } return geo; }; const wallGroup3D = new THREE.Group(); // ------------------------------------------------------------ // Volume-boekhouding voor de specificaties. Puur analytisch, los van de // driehoeksmesh-data: elke balk/dorpel/plaat-vorm wordt via de // schoenveter-formule (shoelace) opgemeten en opgeteld, in m3. // ------------------------------------------------------------ let woodVolumeM3 = 0; let spaanplaatVolumeM3 = 0; let spaanplaatAreaM2 = 0; // enkelzijdig, altijd binnenzijde/noord let cementplaatVolumeM3 = 0; let cementplaatAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='cementplaat' let doekAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='vochtdoek' let spaanplaatBuitenAreaM2 = 0; // alleen als buitenMateriaal='spaanplaat' (binnenwanden) let spaanplaatBuitenVolumeM3 = 0; let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0; // kruislat (alleen vochtdoek); count = inkoop, pieces = gezaagde stukken let rabatAreaM2 = 0; // gevelbekleding rabat (gevelvlak, alleen vochtdoek) let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek) let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok let horizBeamCount = 0; // onderregel-segmenten + topbalk + dorpels per opening function createExtrudedBeam(shape, depth, positionZ) { const extrudeSettings = { steps: 1, depth: depth, bevelEnabled: false }; const geo = new THREE.ExtrudeGeometry(shape, extrudeSettings); const mesh = new THREE.Mesh(geo, woodMaterial); mesh.position.set(0, 0, positionZ); mesh.castShadow = true; woodVolumeM3 += polygonArea(shape.getPoints()) * depth; return mesh; } const toM = 0.001; const sWidthM = CONFIG.studWidth * toM; const sDepthM = CONFIG.studDepth * toM; const pThickM = CONFIG.plateThick * toM; const pDepthM = CONFIG.plateDepth * toM; const sThickM = CONFIG.sheathingThick * toM; const wWidthM = CONFIG.wallWidth * toM; const hLeftM = CONFIG.wallHeightLeft * toM; const hRightM = CONFIG.wallHeightRight * toM; const halfW = wWidthM / 2; const tl = topLine(CONFIG, toM); const startXM = -halfW; const topYAt = tl.at; // Gevel doorgetrokken tot onder de dakrand (opts.skinAbove, mm): de // buitenlagen (doek, latwerk, steen, cementplaat) volgen een bovenrand // die zoveel hoger ligt, met dezelfde helling; regelwerk en binnenzijde // blijven op de wandhoogte. const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0; const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, { wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove, wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove })) }) : CONFIG; const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl; // Latwerk stopt onder het boeideel/de daktrim (opts.battenAbove, mm // t.o.v. de wandbovenkant, kan negatief zijn); zonder rand tot skinAbove. const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove; const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, { wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove, wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove })) }) : CONFIG; // Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok). const underPts = tl.underPts; // Openingen naar meter-coordinaten. LET OP: xMin/xMax hier zijn al naar // wereld-coordinaten verschoven (dezelfde as als de rest van de 3D-opbouw, // -halfW..+halfW), niet lokaal t.o.v. de linker wandrand (0..wallWidth) — // dat onderscheid was eerder de bron van een echte bug (dorpels en de // OSB-uitsparing op de verkeerde plek, deels buiten de plaat). const opsM = CONFIG.openings.map(op => ({ id: op.id, xMin: startXM + op.x * toM, xMax: startXM + (op.x + op.w) * toM, yMin: op.y * toM, yMax: (op.y + op.h) * toM, // bovenkant links yMaxR: (op.y + op.hR) * toM, // bovenkant rechts (schuine bovenzijde) wM: op.w * toM })); // A. ONDERBALK WAND — alleen doorgesneden ter plaatse van openingen die // tot de vloer reiken (deuren); vensters boven de plaat blijven gewoon // op een doorlopende onderregel staan. const floorCuts = opsM .filter(op => op.yMin < pThickM) .map(op => ({ min: op.xMin, max: op.xMax })) .sort((a, b) => a.min - b.min); let cursor = -halfW; floorCuts.forEach(cut => { if (cut.min > cursor) { const segW = cut.min - cursor; const geo = new THREE.BoxGeometry(segW, pThickM, pDepthM); const mesh = new THREE.Mesh(geo, woodMaterial); mesh.position.set(cursor + segW / 2, pThickM / 2, 0); wallGroup3D.add(mesh); woodVolumeM3 += segW * pThickM * pDepthM; horizBeamCount++; } cursor = Math.max(cursor, cut.max); }); if (cursor < halfW) { const segW = halfW - cursor; const geo = new THREE.BoxGeometry(segW, pThickM, pDepthM); const mesh = new THREE.Mesh(geo, woodMaterial); mesh.position.set(cursor + segW / 2, pThickM / 2, 0); wallGroup3D.add(mesh); woodVolumeM3 += segW * pThickM * pDepthM; horizBeamCount++; } // B. TOPBALK WAND (ongewijzigd door openingen) — dit is zelf ook een // "horizontale balk" (liggend, ook al loopt hij mee met de dakhelling). // Met nok: twee bovenregels die in verstek op de nok samenkomen. topPlateOutlines(tl).forEach(pts => { wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2)); horizBeamCount++; }); // Schuine regel op de bovenregel (CONFIG.topWedge): vult de wig tot // de onderkant van het dak, over de diepte van het regelwerk. if (CONFIG.topWedge) { const wg = CONFIG.topWedge.pts, zO = sDepthM / 2, zI = -sDepthM / 2, pos = [], tlMM = topLine(CONFIG); const P = (q, z, h) => [q.x * toM, (tlMM.at(q.x) + h) * toM, z]; const quad = (a, b, c, d) => pos.push(...a, ...b, ...c, ...a, ...c, ...d); for (let k = 0; k < wg.length - 1; k++) { const a = wg[k], b = wg[k + 1]; quad(P(a, zO, a.o), P(b, zO, b.o), P(b, zI, b.i), P(a, zI, a.i)); // boven (dakkant) quad(P(a, zO, 0), P(b, zO, 0), P(b, zO, b.o), P(a, zO, a.o)); // buiten quad(P(a, zI, a.i), P(b, zI, b.i), P(b, zI, 0), P(a, zI, 0)); // binnen woodVolumeM3 += ((a.o + a.i) / 2 + (b.o + b.i) / 2) / 2 * (b.x - a.x) * CONFIG.studDepth * 1e-9; } [wg[0], wg[wg.length - 1]].forEach(q => quad(P(q, zO, 0), P(q, zO, q.o), P(q, zI, q.i), P(q, zI, 0))); // kopse kanten const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.computeVertexNormals(); const wedge = new THREE.Mesh(geo, woodMaterial.side === THREE.DoubleSide ? woodMaterial : wedgeMaterial(woodMaterial)); wedge.castShadow = true; wallGroup3D.add(wedge); } // C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk // stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame. // Ingesloten stijlen (frameEnclosed) tellen mee in het volume, maar // worden niet gebouwd. calcData.studs.forEach(st => { const xM = st.x * toM; const xLeft = xM - (sWidthM / 2); const xRight = xM + (sWidthM / 2); // Onderkant schuin als het stuk op een schuine bovendorpel staat. const y0l = (st.y0l !== undefined ? st.y0l : st.y0) * toM, y0r = (st.y0r !== undefined ? st.y0r : st.y0) * toM; const pts = st.y1 === null ? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()] : [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }]; if (frameEnclosed && HIDDEN_STUDS[st.kind]) { woodVolumeM3 += polygonArea(pts) * sDepthM; return; } wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2)); }); // D. DORPELS, per opening opsM.forEach(op => { // Bovendorpel / linteel (liggende balk, volgt een schuine bovenzijde; // loodrechte dikte = stijldikte) 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); opTopShape.lineTo(op.xMax, op.yMaxR); opTopShape.lineTo(op.xMax, op.yMaxR + vo); opTopShape.lineTo(op.xMin, op.yMax + vo); opTopShape.closePath(); wallGroup3D.add(createExtrudedBeam(opTopShape, sDepthM, -sDepthM / 2)); horizBeamCount++; // Onderdorpel (indien zwevend, dus niet op de onderregel) — zit // ONDER de opening (niet erin), consistent met de BOM/PDF-logica. if (op.yMin > pThickM) { const opBotShape = new THREE.Shape(); opBotShape.moveTo(op.xMin, op.yMin - sWidthM); opBotShape.lineTo(op.xMax, op.yMin - sWidthM); opBotShape.lineTo(op.xMax, op.yMin); opBotShape.lineTo(op.xMin, op.yMin); opBotShape.closePath(); wallGroup3D.add(createExtrudedBeam(opBotShape, sDepthM, -sDepthM / 2)); horizBeamCount++; } }); // (Isolatie wordt niet getekend; de spouwinhoud zit in computeSpecs.) // E. VERTICALE PLATEN — gedeelde opbouw voor spaanplaat (binnenzijde, // altijd) EN cement-gebonden platen (buitenzijde, als die gekozen is): // beide zijn plaatmateriaal van precies 1m breed, elk hoog genoeg om in // een enkel stuk te zagen (geen horizontale naden, alleen verticale). // De platen worden vanaf een ankerzijde gelegd; de laatste (smallere) // reststrook valt aan de andere kant. const maxDepth = Math.max(sDepthM, pDepthM); // Klein, bewust niet-nul luchtgaatje tussen plaat en houtwerk: voorkomt // dat vlakken exact samenvallen (co-planar), wat anders overlap- // artefacten/flikkering (z-fighting) geeft, met name goed zichtbaar op // scherende hoeken zoals de schuine bovenrand. const skinGapM = SKIN_GAP_MM * 0.001; // 0.5 mm const seamM = 0.0005; // 0.5 mm naad tussen twee aangrenzende platen/banen const boardWidthM = 1.0; // platen zijn altijd exact 1m breed // Ankerzijde: gelijke hoogte -> links; linkerkant het hoogst -> rechts; // rechterkant het hoogst -> links. (De volle platen beginnen op de // ankerzijde, de smalle reststrook valt aan de andere kant.) let boardAnchorSide; if (hLeftM === hRightM) boardAnchorSide = 'left'; else if (hLeftM > hRightM) boardAnchorSide = 'right'; else boardAnchorSide = 'left'; const numFullBoards = Math.floor(wWidthM / boardWidthM); const remainderM = wWidthM - numFullBoards * boardWidthM; let boardBoundaries; if (boardAnchorSide === 'left') { boardBoundaries = []; for (let i = 0; i <= numFullBoards; i++) boardBoundaries.push(-halfW + i * boardWidthM); if (remainderM > 1e-9) boardBoundaries.push(halfW); } else { const raw = []; for (let i = 0; i <= numFullBoards; i++) raw.push(halfW - i * boardWidthM); if (remainderM > 1e-9) raw.push(-halfW); boardBoundaries = raw.slice().reverse(); } // Uitgebreide uitsparing per opening (1x stijldikte marge, zie hierboven), // eenmalig berekend zodat elke plaat/baan er alleen zijn eigen deel van // hoeft te knippen. Extra: de bovenkant van elke uitsparing wordt hier // ook nog defensief tegen de lokale daklijn geklemd — bij een sterk // hellende wand met een opening dicht bij de lage kant kan de marge // (+sWidthM t.b.v. de bovendorpel) anders lokaal boven het dak uitkomen, // wat dezelfde soort kapotte-uitsparing-bug zou geven als eerder opgelost. // (Berekening zelf: sheathingHoles, gedeeld met de 2D-tekening.) const edgeEps = 1e-6; // Hoekdoorloop per buitenlaag, in m (zie cornerExt). const extM = (depthMM) => { const e = cornerExt(skinExt, depthMM); return { left: e.left * toM, right: e.right * toM }; }; const holeExtents = sheathingHoles(CONFIG).map(hole => ({ hx0: hole.hx0 * toM, hx1: hole.hx1 * toM, hy0: hole.hy0 * toM, hy1: hole.hy1 * toM, hy1r: hole.hy1r * toM })); // Binnenhoek-doorloop (negatieve skinExt): dat stuk zit binnen de wand // en het dak van de buur-unit; daar geen buitenlagen, tot opts.trimTop // ({ left, right }, mm; bovenkant dak van de buur). Daarboven loopt de // gevel gewoon door. Als uitsparing (zoals een opening) in alle // buitenlagen. const trimL = skinExt && skinExt.left < 0 ? -skinExt.left * toM : 0; const trimR = skinExt && skinExt.right < 0 ? -skinExt.right * toM : 0; const trimTop = opts.trimTop || {}; const topOf = (v) => (v > 0 ? v : 1e7) * toM; // Nooit tot in de bovenrand (dan geeft columnMinusRects geen vorm en // valt de hele laag weg): hooguit 1 mm eronder; dat restje zit in het // dak van de buur. const underTop = (x0, x1) => Math.min(tlOut.at(x0), tlOut.at(x1), ...tlOut.kinks.filter(k => k > x0 && k < x1).map(tlOut.at)) - 0.001; const trimHoles = []; if (trimL > 0) trimHoles.push({ hx0: -halfW - 1, hx1: -halfW + trimL, hy0: -1, hy1: Math.min(topOf(trimTop.left), underTop(-halfW, -halfW + trimL)) }); if (trimR > 0) trimHoles.push({ hx0: halfW - trimR, hx1: halfW + 1, hy0: -1, hy1: Math.min(topOf(trimTop.right), underTop(halfW - trimR, halfW)) }); const outerHoles = holeExtents.concat(trimHoles); const trimHolesMM = trimHoles.map(h => ({ hx0: h.hx0 / toM, hx1: h.hx1 / toM, hy0: h.hy0 / toM, hy1: h.hy1 / toM })); // Steenblok (trasraam en steengevel): isolatieblok met een steenstrip // ervoor, samen STONE.THICK_MM dik, vóór het wandvlak (doek resp. // cementplaat). Van hoogte y0 tot yCap (m) of tot de daklijn, met de // openingen uitgespaard. Hoeken: doorloop zoals de andere buitenlagen // (cornerExt); het laatste stuk van die doorloop is de hoek zelf, // waarvan de kopse kant zichtbaar is: dat stuk is over de volle dikte // steen. UV's in meters in gevel-coördinaten, zodat het metselwerk // over wandstukken en naden doorloopt; per vlakrichting gezet (ook de // kopse kanten horizontaal, niet de standaard van ExtrudeGeometry). // Verticaal: anchor = { y (m), down } — op hoogte y ligt een voeg; // de gevel telt vanaf daar omhoog (hele stenen boven de naad), het // trasraam vanaf zijn bovenkant omlaag (hele stenen onder de naad). // Geeft { areaM2, isoM3 }. function stoneBlock(code, y0, yCap, anchor) { // v (m) op hoogte y: omhoog vanaf de onderste voeg van de textuur; // omlaag vanaf een voeg een aantal hele lagen hoger in de textuur, // zodat de strook eronder nog binnen de textuur valt. const jointM = STONE.JOINT_MM * toM, courseM = STONE.COURSE_MM * toM; const vOff = anchor.down ? jointM + Math.ceil(Math.max(0, anchor.y - y0) / courseM) * courseM - anchor.y : jointM - anchor.y; const setUV = (geo) => { const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv; for (let k = 0; k < pos.count; k++) { const x = pos.getX(k) + uvShiftM, y = pos.getY(k) + vOff, z = pos.getZ(k); const nx = Math.abs(nrm.getX(k)), ny = Math.abs(nrm.getY(k)); if (nx > 0.5) uv.setXY(k, z, y); else if (ny > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y); } uv.needsUpdate = true; return geo; }; const S = STONE; const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM; const tIso = (S.THICK_MM - S.STRIP_MM) * toM, tAll = S.THICK_MM * toM; const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + S.THICK_MM); const xL = -halfW - ext.left, xR = halfW + ext.right; const capL = ext.left > 0 ? xL + tAll : xL, capR = ext.right > 0 ? xR - tAll : xR; const topClamped = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x))); const rects = outerHoles.map(holeRect); const shapesIn = (a, b) => { if (b - a <= edgeEps) return []; const capped = yCap === Infinity; const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects, capped ? { extraXs: tlOut.kinks.concat([-halfW, halfW]) } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap), [-halfW, halfW]) }); return pieces.map(piece => { const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y))))); return shape; }); }; const add = (shapes, depth, z, mat) => { if (!shapes.length || !buitenVisible || !show('gevel')) return; const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat); mesh.position.set(0, 0, z); mesh.castShadow = true; wallGroup3D.add(mesh); }; const stoneMat = stoneMaterial(code); const isoMat = opts.materials.isolatieMaterial || cementplaatMaterial; const mid = shapesIn(capL, capR); const caps = shapesIn(xL, capL).concat(shapesIn(capR, xR)); add(mid, tIso, zB, isoMat); add(mid, tAll - tIso, zB + tIso, stoneMat); add(caps, tAll, zB, stoneMat); const area = (list) => list.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso }; } // side: 'front' (buitenzijde/zuid) of 'back' (binnenzijde/noord). // range (optioneel, m): { y0, y1, zExtra } = alleen de strook daartussen // (trasraam), zExtra verder naar buiten. ext (m): { left, right, zOut } = // doorloop voorbij de wandranden (hoekdoorloop buitenzijde). Zonder zOut // lopen de randplaten door (laag ligt al vóór het wandvlak); met zOut // komt er een losse strook zOut verder naar buiten (laag in de // beplatingsruimte, anders zouden de hoekstijlen erdoor steken). function buildVerticalBoardSkin(material, side, visible, range, ext) { // Geschilderde spaanplaat (opts.painted): één egaal vlak, geen naden. const seamless = painted && material === spaanplaatMaterial; const bounds = seamless ? [boardBoundaries[0], boardBoundaries[boardBoundaries.length - 1]] : boardBoundaries.slice(); if (ext && !ext.zOut) { bounds[0] -= ext.left; bounds[bounds.length - 1] += ext.right; } const yB = range ? range.y0 : 0; const yCap = range ? range.y1 : Infinity; const TL = side === 'front' && !range ? tlOut : tl; // buitenplaat: tot onder de dakrand const HX = side === 'front' ? outerHoles : holeExtents; const skinTopAt = (x) => Math.min(yCap, TL.at(x)); let areaM2 = 0, volumeM3 = 0; const z = (side === 'front' ? (maxDepth / 2) + skinGapM : -(maxDepth / 2) - sThickM - skinGapM) + ((range && range.zExtra) || 0); // Duidelijke naad tussen platen: dunne rand rond elke plaat, 50% // donkerder dan de plaatkleur zelf (ook zichtbaar rond eventuele // opening-uitsparingen, niet alleen tussen platen onderling). const edgeMaterial = new THREE.LineBasicMaterial({ color: material.color.clone().multiplyScalar(0.5) }); for (let i = 0; i < bounds.length - 1; i++) { const bx0 = bounds[i]; const bx1 = bounds[i + 1]; const isWallLeftEdge = i === 0; const isWallRightEdge = i === bounds.length - 2; // Alleen inspringen op interne naden; de buitenste plaatranden blijven // gewoon vlak met de wandrand (geen naad met het kozijn/de hoekstijl). const ex0 = bx0 + (isWallLeftEdge ? 0 : seamM / 2); const ex1 = bx1 - (isWallRightEdge ? 0 : seamM / 2); if (ex1 - ex0 <= edgeEps) continue; // Plaat = strook min uitsparingen (columnMinusRects): een opening // over een plaatnaad wordt een inkeping in de plaatrand, zodat er // geen reepje plaat (met randlijnen) door de opening blijft lopen. const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt, HX.map(holeRect), { yCap: range ? yCap : undefined, extraXs: range ? TL.kinks.concat(TL.crossings(yCap)) : TL.kinks }); const boardShapes = pieces.map(piece => { const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); return shape; }); if (!boardShapes.length) continue; const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); // Niet zichtbaar (fase/weergave): alleen meten, niet bouwen. if (visible) { const boardGeo = new THREE.ExtrudeGeometry(boardShapes, { steps: 1, depth: sThickM, bevelEnabled: false }); const board = new THREE.Mesh(boardGeo, material); board.position.set(0, 0, z); wallGroup3D.add(board); if (!seamless) { const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(boardGeo), edgeMaterial); edgeLines.position.set(0, 0, z); wallGroup3D.add(edgeLines); } } areaM2 += boardAreaM2; volumeM3 += boardAreaM2 * sThickM; } if (ext && ext.zOut) { [[-halfW - ext.left, -halfW], [halfW, halfW + ext.right]].forEach(([a, b]) => { if (b - a <= edgeEps || Math.min(skinTopAt(a), skinTopAt(b)) <= yB + edgeEps) return; const shape = new THREE.Shape([ new THREE.Vector2(a, yB), new THREE.Vector2(b, yB), new THREE.Vector2(b, skinTopAt(b)), new THREE.Vector2(a, skinTopAt(a)) ]); if (visible) { const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false }); const mesh = new THREE.Mesh(geo, material); mesh.position.set(0, 0, z + ext.zOut); wallGroup3D.add(mesh); const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial); edgeLines.position.set(0, 0, z + ext.zOut); wallGroup3D.add(edgeLines); } const a2 = shapeAreaWithHoles(shape); areaM2 += a2; volumeM3 += a2 * sThickM; }); } return { areaM2, volumeM3 }; } // Binnenzijde (noord): altijd spaanplaat. const spaanplaatResult = buildVerticalBoardSkin(spaanplaatMaterial, 'back', spaanplaatVisible); // Kopse kant van een binnenhoek-doorloop (trimL/trimR): die staat open // naar de kamer van de buur-unit; binnenplaat erop (over regelwerk + // beplating), net buiten het stijlvlak tegen flikkeren. [[trimL, -halfW, -1], [trimR, halfW, 1]].forEach(([trim, x, dir]) => { if (!(trim > 0)) return; const h = Math.min(topYAt(x), topOf(dir < 0 ? trimTop.left : trimTop.right)), depth = maxDepth + 2 * (skinGapM + sThickM); const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial); cap.rotation.y = dir * Math.PI / 2; cap.position.set(x + dir * 0.0003, h / 2, 0); if (spaanplaatVisible) wallGroup3D.add(cap); }); spaanplaatAreaM2 = spaanplaatResult.areaM2; spaanplaatVolumeM3 = spaanplaatResult.volumeM3; // Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als // hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide. if (spaanplaatBelow) { const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'), { y0: 0, y1: skinFrom * 0.001 }); spaanplaatBuitenAreaM2 = spaanOnder.areaM2; spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3; } if (buitenMateriaal === 'spaanplaat') { // Binnenwand: aan beide zijden spaanplaat. const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating')); spaanplaatBuitenAreaM2 = spaanBuiten.areaM2; spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3; } else if (buitenMateriaal === 'geen') { // Naadzijde: geen beplating (het deel van de andere unit staat ertegen). } else if (buitenMateriaal === 'cementplaat') { const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('beplating'), null, Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM })); cementplaatAreaM2 = cementResult.areaM2; cementplaatVolumeM3 = cementResult.volumeM3; // Steen (opts.stone): steenblok (isolatie + strip) over de hele gevel, // vóór de cement-gebonden plaat; onderaan het trasraam (eigen steen // of cementplaat), de gevelsteen begint erboven. if (stone) { let y0 = 0; if (trasraam && isStoneFinish(trasraam.finish)) { const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); trasraamStoneAreaM2 = r.areaM2; trasraamIsolatieM3 = r.isoM3; y0 = trasraam.top * toM; } else if (trasraam) { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + skinGapM }, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM)); trasraamAreaM2 = tras.areaM2; trasraamVolumeM3 = tras.volumeM3; y0 = trasraam.top * toM; } // Anker op de gevel-hoogte van de naad (facade.gridY0 = bovenkant // trasraam t.o.v. dit stuk), zodat ook een opbouw aansluit. const anchorY = facade && facade.gridY0 !== undefined ? facade.gridY0 * toM : y0; const r = stoneBlock(stone, y0, Infinity, { y: anchorY, down: false }); stoneAreaM2 = r.areaM2; stoneIsolatieM3 = r.isoM3; } } else { // F. VOCHTDOEK BUITENZIJDE (zuid) — horizontale banen van 1500mm // (rolbreedte), "oneindig" strekkend in de breedte (dus geen // verticale naden nodig binnen één baan). Begint onderaan // (vloerhoogte); de bovenste baan wordt afgeknipt op de schuine // daklijn. Elke baan overlapt de vorige met 5cm. const doekHeightM = 1.5; const doekOverlapM = 0.05; const doekStepM = doekHeightM - doekOverlapM; // Kleine Z-stap per baan: zorgt dat een hogere baan (die de vorige met // 5cm overlapt) in de overlapzone net iets vóór de onderliggende baan // ligt — zoals bij een echte shingle-overlap — i.p.v. exact co-planair // (wat flikkering/z-fighting zou geven nu de platen niet meer // transparant zijn). const doekZStepM = 0.0002; const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte // Doek tot de bovenrand van de gevel (achter de dakrand langs, ook // zichtbaar in fasen zonder dakrand); latwerk en rabat stoppen onder // het boeideel/de daktrim (tlBat). const tlBat = CONFIG_BAT === CONFIG_OUT ? tlOut : topLine(CONFIG_BAT, toM); const maxRoofY = tlOut.maxY; let rowY0 = skinFrom * toM; let rowIndex = 0; while (rowY0 < maxRoofY - 1e-9) { const rowY1 = rowY0 + doekHeightM; // Baan = strook [rowY0, min(rowY1, daklijn)] min uitsparingen // (columnMinusRects, zelfde als de platen): een opening over de // baanrand wordt een inkeping, geen reepje doek door de opening. const roofAt = tlOut.at; const [rx0, rx1] = tlOut.rangeAbove(rowY0) || [0, 0]; const extraXs = tlOut.crossings(rowY1).concat(tlOut.kinks); const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)), outerHoles.map(holeRect), { yCap: rowY1, extraXs }) : []; const rowShapes = pieces.map(piece => { const shape = new THREE.Shape(piece.outer.map(p => new THREE.Vector2(p.x, p.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(p => new THREE.Vector2(p.x, p.y))))); return shape; }); if (rowShapes.length) { const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); if (doekOn) { const doekRow = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(rowShapes, { steps: 1, depth: sThickM, bevelEnabled: false })), doekMaterial); // 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op // de wand), anders flikkert het dak door het doek heen waar de // gevel tot onder de dakrand doorloopt (skinAbove). doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M); wallGroup3D.add(doekRow); } // Elke baan telt met zijn volle oppervlak mee (incl. de overlapzone // met de vorige baan) — dat is fysiek correct voor een materiaal- // hoeveelheid: de overlap wordt ook echt van de rol verbruikt. doekAreaM2 += rowAreaM2; } // Hoekdoorloop: strook over de kopse kant van de doorlopende wand, // net vóór het wandvlak (het doek zelf ligt in de beplatingsruimte). const dExt = extM(0); // Dun (1 mm, membraan); het latwerk ligt eroverheen. const zOut = (maxDepth / 2) + skinGapM + sThickM + skinGapM + rowIndex * doekZStepM; const stripM = 0.001; [[-halfW - dExt.left, -halfW], [halfW, halfW + dExt.right]].forEach(([a, b]) => { if (b - a <= edgeEps || Math.min(roofAt(a), roofAt(b)) <= rowY0 + edgeEps) return; const pts = [{ x: a, y: rowY0 }, { x: b, y: rowY0 }, { x: b, y: Math.min(rowY1, roofAt(b)) }, { x: a, y: Math.min(rowY1, roofAt(a)) }]; const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))); if (doekOn) { const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial); mesh.position.set(0, 0, zOut); wallGroup3D.add(mesh); } doekAreaM2 += shapeAreaWithHoles(shape); }); rowY0 += doekStepM; rowIndex++; } // H. TRASRAAM: onderaan cement-gebonden plaat (zelfde plaatlogica en // uitsparingen als de buitenplaten), vóór het doek // (zelfde vlak als de latten). Het doek loopt // er ongewijzigd achter door; het latwerk begint erboven. if (trasraam && isStoneFinish(trasraam.finish)) { const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); trasraamStoneAreaM2 = r.areaM2; trasraamIsolatieM3 = r.isoM3; } else if (trasraam) { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM }, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM)); trasraamAreaM2 = tras.areaM2; trasraamVolumeM3 = tras.volumeM3; } // G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op // het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen. const bat = computeBattens(CONFIG_BAT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM); const bT = BATTEN.thick * toM, bW = BATTEN.width * toM; const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM + DOEK_OUT_M; const addLayer = (orientation, z) => { bat.layers[orientation].forEach(b => { const horiz = orientation === 'horizontaal'; const len = horiz ? (b.x1 - b.x0) * toM : (b.y1 - b.y0) * toM; const mesh = new THREE.Mesh(new THREE.BoxGeometry(horiz ? len : bW, horiz ? bW : len, bT), battenMaterial); mesh.position.set(horiz ? (b.x0 + b.x1) / 2 * toM : b.x * toM, horiz ? b.y * toM : (b.y0 + b.y1) / 2 * toM, z + bT / 2); wallGroup3D.add(mesh); }); }; if (battensOn) { addLayer(bat.under, zDoek); addLayer(bat.top, zDoek + bT); } battenLengthM = bat.totalLengthMM * toM; battenVolumeM3 = battenLengthM * bT * bW; battenCount = bat.stock.count; battenPieces = bat.lengths.length; // I. RABAT op de kruislat: één blok van het doek tot de voorkant van // de planken (RABAT), boven het trasraam tot onder de dakrand (zoals // het latwerk), met dezelfde uitsparingen en hoekdoorloop als de // andere buitenlagen. Oppervlak (gevelvlak) altijd, het blok alleen // vanaf fase "gevel". const rabatDepthMM = 2 * BATTEN.thick + CLADDING_THICK_MM; const rExt = extM(rabatDepthMM); const rxL = -halfW - rExt.left, rxR = halfW + rExt.right; const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM; const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x))); const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps) ? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => { const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y))))); return shape; }) : []; rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); if (rabatOn && rabatShapes.length) { const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false }); // UV's in gevel-coördinaten: u langs de gevel, v vanaf het latten- // raster (bovenkant trasraam), zodat de planknaden doorlopen. const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM; const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv; for (let k = 0; k < pos.count; k++) { const x = pos.getX(k) + uvShiftM, y = pos.getY(k) - gridM, z = pos.getZ(k); if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y); } uv.needsUpdate = true; const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding)); mesh.position.set(0, 0, zDoek); mesh.castShadow = true; mesh.receiveShadow = true; wallGroup3D.add(mesh); } } // Kozijnen (KozijnModel): vanaf de fase gevel. Buitenwand: buitenvlak // gelijk met de buitenbeplating, 120 mm diep, met dorpel; binnen- en // naadwand: over de volle wanddikte, zonder dorpel. if (show('gevel') && window.KozijnModel) { const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2; const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat'; const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true } : buitenMateriaal === 'geen' ? { zOut: half, depth: CONFIG.studDepth + sh, exterior: false } : { zOut: half + sh, depth: CONFIG.studDepth + 2 * sh, exterior: false }; CONFIG.openings.forEach(op => { const k = op.fill ? KozijnModel.build3D(op, op.fill, place) : null; if (!k) return; k.position.set(startXM + op.x * toM, op.y * toM, 0); wallGroup3D.add(k); }); } return { group: wallGroup3D, stats: { woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2, spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount, battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3 } }; } // Hout, 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); } return wedgeMats.get(wood); } // Specificaties (volumes -> gewichten), op basis van de stats uit build3D. function computeSpecs(CONFIG, stats, buitenMateriaal) { const toM = 0.001; const wWidthM = CONFIG.wallWidth * toM; const hLeftM = CONFIG.wallHeightLeft * toM; const hRightM = CONFIG.wallHeightRight * toM; const sDepthM = CONFIG.studDepth * toM; const halfW = wWidthM / 2; // Omhullende van de wand (trapezium, incl. schuine top) als referentie // voor de leegte-berekening — spouwdiepte wordt gelijkgesteld aan de // stijldiepte (vereenvoudiging, zie toelichting in de UI). const envelopePoints = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...topLine(CONFIG, toM).pts.slice().reverse()]; const envelopeAreaM2 = polygonArea(envelopePoints); const envelopeVolumeM3 = envelopeAreaM2 * sDepthM; const openingsVolumeM3 = CONFIG.openings.reduce((sum, op) => sum + openingArea(op) * toM * toM * sDepthM, 0); let voidVolumeM3 = envelopeVolumeM3 - stats.woodVolumeM3 - openingsVolumeM3; if (voidVolumeM3 < 0) voidVolumeM3 = 0; const woodKg = stats.woodVolumeM3 * DENSITY_WOOD_KGM3; const spaanplaatKg = stats.spaanplaatVolumeM3 * DENSITY_SPAANPLAAT_KGM3; let buitenAreaM2, buitenKg, buitenVolumeM3; if (buitenMateriaal === 'spaanplaat') { buitenAreaM2 = stats.spaanplaatBuitenAreaM2; buitenKg = stats.spaanplaatBuitenVolumeM3 * DENSITY_SPAANPLAAT_KGM3; buitenVolumeM3 = stats.spaanplaatBuitenVolumeM3; } else if (buitenMateriaal === 'geen') { buitenAreaM2 = 0; buitenKg = 0; buitenVolumeM3 = 0; } else if (buitenMateriaal === 'cementplaat') { buitenAreaM2 = stats.cementplaatAreaM2; buitenKg = stats.cementplaatVolumeM3 * DENSITY_CEMENTPLAAT_KGM3; buitenVolumeM3 = stats.cementplaatVolumeM3; } else { // Vochtdoek wordt per m2 gewogen (rol/dun membraan), niet per m3. buitenAreaM2 = stats.doekAreaM2; buitenKg = stats.doekAreaM2 * DENSITY_DOEK_KGM2; buitenVolumeM3 = null; // n.v.t. (rolmateriaal) } const battenKg = (stats.battenVolumeM3 || 0) * DENSITY_WOOD_KGM3; const trasraamKg = (stats.trasraamVolumeM3 || 0) * DENSITY_CEMENTPLAAT_KGM3; const totalKg = woodKg + spaanplaatKg + buitenKg + battenKg + trasraamKg; return { woodKg, spaanplaatKg, buitenAreaM2, buitenKg, buitenVolumeM3, voidVolumeM3, battenKg, trasraamKg, totalKg }; } // Materiaallijst (BOM): elk los stuk hout met zijn maat, soort en // positie (voor de PDF-tekening en de unit-totalen). // Elk stuk krijgt ook cutAngles: per zaagsnede de hoek t.o.v. haaks (graden; // 0 = haaks). Schuine kop onder de daklijn, bovenregel langs de helling // (loodrechte snede), keep onder een nok = twee sneden bovenaan. const cutDeg = (dy, dx) => Math.round(Math.atan2(Math.abs(dy), Math.abs(dx)) * 180 / Math.PI * 10) / 10; function computeBOM(CONFIG, calcData) { const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight; const halfW = wallWidth / 2; const tl = topLine(CONFIG); const startX = -halfW; const pieces = []; const opsWorld = CONFIG.openings.map(op => ({ xMin: startX + op.x, xMax: startX + op.x + op.w, yMin: op.y })); const floorCuts = opsWorld.filter(o => o.yMin < CONFIG.plateThick) .map(o => ({ min: o.xMin, max: o.xMax })).sort((a, b) => a.min - b.min); let cursor = -halfW; floorCuts.forEach(cut => { if (cut.min > cursor) { pieces.push({ length: cut.min - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + cut.min) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] }); } cursor = Math.max(cursor, cut.max); }); if (cursor < halfW) { pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] }); } // Schuine regel (CONFIG.topWedge): één stuk over de wand, uit een balk // van de grootste wighoogte gezaagd onder de dakhelling. // Per aaneengesloten stuk waar een wig zit (bijv. alleen onder een wolfseind). if (CONFIG.topWedge) { const wg = CONFIG.topWedge.pts, has = (q) => Math.max(q.o, q.i) >= 5, runs = []; for (let k = 0; k < wg.length - 1; k++) { if (!has(wg[k]) && !has(wg[k + 1])) continue; const last = runs[runs.length - 1]; if (last && last[last.length - 1] === wg[k]) last.push(wg[k + 1]); else runs.push([wg[k], wg[k + 1]]); } runs.forEach(run => { const hMax = Math.max(...run.map(q => Math.max(q.o, q.i))); const bevel = Math.round(Math.max(...run.map(q => Math.atan(Math.abs(q.o - q.i) / CONFIG.studDepth))) * 1800 / Math.PI) / 10; let len = 0; for (let k = 0; k < run.length - 1; k++) len += Math.hypot(run[k + 1].x - run[k].x, tl.at(run[k + 1].x) - tl.at(run[k].x)); const outline = run.map(q => ({ x: q.x, y: tl.at(q.x) })).concat(run.slice().reverse().map(q => ({ x: q.x, y: tl.at(q.x) + Math.max(q.o, q.i) }))); const xm = (run[0].x + run[run.length - 1].x) / 2; pieces.push({ length: Math.round(len), width: Math.max(1, Math.round(hMax)), depth: CONFIG.studDepth, kind: 'schuine regel', x: xm, y: tl.at(xm) + hMax / 2, outline, cutAngles: [bevel, bevel] }); }); } // Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke // omtrek (PDF-tekening). topPlateOutlines(tl).forEach((outline, i) => { const sg = tl.segs[i]; pieces.push({ length: Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y), width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel', x: (sg.a.x + sg.b.x) / 2, y: (sg.a.y + sg.b.y) / 2 - CONFIG.plateThick / 2, outline, cutAngles: [cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x), cutDeg(sg.b.y - sg.a.y, sg.b.x - sg.a.x)] }); }); // Verticale stukken krijgen, naast de bounding-box velden (length/y — // gebruikt voor de materiaallijst: de lengte van het rechte stuk hout // waar de schuine kop uit gezaagd wordt), ook hun 4 werkelijke // hoekpunten (yBottomLeft/Right, yTopLeft/Right). De PDF-tekening // gebruikt die hoekpunten om het stuk als schuin viervlak te tekenen // i.p.v. als platte rechthoek tot het hoogste punt — anders komt alleen // de hoogste kant van de balk goed op de daklijn te staan, en blijft de // laagste kant er (visueel) los van. // (outline: ook de knik onder de nok, als de stijl daar staat.) calcData.studs.forEach(st => { const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2; const yBottom = st.y0; const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk'; // Schuine onderkant: stuk op een schuine bovendorpel. const yBottomLeft = st.y0l !== undefined ? st.y0l : yBottom, yBottomRight = st.y0r !== undefined ? st.y0r : yBottom; const botCut = cutDeg(yBottomRight - yBottomLeft, xRight - xLeft); if (st.y1 !== null) { pieces.push({ length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, y: (yBottom + st.y1) / 2, yBottomLeft, yBottomRight, yTopLeft: st.y1, yTopRight: st.y1, cutAngles: [botCut, 0] }); return; } const topPts = tl.underPts(xLeft, xRight); const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y; pieces.push({ length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x, y: (yBottomLeft + yBottomRight + yTopLeft + yTopRight) / 4, yBottomLeft, yBottomRight, yTopLeft, yTopRight, outline: [{ x: xLeft, y: yBottomLeft }, { x: xRight, y: yBottomRight }, ...topPts.slice().reverse()], cutAngles: [botCut].concat(topPts.slice(1).map((q, k) => cutDeg(q.y - topPts[k].y, q.x - topPts[k].x))) }); }); CONFIG.openings.forEach(op => { const opXWorld = startX + op.x; // Schuine bovenzijde: dorpel langs de helling (loodrechte sneden, // zoals de bovenregel), met de werkelijke omtrek voor de tekening. const dh = op.hR - op.h; const header = { length: Math.hypot(op.w, dh), width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + (op.h + op.hR) / 2 + CONFIG.studWidth / 2, cutAngles: [cutDeg(dh, op.w), cutDeg(dh, op.w)] }; if (Math.abs(dh) > 0.5) { const vo = CONFIG.studWidth * Math.hypot(op.w, dh) / op.w; header.outline = [{ x: opXWorld, y: op.y + op.h }, { x: opXWorld + op.w, y: op.y + op.hR }, { x: opXWorld + op.w, y: op.y + op.hR + vo }, { x: opXWorld, y: op.y + op.h + vo }]; } pieces.push(header); if (op.y > CONFIG.plateThick) { pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'onderdorpel', x: opXWorld + op.w / 2, y: op.y - CONFIG.studWidth / 2, cutAngles: [0, 0] }); } }); return pieces; } function groupBOM(pieces) { const map = new Map(); pieces.forEach(p => { const length = Math.round(p.length); const key = `${p.width}|${p.depth}|${length}`; if (!map.has(key)) map.set(key, { width: p.width, depth: p.depth, length, count: 0 }); map.get(key).count++; }); const groups = Array.from(map.values()); groups.sort((a, b) => b.length - a.length); groups.forEach((g, i) => { g.posnum = i + 1; }); const keyToPosnum = new Map(); groups.forEach(g => keyToPosnum.set(`${g.width}|${g.depth}|${g.length}`, g.posnum)); pieces.forEach(p => { p.posnum = keyToPosnum.get(`${p.width}|${p.depth}|${Math.round(p.length)}`); }); return groups; } // Inkoop/zaagplan per balkdoorsnede (dikte x diepte) over de gegeven // BOM-stukken — ook over meerdere wanden tegelijk (unit), zodat // reststukken van de ene wand in de andere gebruikt kunnen worden. function planPurchase(pieces) { const bySection = new Map(); pieces.forEach(p => { const key = `${p.width}x${p.depth}`; if (!bySection.has(key)) bySection.set(key, { width: p.width, depth: p.depth, lengths: [] }); bySection.get(key).lengths.push(p.length); }); return Array.from(bySection.values()).map(sec => { const bars = planStockCutting(sec.lengths, STOCK_LENGTHS_MM); return { width: sec.width, depth: sec.depth, bars, ...summarizeStockPlan(bars) }; }); } // Korte tekst, bijv. "38x235: 2x 3600, 5x 4800". function purchaseText(plan) { return plan.map(sec => `${sec.width}x${sec.depth}: ` + sec.perStock.map(s => `${s.count}x ${s.stock}`).join(', ')).join(' | ') || '-'; } // Structuurcontrole van een wand-state (bijv. bij JSON-import). Gooit een // Error met een leesbare melding als er iets ontbreekt/ongeldig is. function validateState(design) { if (!design.wall || typeof design.wall !== 'object') throw new Error('veld "wall" ontbreekt'); const requiredWallFields = ['width', 'heightAll', 'heightLeft', 'heightRight', 'studWidth', 'studDepth', 'plateThick', 'plateDepth', 'sheathingThick', 'minSpacing', 'maxSpacing']; requiredWallFields.forEach(f => { if (typeof design.wall[f] !== 'number' || !isFinite(design.wall[f])) { throw new Error(`wall.${f} ontbreekt of is geen geldig getal`); } }); // materials is optioneel bij import (oudere bestanden hebben dit veld // niet), maar als het aanwezig is moet de waarde wel geldig zijn. if (design.materials !== undefined) { if (typeof design.materials !== 'object' || design.materials === null) { throw new Error('veld "materials" is aanwezig maar geen object'); } if (design.materials.exterior !== undefined && !EXTERIOR_MATERIALS[design.materials.exterior]) { throw new Error(`materials.exterior moet ${Object.keys(EXTERIOR_MATERIALS).join('/')} zijn (was: ${design.materials.exterior})`); } if (design.materials.cladding !== undefined && !CLADDING_DIRECTIONS[design.materials.cladding]) { throw new Error(`materials.cladding moet ${Object.keys(CLADDING_DIRECTIONS).join('/')} zijn (was: ${design.materials.cladding})`); } } if (!Array.isArray(design.openings)) throw new Error('veld "openings" ontbreekt of is geen lijst'); design.openings.forEach((o, i) => { if (typeof o.w !== 'number' || typeof o.h !== 'number' || typeof o.y !== 'number' || typeof o.offset !== 'number') { throw new Error(`opening #${i + 1}: w/h/y/offset moeten getallen zijn`); } if (o.hR !== undefined && (typeof o.hR !== 'number' || !isFinite(o.hR))) { throw new Error(`opening #${i + 1}: hR (hoogte rechts) moet een getal zijn`); } if (o.type !== undefined && !OPENING_TYPES[o.type]) { throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`); } if (o.fill != null && (typeof o.fill !== 'object' || !KozijnModel.FILL_TYPES[o.fill.type])) { throw new Error(`opening #${i + 1}: fill.type moet ${Object.keys(KozijnModel.FILL_TYPES).join('/')} zijn`); } if (!['left', 'middle', 'right'].includes(o.anchor)) { throw new Error(`opening #${i + 1}: anchor moet 'left', 'middle' of 'right' zijn (was: ${o.anchor})`); } }); } return { STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs }; })();