1737 lines
93 KiB
JavaScript
1737 lines
93 KiB
JavaScript
/*!
|
||
* Tectum
|
||
* Copyright © 2026 Machiel Heinen.
|
||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||
* Zie LICENTIE.md.
|
||
*/
|
||
|
||
window.WallModel = (() => {
|
||
const DENSITY_WOOD_KGM3 = 450;
|
||
const DENSITY_SPAANPLAAT_KGM3 = 650;
|
||
const DENSITY_CEMENTPLAAT_KGM3 = 1250;
|
||
const DENSITY_DOEK_KGM2 = 1.5;
|
||
|
||
// Bewust niet-nul luchtgaatje tussen plaat en houtwerk (zie build3D).
|
||
const SKIN_GAP_MM = 0.5;
|
||
|
||
const STANDARD_BEAM = { width: 38, depth: 235 };
|
||
const STOCK_LENGTHS_MM = [3600, 4200, 4800, 5400];
|
||
|
||
const CORNER_STUDS = 2;
|
||
|
||
const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 };
|
||
const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' };
|
||
const CLADDING_THICK_MM = 12;
|
||
|
||
// In 3D één blok per wandstuk, van het doek tot de voorkant van de planken: zo zijn hoeken, dagkanten
|
||
// en onderrand dicht en vervallen doek en latten erachter.
|
||
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;
|
||
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();
|
||
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;
|
||
}
|
||
|
||
// Felsplaten (staande naad) op de gevel: zelfde opbouw als verticale rabat (doek, latten, bekleding), alleen het
|
||
// materiaal verschilt. Kleuren ook voor het dak (RoofModel.FELS). In 3D de naden als textuur over het blok.
|
||
const FELS = {
|
||
SEAM_MM: 500, TILE_SEAMS: 6,
|
||
COLORS: {
|
||
zink: { label: 'Zink', hex: '#8e9497' },
|
||
antraciet: { label: 'Antraciet', hex: '#3d4146' },
|
||
zwart: { label: 'Zwart', hex: '#1f2022' }
|
||
}
|
||
};
|
||
const isFelsColor = (key) => !!FELS.COLORS[key];
|
||
const felsHex = (key) => (FELS.COLORS[key] || FELS.COLORS.antraciet).hex;
|
||
const felsMaterials = {};
|
||
function felsMaterial(key) {
|
||
const k = isFelsColor(key) ? key : 'antraciet';
|
||
if (felsMaterials[k]) return felsMaterials[k];
|
||
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: felsHex(k), roughness: 0.4, metalness: 0.55 });
|
||
else {
|
||
const B = 100, N = FELS.TILE_SEAMS; // 5 mm/px
|
||
canvas.width = B * N;
|
||
canvas.height = 16;
|
||
const base = new THREE.Color(felsHex(k));
|
||
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
|
||
const col = (a, b, color) => { ctx.fillStyle = color; ctx.fillRect(a, 0, b - a, canvas.height); };
|
||
for (let i = 0; i < N; i++) {
|
||
const a = i * B, f = 0.97 + ((i * 29) % 7) / 100; // per baan net iets anders
|
||
col(a, a + B, shade(f));
|
||
col(a + 6, a + 14, shade(f * 1.04)); // lichte zetting naast de naad
|
||
col(a, a + 2, shade(f * 0.6)); // schaduw van de naad
|
||
col(a + 2, a + 5, shade(f * 1.35)); // staande naad (licht)
|
||
}
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(1000 / (FELS.SEAM_MM * N), 1);
|
||
tex.anisotropy = 8;
|
||
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.4, metalness: 0.55 });
|
||
}
|
||
felsMaterials[k] = mat;
|
||
return mat;
|
||
}
|
||
|
||
// Afwerking in zetwerk (aluminium, gepoedercoat): afdekker op een stenen trasraam en het ventilatierooster onder
|
||
// de boeidelen.
|
||
const TRIM_COLORS = {
|
||
zwart: { label: 'Zwart', hex: '#1f2022' },
|
||
antraciet: { label: 'Antraciet', hex: '#3d4146' },
|
||
wit: { label: 'Wit', hex: '#f3f2ee' }
|
||
};
|
||
const isTrimColor = (key) => !!TRIM_COLORS[key];
|
||
const trimHex = (key) => (TRIM_COLORS[key] || TRIM_COLORS.antraciet).hex;
|
||
const trimMaterials = {};
|
||
function trimMaterial(key) {
|
||
const k = isTrimColor(key) ? key : 'antraciet';
|
||
return trimMaterials[k] || (trimMaterials[k] = new THREE.MeshStandardMaterial({ color: trimHex(k), roughness: 0.45, metalness: 0.3 }));
|
||
}
|
||
// Afdekker trasraam: bovenkant 5° aflopend (85° met de neus), neus 25 mm over de steen, 3 mm ervoor.
|
||
const TRAS_CAP = { SLOPE_DEG: 5, DROP_MM: 25, GAP_MM: 3, THICK_MM: 2 };
|
||
|
||
function claddingOffset(buitenMateriaal, stone, stuc) {
|
||
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||
if (buitenMateriaal !== 'cementplaat') return 0;
|
||
return stone ? STONE.THICK_MM : stuc ? STUC.THICK_MM : 0;
|
||
}
|
||
|
||
const STUC = { THICK_MM: 15 };
|
||
const STUC_COLORS = {
|
||
wit: { label: 'Wit', hex: '#f3f2ee' },
|
||
gebroken_wit: { label: 'Gebroken wit', hex: '#ebe5d8' },
|
||
zand: { label: 'Zand', hex: '#ddd0b8' },
|
||
lichtgrijs: { label: 'Lichtgrijs', hex: '#cfd0cd' },
|
||
grijs: { label: 'Grijs', hex: '#9c9e9c' },
|
||
antraciet: { label: 'Antraciet', hex: '#4b4e51' }
|
||
};
|
||
const isStucColor = (key) => !!STUC_COLORS[key];
|
||
const stucHex = (key) => (STUC_COLORS[key] || STUC_COLORS.wit).hex;
|
||
const stucMaterials = {};
|
||
function stucMaterial(key) {
|
||
const k = isStucColor(key) ? key : 'wit';
|
||
if (stucMaterials[k]) return stucMaterials[k];
|
||
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: stucHex(k), roughness: 0.95 });
|
||
else {
|
||
const N = 128;
|
||
canvas.width = canvas.height = N;
|
||
const base = new THREE.Color(stucHex(k));
|
||
const img = ctx.createImageData(N, N);
|
||
let seed = 7;
|
||
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
|
||
for (let i = 0; i < N * N; i++) {
|
||
const f = 0.94 + rnd() * 0.09;
|
||
img.data[i * 4] = Math.min(255, base.r * 255 * f);
|
||
img.data[i * 4 + 1] = Math.min(255, base.g * 255 * f);
|
||
img.data[i * 4 + 2] = Math.min(255, base.b * 255 * f);
|
||
img.data[i * 4 + 3] = 255;
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(4, 4); // 250 mm per tegel
|
||
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.97 });
|
||
}
|
||
stucMaterials[k] = mat;
|
||
return mat;
|
||
}
|
||
|
||
// Materiaal van een gevelbekleding (UV in meters): felsplaten, steen, stuc of rabat; null = kale plaat.
|
||
function claddingMaterial(f) {
|
||
if (isFelsColor(f.fels)) return felsMaterial(f.fels);
|
||
if (isStoneFinish(f.stone)) return stoneMaterial(f.stone);
|
||
if (isStucColor(f.stuc)) return stucMaterial(f.stuc);
|
||
return f.rabatColor ? rabatMaterial(f.rabatColor, f.cladding) : null;
|
||
}
|
||
|
||
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
||
const TRASRAAM_DEFAULT_MM = 350;
|
||
function trasraamFor(heightMM, finish, capColor) {
|
||
if (!(heightMM > 0)) return null;
|
||
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat', capColor: isTrimColor(capColor) ? capColor : 'antraciet' };
|
||
}
|
||
|
||
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
|
||
// COURSE_MM: steen + voeg (31 px). De bovenrand snijdt door een steen (1125 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(' ');
|
||
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');
|
||
// UV's 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;
|
||
}
|
||
// Negatief (binnenhoek): het stuk loopt zoveel de naad in; geen doorloop, de buitenlagen krijgen daar een uitsparing.
|
||
function cornerExt(skinExt, depth) {
|
||
const e = skinExt || {};
|
||
const one = (v) => v > 0 ? v + depth : 0;
|
||
return { left: one(e.left), right: one(e.right) };
|
||
}
|
||
|
||
function skinStartY(trasraam) {
|
||
return trasraam ? Math.max(0, trasraam.top) : 0;
|
||
}
|
||
|
||
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 }
|
||
};
|
||
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
|
||
|
||
// Ruwe openingen hebben hR alleen als ze bewust schuin zijn; opgeloste openingen altijd.
|
||
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;
|
||
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);
|
||
|
||
// 'geen': naadwand-deel dat tegen het deel van de andere unit staat.
|
||
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
|
||
|
||
// De daklijn is altijd bol, dus de bovenrand is overal het minimum van de doorgetrokken stukken.
|
||
// x t.o.v. het wandmidden, mm × k (k = 0.001: meters).
|
||
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));
|
||
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;
|
||
};
|
||
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)
|
||
};
|
||
}
|
||
|
||
function raiseTop(CONFIG, d) {
|
||
return d ? Object.assign({}, CONFIG, {
|
||
wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d,
|
||
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d }))
|
||
}) : CONFIG;
|
||
}
|
||
// Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove.
|
||
function skinConfig(CONFIG, skinAbove, skinTop) {
|
||
return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove);
|
||
}
|
||
|
||
function topPlateOutlines(tl) {
|
||
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
|
||
return tl.segs.map((sg, i) => [
|
||
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 }
|
||
]);
|
||
}
|
||
|
||
// Alleen punten die de daklijn bol houden (boven de koorde tussen hun buren).
|
||
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 constraint-solver: eerst x (ankers, tegen elkaar aan duwen, terug binnen de wand),
|
||
// dan per opening de hoogte tegen de daklijn op zijn eigen positie (niet het laagste punt van de wand).
|
||
// Past het niet, dan melden i.p.v. stil verkeerd tekenen.
|
||
function resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) {
|
||
// 2850 mm: max. sparingbreedte in dit constructiesysteem.
|
||
const maxOpW = Math.min(2850, Math.max(400, wallWidth - (studWidth * (2 * CORNER_STUDS + 2))));
|
||
// De koningsstijl mag tegen de binnenste hoekstijl aan vallen.
|
||
const edgeMargin = studWidth * CORNER_STUDS;
|
||
|
||
let resolved = rawOpenings.map(raw => {
|
||
// Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
|
||
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 400, raw.w), maxOpW);
|
||
|
||
let x;
|
||
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
|
||
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
|
||
else x = raw.offset;
|
||
|
||
const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
|
||
if (raw.chimney) o.chimney = raw.chimney;
|
||
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
|
||
return o;
|
||
});
|
||
|
||
resolved.sort((a, b) => a.x - b.x);
|
||
|
||
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
|
||
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
|
||
resolved.forEach(o => {
|
||
o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
|
||
});
|
||
|
||
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
|
||
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;
|
||
};
|
||
// 2× stijldikte: de OSB-uitsparing steekt aan weerszijden 1× stijldikte uit, anders overlappen de
|
||
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
|
||
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
|
||
|
||
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
|
||
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
|
||
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
|
||
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
|
||
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
|
||
const FAR = 'staat niet tegen de opening ernaast aan';
|
||
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
|
||
resolved.forEach((a, i) => {
|
||
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
|
||
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
|
||
let why = null;
|
||
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
|
||
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
|
||
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
|
||
if (why) { blame(a, b, why); return; }
|
||
rightOf.set(a, b);
|
||
leftOf.set(b, a);
|
||
});
|
||
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
|
||
|
||
for (let i = 1; i < resolved.length; i++) {
|
||
const cur = resolved[i], partner = leftOf.get(cur);
|
||
if (partner) cur.x = partner.x + partner.w;
|
||
for (let j = 0; j < i; j++) {
|
||
const prev = resolved[j];
|
||
if (stacked(prev, cur)) continue;
|
||
cur.x = Math.max(cur.x, prev.x + prev.w + gap(prev, cur));
|
||
}
|
||
}
|
||
|
||
for (let i = resolved.length - 1; i >= 0; i--) {
|
||
const cur = resolved[i];
|
||
let maxX = wallWidth - mR(cur) - cur.w;
|
||
for (let j = i + 1; j < resolved.length; j++) {
|
||
const next = resolved[j];
|
||
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gap(cur, next) - cur.w);
|
||
}
|
||
if (cur.x > maxX) cur.x = maxX;
|
||
}
|
||
|
||
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
|
||
resolved.forEach(o => {
|
||
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
|
||
// Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
|
||
const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
|
||
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
|
||
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
|
||
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
|
||
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
|
||
o.h = hL;
|
||
o.hR = hR;
|
||
});
|
||
|
||
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
|
||
const swing = (o) => !!o.fill && o.fill.type === 'deur';
|
||
rightOf.forEach((b, a) => {
|
||
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
|
||
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
|
||
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
|
||
});
|
||
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
|
||
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
|
||
|
||
resolved.forEach(o => {
|
||
o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 0.5);
|
||
});
|
||
for (let i = 0; i < resolved.length; i++) {
|
||
for (let j = i + 1; j < resolved.length; j++) {
|
||
const a = resolved[i], b = resolved[j];
|
||
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
|
||
if (!stacked(a, b, true) && r.x < l.x + l.w + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
|
||
}
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
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);
|
||
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 || []);
|
||
}
|
||
|
||
function thicknessOf(config, buitenMateriaal) {
|
||
const sides = buitenMateriaal === 'geen' ? 1 : 2;
|
||
return Math.max(config.studDepth, config.plateDepth) + sides * (config.sheathingThick + SKIN_GAP_MM);
|
||
}
|
||
|
||
function computeFrame(CONFIG) {
|
||
const startX = -CONFIG.wallWidth / 2;
|
||
const top = topLine(CONFIG);
|
||
const slope = top.segs[0].slope;
|
||
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));
|
||
|
||
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);
|
||
}
|
||
|
||
// kind: hoek (dubbele hoekstijl), koning (naast een opening), dubbel (tegen de koningsstijl van een deur),
|
||
// regel, kreupel (boven/onder een opening).
|
||
const sw = CONFIG.studWidth;
|
||
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
|
||
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' }));
|
||
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
|
||
ops.forEach(op => {
|
||
const x0 = startX + op.x, x1 = x0 + op.w;
|
||
if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
|
||
if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
|
||
else cands.push({ x: x1, kind: 'kreupel', seam: true });
|
||
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
|
||
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
|
||
});
|
||
|
||
const cols = [];
|
||
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];
|
||
}).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 })));
|
||
|
||
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
|
||
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
|
||
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
|
||
const segs = [];
|
||
cands.filter(inWall).forEach(c => {
|
||
const x = Math.round(c.x);
|
||
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
|
||
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
|
||
const curMax = () => Math.max(cur[0], cur[1]);
|
||
const push = (y1, above) => {
|
||
if (c.kind === 'kreupel' && !c.seam) {
|
||
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
|
||
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
|
||
if (!onTop && !(underneath && !below)) return;
|
||
}
|
||
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);
|
||
});
|
||
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);
|
||
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 };
|
||
}
|
||
|
||
// Bovenkant defensief tegen de lokale daklijn geklemd: bij een sterk hellende wand kan de marge anders
|
||
// boven het dak uitkomen (kapotte uitsparing).
|
||
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 => {
|
||
// Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
|
||
const m = op.type === 'schoorsteen' ? 0 : sw;
|
||
// Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
|
||
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
|
||
const hx0 = -halfW + op.x - mL;
|
||
const hx1 = -halfW + op.x + op.w + mR;
|
||
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
|
||
const clampTop = (y, x) => {
|
||
const ceil = topYAt(x) - roofSafetyMargin;
|
||
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
|
||
};
|
||
const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
|
||
const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
|
||
return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
|
||
});
|
||
}
|
||
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
|
||
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) });
|
||
|
||
function skinSegmentsAtY(CONFIG, y, extraHoles) {
|
||
if (y <= 0) return [];
|
||
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;
|
||
}
|
||
|
||
// Verticale latten per wandstuk (modules worden los vervoerd, de bekleding eindigt op de rand van het stuk),
|
||
// plus langs beide zijden van elke opening (de bekleding moet daar op een lat eindigen).
|
||
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom, extraHoles) {
|
||
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0);
|
||
const ext = cornerExt(skinExt, 2 * BATTEN.thick);
|
||
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';
|
||
|
||
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) {
|
||
// Afkappen op de bovenkant van de lat (y + w/2), anders steekt het uiteinde 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 });
|
||
});
|
||
}
|
||
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;
|
||
[!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
|
||
});
|
||
fixed.sort((a, b) => a - b);
|
||
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
|
||
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 => {
|
||
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)
|
||
};
|
||
}
|
||
|
||
// ExtrudeGeometry-UV's in meters, dus repeat = 1/tegel. Zonder DOM (node-tests) valt de textuur 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 = () => {
|
||
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);
|
||
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 })
|
||
};
|
||
}
|
||
|
||
// Lokaal (m): X = breedte gecentreerd, Y vanaf de onderkant onderregel, +Z = buitenzijde.
|
||
function build3D(CONFIG, calcData, opts) {
|
||
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||
const layers = ViewLayers.normalize(opts.layers);
|
||
const show = (key) => layers[key];
|
||
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
||
const buitenVisible = opts.buitenVisible !== false;
|
||
const cladding = opts.cladding || 'horizontaal';
|
||
const stucKey = buitenMateriaal === 'cementplaat' && !isStoneFinish(opts.stone) && isStucColor(opts.stuc) ? opts.stuc : null;
|
||
const trasraam = buitenMateriaal === 'vochtdoek' || (buitenMateriaal === 'cementplaat' && (isStoneFinish(opts.stone) || stucKey)) ? (opts.trasraam || null) : null;
|
||
const skinExt = opts.skinExt || null;
|
||
const facade = opts.facade || null;
|
||
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
|
||
const painted = !!opts.painted;
|
||
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
|
||
const felsKey = buitenMateriaal === 'vochtdoek' && isFelsColor(opts.fels) ? opts.fels : null;
|
||
// spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand.
|
||
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
||
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
||
const doekOn = buitenVisible && show('doek') && !rabatOn;
|
||
const battensOn = buitenVisible && show('latwerk') && !rabatOn;
|
||
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 in gevel-coördinaten, zodat het patroon over wandstukken doorloopt.
|
||
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
|
||
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();
|
||
let woodVolumeM3 = 0;
|
||
let spaanplaatVolumeM3 = 0;
|
||
let spaanplaatAreaM2 = 0;
|
||
let cementplaatVolumeM3 = 0;
|
||
let cementplaatAreaM2 = 0;
|
||
let doekAreaM2 = 0;
|
||
let spaanplaatBuitenAreaM2 = 0;
|
||
let spaanplaatBuitenVolumeM3 = 0;
|
||
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
|
||
let rabatAreaM2 = 0, felsAreaM2 = 0;
|
||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
||
let stucAreaM2 = 0;
|
||
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
|
||
let horizBeamCount = 0;
|
||
|
||
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;
|
||
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
|
||
const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop);
|
||
const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM);
|
||
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
|
||
const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove);
|
||
const underPts = tl.underPts;
|
||
|
||
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
|
||
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,
|
||
yMaxR: (op.y + op.hR) * toM,
|
||
wM: op.w * toM,
|
||
chimney: op.type === 'schoorsteen'
|
||
}));
|
||
|
||
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++;
|
||
}
|
||
|
||
// Bovenregel onderbroken waar een schoorsteen doorloopt.
|
||
const clipX = (pts, x, keepBelow) => {
|
||
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
|
||
pts.forEach((p, k) => {
|
||
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
|
||
if (fp >= 0) out.push(p);
|
||
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
|
||
});
|
||
return out;
|
||
};
|
||
let plates = topPlateOutlines(tl);
|
||
opsM.filter(op => op.chimney).forEach(op => {
|
||
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
|
||
});
|
||
plates.forEach(pts => {
|
||
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
|
||
horizBeamCount++;
|
||
});
|
||
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);
|
||
}
|
||
|
||
// 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);
|
||
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));
|
||
});
|
||
|
||
opsM.forEach(op => {
|
||
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
|
||
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
|
||
const opTopShape = new THREE.Shape();
|
||
opTopShape.moveTo(op.xMin, op.yMax);
|
||
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 zit onder de opening (niet erin), zoals in de BOM/PDF.
|
||
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.)
|
||
|
||
// Platen zijn 1 m breed en hoog genoeg voor één stuk: alleen verticale naden.
|
||
const maxDepth = Math.max(sDepthM, pDepthM);
|
||
// Bewust niet-nul luchtgaatje tussen plaat en houtwerk: samenvallende vlakken geven z-fighting,
|
||
// vooral op scherende hoeken zoals de schuine bovenrand.
|
||
const skinGapM = SKIN_GAP_MM * 0.001;
|
||
const seamM = 0.0005;
|
||
const boardWidthM = 1.0;
|
||
|
||
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();
|
||
}
|
||
|
||
const edgeEps = 1e-6;
|
||
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
|
||
}));
|
||
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 }));
|
||
|
||
// Verticaal: op anchor.y ligt een voeg; de gevel telt omhoog, het trasraam omlaag (hele stenen aan de naad).
|
||
const meterUV = (geo, vOff) => {
|
||
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;
|
||
};
|
||
// Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit.
|
||
const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x))));
|
||
const topClampedOut = beyondEnd(tlOut);
|
||
const layerShapes = (a, b, y0, yCap) => {
|
||
if (b - a <= edgeEps) return [];
|
||
const capped = yCap === Infinity;
|
||
const pieces = columnMinusRects(a, b, y0, capped ? topClampedOut : (x) => Math.min(yCap, topClampedOut(x)), outerHoles.map(holeRect),
|
||
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;
|
||
});
|
||
};
|
||
|
||
function stoneBlock(code, y0, yCap, anchor) {
|
||
// 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) => meterUV(geo, vOff);
|
||
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 shapesIn = (a, b) => layerShapes(a, b, y0, yCap);
|
||
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 };
|
||
}
|
||
|
||
// Afdekker op een stenen trasraam waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): zetwerk
|
||
// over de bovenkant van de steen, achter de bekleding beginnend, aan de voorkant een neus over de steen. Onderbroken
|
||
// waar een opening door de bovenkant van het trasraam gaat; bij een buitenhoek tot voorbij de steen van de buurwand.
|
||
function stoneCap(tr) {
|
||
const C = TRAS_CAP, T = tr.top * toM;
|
||
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM, zF = zB + STONE.THICK_MM * toM;
|
||
const zIn = zF + C.GAP_MM * toM, zOut = zIn + C.THICK_MM * toM, lip = zOut - zF;
|
||
const yFront = T + C.THICK_MM * toM, yBack = yFront + Math.tan(C.SLOPE_DEG * Math.PI / 180) * (zOut - zB);
|
||
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STONE.THICK_MM);
|
||
const a = -halfW - ext.left - (ext.left > 0 ? lip : 0), b = halfW + ext.right + (ext.right > 0 ? lip : 0);
|
||
const yLo = T - C.DROP_MM * toM;
|
||
const cuts = outerHoles.filter(h => h.hy0 < yBack && h.hy1 > yLo).map(h => [h.hx0, h.hx1]).sort((p, q) => p[0] - q[0]);
|
||
const runs = [];
|
||
let x = a;
|
||
cuts.forEach(([c0, c1]) => { if (c0 > x) runs.push([x, Math.min(b, c0)]); x = Math.max(x, c1); });
|
||
if (x < b) runs.push([x, b]);
|
||
const shape = new THREE.Shape([
|
||
[zB, T + 0.0005], [zIn, T + 0.0005], [zIn, yLo], [zOut, yLo], [zOut, yFront], [zB, yBack]
|
||
].map(([pz, py]) => new THREE.Vector2(pz, py)));
|
||
let lengthM = 0;
|
||
runs.forEach(([r0, r1]) => {
|
||
if (r1 - r0 <= edgeEps) return;
|
||
lengthM += r1 - r0;
|
||
if (!buitenVisible || !show('gevel')) return;
|
||
// Profiel in (z, y), langs de wand uitgetrokken: -90° om Y legt de extrusie langs -X.
|
||
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: r1 - r0, bevelEnabled: false }), trimMaterial(tr.capColor));
|
||
mesh.rotation.y = -Math.PI / 2;
|
||
mesh.position.x = r1;
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
wallGroup3D.add(mesh);
|
||
});
|
||
return lengthM;
|
||
}
|
||
|
||
function stucBlock(colorKey, y0) {
|
||
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
|
||
const t = STUC.THICK_MM * toM;
|
||
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STUC.THICK_MM);
|
||
const shapes = layerShapes(-halfW - ext.left, halfW + ext.right, y0, Infinity);
|
||
if (shapes.length && buitenVisible && show('gevel')) {
|
||
const mesh = new THREE.Mesh(meterUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth: t, bevelEnabled: false }), 0), stucMaterial(colorKey));
|
||
mesh.position.set(0, 0, zB);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
wallGroup3D.add(mesh);
|
||
}
|
||
return shapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||
}
|
||
|
||
// Met ext.zOut een losse strook verder naar buiten: de laag ligt in de beplatingsruimte, anders steken de hoekstijlen erdoor.
|
||
function buildVerticalBoardSkin(material, side, visible, range, ext) {
|
||
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;
|
||
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);
|
||
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;
|
||
const ex0 = bx0 + (isWallLeftEdge ? 0 : seamM / 2);
|
||
const ex1 = bx1 - (isWallRightEdge ? 0 : seamM / 2);
|
||
if (ex1 - ex0 <= edgeEps) continue;
|
||
|
||
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);
|
||
|
||
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 };
|
||
}
|
||
|
||
const spaanplaatResult = buildVerticalBoardSkin(spaanplaatMaterial, 'back', spaanplaatVisible);
|
||
// 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;
|
||
|
||
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') {
|
||
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'));
|
||
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
|
||
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
|
||
} else if (buitenMateriaal === 'geen') {
|
||
} 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;
|
||
if (stone || stucKey) {
|
||
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;
|
||
if (stucKey) trasraamCapM = stoneCap(trasraam);
|
||
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;
|
||
}
|
||
if (stucKey) stucAreaM2 = stucBlock(stucKey, y0);
|
||
else {
|
||
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 {
|
||
const doekHeightM = 1.5;
|
||
const doekOverlapM = 0.05;
|
||
const doekStepM = doekHeightM - doekOverlapM;
|
||
// Kleine Z-stap per baan: de overlap ligt net vóór de vorige baan (zoals echte shingles), niet co-planair (z-fighting).
|
||
const doekZStepM = 0.0002;
|
||
const DOEK_OUT_M = 0.001;
|
||
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;
|
||
|
||
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: anders flikkert het dak door het doek waar de gevel tot onder de dakrand doorloopt.
|
||
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
|
||
wallGroup3D.add(doekRow);
|
||
}
|
||
|
||
// Volle oppervlak incl. overlap: die wordt ook echt van de rol verbruikt.
|
||
doekAreaM2 += rowAreaM2;
|
||
}
|
||
const dExt = extM(0);
|
||
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++;
|
||
}
|
||
|
||
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;
|
||
trasraamCapM = stoneCap(trasraam);
|
||
} 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;
|
||
}
|
||
|
||
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;
|
||
|
||
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 = beyondEnd(tlBat);
|
||
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
|
||
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
|
||
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
||
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 (felsKey) { felsAreaM2 = rabatAreaM2; rabatAreaM2 = 0; }
|
||
if (rabatOn && rabatShapes.length) {
|
||
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
||
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, felsKey ? felsMaterial(felsKey) : rabatMaterial(opts.rabatColor, cladding));
|
||
mesh.position.set(0, 0, zDoek);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
wallGroup3D.add(mesh);
|
||
}
|
||
}
|
||
|
||
if (show('kozijnen') && 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 };
|
||
place.faceIn = -(half + sh + SKIN_GAP_MM);
|
||
place.faceOut = buitenMateriaal === 'geen' ? half : half + sh + SKIN_GAP_MM + claddingOffset(buitenMateriaal, stone, stucKey);
|
||
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);
|
||
});
|
||
}
|
||
|
||
if (window.KozijnModel && CONFIG.openings.length) {
|
||
const sh = CONFIG.sheathingThick, halfD = Math.max(CONFIG.studDepth, CONFIG.plateDepth) / 2, G = SKIN_GAP_MM;
|
||
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
||
const zIn = -(halfD + G + sh), zSheetOut = halfD + G + sh;
|
||
const zFacade = zSheetOut + (exterior ? G + claddingOffset(buitenMateriaal, stone, stucKey) : 0);
|
||
const kozBack = CONFIG.studDepth / 2 + sh - KozijnModel.PROFILE.depth;
|
||
const T = 12, SILL = 25, SILL_OUT = 25, SLAG_OUT = 35;
|
||
const finishIn = show('afwerking') && spaanplaatVisible, finishOut = exterior && buitenVisible && show('gevel');
|
||
const prism = (pts, z0, z1, mat) => {
|
||
if (z1 - z0 < 0.5 || pts.length < 3) return;
|
||
const geo = new THREE.ExtrudeGeometry(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM))), { steps: 1, depth: (z1 - z0) * toM, bevelEnabled: false });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.z = z0 * toM;
|
||
mesh.receiveShadow = true;
|
||
wallGroup3D.add(mesh);
|
||
};
|
||
const area = (pts) => Math.abs(pts.reduce((a, p, k) => { const q = pts[(k + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
|
||
const holes = sheathingHoles(CONFIG);
|
||
CONFIG.openings.forEach((op, i) => {
|
||
const h = holes[i];
|
||
if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
|
||
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
|
||
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
|
||
const hTop = (x) => holeTopAt(h, x);
|
||
const ring = [
|
||
[{ x: h.hx0, y: h.hy0 }, { x: x0, y: h.hy0 }, { x: x0, y: topAt(x0) }, { x: h.hx0, y: topAt(h.hx0) }],
|
||
[{ x: x1, y: h.hy0 }, { x: h.hx1, y: h.hy0 }, { x: h.hx1, y: topAt(h.hx1) }, { x: x1, y: topAt(x1) }],
|
||
[{ x: h.hx0, y: topAt(h.hx0) }, { x: h.hx1, y: topAt(h.hx1) }, { x: h.hx1, y: Math.max(topAt(h.hx1), hTop(h.hx1)) }, { x: h.hx0, y: Math.max(topAt(h.hx0), hTop(h.hx0)) }]
|
||
].filter(pts => area(pts) > 1);
|
||
const fill = KozijnModel.normalize(op.fill);
|
||
const isWindow = op.y >= CONFIG.plateThick;
|
||
// Gekoppelde zijde: geen dagkant; vensterbank en waterslag lopen door tot de naad.
|
||
const over = (d) => [op.joinL ? x0 : x0 - d, op.joinR ? x1 : x1 + d];
|
||
if (finishIn) {
|
||
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
|
||
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
|
||
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
|
||
if (zB !== null) {
|
||
const boards = [
|
||
!op.joinL && [{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
|
||
!op.joinR && [{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
|
||
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
|
||
].filter(Boolean);
|
||
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
|
||
const [sa, sb] = over(30);
|
||
if (isWindow) prism([{ x: sa, y: y0 - SILL + 5 }, { x: sb, y: y0 - SILL + 5 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
|
||
}
|
||
}
|
||
if (finishOut) {
|
||
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
|
||
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
|
||
const [sa, sb] = over(20);
|
||
if (isWindow && fill) prism([{ x: sa, y: y0 - 25 }, { x: sb, y: y0 - 25 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }],
|
||
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
|
||
}
|
||
});
|
||
}
|
||
if (!show('regelwerk')) ViewLayers.hideByMaterial(wallGroup3D, [woodMaterial]);
|
||
|
||
return {
|
||
group: wallGroup3D,
|
||
stats: {
|
||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||
}
|
||
};
|
||
}
|
||
|
||
// 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; m.userData.base = wood; wedgeMats.set(wood, m); }
|
||
return wedgeMats.get(wood);
|
||
}
|
||
|
||
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;
|
||
|
||
// Spouwdiepte = stijldiepte (vereenvoudiging).
|
||
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 {
|
||
buitenAreaM2 = stats.doekAreaM2;
|
||
buitenKg = stats.doekAreaM2 * DENSITY_DOEK_KGM2;
|
||
buitenVolumeM3 = null;
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
// cutAngles: hoek per zaagsnede t.o.v. haaks (graden).
|
||
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] });
|
||
}
|
||
|
||
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] });
|
||
});
|
||
}
|
||
|
||
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)]
|
||
});
|
||
});
|
||
|
||
// length/y = omhullende (het rechte stuk hout voor de materiaallijst); de PDF tekent met de 4 werkelijke hoekpunten.
|
||
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';
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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) };
|
||
});
|
||
}
|
||
|
||
function purchaseText(plan) {
|
||
return plan.map(sec => `${sec.width}x${sec.depth}: ` +
|
||
sec.perStock.map(s => `${s.count}x ${s.stock}`).join(', ')).join(' | ') || '-';
|
||
}
|
||
|
||
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, FELS, isFelsColor, felsHex, TRIM_COLORS, isTrimColor, trimHex, trimMaterial, TRAS_CAP, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||
createMaterials, build3D, computeSpecs
|
||
};
|
||
})();
|