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

275 lines
19 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,
* u příjezdu dvoukřídlá brána pro auto a branka pro lidi. Na webu ho zapíná 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ř; výška se bere
* z viditelného terénu. Pozice bran jsou souřadnice z na hraně parcely u silnice (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 = { // z na hraně u silnice; branka naproti schodům, brána přes obě stání
pedestrian: [0.40, -0.85],
car: [-0.85, -5.05],
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);
const S0 = corners[streetIndex], S1 = corners[(streetIndex + 1) % corners.length];
const onStreet = z => { const t = (z - S0[1]) / (S1[1] - S0[1]); if (t < 0 || t > 1) throw Error('gate outside street edge: ' + z); return [S0[0] + (S1[0] - S0[0]) * t, z]; };
const [pedA, mid] = GATES.pedestrian.map(onStreet), carEnd = onStreet(GATES.car[1]);
assert(Math.abs(GATES.pedestrian[1] - GATES.car[0]) < 1e-9, 'gates must share the middle post');
function assert(ok, msg) { if (!ok) throw Error(msg); }
// fence: from the car gate around the parcel back to the pedestrian gate
const ring = [carEnd];
for (let i = 1; i <= corners.length; i++) ring.push(corners[(streetIndex + i) % corners.length]);
ring.push(pedA);
let posts = 0, panels = 0;
const edges = [];
for (let e = 0; e < ring.length - 1; e++) {
const f = edgeFrame(ring[e], ring[e + 1]), n = Math.ceil(f.L / FENCE.postSpacing - 1e-6), tops = [];
for (let i = 0; i < n; i++) { tops.push(panel(f, f.L * i / n, f.L * (i + 1) / n)); panels++; }
edges.push({ f, n, tops });
}
edges.forEach(({ f, n, tops }, e) => {
for (let i = 0; i <= n; i++) {
if (e > 0 && i === 0) continue; // roh má sloupek z konce předchozí hrany
const near = [tops[Math.max(0, i - 1)], tops[Math.min(n - 1, i)]];
if (i === n && e < edges.length - 1) near.push(edges[e + 1].tops[0]);
const atGate = (e === 0 && i === 0) || (e === edges.length - 1 && i === n);
const u = f.L * i / n, reg = region(at(f, u, 0, 0)[0]);
if (!atGate) { post(f, u, Math.max(...near) + 0.04, reg); posts++; }
clamps(f, u, Math.min(...near) - FENCE.panelHeight, reg);
}
});
// gates along the street edge (pedA -> mid -> carEnd)
const gf = edgeFrame(pedA, carEnd), gp = GATES.post, uMid = Math.hypot(mid[0] - pedA[0], mid[1] - pedA[1]), uEnd = gf.L;
const gy = (u0, u1) => Math.max(...sampleGround(gf, u0, u1, 8)) + 0.05;
const pedY = gy(0, uMid), carY = gy(uMid, uEnd), top = Math.max(pedY, carY) + GATES.height + 0.06;
for (const u of [0, uMid, uEnd]) post(gf, u, top, 'north', [gp, gp]);
gateLeaf(gf, gp / 2 + 0.02, uMid - gp / 2 - 0.02, pedY, pedY + GATES.height, 'north', uMid - gp / 2 - 0.02);
const half = (uMid + uEnd) / 2;
gateLeaf(gf, uMid + gp / 2 + 0.02, half - 0.01, carY, carY + GATES.height, 'north', half - 0.01);
gateLeaf(gf, half + 0.01, uEnd - gp / 2 - 0.02, carY, carY + GATES.height, 'north');
// --- 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 ${(uEnd - uMid - gp).toFixed(2)} m, branka ${(uMid - gp).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)`);