Files
martinfencl.eu/locenice/tools/fence.mjs
T
martin.fencl c32d1e5a04
continuous-integration/drone/push Build is passing
feat(fence): add height selection for fence panels and update related logic
- Introduced a new section in the UI for selecting fence heights, visible only when the fence option is enabled.
- Updated the fence label to clarify the presence of gates.
- Enhanced the JavaScript logic to manage the visibility of fence heights based on user selection.
- Modified the fence rendering logic to accommodate different heights and ensure proper visibility based on the selected height.
- Updated CSS to style the new fence height controls appropriately.
- Adjusted the model configuration to reflect changes in the fence height options.
2026-10-26 21:24:38 +01:00

374 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plot kolem parcely 3480/37: 3D svařované panely na betonových podhrabových deskách. Ke silnici
* je pozemek otevřený; zahradu uzavírají brány mezi domem a bočními ploty u severní fasády:
* vpravo od silnice (u vchodu) branka pro lidi, vlevo brána pro auta. Na webu přepínač „Plot“.
*
* node tools/fence.mjs [models/dum.glb]
*
* Výšku plotu volí web (řada „Výška plotu“): panel je složený z vodorovných pásů po vyráběných
* výškách (FENCE.heights) a nižší varianta jen skryje horní pásy; krytky sloupků a brány jsou
* pro každou výšku zvlášť. Uzly mají v extras fenceBand (vidět od varianty) nebo fenceOnly.
*
* Skript přepíše uzly Site_Fence_* v modelu (opakované spuštění plot nahradí, nepřidá druhý).
* Linie plotu = obrys Site_Cadastral_outline posunutý o FENCE.inset dovnitř, bez hrany
* u silnice; výška se bere z viditelného terénu. Polohu a šířky bran určuje GATES.
* Po změně modelu zvyšte ?v= u "model" v data/app-config.json.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseGLB, encodeGLB } from '../js/model-io.js';
const FENCE = {
inset: 0.06, // osa sloupků od hranice parcely dovnitř [m]
postSpacing: 2.5, // max. rozteč sloupků = šířka panelu
// Vyráběné 3D panely (830–2030 mm) na podhrabové desce 200 mm (50 mm v zemi) do 2 m celkem:
// panel + 0,16 m = 1,0 / 1,2 / 1,4 / 1,7 / 1,9 m. Panel 2030 mm by sám přesáhl 2 m.
heights: [0.83, 1.03, 1.23, 1.53, 1.73],
maxHeight: 2.0, // kontrola: nikde nad terénem výš
rakeTolerance: 0.04, // max. odchylka terénu od přímky pole, jinak se pole zkrátí
minField: 0.6,
boardVisible: 0.15, // deska 200 mm, nad nejvyšším terénem pole z ní kouká 150 mm
boardThickness: 0.05,
post: [0.06, 0.04], // sloupek 60 × 40 mm
wire: 0.005,
wirePitch: 0.05, // oko 50 × 200 mm
bends: [0.30, 0.62, 1.13, 1.40], // každý prolis celý uvnitř jednoho pásu
wires: [0, 0.18, 0.45], // + vodorovný drát na horní hraně každého pásu
bendDepth: 0.035,
bendHalf: 0.05,
};
const GATES = { // čára bran kousek za severní fasádou (x = -7.67), sloupek tak mine svod v SV rohu
lineX: -7.45,
car: { houseZ: -4.695 }, // východní stěna; dvoukřídlá brána přes celou mezeru k plotu
pedestrian: { houseZ: 3.902, width: 1.10 }, // obklad u vstupu; branka na dlažbě u domu, zbytek pevné pole
post: 0.08, // křídla jsou stejně vysoká jako panel zvolené výšky
};
const H = FENCE.heights, TOP = H.length - 1;
const bandOf = v => H.findIndex(h => v <= h + 1e-9);
let tallest = { h: 0 }; // nejvyšší místo plotu nad terénem (kontrola FENCE.maxHeight)
const track = (top, groundMin, where) => { if (top - groundMin > tallest.h) tallest = { h: top - groundMin, where }; };
const RAL7016 = '#383e42', CONCRETE = '#c2c1bb';
const here = path.dirname(fileURLToPath(import.meta.url));
const file = process.argv[2] || path.join(here, '..', 'models', 'dum.glb');
const { gltf, binary } = parseGLB(new Uint8Array(fs.readFileSync(file)));
const views = gltf.bufferViews, viewBytes = views.map(v => binary.subarray(v.byteOffset || 0, (v.byteOffset || 0) + v.byteLength));
const COMPS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
function read(i) {
const a = gltf.accessors[i], v = views[a.bufferView], n = COMPS[a.type], size = { 5126: 4, 5125: 4, 5123: 2, 5121: 1 }[a.componentType];
const dv = new DataView(viewBytes[a.bufferView].buffer, viewBytes[a.bufferView].byteOffset + (a.byteOffset || 0)), stride = v.byteStride || n * size, out = [];
for (let k = 0; k < a.count; k++) {
const row = [];
for (let c = 0; c < n; c++) { const o = k * stride + c * size; row.push(a.componentType === 5126 ? dv.getFloat32(o, true) : a.componentType === 5125 ? dv.getUint32(o, true) : a.componentType === 5123 ? dv.getUint16(o, true) : dv.getUint8(o)); }
out.push(n === 1 ? row[0] : row);
}
return out;
}
const named = name => gltf.nodes.findIndex(n => n.name === name);
const primitivesOf = re => gltf.nodes.filter(n => n.mesh !== undefined && re.test(n.name || '')).flatMap(n => gltf.meshes[n.mesh].primitives.map(p => ({ pos: read(p.attributes.POSITION), idx: read(p.indices) })));
// --- parcel line ------------------------------------------------------------------------------
function outline() {
const p = gltf.meshes[gltf.nodes[named('Site_Cadastral_outline')].mesh].primitives[0], pos = read(p.attributes.POSITION), idx = read(p.indices);
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[2] + b[2]) / 2], key = q => q[0].toFixed(3) + ',' + q[1].toFixed(3);
const segs = [];
for (let t = 0; t < idx.length; t += 6) segs.push([mid(pos[idx[t]], pos[idx[t + 5]]), mid(pos[idx[t + 1]], pos[idx[t + 2]])]);
const next = new Map(segs.map(s => [key(s[0]), s])), chain = [segs[0][0]];
for (let s = segs[0], g = 0; g <= segs.length; g++) { s = next.get(key(s[1])); if (!s || s === segs[0]) break; chain.push(s[0]); }
const simplify = (pts, tol) => {
let best = -1, at = 0; const [a, b] = [pts[0], pts[pts.length - 1]], L = Math.hypot(b[0] - a[0], b[1] - a[1]);
for (let i = 1; i < pts.length - 1; i++) { const d = Math.abs((b[1] - a[1]) * (pts[i][0] - a[0]) - (b[0] - a[0]) * (pts[i][1] - a[1])) / L; if (d > best) { best = d; at = i; } }
return best <= tol ? [a, b] : [...simplify(pts.slice(0, at + 1), tol).slice(0, -1), ...simplify(pts.slice(at), tol)];
};
// open the ring at the point farthest from the start, so neither half is degenerate
const far = chain.reduce((best, p, i) => Math.hypot(p[0] - chain[0][0], p[1] - chain[0][1]) > Math.hypot(chain[best][0] - chain[0][0], chain[best][1] - chain[0][1]) ? i : best, 0);
return [...simplify(chain.slice(0, far + 1), 0.05).slice(0, -1), ...simplify([...chain.slice(far), chain[0]], 0.05).slice(0, -1)];
}
const signedArea = poly => poly.reduce((s, p, i) => s + p[0] * poly[(i + 1) % poly.length][1] - poly[(i + 1) % poly.length][0] * p[1], 0) / 2;
function inset(poly, d) {
const n = poly.length, area = signedArea(poly);
const lines = poly.map((p, i) => {
const q = poly[(i + 1) % n], L = Math.hypot(q[0] - p[0], q[1] - p[1]), dir = [(q[0] - p[0]) / L, (q[1] - p[1]) / L];
const inward = area > 0 ? [-dir[1], dir[0]] : [dir[1], -dir[0]];
return { p: [p[0] + inward[0] * d, p[1] + inward[1] * d], dir };
});
return lines.map((l, i) => {
const k = lines[(i + n - 1) % n], det = k.dir[0] * l.dir[1] - k.dir[1] * l.dir[0];
const t = ((l.p[0] - k.p[0]) * l.dir[1] - (l.p[1] - k.p[1]) * l.dir[0]) / det;
return [k.p[0] + k.dir[0] * t, k.p[1] + k.dir[1] * t];
});
}
// --- visible ground ---------------------------------------------------------------------------
const ground = primitivesOf(/^Site_(Terrain_|Parking_pavers$|Parking_ramp$|Road_shoulder$|Entrance_paving$)/);
function groundY(x, z) {
let best = -Infinity;
for (const s of ground) for (let t = 0; t < s.idx.length; t += 3) {
const a = s.pos[s.idx[t]], b = s.pos[s.idx[t + 1]], c = s.pos[s.idx[t + 2]];
if (Math.max(a[0], b[0], c[0]) < x || Math.min(a[0], b[0], c[0]) > x || Math.max(a[2], b[2], c[2]) < z || Math.min(a[2], b[2], c[2]) > z) continue;
const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
if (Math.abs(d) < 1e-12) continue;
const l1 = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / d, l2 = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / d, l3 = 1 - l1 - l2;
if (l1 >= -1e-6 && l2 >= -1e-6 && l3 >= -1e-6) best = Math.max(best, l1 * a[1] + l2 * b[1] + l3 * c[1]);
}
if (best === -Infinity) throw Error(`no ground under ${x.toFixed(2)}, ${z.toFixed(2)}`);
return best;
}
const region = x => x < -11.17 ? 'north' : x > 13.53 ? 'parcel' : 'local'; // terrain slabs Site_Terrain_*
// --- geometry ---------------------------------------------------------------------------------
const meshes = {};
// vis: { band: j } = vidět od výšky j výš, { only: k } = jen u výšky k, {} = vždy
const bucket = (reg, part, vis = {}) => {
const key = reg + '_' + part + (vis.band !== undefined ? '_b' + vis.band : vis.only !== undefined ? '_h' + vis.only : '');
return (meshes[key] ||= { key, reg, part, vis, pos: [], nor: [], idx: [] });
};
const add = (a, b) => a.map((v, i) => v + b[i]), mul = (a, s) => a.map(v => v * s);
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const unit = a => mul(a, 1 / Math.hypot(...a));
// frame: origin o (x,0,z), u along the line, w outward, y up; local (u, y, w) -> world
const at = (f, u, y, w) => [f.o[0] + f.u[0] * u + f.w[0] * w, y, f.o[2] + f.u[2] * u + f.w[2] * w];
function box(m, f, u0, u1, y0, y1, w0, w1, rise = 0) { // rise: shear along u (pole ve svahu)
const P = (u, y, w) => at(f, u, y + rise * (u - u0) / (u1 - u0), w), faces = [
[[u0, y0, w1], [u1, y0, w1], [u1, y1, w1], [u0, y1, w1], f.w], [[u1, y0, w0], [u0, y0, w0], [u0, y1, w0], [u1, y1, w0], mul(f.w, -1)],
[[u1, y0, w1], [u1, y0, w0], [u1, y1, w0], [u1, y1, w1], f.u], [[u0, y0, w0], [u0, y0, w1], [u0, y1, w1], [u0, y1, w0], mul(f.u, -1)],
[[u0, y1, w1], [u1, y1, w1], [u1, y1, w0], [u0, y1, w0], [0, 1, 0]], [[u0, y0, w0], [u1, y0, w0], [u1, y0, w1], [u0, y0, w1], [0, -1, 0]],
];
for (const [a, b, c, d, n] of faces) {
const k = m.pos.length;
for (const q of [a, b, c, d]) { m.pos.push(P(...q)); m.nor.push(n); }
m.idx.push(k, k + 1, k + 2, k, k + 2, k + 3);
}
}
// thin ribbon along a polyline of local (u, y, w) points, widened along `across` (local axis vector)
function ribbon(m, f, pts, across, width) {
const k = m.pos.length, side = mul(add(mul(f.u, across[0]), [0, across[1], 0]), width / 2);
pts.forEach((p, i) => {
const q = at(f, ...p), a = at(f, ...pts[Math.max(0, i - 1)]), b = at(f, ...pts[Math.min(pts.length - 1, i + 1)]);
const n = unit(cross(unit([b[0] - a[0], b[1] - a[1], b[2] - a[2]]), unit(side)));
m.pos.push(add(q, mul(side, -1)), add(q, side)); m.nor.push(n, n);
if (i) m.idx.push(k + 2 * i - 2, k + 2 * i - 1, k + 2 * i + 1, k + 2 * i - 2, k + 2 * i + 1, k + 2 * i);
});
}
let outwardSign = 1; // set from the ring orientation below
const edgeFrame = (a, b) => { const L = Math.hypot(b[0] - a[0], b[1] - a[1]), u = [(b[0] - a[0]) / L, 0, (b[1] - a[1]) / L]; return { o: [a[0], 0, a[1]], u, w: [outwardSign * u[2], 0, -outwardSign * u[0]], L }; };
const sampleGround = (f, u0, u1, n = 6) => Array.from({ length: n }, (_, i) => { const p = at(f, u0 + (u1 - u0) * i / (n - 1), 0, 0); return groundY(p[0], p[2]); });
// Pole kopíruje svah: deska i panel jdou rovnoběžně s přímkou terénu mezi sloupky, takže plot má
// všude stejnou výšku nad zemí. Kde terén od přímky víc uhýbá, rozdělí se pole kratšími (refine).
const groundProfile = (f, u0, u1) => {
const n = Math.max(6, Math.ceil((u1 - u0) / 0.25) + 1), g = sampleGround(f, u0, u1, n);
const dev = g.map((v, i) => v - (g[0] + (g[n - 1] - g[0]) * i / (n - 1)));
return { g, up: Math.max(0, ...dev), down: Math.min(0, ...dev), rise: g[n - 1] - g[0] };
};
function refine(f, a, b, out) {
const p = groundProfile(f, a, b);
if (b - a > FENCE.minField && p.up - p.down > FENCE.rakeTolerance) { refine(f, a, (a + b) / 2, out); refine(f, (a + b) / 2, b, out); }
else out.push(b);
}
function fields(f, a, b) { // post positions in [a, b], a included
const n = Math.ceil((b - a) / FENCE.postSpacing - 1e-6), us = [a];
for (let i = 1; i <= n; i++) refine(f, a + (b - a) * (i - 1) / n, a + (b - a) * i / n, us);
return us;
}
function panel(f, u0, u1) {
const mid = at(f, (u0 + u1) / 2, 0, 0), reg = region(mid[0]), { g, up, down, rise } = groundProfile(f, u0, u1);
const slope = rise / (u1 - u0), base = u => g[0] + up + slope * (u - u0); // terén zvednutý nad hrboly
const [pw, pd] = FENCE.post, t = FENCE.boardThickness / 2, plane = pd / 2 + 0.006;
const b0 = u0 + pw / 2, b1 = u1 - pw / 2;
box(bucket(reg, 'boards'), f, b0, b1, base(b0) - up + down - 0.05, base(b0) + FENCE.boardVisible, -t, t, slope * (b1 - b0));
const y0 = u => base(u) + FENCE.boardVisible + 0.01;
const count = Math.floor((u1 - u0 - 0.07) / FENCE.wirePitch);
const start = (u0 + u1) / 2 - count * FENCE.wirePitch / 2, end = start + count * FENCE.wirePitch;
const levels = [...FENCE.wires, ...H].map(v => [v, 0]).concat(FENCE.bends.map(b => [b, FENCE.bendDepth]));
H.forEach((hi, j) => { // pás j: od horní hrany pásu j-1 po hi
const lo = j ? H[j - 1] : 0, mesh = bucket(reg, 'mesh', { band: j }), profile = [[lo, 0]];
for (const b of FENCE.bends) if (b > lo && b < hi) profile.push([b - FENCE.bendHalf, 0], [b, FENCE.bendDepth], [b + FENCE.bendHalf, 0]);
profile.push([hi, 0]);
for (let i = 0; i <= count; i++) { const u = start + i * FENCE.wirePitch; ribbon(mesh, f, profile.map(([v, w]) => [u, y0(u) + v, plane + w]), [1, 0], FENCE.wire); }
// vodorovné dráty pásu, v prolisu jeden na vrcholu
for (const [v, w] of levels) if (bandOf(v) === j) ribbon(mesh, f, [[start - 0.01, y0(start - 0.01) + v, plane + w - 0.003], [end + 0.01, y0(end + 0.01) + v, plane + w - 0.003]], [0, 1], FENCE.wire);
});
g.forEach((gy, i) => track(y0(u0 + (u1 - u0) * i / (g.length - 1)) + H[TOP], gy, `pole x ${mid[0].toFixed(1)} z ${mid[2].toFixed(1)}`));
return { start: y0(u0), end: y0(u1) }; // spodní hrana panelu u obou sloupků
}
// sloupek z dílů po pásech + krytka pro každou výšku zvlášť; bottom = nejvyšší spodek sousedních panelů
function fencePost(f, u, bottom, bottomMin, reg) {
const p = at(f, u, 0, 0), [pw, pd] = FENCE.post, g = groundY(p[0], p[2]);
const top = k => Math.min(bottom + H[k] + 0.04, g + FENCE.maxHeight - 0.013); // s krytkou nikdy nad limitem
H.forEach((_, j) => {
const m = bucket(reg, 'posts', { band: j });
box(m, f, u - pw / 2, u + pw / 2, j ? top(j - 1) : g - 0.3, top(j), -pd / 2, pd / 2);
box(bucket(reg, 'caps', { only: j }), f, u - pw / 2 - 0.002, u + pw / 2 + 0.002, top(j), top(j) + 0.012, -pd / 2 - 0.002, pd / 2 + 0.002);
for (const v of j ? [H[j] - 0.12] : [0.25, 0.65]) box(m, f, u - 0.045, u + 0.045, bottomMin + v - 0.018, bottomMin + v + 0.018, pd / 2, pd / 2 + 0.03);
});
track(top(TOP) + 0.012, g, `sloupek x ${p[0].toFixed(1)} z ${p[2].toFixed(1)}`);
}
function gatePost(f, u, top, reg, k) {
const p = at(f, u, 0, 0), m = bucket(reg, 'gates', { only: k }), s = GATES.post, g = groundY(p[0], p[2]);
top = Math.min(top, g + FENCE.maxHeight - 0.013);
box(m, f, u - s / 2, u + s / 2, g - 0.3, top, -s / 2, s / 2);
box(m, f, u - s / 2 - 0.002, u + s / 2 + 0.002, top, top + 0.012, -s / 2 - 0.002, s / 2 + 0.002);
track(top + 0.012, g, `sloupek brány x ${p[0].toFixed(1)} z ${p[2].toFixed(1)}`);
}
function gateLeaf(f, u0, u1, y0, y1, reg, latch, k) {
const m = bucket(reg, 'gates', { only: k }), fr = 0.04, d = 0.02;
box(m, f, u0, u0 + fr, y0, y1, -d, d); box(m, f, u1 - fr, u1, y0, y1, -d, d);
box(m, f, u0 + fr, u1 - fr, y0, y0 + fr, -d, d); box(m, f, u0 + fr, u1 - fr, y1 - fr, y1, -d, d);
if (u1 - u0 > 1.5) box(m, f, u0 + fr, u1 - fr, (y0 + y1) / 2 - 0.02, (y0 + y1) / 2 + 0.02, -d, d);
const mesh = bucket(reg, 'mesh', { only: k }), n = Math.floor((u1 - u0 - 2 * fr) / FENCE.wirePitch);
for (let i = 1; i < n; i++) { const u = u0 + fr + i * (u1 - u0 - 2 * fr) / n; ribbon(mesh, f, [[u, y0 + fr, 0], [u, y1 - fr, 0]], [1, 0], FENCE.wire); }
if (latch !== undefined) { // klika + zámek
const s = Math.sign(latch - (u0 + u1) / 2), uL = latch - s * 0.07;
box(m, f, uL - 0.03, uL + 0.03, y0 + 0.95, y0 + 1.12, -0.03, 0.03);
box(m, f, uL - s * 0.12, uL, y0 + 1.04, y0 + 1.06, 0.03, 0.06);
box(m, f, uL - s * 0.12, uL, y0 + 1.04, y0 + 1.06, -0.06, -0.03);
}
}
// --- layout -----------------------------------------------------------------------------------
const corners = inset(outline(), FENCE.inset);
outwardSign = signedArea(corners) > 0 ? 1 : -1; // right-hand normal of a CCW ring points out
const streetIndex = corners.reduce((best, c, i) => (c[0] + corners[(i + 1) % corners.length][0]) < (corners[best][0] + corners[(best + 1) % corners.length][0]) ? i : best, 0);
function assert(ok, msg) { if (!ok) throw Error(msg); }
// fence: from one street corner around the garden to the other; the street side stays open
const ring = [];
for (let i = 1; i <= corners.length; i++) ring.push(corners[(streetIndex + i) % corners.length]);
// where the gate line meets the side fences (a post is forced there)
const junctions = [];
for (let e = 0; e < ring.length - 1; e++) {
const [a, b] = [ring[e], ring[e + 1]];
if ((a[0] - GATES.lineX) * (b[0] - GATES.lineX) > 0) continue;
const t = (GATES.lineX - a[0]) / (b[0] - a[0]);
junctions.push({ e, t, z: a[1] + t * (b[1] - a[1]) });
}
const east = junctions.find(j => j.z < 0), west = junctions.find(j => j.z > 0);
assert(junctions.length === 2 && east && west, 'gate line must cross both side fences');
let posts = 0, panels = 0;
const edges = ring.slice(0, -1).map((a, e) => {
const f = edgeFrame(a, ring[e + 1]), cuts = [0, ...junctions.filter(j => j.e === e).map(j => j.t * f.L), f.L], us = [0];
for (let k = 0; k < cuts.length - 1; k++) us.push(...fields(f, cuts[k], cuts[k + 1]).slice(1));
const bottoms = us.slice(1).map((u, i) => panel(f, us[i], u));
panels += bottoms.length;
return { f, us, bottoms };
});
// spodní hrany panelů u sloupku i (z obou stran)
const nearBottoms = (bottoms, i) => [i > 0 ? bottoms[i - 1].end : bottoms[0].start, i < bottoms.length ? bottoms[i].start : bottoms[i - 1].end];
edges.forEach(({ f, us, bottoms }, e) => {
us.forEach((u, i) => {
if (e > 0 && i === 0) return; // roh má sloupek z konce předchozí hrany
const near = nearBottoms(bottoms, i);
if (i === us.length - 1 && e < edges.length - 1) near.push(edges[e + 1].bottoms[0].start);
fencePost(f, u, Math.max(...near), Math.min(...near), region(at(f, u, 0, 0)[0])); posts++;
for (const j of junctions) if (j.e === e && Math.abs(j.t * f.L - u) < 1e-6) j.fenceBottom = Math.max(...near);
});
});
// gates: a line from the house wall to the side fence; d = distance from the house
const gp = GATES.post, gap = 0.02;
function gateLine(houseZ, junction) {
const dir = Math.sign(junction.z - houseZ), h = [GATES.lineX, houseZ + dir * (gp / 2 + 0.005)], j = [GATES.lineX, junction.z];
let f = edgeFrame(h, j);
const flip = f.w[0] > 0; // outer side (w) faces the street
if (flip) f = edgeFrame(j, h);
const g = { f, L: f.L, reg: region(GATES.lineX), fenceBottom: junction.fenceBottom, s: d => flip ? f.L - d : d };
g.span = (d0, d1) => [Math.min(g.s(d0), g.s(d1)), Math.max(g.s(d0), g.s(d1))];
const ground = (d0, d1) => sampleGround(f, ...g.span(d0, d1), 8);
g.bottom = (d0, d1) => Math.max(...ground(d0, d1)) + 0.05;
// křídla vysoká jako panel výšky k, k sousednímu poli dorovnaná, ale nikde nad limitem
g.top = (d0, d1, bottom, neighbourTop, k) => Math.min(Math.min(...ground(d0, d1)) + FENCE.maxHeight - 0.02, Math.max(bottom + H[k], neighbourTop));
g.leaf = (d0, d1, y0, y1, latch, k) => {
track(y1, Math.min(...ground(d0, d1)), 'brána');
gateLeaf(f, ...g.span(d0, d1), y0, y1, g.reg, latch === undefined ? undefined : g.s(latch), k);
};
return g;
}
// vlevo od silnice: dvoukřídlá brána pro auta přes celou mezeru mezi domem a plotem
const car = gateLine(GATES.car.houseZ, east);
H.forEach((h, k) => {
const y0 = car.bottom(0, car.L), y1 = car.top(0, car.L, y0, car.fenceBottom + h, k), half = car.L / 2;
gatePost(car.f, car.s(0), y1 + 0.06, car.reg, k);
gatePost(car.f, car.s(car.L), Math.max(y1, car.fenceBottom + h) + 0.06, car.reg, k);
car.leaf(gp / 2 + gap, half - 0.01, y0, y1, half - 0.01, k);
car.leaf(half + 0.01, car.L - gp / 2 - gap, y0, y1, undefined, k);
});
// vpravo od silnice (u vchodu): branka pro lidi na dlažbě u domu, zbytek mezery pevné pole(e) ve svahu
const ped = gateLine(GATES.pedestrian.houseZ, west), dMid = gp + GATES.pedestrian.width;
{
const [a, b] = ped.span(dMid, ped.L), us = fields(ped.f, a, b), bottoms = us.slice(1).map((u, i) => panel(ped.f, us[i], u));
const bottomAt = d => ped.s(d) === a ? bottoms[0].start : bottoms[bottoms.length - 1].end; // pole u branky / u plotu
us.forEach((u, i) => {
const near = nearBottoms(bottoms, i);
if (i > 0 && i < us.length - 1) { fencePost(ped.f, u, Math.max(...near), Math.min(...near), ped.reg); posts++; }
});
H.forEach((h, k) => {
const y0 = ped.bottom(0, dMid), y1 = ped.top(0, dMid, y0, bottomAt(dMid) + h, k);
gatePost(ped.f, ped.s(0), y1 + 0.06, ped.reg, k);
gatePost(ped.f, ped.s(dMid), Math.max(y1, bottomAt(dMid) + h) + 0.06, ped.reg, k);
gatePost(ped.f, ped.s(ped.L), Math.max(bottomAt(ped.L), ped.fenceBottom) + h + 0.06, ped.reg, k);
ped.leaf(gp / 2 + gap, dMid - gp / 2 - gap, y0, y1, dMid - gp / 2 - gap, k);
});
panels += bottoms.length;
}
// --- write into the glTF ----------------------------------------------------------------------
const srgbToLinear = h => [1, 3, 5].map(i => { const c = parseInt(h.slice(i, i + 2), 16) / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; });
function material(name, hex, metallic, roughness) {
let i = gltf.materials.findIndex(m => m.name === name);
if (i < 0) { gltf.materials.push({ name }); i = gltf.materials.length - 1; }
gltf.materials[i] = { name, doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [...srgbToLinear(hex), 1], metallicFactor: metallic, roughnessFactor: roughness }, extras: { loceniceId: 'material-' + i } };
return i;
}
const MAT = { metal: material('Plot / antracit RAL 7016', RAL7016, 0.35, 0.55), concrete: material('Plot / betonova podhrabova deska', CONCRETE, 0, 0.92) };
function accessor(data, type, componentType, target) {
const flat = data.flat(), arr = componentType === 5126 ? new Float32Array(flat) : componentType === 5123 ? new Uint16Array(flat) : new Uint32Array(flat);
views.push({ buffer: 0, byteLength: arr.byteLength, target }); viewBytes.push(new Uint8Array(arr.buffer));
const a = { bufferView: views.length - 1, componentType, count: data.length, type };
if (type === 'VEC3') { a.min = [0, 1, 2].map(c => Math.min(...data.map(v => Math.fround(v[c])))); a.max = [0, 1, 2].map(c => Math.max(...data.map(v => Math.fround(v[c])))); }
gltf.accessors.push(a); return gltf.accessors.length - 1;
}
const existing = gltf.nodes.map((n, i) => n.extras?.siteLayer === 'fence' ? i : -1).filter(i => i >= 0);
const wanted = Object.values(meshes).filter(m => m.idx.length);
const root = gltf.nodes[0].children;
wanted.forEach((m, k) => {
const prim = {
attributes: { POSITION: accessor(m.pos, 'VEC3', 5126, 34962), NORMAL: accessor(m.nor, 'VEC3', 5126, 34962) },
indices: accessor(m.idx, 'SCALAR', m.pos.length < 65536 ? 5123 : 5125, 34963), material: m.part === 'boards' ? MAT.concrete : MAT.metal,
};
const name = `Site_Fence_${m.key}`;
let ni = existing[k];
if (ni === undefined) { gltf.meshes.push({ name, primitives: [prim] }); gltf.nodes.push({ mesh: gltf.meshes.length - 1 }); ni = gltf.nodes.length - 1; root.push(ni); }
else { gltf.meshes[gltf.nodes[ni].mesh] = { name, primitives: [prim] }; if (!root.includes(ni)) root.push(ni); }
gltf.nodes[ni].name = name;
gltf.nodes[ni].extras = {
siteLayer: 'fence', fenceRegion: m.reg, fencePart: m.part, castShadow: m.part !== 'mesh',
...(m.vis.band !== undefined ? { fenceBand: m.vis.band } : {}), ...(m.vis.only !== undefined ? { fenceOnly: m.vis.only } : {}), loceniceId: 'node-' + ni,
};
});
for (const ni of existing.slice(wanted.length)) { // nepotřebné staré uzly odpojit
delete gltf.nodes[ni].mesh; gltf.nodes[ni].name += '_unused';
gltf.nodes[0].children = gltf.nodes[0].children.filter(c => c !== ni);
}
// drop accessors / bufferViews nothing refers to any more, then repack the BIN chunk
const usedAcc = new Set(gltf.meshes.flatMap(m => m.primitives.flatMap(p => [...Object.values(p.attributes), p.indices].filter(v => v !== undefined))));
const accMap = new Map(); gltf.accessors = gltf.accessors.filter((a, i) => usedAcc.has(i) && accMap.set(i, accMap.size));
for (const m of gltf.meshes) for (const p of m.primitives) { for (const k in p.attributes) p.attributes[k] = accMap.get(p.attributes[k]); if (p.indices !== undefined) p.indices = accMap.get(p.indices); }
const usedView = new Set([...gltf.accessors.map(a => a.bufferView), ...gltf.images.map(i => i.bufferView)].filter(v => v !== undefined));
const viewMap = new Map(), order = views.map((v, i) => i).filter(i => usedView.has(i));
order.forEach(i => viewMap.set(i, viewMap.size));
for (const a of gltf.accessors) a.bufferView = viewMap.get(a.bufferView);
for (const im of gltf.images) if (im.bufferView !== undefined) im.bufferView = viewMap.get(im.bufferView);
let total = 0; const place = order.map(i => { total = (total + 3) & ~3; const off = total; total += viewBytes[i].length; return [i, off]; });
const bin = new Uint8Array((total + 3) & ~3);
gltf.bufferViews = place.map(([i, off]) => { bin.set(viewBytes[i], off); const v = { ...views[i], byteOffset: off, byteLength: viewBytes[i].length }; return v; });
gltf.buffers[0].byteLength = bin.length;
assert(tallest.h <= FENCE.maxHeight, `plot přesahuje ${FENCE.maxHeight} m: ${tallest.h.toFixed(3)} m (${tallest.where}); model nezapsán`);
fs.writeFileSync(file, encodeGLB(gltf, bin));
const tris = wanted.reduce((s, m) => s + m.idx.length / 3, 0);
console.log(`plot: ${panels} polí, ${posts} sloupků, brána ${(car.L - gp).toFixed(2)} m, branka ${GATES.pedestrian.width.toFixed(2)} m + pole ${(ped.L - dMid).toFixed(2)} m, ${tris} trojúhelníků`);
console.log(`nejvyšší místo nad terénem: ${tallest.h.toFixed(3)} m (${tallest.where}), limit ${FENCE.maxHeight} m`);
console.log(`uzly: ${wanted.length} (pásy, krytky a brány pro výšky ${H.map(h => (h + FENCE.boardVisible + 0.01).toFixed(2)).join(' / ')} m)`);
console.log(`zapsáno ${file} (${fs.statSync(file).size} B)`);