Files
martinfencl.eu/locenice/tools/fence.mjs
T
2026-10-26 14:39:41 +01:00

312 lines
21 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]
*
* 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
panelHeight: 1.53,
boardHeight: 0.20, // nejmenší viditelná výška podhrabové desky nad nejvyšším terénem pole
boardThickness: 0.05,
post: [0.06, 0.04], // sloupek 60 × 40 mm
wire: 0.005,
wirePitch: 0.05, // oko 50 mm
bends: [0.35, 0.80, 1.25],
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
height: 1.50,
post: 0.08,
};
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 = {};
const bucket = (reg, part) => (meshes[reg + '_' + part] ||= { reg, part, 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) {
const P = (u, y, w) => at(f, u, y, 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]); });
function panel(f, u0, u1) {
const mid = at(f, (u0 + u1) / 2, 0, 0), reg = region(mid[0]), g = sampleGround(f, u0, u1);
const boardTop = Math.max(...g) + FENCE.boardHeight * 0.75, boardBottom = Math.min(...g) - 0.05;
const [pw, pd] = FENCE.post, t = FENCE.boardThickness / 2, plane = pd / 2 + 0.006, y0 = boardTop + 0.01, H = FENCE.panelHeight;
box(bucket(reg, 'boards'), f, u0 + pw / 2, u1 - pw / 2, boardBottom, boardTop, -t, t);
const mesh = bucket(reg, 'mesh'), profile = [[0, 0]];
for (const b of FENCE.bends) profile.push([b - FENCE.bendHalf, 0], [b, FENCE.bendDepth], [b + FENCE.bendHalf, 0]);
profile.push([H + 0.03, 0]); // trny nad horním drátem
const count = Math.floor((u1 - u0 - 0.07) / FENCE.wirePitch);
const start = (u0 + u1) / 2 - count * FENCE.wirePitch / 2;
for (let i = 0; i <= count; i++) { const u = start + i * FENCE.wirePitch; ribbon(mesh, f, profile.map(([v, w]) => [u, y0 + v, plane + w]), [1, 0], FENCE.wire); }
const levels = [0, 0.2, 0.6, 1.0, 1.4, H - 0.03].map(v => [v, 0]).concat(FENCE.bends.map(b => [b, FENCE.bendDepth]));
for (const [v, w] of levels) ribbon(mesh, f, [[start - 0.01, y0 + v, plane + w - 0.003], [start + count * FENCE.wirePitch + 0.01, y0 + v, plane + w - 0.003]], [0, 1], FENCE.wire);
return y0 + H;
}
function post(f, u, top, reg, size = FENCE.post) {
const p = at(f, u, 0, 0), m = bucket(reg, 'posts'), [pw, pd] = size, g = groundY(p[0], p[2]);
box(m, f, u - pw / 2, u + pw / 2, g - 0.3, top, -pd / 2, pd / 2);
box(m, f, u - pw / 2 - 0.002, u + pw / 2 + 0.002, top, top + 0.012, -pd / 2 - 0.002, pd / 2 + 0.002);
return g;
}
function clamps(f, u, y0, reg) {
const m = bucket(reg, 'posts'), pd = FENCE.post[1] / 2;
for (const v of [0.25, 0.75, 1.3]) box(m, f, u - 0.045, u + 0.045, y0 + v - 0.018, y0 + v + 0.018, pd, pd + 0.03);
}
function gateLeaf(f, u0, u1, y0, y1, reg, latch) {
const m = bucket(reg, 'gates'), 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'), 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++) {
const n = Math.ceil((cuts[k + 1] - cuts[k]) / FENCE.postSpacing - 1e-6);
for (let i = 1; i <= n; i++) us.push(cuts[k] + (cuts[k + 1] - cuts[k]) * i / n);
}
const tops = us.slice(1).map((u, i) => panel(f, us[i], u));
panels += tops.length;
return { f, us, tops };
});
edges.forEach(({ f, us, tops }, e) => {
us.forEach((u, i) => {
if (e > 0 && i === 0) return; // roh má sloupek z konce předchozí hrany
const near = [tops[Math.max(0, i - 1)], tops[Math.min(tops.length - 1, i)]];
if (i === us.length - 1 && e < edges.length - 1) near.push(edges[e + 1].tops[0]);
const reg = region(at(f, u, 0, 0)[0]);
post(f, u, Math.max(...near) + 0.04, reg); posts++;
clamps(f, u, Math.min(...near) - FENCE.panelHeight, reg);
for (const j of junctions) if (j.e === e && Math.abs(j.t * f.L - u) < 1e-6) j.fenceTop = 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), fenceTop: junction.fenceTop, 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))];
g.bottom = (d0, d1) => Math.max(...sampleGround(f, ...g.span(d0, d1), 8)) + 0.05;
g.leaf = (d0, d1, y0, y1, latch) => gateLeaf(f, ...g.span(d0, d1), y0, y1, g.reg, latch === undefined ? undefined : g.s(latch));
return g;
}
const gateTop = (bottom, neighbourTop) => Math.min(bottom + 1.8, Math.max(bottom + GATES.height, neighbourTop));
// vlevo od silnice: dvoukřídlá brána pro auta přes celou mezeru mezi domem a plotem
const car = gateLine(GATES.car.houseZ, east);
{
const y0 = car.bottom(0, car.L), y1 = gateTop(y0, car.fenceTop), half = car.L / 2;
post(car.f, car.s(0), y1 + 0.06, car.reg, [gp, gp]);
post(car.f, car.s(car.L), Math.max(y1, car.fenceTop) + 0.06, car.reg, [gp, gp]);
car.leaf(gp / 2 + gap, half - 0.01, y0, y1, half - 0.01);
car.leaf(half + 0.01, car.L - gp / 2 - gap, y0, y1);
}
// vpravo od silnice (u vchodu): branka pro lidi na dlažbě u domu, zbytek mezery pevné pole
const ped = gateLine(GATES.pedestrian.houseZ, west), dMid = gp + GATES.pedestrian.width;
{
const fixedTop = panel(ped.f, ...ped.span(dMid, ped.L));
const y0 = ped.bottom(0, dMid), y1 = gateTop(y0, fixedTop);
post(ped.f, ped.s(0), y1 + 0.06, ped.reg, [gp, gp]);
post(ped.f, ped.s(dMid), Math.max(y1, fixedTop) + 0.06, ped.reg, [gp, gp]);
post(ped.f, ped.s(ped.L), Math.max(fixedTop, ped.fenceTop) + 0.06, ped.reg, [gp, gp]);
for (const d of [dMid, ped.L]) clamps(ped.f, ped.s(d), fixedTop - FENCE.panelHeight, ped.reg);
ped.leaf(gp / 2 + gap, dMid - gp / 2 - gap, y0, y1, dMid - gp / 2 - gap);
panels++;
}
// --- 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.reg}_${m.part}`;
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', 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;
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('uzly:', wanted.map(m => `Site_Fence_${m.reg}_${m.part} (${m.idx.length / 3})`).join(', '));
console.log(`zapsáno ${file} (${fs.statSync(file).size} B)`);