/** * 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)`);