fix: update model version in app-config.json and add apple tree generation script
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "locenice-web-01",
|
||||
"model": "./models/dum.glb?v=20261026e",
|
||||
"model": "./models/dum.glb?v=20261029",
|
||||
"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",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Mladá jabloň ze školky (kmínek s vodičem ~1,8 m, pár postranních větví, bambusová tyčka)
|
||||
* v zahradě 3 m od hranice parcely v jihozápadním rohu. Je součástí vrstvy „Celý pozemek“.
|
||||
*
|
||||
* node tools/apple_tree.mjs [models/dum.glb]
|
||||
*
|
||||
* Skript přepíše uzly Site_AppleTree_* (opakované spuštění strom nahradí, nepřidá druhý).
|
||||
* Polohu určuje TREE.corner (roh parcely) a TREE.fromEdge (vzdálenost od obou hran rohu).
|
||||
* 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 TREE = {
|
||||
corner: [47.42, 4.04], // jihozápadní roh parcely (konec zahrady u západní hranice), x / z
|
||||
fromEdge: 3.0, // od jižní i západní hranice
|
||||
height: 1.8, // vodič nad terénem
|
||||
trunk: [0.024, 0.008], // průměr kmínku dole / nahoře
|
||||
crownFrom: 0.9, // první větve
|
||||
branches: 7,
|
||||
seed: 7,
|
||||
};
|
||||
const BARK = '#75604d', LEAF = '#6a8c38', BAMBOO = '#c8b27a';
|
||||
|
||||
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 assert = (ok, msg) => { if (!ok) throw Error(msg); };
|
||||
|
||||
// --- position: 3 m inside both parcel edges at the corner ---------------------------------------
|
||||
function outline() { // centreline of Site_Cadastral_outline, simplified to corners
|
||||
const n = gltf.nodes.find(q => q.name === 'Site_Cadastral_outline'), p = gltf.meshes[n.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)];
|
||||
};
|
||||
const far = chain.reduce((b, p, i) => Math.hypot(p[0] - chain[0][0], p[1] - chain[0][1]) > Math.hypot(chain[b][0] - chain[0][0], chain[b][1] - chain[0][1]) ? i : b, 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 poly = outline();
|
||||
const ci = poly.reduce((b, p, i) => Math.hypot(p[0] - TREE.corner[0], p[1] - TREE.corner[1]) < Math.hypot(poly[b][0] - TREE.corner[0], poly[b][1] - TREE.corner[1]) ? i : b, 0);
|
||||
assert(Math.hypot(poly[ci][0] - TREE.corner[0], poly[ci][1] - TREE.corner[1]) < 0.5, 'TREE.corner is not a parcel corner');
|
||||
const area = poly.reduce((s, p, i) => s + p[0] * poly[(i + 1) % poly.length][1] - poly[(i + 1) % poly.length][0] * p[1], 0);
|
||||
// both edges at the corner, moved inward by fromEdge; their intersection is the tree
|
||||
const edgeLine = (a, b) => {
|
||||
const L = Math.hypot(b[0] - a[0], b[1] - a[1]), dir = [(b[0] - a[0]) / L, (b[1] - a[1]) / L], inward = area > 0 ? [-dir[1], dir[0]] : [dir[1], -dir[0]];
|
||||
return { p: [a[0] + inward[0] * TREE.fromEdge, a[1] + inward[1] * TREE.fromEdge], dir };
|
||||
};
|
||||
const prev = poly[(ci + poly.length - 1) % poly.length], C = poly[ci], next = poly[(ci + 1) % poly.length];
|
||||
const l1 = edgeLine(prev, C), l2 = edgeLine(C, next), det = l1.dir[0] * l2.dir[1] - l1.dir[1] * l2.dir[0];
|
||||
const t = ((l2.p[0] - l1.p[0]) * l2.dir[1] - (l2.p[1] - l1.p[1]) * l2.dir[0]) / det;
|
||||
const X = l1.p[0] + l1.dir[0] * t, Z = l1.p[1] + l1.dir[1] * t;
|
||||
|
||||
const ground = gltf.nodes.filter(n => n.mesh !== undefined && /^Site_Terrain_/.test(n.name || '')).flatMap(n => gltf.meshes[n.mesh].primitives.map(p => ({ pos: read(p.attributes.POSITION), idx: read(p.indices) })));
|
||||
function groundY(x, z) {
|
||||
let best = -Infinity;
|
||||
for (const s of ground) for (let k = 0; k < s.idx.length; k += 3) {
|
||||
const a = s.pos[s.idx[k]], b = s.pos[s.idx[k + 1]], c = s.pos[s.idx[k + 2]];
|
||||
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 w1 = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / d, w2 = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / d, w3 = 1 - w1 - w2;
|
||||
if (w1 >= -1e-6 && w2 >= -1e-6 && w3 >= -1e-6) best = Math.max(best, w1 * a[1] + w2 * b[1] + w3 * c[1]);
|
||||
}
|
||||
assert(best > -Infinity, 'no terrain under the tree');
|
||||
return best;
|
||||
}
|
||||
const G = groundY(X, Z);
|
||||
|
||||
// --- geometry -----------------------------------------------------------------------------------
|
||||
let seed = TREE.seed;
|
||||
const rnd = () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; };
|
||||
const add = (a, b) => a.map((v, i) => v + b[i]), sub = (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));
|
||||
const mesh = () => ({ pos: [], nor: [], idx: [] });
|
||||
const wood = mesh(), leaves = mesh(), stake = mesh();
|
||||
// tapered tube along a polyline
|
||||
function tube(m, pts, r0, r1, sides = 6) {
|
||||
const k0 = m.pos.length;
|
||||
pts.forEach((p, i) => {
|
||||
const d = unit(sub(pts[Math.min(i + 1, pts.length - 1)], pts[Math.max(i - 1, 0)]));
|
||||
const a = unit(cross(d, Math.abs(d[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0])), b = cross(d, a), r = r0 + (r1 - r0) * i / (pts.length - 1);
|
||||
for (let s = 0; s < sides; s++) { const ang = 2 * Math.PI * s / sides, n = add(mul(a, Math.cos(ang)), mul(b, Math.sin(ang))); m.pos.push(add(p, mul(n, r))); m.nor.push(n); }
|
||||
if (i) for (let s = 0; s < sides; s++) { const q = k0 + (i - 1) * sides, s1 = (s + 1) % sides; m.idx.push(q + s, q + s1, q + sides + s1, q + s, q + sides + s1, q + sides + s); }
|
||||
});
|
||||
}
|
||||
// leaf: pointed oval as 4 vertices (stalk, two sides, tip), double-sided material
|
||||
function leaf(m, at, dir, size) {
|
||||
const side = unit(cross(dir, Math.abs(dir[1]) > 0.95 ? [1, 0, 0] : [0, 1, 0])), n = unit(cross(side, dir));
|
||||
const L = size, W = size * 0.45, k = m.pos.length;
|
||||
m.pos.push(at, add(add(at, mul(dir, L * 0.45)), mul(side, W / 2)), add(at, mul(dir, L)), add(add(at, mul(dir, L * 0.45)), mul(side, -W / 2)));
|
||||
m.nor.push(n, n, n, n);
|
||||
m.idx.push(k, k + 1, k + 2, k, k + 2, k + 3);
|
||||
}
|
||||
function leavesAlong(pts, every, size) {
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = pts[i - 1], b = pts[i], len = Math.hypot(...sub(b, a)), d = unit(sub(b, a));
|
||||
for (let s = 0; s < len; s += every) {
|
||||
const p = add(a, mul(d, s)), ang0 = rnd() * 2 * Math.PI;
|
||||
for (const ang of [ang0, ang0 + Math.PI * (0.8 + 0.4 * rnd())]) { // dva listy v uzlu
|
||||
const out = unit(add(add(mul(unit(cross(d, [0, 1, 0.001])), Math.cos(ang)), mul(unit(cross(d, unit(cross(d, [0, 1, 0.001])))), Math.sin(ang))), [0, 0.6, 0]));
|
||||
leaf(leaves, p, unit(add(out, mul(d, 0.5))), size * (0.8 + 0.4 * rnd()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// leader with a slight natural sway
|
||||
const leader = Array.from({ length: 10 }, (_, i) => { const h = TREE.height * i / 9; return [X + 0.03 * Math.sin(h * 2.1) * h / TREE.height, G - 0.05 + h, Z + 0.025 * Math.sin(h * 1.4 + 1) * h / TREE.height]; });
|
||||
tube(wood, leader, TREE.trunk[0] / 2, TREE.trunk[1] / 2);
|
||||
leavesAlong(leader.filter(p => p[1] - G > TREE.crownFrom + 0.1), 0.035, 0.085);
|
||||
for (let b = 0; b < TREE.branches; b++) {
|
||||
const h = TREE.crownFrom + (TREE.height - 0.25 - TREE.crownFrom) * b / (TREE.branches - 1), ang = b * 2.39996 + rnd() * 0.4;
|
||||
const len = 0.42 - 0.2 * b / TREE.branches + rnd() * 0.08, rise = 0.55 + rnd() * 0.35;
|
||||
const start = leader[Math.round((h / TREE.height) * 9)], dir = unit([Math.cos(ang), rise, Math.sin(ang)]);
|
||||
const pts = Array.from({ length: 5 }, (_, i) => add(start, add(mul(dir, len * i / 4), [0, 0.04 * (i / 4) ** 2, 0])));
|
||||
tube(wood, pts, 0.0055, 0.0025, 5);
|
||||
leavesAlong(pts.slice(1), 0.032, 0.08);
|
||||
}
|
||||
// bamboo stake next to the trunk with two ties
|
||||
const sp = [X + 0.035, 0, Z - 0.02];
|
||||
tube(stake, [[sp[0], G - 0.25, sp[2]], [sp[0], G + 1.35, sp[2]]], 0.006, 0.005, 6);
|
||||
for (const h of [0.6, 1.2]) tube(stake, [[X, G + h, Z], [sp[0], G + h, sp[2]]], 0.004, 0.004, 4);
|
||||
|
||||
// --- write ------------------------------------------------------------------------------------
|
||||
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, 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: 0, roughnessFactor: roughness }, extras: { loceniceId: 'material-' + i } };
|
||||
return i;
|
||||
}
|
||||
function accessor(data, type, componentType, target) {
|
||||
const flat = data.flat(), arr = componentType === 5126 ? new Float32Array(flat) : new Uint16Array(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 parts = [
|
||||
['wood', wood, material('Strom / kura jablone', BARK, 0.9)],
|
||||
['leaves', leaves, material('Strom / list jablone', LEAF, 0.7)],
|
||||
['stake', stake, material('Strom / bambusova tycka', BAMBOO, 0.6)],
|
||||
];
|
||||
const existing = gltf.nodes.map((n, i) => n.extras?.prop === 'apple_tree' ? i : -1).filter(i => i >= 0);
|
||||
const root = gltf.nodes[0].children;
|
||||
parts.forEach(([part, m, mat], k) => {
|
||||
assert(m.pos.length < 65536, 'too many vertices for 16-bit indices');
|
||||
const prim = { attributes: { POSITION: accessor(m.pos, 'VEC3', 5126, 34962), NORMAL: accessor(m.nor, 'VEC3', 5126, 34962) }, indices: accessor(m.idx, 'SCALAR', 5123, 34963), material: mat };
|
||||
const name = 'Site_AppleTree_' + 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] };
|
||||
gltf.nodes[ni].name = name;
|
||||
gltf.nodes[ni].extras = { siteLayer: 'parcel', castShadow: true, prop: 'apple_tree', loceniceId: 'node-' + 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); return { ...views[i], byteOffset: off, byteLength: viewBytes[i].length }; });
|
||||
gltf.buffers[0].byteLength = bin.length;
|
||||
fs.writeFileSync(file, encodeGLB(gltf, bin));
|
||||
|
||||
const dEdge = (a, b) => Math.abs((b[0] - a[0]) * (Z - a[1]) - (b[1] - a[1]) * (X - a[0])) / Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
console.log(`jabloň: x ${X.toFixed(2)} z ${Z.toFixed(2)}, terén ${G.toFixed(2)} m, od hran ${dEdge(prev, C).toFixed(2)} / ${dEdge(C, next).toFixed(2)} m, výška ${TREE.height} m`);
|
||||
console.log(`trojúhelníky: kmen+větve ${wood.idx.length / 3}, listy ${leaves.idx.length / 3}, tyčka ${stake.idx.length / 3}; zapsáno ${file} (${fs.statSync(file).size} B)`);
|
||||
Reference in New Issue
Block a user