feat: Add fence functionality with 3D model integration and site controls
continuous-integration/drone/push Build is passing

This commit is contained in:
martin.fencl
2026-10-26 14:25:31 +01:00
parent 222fb6f6f7
commit 9defe3ac98
6 changed files with 292 additions and 12 deletions
+3 -1
View File
@@ -141,6 +141,8 @@
id="siteParcel" autocomplete="off" checked><span>Celý pozemek</span></label>
<label class="site-switch" title="Parkovací plocha, napojení a schody; dvě orientační auta"><input
type="checkbox" id="siteParking" autocomplete="off" checked><span>Parkování</span></label>
<label class="site-switch" title="Plot z 3D panelů na podhrabových deskách; u příjezdu brána a branka"><input
type="checkbox" id="siteFence" autocomplete="off"><span>Plot</span></label>
<button id="siteFit" class="site-fit" type="button" title="Zarámovat zapnuté okolí"
aria-label="Zarámovat zapnuté okolí"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" aria-hidden="true">
@@ -345,7 +347,7 @@
<script>
// Firefox and Safari accept an import map only before the first module starts loading, so the map
// for Three.js is added here and bootstrap.js is requested only afterwards.
fetch('./data/app-config.json',{cache:'no-cache'}).then(r=>r.json()).then(c=>{const m=document.createElement('script');m.type='importmap';m.textContent=JSON.stringify({imports:{three:new URL(c.threeModule,location.href).href}});document.head.appendChild(m);}).catch(()=>{}).finally(()=>{const s=document.createElement('script');s.type='module';s.src='./js/bootstrap.js?v=20261022c';document.body.appendChild(s);});
fetch('./data/app-config.json',{cache:'no-cache'}).then(r=>r.json()).then(c=>{const m=document.createElement('script');m.type='importmap';m.textContent=JSON.stringify({imports:{three:new URL(c.threeModule,location.href).href}});document.head.appendChild(m);}).catch(()=>{}).finally(()=>{const s=document.createElement('script');s.type='module';s.src='./js/bootstrap.js?v=20261026';document.body.appendChild(s);});
</script>
</body>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "locenice-web-01",
"model": "./models/dum.glb?v=20261026",
"model": "./models/dum.glb?v=20261026b",
"renderer": "auto",
"threeModule": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"threeLoader": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/loaders/GLTFLoader.js",
+13 -9
View File
@@ -312,7 +312,7 @@ let backend=null,projectPanel=null;
}
// Independent site layers: initSiteControls enables all three on each fresh open.
const site = { road: false, parcel: false, parking: false, savedCamera: null, lastFocus: null };
const site = { road: false, parcel: false, parking: false, fence: false, savedCamera: null, lastFocus: null };
const siteActive = () => site.road || site.parcel || site.parking;
function meshVisible(m) {
if (m.legacySite) return false;
@@ -322,6 +322,8 @@ let backend=null,projectPanel=null;
case 'parcel': case 'boundary': return showTerrain && site.parcel;
case 'road': return site.road;
case 'parking': return site.parking;
// Each fence part stands on one terrain slab and is shown only together with it.
case 'fence': return showTerrain && site.fence && (m.std?.fenceRegion === 'parcel' ? site.parcel : m.std?.fenceRegion === 'north' ? siteActive() : true);
default: return showTerrain || !m.context;
}
}
@@ -363,18 +365,20 @@ let backend=null,projectPanel=null;
setCamera(fittedSiteCamera(mode), label, instant);
}
function setSiteLayer(key, on, focus = true) {
if (!['road', 'parcel', 'parking'].includes(key)) return;
if (!['road', 'parcel', 'parking', 'fence'].includes(key)) return;
// The fence is only shown or hidden: it does not count as active surroundings and keeps the camera.
const surroundings = key !== 'fence';
const was = siteActive();
if (!was && on) site.savedCamera = { theta, phi, distance, target: [...target], ortho: orthographic, label: $('#directionText').textContent };
if (surroundings && !was && on) site.savedCamera = { theta, phi, distance, target: [...target], ortho: orthographic, label: $('#directionText').textContent };
site[key] = !!on;
const ids = { road: 'siteRoad', parcel: 'siteParcel', parking: 'siteParking' };
const ids = { road: 'siteRoad', parcel: 'siteParcel', parking: 'siteParking', fence: 'siteFence' };
const input = $('#' + ids[key]); input.checked = site[key];
input.closest('label').classList.toggle('selected', site[key]);
document.body.classList.toggle('site-active', siteActive());
$('#siteMeta').hidden = !siteActive();
$('#siteMeta').textContent = 'C.3 + výškopis · mezi body interpolováno';
updateSolar();
if (focus) {
if (focus && surroundings) {
if (siteActive()) focusSite(on ? key : null);
else if (site.savedCamera) {
setCamera(site.savedCamera, site.savedCamera.label || 'Dům');
@@ -407,20 +411,20 @@ let backend=null,projectPanel=null;
}
function initSiteControls() {
for (const [id, key] of [['siteRoad', 'road'], ['siteParcel', 'parcel'], ['siteParking', 'parking']]) {
for (const [id, key] of [['siteRoad', 'road'], ['siteParcel', 'parcel'], ['siteParking', 'parking'], ['siteFence', 'fence']]) {
const input = $('#' + id); input.onchange = () => setSiteLayer(key, input.checked);
setSiteLayer(key, true, false);
setSiteLayer(key, key !== 'fence', false);
}
$('#siteFit').onclick = () => focusSite();
window.SITE_SIM = {
getState: () => ({
road: site.road, parcel: site.parcel, parking: site.parking, terrain: showTerrain,
road: site.road, parcel: site.parcel, parking: site.parking, fence: site.fence, terrain: showTerrain,
visibleObjects: meshes.filter(visible).length, referenceElevation: 591.840,
projectArea: 1010, digitizedArea: 987.89, houseGeometryUnchanged: true
}),
setLayer: (key, on, focus = true) => setSiteLayer(key, on, focus),
focus: mode => focusSite(mode),
reset: () => { for (const key of ['road', 'parcel', 'parking']) setSiteLayer(key, false, false); setPreset('terrace'); },
reset: () => { for (const key of ['road', 'parcel', 'parking', 'fence']) setSiteLayer(key, false, false); setPreset('terrace'); },
getCamera: () => ({ theta, phi, distance, target: [...target], aspect }),
checkGround: () => gltf.extras.siteStudy.finishedGroundC3
};
+1 -1
View File
@@ -17,7 +17,7 @@ try{
document.getElementById('stdSavedState').textContent=JSON.stringify({selection:payload.selection});
}catch(error){console.warn('Invalid shared selection:',error);}
window.LOCENICE_RESOURCES={config,catalog,assets,references,scene,site,modelBytes};
await import('./app.js?v=20261022c');
await import('./app.js?v=20261026');
}catch(error){
console.error(error);const box=document.getElementById('loading');box.className='error';box.innerHTML='<div class="errorDetail"></div><p>Nahrajte celou slozku vcetne js, css, data, models a assets. Otevrete ji pres HTTP(S), nikoli dvojklikem na HTML.</p><a href="./models/dum.glb" download>Stahnout model GLB</a>';
box.querySelector('.errorDetail').textContent=error.message;document.getElementById('webStatus').textContent='Chyba nacitani';
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
/**
* 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)`);