Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// Drill into the head/mouth region. Front = +Z. Looks at body vertices in the
|
||||
// front quarter and reports the vertical (Y) profile from nose back, plus the
|
||||
// lowest points near the mouth — to test "lower lip sits below the body".
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(process.argv[2]);
|
||||
const root = doc.getRoot();
|
||||
const joints = root.listSkins()[0].listJoints();
|
||||
const isBody = joints.map((n) => n.getName().startsWith('Spine'));
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
|
||||
const n = pos.getCount();
|
||||
|
||||
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
// bucket body vertices by Z slab from nose (+Z) backward; track Y min/max per slab
|
||||
const slabs = {};
|
||||
let bodyMinY = 1e9, bodyMaxY = -1e9;
|
||||
const front = []; // {x,y,z} of front-most body verts
|
||||
for (let i = 0; i < n; i++) {
|
||||
jA.getElement(i, j); wA.getElement(i, w);
|
||||
let best = 0, bw = -1; for (let k = 0; k < 4; k++) if (w[k] > bw) { bw = w[k]; best = j[k]; }
|
||||
if (!isBody[best]) continue;
|
||||
pos.getElement(i, p);
|
||||
bodyMinY = Math.min(bodyMinY, p[1]); bodyMaxY = Math.max(bodyMaxY, p[1]);
|
||||
const slab = Math.round(p[2] * 10) / 10;
|
||||
const e = slabs[slab] || (slabs[slab] = { c: 0, ymin: 1e9, ymax: -1e9 });
|
||||
e.c++; e.ymin = Math.min(e.ymin, p[1]); e.ymax = Math.max(e.ymax, p[1]);
|
||||
if (p[2] > 0.30) front.push([p[0], p[1], p[2]]);
|
||||
}
|
||||
console.log(`body Y range overall: ${bodyMinY.toFixed(3)} .. ${bodyMaxY.toFixed(3)}\n`);
|
||||
console.log('Z slab (nose=+Z → tail) bodyY min..max (height)');
|
||||
for (const z of Object.keys(slabs).map(Number).sort((a, b) => b - a)) {
|
||||
const e = slabs[z];
|
||||
console.log(` z=${z.toFixed(1).padStart(5)} n=${String(e.c).padStart(4)} Y ${e.ymin.toFixed(3)} .. ${e.ymax.toFixed(3)} (${(e.ymax - e.ymin).toFixed(3)})`);
|
||||
}
|
||||
// the lowest front verts (potential lower lip / jaw)
|
||||
front.sort((a, b) => a[1] - b[1]);
|
||||
console.log('\nlowest 6 front (z>0.30) body verts [x, y, z]:');
|
||||
for (const v of front.slice(0, 6)) console.log(' ', v.map((x) => x.toFixed(3).padStart(7)).join(', '));
|
||||
console.log('frontmost 6 (max z) body verts [x, y, z]:');
|
||||
front.sort((a, b) => b[2] - a[2]);
|
||||
for (const v of front.slice(0, 6)) console.log(' ', v.map((x) => x.toFixed(3).padStart(7)).join(', '));
|
||||
@@ -0,0 +1,60 @@
|
||||
// Independent structural audit of UB. Groups every vertex by its dominant bone,
|
||||
// then reports each anatomical group's vertex count, centroid, and bounding box in
|
||||
// model space. Reveals off-midline parts (dorsal), mis-located parts (lip), and
|
||||
// which primitive/material each region landed in after the split.
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(process.argv[2]);
|
||||
const root = doc.getRoot();
|
||||
const joints = root.listSkins()[0].listJoints();
|
||||
|
||||
const groupOf = (name) => {
|
||||
if (name.startsWith('Spine')) return 'body';
|
||||
if (name.startsWith('Dorsal')) return 'dorsal';
|
||||
if (name.startsWith('Pec_Fin_Left')) return 'pec_L';
|
||||
if (name.startsWith('Pec_Fin_Right')) return 'pec_R';
|
||||
if (name.startsWith('Left_Rear_Belly')) return 'belly_L';
|
||||
if (name.startsWith('Right_Rear_Belly')) return 'belly_R';
|
||||
if (name.startsWith('Rear_Center_Fin')) return 'anal';
|
||||
if (name.startsWith('Tail_Fork')) return 'tail';
|
||||
if (name.startsWith('Joint')) return 'joint(tail-ray?)';
|
||||
return `?:${name}`;
|
||||
};
|
||||
const jointGroup = joints.map((n) => groupOf(n.getName()));
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
const jA = prim.getAttribute('JOINTS_0');
|
||||
const wA = prim.getAttribute('WEIGHTS_0');
|
||||
const n = pos.getCount();
|
||||
|
||||
const G = {};
|
||||
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
jA.getElement(i, j); wA.getElement(i, w);
|
||||
let best = 0, bestW = -1;
|
||||
for (let k = 0; k < 4; k++) if (w[k] > bestW) { bestW = w[k]; best = j[k]; }
|
||||
const g = jointGroup[best];
|
||||
pos.getElement(i, p);
|
||||
const e = G[g] || (G[g] = { c: 0, sum: [0, 0, 0], min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] });
|
||||
e.c++;
|
||||
for (let d = 0; d < 3; d++) { e.sum[d] += p[d]; e.min[d] = Math.min(e.min[d], p[d]); e.max[d] = Math.max(e.max[d], p[d]); }
|
||||
}
|
||||
|
||||
const f = (x) => x.toFixed(3).padStart(8);
|
||||
console.log('axes: X Y Z (model space)\n');
|
||||
for (const [g, e] of Object.entries(G).sort((a, b) => b[1].c - a[1].c)) {
|
||||
const ctr = e.sum.map((s) => s / e.c);
|
||||
const sz = e.max.map((m, d) => m - e.min[d]);
|
||||
console.log(`${g.padEnd(16)} n=${String(e.c).padStart(5)} centroid[${ctr.map(f).join(',')}] size[${sz.map(f).join(',')}]`);
|
||||
}
|
||||
|
||||
// whole-model bbox to know which axis is length/height/depth
|
||||
const bb = Object.values(G).reduce((acc, e) => {
|
||||
for (let d = 0; d < 3; d++) { acc.min[d] = Math.min(acc.min[d], e.min[d]); acc.max[d] = Math.max(acc.max[d], e.max[d]); }
|
||||
return acc;
|
||||
}, { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] });
|
||||
console.log(`\nwhole bbox min[${bb.min.map(f).join(',')}] max[${bb.max.map(f).join(',')}] size[${bb.max.map((m, d) => f(m - bb.min[d])).join(',')}]`);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Measure the two tail lobes separately. For each of Tail_Fork_Top and
|
||||
// Tail_Fork_Bottom (subtree), report the vertex centroid + bbox. If the lobes are
|
||||
// separated in Y → a normal vertical fork (upper/lower). If separated in X →
|
||||
// a genuine LEFT/RIGHT double tail (two tails side by side), which is what reads
|
||||
// as "two tails" and is a geometry problem, not a render one.
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(process.argv[2]);
|
||||
const root = doc.getRoot();
|
||||
const joints = root.listSkins()[0].listJoints();
|
||||
const idxOf = new Map(joints.map((n, i) => [n, i]));
|
||||
|
||||
const subtree = (rootName) => {
|
||||
const set = new Set();
|
||||
const start = joints.find((n) => n.getName() === rootName);
|
||||
if (!start) return set;
|
||||
const mark = (n) => { if (idxOf.has(n)) set.add(idxOf.get(n)); n.listChildren().forEach(mark); };
|
||||
mark(start);
|
||||
return set;
|
||||
};
|
||||
const topSet = subtree('Tail_Fork_Top');
|
||||
const botSet = subtree('Tail_Fork_Bottom');
|
||||
console.log(`Top subtree joints: ${topSet.size}, Bottom subtree joints: ${botSet.size}`);
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
|
||||
const n = pos.getCount();
|
||||
|
||||
const acc = { top: mk(), bot: mk() };
|
||||
function mk() { return { c: 0, sum: [0, 0, 0], min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] }; }
|
||||
function add(e, p) { e.c++; for (let d = 0; d < 3; d++) { e.sum[d] += p[d]; e.min[d] = Math.min(e.min[d], p[d]); e.max[d] = Math.max(e.max[d], p[d]); } }
|
||||
|
||||
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
jA.getElement(i, j); wA.getElement(i, w);
|
||||
let top = 0, bot = 0;
|
||||
for (let k = 0; k < 4; k++) { if (topSet.has(j[k])) top += w[k]; if (botSet.has(j[k])) bot += w[k]; }
|
||||
if (top < 0.5 && bot < 0.5) continue;
|
||||
pos.getElement(i, p);
|
||||
add(top >= bot ? acc.top : acc.bot, p);
|
||||
}
|
||||
const f = (x) => x.toFixed(3).padStart(8);
|
||||
for (const [k, e] of Object.entries(acc)) {
|
||||
if (!e.c) { console.log(`${k}: (no verts)`); continue; }
|
||||
const ctr = e.sum.map((s) => s / e.c);
|
||||
console.log(`${k.padEnd(4)} n=${String(e.c).padStart(4)} centroid[${ctr.map(f).join(',')}] X:[${f(e.min[0])},${f(e.max[0])}] Y:[${f(e.min[1])},${f(e.max[1])}] Z:[${f(e.min[2])},${f(e.max[2])}]`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(process.argv[2]);
|
||||
const skin = doc.getRoot().listSkins()[0];
|
||||
const joints = skin.listJoints();
|
||||
|
||||
const tailRoots = joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName()));
|
||||
const tailSet = new Set();
|
||||
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
|
||||
tailRoots.forEach(mark);
|
||||
const tailJoints = joints.filter((n) => tailSet.has(n));
|
||||
console.log(`tail roots: ${tailRoots.map((n) => n.getName()).join(', ')}`);
|
||||
console.log(`tail subtree joints (${tailJoints.length}): ${tailJoints.map((n) => n.getName()).join(', ')}`);
|
||||
|
||||
// classify vertices into body / tail / otherfin and count triangles per group
|
||||
const isFin = joints.map((n) => !n.getName().startsWith('Spine'));
|
||||
const isTail = joints.map((n) => tailSet.has(n));
|
||||
const mesh = doc.getRoot().listMeshes()[0];
|
||||
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
|
||||
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
|
||||
const n = prim.getAttribute('POSITION').getCount();
|
||||
const vFin = new Uint8Array(n), vTail = new Uint8Array(n);
|
||||
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
jA.getElement(i, j); wA.getElement(i, w);
|
||||
let f = 0, t = 0, tot = 0;
|
||||
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFin[j[k]]) f += w[k]; if (isTail[j[k]]) t += w[k]; }
|
||||
vFin[i] = tot > 0 && f / tot >= 0.5 ? 1 : 0;
|
||||
vTail[i] = tot > 0 && t / tot >= 0.5 ? 1 : 0;
|
||||
}
|
||||
console.log(`tail vertices: ${vTail.reduce((a, b) => a + b, 0)} / ${n}`);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const input = process.argv[2];
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(input);
|
||||
const root = doc.getRoot();
|
||||
|
||||
const skin = root.listSkins()[0];
|
||||
const joints = skin.listJoints();
|
||||
const isFinJoint = joints.map((n) => !n.getName().startsWith('Spine'));
|
||||
console.log(`skin joints: ${joints.length} (fin joints: ${isFinJoint.filter(Boolean).length}, body/Spine: ${isFinJoint.filter((x) => !x).length})`);
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prims = mesh.listPrimitives();
|
||||
console.log(`meshes: ${root.listMeshes().length}, primitives: ${prims.length}`);
|
||||
const prim = prims[0];
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
const jA = prim.getAttribute('JOINTS_0');
|
||||
const wA = prim.getAttribute('WEIGHTS_0');
|
||||
const idx = prim.getIndices();
|
||||
console.log(`semantics: ${prim.listSemantics().join(', ')}`);
|
||||
console.log(`vertices: ${pos.getCount()}, indices: ${idx.getCount()} (${idx.getCount() / 3} tris)`);
|
||||
|
||||
const vcount = pos.getCount();
|
||||
const vIsFin = new Uint8Array(vcount);
|
||||
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < vcount; i++) {
|
||||
jA.getElement(i, j);
|
||||
wA.getElement(i, w);
|
||||
let fin = 0, tot = 0;
|
||||
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFinJoint[j[k]]) fin += w[k]; }
|
||||
vIsFin[i] = tot > 0 && fin / tot >= 0.5 ? 1 : 0;
|
||||
}
|
||||
let finV = 0;
|
||||
for (let i = 0; i < vcount; i++) finV += vIsFin[i];
|
||||
|
||||
const arr = idx.getArray();
|
||||
let bodyT = 0, finT = 0, mixed = 0;
|
||||
for (let t = 0; t < arr.length; t += 3) {
|
||||
const votes = vIsFin[arr[t]] + vIsFin[arr[t + 1]] + vIsFin[arr[t + 2]];
|
||||
if (votes >= 2) finT++; else bodyT++;
|
||||
if (votes === 1 || votes === 2) mixed++;
|
||||
}
|
||||
console.log(`fin vertices: ${finV}/${vcount} (${(100 * finV / vcount).toFixed(1)}%)`);
|
||||
console.log(`triangles → body: ${bodyT}, fin: ${finT}, (boundary/mixed tris: ${mixed})`);
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "glb-split",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "glb-split",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.4.0",
|
||||
"@gltf-transform/extensions": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gltf-transform/core": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@gltf-transform/core/-/core-4.4.0.tgz",
|
||||
"integrity": "sha512-cOPxOhHFFz5hwmix+li1+Nnq5qMV/QD3fTCsVlApxxFACtFdjkt2R/juseD4gvZ7D2c/yl6OilKH0pvI735YyQ==",
|
||||
"dependencies": {
|
||||
"property-graph": "^4.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/donmccurdy"
|
||||
}
|
||||
},
|
||||
"node_modules/@gltf-transform/extensions": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@gltf-transform/extensions/-/extensions-4.4.0.tgz",
|
||||
"integrity": "sha512-ZwEgFkkqnUR7d4m6roK9BycxxdoqJNtVyo7w5ShJ9syKBoQiXw2QrTSLwXaUAImSrEIl9Jh/wZTtvSVyviQuXg==",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.4.0",
|
||||
"ktx-parse": "^1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/donmccurdy"
|
||||
}
|
||||
},
|
||||
"node_modules/ktx-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
|
||||
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
|
||||
},
|
||||
"node_modules/property-graph": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
|
||||
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Render UB's silhouette as ASCII through the ACTUAL camera + transform, so we can
|
||||
// literally see the tail's on-screen shape. Body='#', side fins='f', tail='T'.
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const yaw = parseFloat(process.argv[3] ?? (Math.PI / 2 + 0.10));
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(process.argv[2]);
|
||||
const root = doc.getRoot();
|
||||
const joints = root.listSkins()[0].listJoints();
|
||||
const tailSet = new Set();
|
||||
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
|
||||
joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName())).forEach(mark);
|
||||
const grp = joints.map((n) => tailSet.has(n) ? 'T' : (n.getName().startsWith('Spine') ? '#' : 'f'));
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
|
||||
const pos = prim.getAttribute('POSITION'), jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
|
||||
const N = pos.getCount();
|
||||
|
||||
// match aquarium.js: center, scale, rotate yaw about Y, position = -center
|
||||
const box = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] };
|
||||
const p = [0, 0, 0];
|
||||
for (let i = 0; i < N; i++) { pos.getElement(i, p); for (let d = 0; d < 3; d++) { box.min[d] = Math.min(box.min[d], p[d]); box.max[d] = Math.max(box.max[d], p[d]); } }
|
||||
const center = box.min.map((m, d) => (m + box.max[d]) / 2);
|
||||
const s = 2.6 / Math.max(...box.max.map((m, d) => m - box.min[d]));
|
||||
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
||||
|
||||
// camera lookAt
|
||||
const cam = [0, 0.25, 4.6], tgt = [0, 0, 0];
|
||||
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const norm = (a) => { const l = Math.hypot(...a); return [a[0] / l, a[1] / l, a[2] / l]; };
|
||||
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 dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const f = norm(sub(tgt, cam)), r = norm(cross(f, [0, 1, 0])), u = cross(r, f);
|
||||
const aspect = 4 / 3, t = Math.tan((42 * Math.PI / 180) / 2);
|
||||
|
||||
const COLS = 96, ROWS = 40;
|
||||
const grid = Array.from({ length: ROWS }, () => Array(COLS).fill(' '));
|
||||
const zbuf = Array.from({ length: ROWS }, () => Array(COLS).fill(1e9));
|
||||
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < N; i++) {
|
||||
jA.getElement(i, j); wA.getElement(i, w);
|
||||
let best = 0, bw = -1; for (let k = 0; k < 4; k++) if (w[k] > bw) { bw = w[k]; best = j[k]; }
|
||||
const g = grp[best];
|
||||
pos.getElement(i, p);
|
||||
// scale, rotate Y, translate by -center
|
||||
const x = p[0] * s, y = p[1] * s, z = p[2] * s;
|
||||
const wx = (cy * x + sy * z) - center[0];
|
||||
const wy = y - center[1];
|
||||
const wz = (-sy * x + cy * z) - center[2];
|
||||
const rel = [wx - cam[0], wy - cam[1], wz - cam[2]];
|
||||
const vz = dot(rel, f); if (vz <= 0.01) continue;
|
||||
const ndcX = dot(rel, r) / (vz * t * aspect), ndcY = dot(rel, u) / (vz * t);
|
||||
const col = Math.round((ndcX + 1) / 2 * (COLS - 1)), row = Math.round((1 - (ndcY + 1) / 2) * (ROWS - 1));
|
||||
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) continue;
|
||||
if (vz < zbuf[row][col]) { zbuf[row][col] = vz; grid[row][col] = g; }
|
||||
}
|
||||
console.log(`yaw=${yaw.toFixed(3)} (# body, f fins, T tail)`);
|
||||
console.log(grid.map((r) => r.join('')).join('\n'));
|
||||
@@ -0,0 +1,81 @@
|
||||
// Split UB (single skinned primitive, one BLEND/double-sided material) into THREE
|
||||
// primitives — opaque body+eyes, translucent side fins, and the translucent tail —
|
||||
// sharing the SAME vertex data, skeleton, and animations. Classification is by
|
||||
// skin weight: a vertex is "fin" when the majority of its weight rides non-Spine
|
||||
// bones, and "tail" when the majority rides the Tail_Fork subtree (the forked
|
||||
// caudal fin + its ray joints). Triangles vote 2-of-3. The tail gets its own
|
||||
// material so it can render SINGLE-sided — a thin double-sided translucent fan
|
||||
// shows its front and back faces at once and they bleed through each other while
|
||||
// it sweeps, which reads as "two tails." All three primitives reference the same
|
||||
// attribute accessors (no vertex duplication); only index buffer + material differ.
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const [input, output] = process.argv.slice(2);
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(input);
|
||||
const root = doc.getRoot();
|
||||
|
||||
const skin = root.listSkins()[0];
|
||||
const joints = skin.listJoints();
|
||||
const isFinJoint = joints.map((n) => !n.getName().startsWith('Spine'));
|
||||
// Tail = the Tail_Fork_Top/Bottom subtree (fork bones + their fin-ray joints).
|
||||
const tailSet = new Set();
|
||||
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
|
||||
joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName())).forEach(mark);
|
||||
const isTailJoint = joints.map((n) => tailSet.has(n));
|
||||
|
||||
const mesh = root.listMeshes()[0];
|
||||
const prim = mesh.listPrimitives()[0];
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
const jA = prim.getAttribute('JOINTS_0');
|
||||
const wA = prim.getAttribute('WEIGHTS_0');
|
||||
const idx = prim.getIndices();
|
||||
|
||||
// Per-vertex classification: fin (non-Spine majority) and tail (Tail_Fork majority).
|
||||
const vcount = pos.getCount();
|
||||
const vIsFin = new Uint8Array(vcount);
|
||||
const vIsTail = new Uint8Array(vcount);
|
||||
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
|
||||
for (let i = 0; i < vcount; i++) {
|
||||
jA.getElement(i, j);
|
||||
wA.getElement(i, w);
|
||||
let fin = 0, tail = 0, tot = 0;
|
||||
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFinJoint[j[k]]) fin += w[k]; if (isTailJoint[j[k]]) tail += w[k]; }
|
||||
vIsFin[i] = tot > 0 && fin / tot >= 0.5 ? 1 : 0;
|
||||
vIsTail[i] = tot > 0 && tail / tot >= 0.5 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Per-triangle split: body unless ≥2 fin verts; within fin, tail if ≥2 tail verts.
|
||||
const arr = idx.getArray();
|
||||
const bodyIdx = [], finIdx = [], tailIdx = [];
|
||||
for (let t = 0; t < arr.length; t += 3) {
|
||||
const a = arr[t], b = arr[t + 1], c = arr[t + 2];
|
||||
if (vIsFin[a] + vIsFin[b] + vIsFin[c] >= 2) {
|
||||
(vIsTail[a] + vIsTail[b] + vIsTail[c] >= 2 ? tailIdx : finIdx).push(a, b, c);
|
||||
} else {
|
||||
bodyIdx.push(a, b, c);
|
||||
}
|
||||
}
|
||||
const IndexArr = vcount > 65535 ? Uint32Array : Uint16Array;
|
||||
const buffer = root.listBuffers()[0];
|
||||
const mkIndex = (a) => doc.createAccessor().setType('SCALAR').setArray(new IndexArr(a)).setBuffer(buffer);
|
||||
|
||||
// Materials: original → opaque body; clones → translucent fins / tail.
|
||||
const origMat = prim.getMaterial();
|
||||
const bodyMat = origMat.setName('UB_Body').setAlphaMode('OPAQUE').setDoubleSided(false);
|
||||
const finMat = origMat.clone().setName('UB_Fins').setAlphaMode('BLEND').setDoubleSided(true);
|
||||
const tailMat = origMat.clone().setName('UB_Tail').setAlphaMode('BLEND').setDoubleSided(false);
|
||||
|
||||
// Body reuses the existing primitive; fins + tail get new primitives sharing attrs.
|
||||
const addPrim = (indices, mat) => {
|
||||
const p = doc.createPrimitive().setIndices(mkIndex(indices)).setMaterial(mat);
|
||||
for (const sem of prim.listSemantics()) p.setAttribute(sem, prim.getAttribute(sem));
|
||||
mesh.addPrimitive(p);
|
||||
};
|
||||
prim.setIndices(mkIndex(bodyIdx)).setMaterial(bodyMat);
|
||||
addPrim(finIdx, finMat);
|
||||
addPrim(tailIdx, tailMat);
|
||||
|
||||
await io.write(output, doc);
|
||||
console.log(`wrote ${output}: body ${bodyIdx.length / 3}, fins ${finIdx.length / 3}, tail ${tailIdx.length / 3} tris`);
|
||||
Reference in New Issue
Block a user