From edb0ab3fb624df136ad75e71290bb04426c53336 Mon Sep 17 00:00:00 2001 From: Moshu <1709219+DatMoshu@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:19:27 -0500 Subject: [PATCH 1/2] Fit Lab: poke metric follows the renderer's holdout margin, push-out and occlusion modes Fixes #16. Pure rule in poke-rules.mjs with Node tests; README Measure section states the rule. Co-Authored-By: Claude Sonnet 5.5 --- docs/handoff.md | 5 +++-- tools/fit-lab/README.md | 19 ++++++++++++++---- tools/fit-lab/web/index.html | 2 +- tools/fit-lab/web/lab.js | 20 +++++++++++++++---- tools/fit-lab/web/poke-rules.mjs | 20 +++++++++++++++++++ tools/fit-lab/web/poke-rules.test.mjs | 28 +++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 tools/fit-lab/web/poke-rules.mjs create mode 100644 tools/fit-lab/web/poke-rules.test.mjs diff --git a/docs/handoff.md b/docs/handoff.md index a90e137..12d3a23 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -103,8 +103,9 @@ Rollback: `pipeline.py setup --source workspace/uo-model-review/main`, and resto ## Known gaps -- The lab's poke metric does not model the renderer's 1 cm holdout margin or 6 mm body push-out (counts run high; - compare, don't read as absolutes). +- The lab's poke metric models the renderer's 1 cm holdout margin, the 6 mm push-out as an upper bound, and the + clothing/body/none occlusion modes (`tools/fit-lab/web/poke-rules.mjs`); it still does not solve the push-out itself, + so compare counts, don't read them as absolutes. - Rig targets marked `proposed_target` in mappings (twist bones, fingers, shield, cloak/skirt chains) are not used by `pack_fit.py` yet. - Untested contributed outfit-lab options: `merge_items.py`, `make_gump_cloak.py`, `build_item.py --planar/--cut`, diff --git a/tools/fit-lab/README.md b/tools/fit-lab/README.md index 28efd6e..24c45eb 100644 --- a/tools/fit-lab/README.md +++ b/tools/fit-lab/README.md @@ -115,10 +115,21 @@ existing manifest item format; `file` paths point into the cache. Limits: 100 GL the baseline, and each new run shows the change. - **Save** writes `/packs//lab-adjustments.json`. -Poke pixels count where body limb and head faces that lie under the item (within 5 cm outside or 3 cm inside it at -rest) show in front of it. Those are the holes the renderer's body holdout would cut (the torso never holds out -there). The renderer's 1 cm holdout margin and 6 mm push-out are not modelled, so the counts run high. Compare them, -don't read them as absolutes. +Poke pixels follow the renderer's body holdout (rule in `web/poke-rules.mjs`, tested by `poke-rules.test.mjs`). +A body pixel counts where the body shows in front of the item by more than an allowance, which is: + +- the **1 cm holdout margin** (`HOLDOUT_MARGIN` in the installed canonical renderer's `render_uo_layer.py`; the same + value the lab already uses for the preview cutout), or in clothing mode with Hide body on, the larger of that and the + Hide body inward distance (`tools/uo-content/occlusion.py`, `blocked_pixels`), plus +- the **6 mm push-out** (`BODY_GAP` in the same file). It is left out where the build sets it to 0: rigid items and + helm, weapon, shield, bow, quiver (`tools/uo-content/blender_build.py`). The push-out moves the item away from the + body, so adding it to the allowance is an upper bound on the holes it removes. + +The item's occlusion mode decides what is counted, as in the renderer: **clothing** counts limb and head faces under +the item (within 5 cm outside or 3 cm inside it at rest; the torso never holds out there), **body** counts the whole +body and ignores Hide body, and **none** counts nothing because the renderer cuts no holes. Counts are lower than +before this rule, so compare runs; don't read them as absolutes. Baselines live only in the open page (Measure +"first" resets on reload), so counts from before this rule are never mixed with new ones. ## From the lab to builds diff --git a/tools/fit-lab/web/index.html b/tools/fit-lab/web/index.html index df6228b..8d1d922 100644 --- a/tools/fit-lab/web/index.html +++ b/tools/fit-lab/web/index.html @@ -142,7 +142,7 @@

Poke-through

-
How poke pixels are counted

Limb and head faces lying under the item (within 5 cm outside or 3 cm inside it at rest) that show in front of it: the holes the renderer's body holdout would cut. The renderer's 1 cm holdout margin and 6 mm push-out are not modelled, so counts run high. Compare them; don't read them as absolutes.

+
How poke pixels are counted

Body faces that show in front of the item by more than the renderer's allowance: the 1 cm holdout margin (or the Hide body inward distance, if larger, in clothing mode) plus the 6 mm push-out for skinned garments (not rigid, helm, weapon, shield, bow, quiver). Clothing counts limb and head faces under the item; body counts the whole body; none counts nothing. These are the holes the renderer would cut. Compare counts; don't read them as absolutes.

Head steadying A/B

Compares original motion with a steadied head on the first three items of every slot.

diff --git a/tools/fit-lab/web/lab.js b/tools/fit-lab/web/lab.js index 555b311..c3dae33 100644 --- a/tools/fit-lab/web/lab.js +++ b/tools/fit-lab/web/lab.js @@ -7,6 +7,7 @@ import { computeBoundsTree } from 'three-mesh-bvh'; import { FitPersistence } from './persistence.js'; import { same } from './history.mjs'; import { resolveFit, storedDirection } from './fit-rules.mjs'; +import { pokeRule } from './poke-rules.mjs'; import { RenderPanel } from './render-panel.js'; import { dominantJoint } from './rigid-binding.mjs'; THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; @@ -45,10 +46,17 @@ const pixels = new Uint8Array(W * H * 4); const flat = c => new THREE.MeshBasicMaterial({ color: c, side: THREE.DoubleSide }); const RED = flat(0xff0000), GREEN = flat(0x00ff00); const DEPTH = new THREE.MeshBasicMaterial({colorWrite:false, depthWrite:true, side:THREE.DoubleSide}); -DEPTH.onBeforeCompile = shader => { +const pushBack = metres => shader => { // move the body `metres` away from the UO camera, as the renderer's margin does shader.vertexShader = shader.vertexShader.replace('#include ', - '#include \ngl_Position.z += ' + (2 * .01 / (uoCam.far - uoCam.near)).toFixed(8) + ' * gl_Position.w;'); + '#include \ngl_Position.z += ' + (2 * metres / (uoCam.far - uoCam.near)).toFixed(8) + ' * gl_Position.w;'); }; +DEPTH.onBeforeCompile = pushBack(.01); +const pokeMaterials = new Map(); // allowance in metres -> red body material shifted back by it +function pokeMaterial(allowance) { + const key = allowance.toFixed(4); + if (!pokeMaterials.has(key)) { const m = flat(0xff0000); m.onBeforeCompile = pushBack(allowance); pokeMaterials.set(key, m); } + return pokeMaterials.get(key); +} let poseAction = null, poseDirection = null, currentPoseFrame = 0, referenceCanvas; let body, bodyMesh, mixer, clips = {}, bodyBase, bodyTris, occluderTri, hiddenOverlay, bodyMeasure; let reference, referenceImage, stabilizeHead = false, headBone; @@ -295,7 +303,11 @@ function soloRender(item, mode) { // mode: 'mask' | 'poke' | 'look' const keep = []; scene.traverse(o => { if (o.isMesh) { keep.push([o, o.visible, o.material]); o.visible = false; } }); const rigid = fitFor(item.info).bind === 'rigid', meshes = rigid ? item.rigid : item.skinned; meshes.forEach(m => { m.visible = true; if (mode !== 'look') m.material = GREEN; }); - if (mode === 'poke') { bodyMeasure.geometry.setIndex(bodyIndex(hiddenFor(item), coveredFor(item))); bodyMeasure.visible = true; } + if (mode === 'poke') { // the renderer's holdout rule for this item, see poke-rules.mjs + const rule = pokeRule(fitFor(item.info), item.info); + bodyMeasure.geometry.setIndex(rule.measure ? (rule.faces === 'all' ? bodyIndex(new Set(), null) : bodyIndex(hiddenFor(item), coveredFor(item))) : []); + bodyMeasure.material = pokeMaterial(rule.allowance); bodyMeasure.visible = rule.measure; + } if (mode === 'look' && $('previewBase').value === 'model') { bodyMesh.geometry.setIndex(bodyIndex(hiddenFor(item), null)); bodyMesh.visible = true; } let out = renderTarget().slice(); if (mode === 'look' && $('previewBase').value !== 'model' && fitFor(item.info).occlusion !== 'none') { @@ -317,7 +329,7 @@ function soloRender(item, mode) { // mode: 'mask' | 'poke' | 'look' } for (const [o, v, m] of keep) { o.visible = v; o.material = m; } meshes.forEach((m, k) => m.material = item.parts[k].material); - bodyMeasure.visible = false; + bodyMeasure.visible = false; bodyMeasure.material = RED; bodyMesh.geometry.setIndex(bodyIndexBefore); scene.background = background; return out; diff --git a/tools/fit-lab/web/poke-rules.mjs b/tools/fit-lab/web/poke-rules.mjs new file mode 100644 index 0000000..f74dfa8 --- /dev/null +++ b/tools/fit-lab/web/poke-rules.mjs @@ -0,0 +1,20 @@ +// The rule the Measure poke count follows, kept pure so Node can test it. It mirrors the final renderer: +// - tools/uo-content/occlusion.py (BodyHoldout / blocked_pixels): the body cuts an item pixel only where it is +// more than `margin` in front of the item; in clothing mode with hide-body on, a same-region contact gets +// max(margin, hide.inward). +// - the installed canonical renderer, render_uo_layer.py: HOLDOUT_MARGIN = 0.01 m and BODY_GAP = 0.006 m (push-out), +// which tools/uo-content/blender_build.py sets to 0 for rigid items and for helm, weapon, shield, bow and quiver. +export const HOLDOUT_MARGIN = 0.01; +export const PUSH_OUT_GAP = 0.006; +const NO_PUSH_OUT = new Set(['helm', 'weapon', 'shield', 'bow', 'quiver']); + +export function pokeRule(fit, info) { + const mode = fit.occlusion || 'clothing'; + if (mode === 'none') return { measure: false, mode, allowance: 0, faces: 'none' }; // renderer skips the holdout + const tolerance = mode === 'clothing' && fit.hide?.enabled ? (fit.hide.inward ?? 0.01) : 0; + const pushed = fit.bind !== 'rigid' && !NO_PUSH_OUT.has(info.part) && !NO_PUSH_OUT.has(info.slot); + return { measure: true, mode, + allowance: Math.max(HOLDOUT_MARGIN, tolerance) + (pushed ? PUSH_OUT_GAP : 0), + // clothing: limb/head faces under the item (the torso never holds out); body: the whole body, hide-body ignored + faces: mode === 'body' ? 'all' : 'under' }; +} diff --git a/tools/fit-lab/web/poke-rules.test.mjs b/tools/fit-lab/web/poke-rules.test.mjs new file mode 100644 index 0000000..579783f --- /dev/null +++ b/tools/fit-lab/web/poke-rules.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { pokeRule, HOLDOUT_MARGIN, PUSH_OUT_GAP } from './poke-rules.mjs'; + +const fit = (o = {}) => ({ occlusion: 'clothing', bind: 'skinned', hide: { enabled: true, outward: .02, inward: .01 }, ...o }); +const info = (o = {}) => ({ slot: 'chest', part: 'chest', ...o }); + +test('clothing: 1 cm margin plus 6 mm push-out for a skinned garment', () => { + const r = pokeRule(fit(), info()); + assert.equal(r.measure, true); assert.equal(r.faces, 'under'); + assert.ok(Math.abs(r.allowance - (HOLDOUT_MARGIN + PUSH_OUT_GAP)) < 1e-12); +}); +test('hide-body inward larger than the margin is the contact allowance; off falls back to the margin', () => { + assert.ok(Math.abs(pokeRule(fit({ hide: { enabled: true, inward: .03 } }), info()).allowance - .036) < 1e-12); + assert.ok(Math.abs(pokeRule(fit({ hide: { enabled: false, inward: .03 } }), info()).allowance - .016) < 1e-12); +}); +test('rigid items and helm/weapon/shield/bow/quiver get no push-out', () => { + assert.equal(pokeRule(fit({ bind: 'rigid' }), info()).allowance, HOLDOUT_MARGIN); + for (const part of ['helm', 'weapon', 'shield', 'bow', 'quiver']) + assert.equal(pokeRule(fit(), info({ part, slot: part })).allowance, HOLDOUT_MARGIN); +}); +test('body mode uses the whole body and ignores the hide-body tolerance', () => { + const r = pokeRule(fit({ occlusion: 'body' }), info()); + assert.equal(r.faces, 'all'); assert.ok(Math.abs(r.allowance - .016) < 1e-12); +}); +test('none mode measures nothing, like the renderer', () => { + assert.equal(pokeRule(fit({ occlusion: 'none' }), info()).measure, false); +}); From 373c2d7bafbd95ce8afdb95f92999f2b3ac06d3f Mon Sep 17 00:00:00 2001 From: Moshu <1709219+DatMoshu@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:06:29 -0500 Subject: [PATCH 2/2] Fit Lab poke rule: decide push-out from the mapping's studio_part, not pack part code or layer name Co-Authored-By: Claude Sonnet 5.5 --- tools/fit-lab/README.md | 4 ++- tools/fit-lab/web/lab.js | 2 +- tools/fit-lab/web/poke-rules.mjs | 6 ++-- tools/fit-lab/web/poke-rules.test.mjs | 45 ++++++++++++++++++++------- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/tools/fit-lab/README.md b/tools/fit-lab/README.md index 24c45eb..f84eb01 100644 --- a/tools/fit-lab/README.md +++ b/tools/fit-lab/README.md @@ -122,7 +122,9 @@ A body pixel counts where the body shows in front of the item by more than an al value the lab already uses for the preview cutout), or in clothing mode with Hide body on, the larger of that and the Hide body inward distance (`tools/uo-content/occlusion.py`, `blocked_pixels`), plus - the **6 mm push-out** (`BODY_GAP` in the same file). It is left out where the build sets it to 0: rigid items and - helm, weapon, shield, bow, quiver (`tools/uo-content/blender_build.py`). The push-out moves the item away from the + helm, weapon, shield, bow, quiver (`tools/uo-content/blender_build.py`), decided from the mapping part's + `studio_part` (what the build passes the renderer), not the pack part code or layer name; a part with no + `studio_part` counts as pushed. The push-out moves the item away from the body, so adding it to the allowance is an upper bound on the holes it removes. The item's occlusion mode decides what is counted, as in the renderer: **clothing** counts limb and head faces under diff --git a/tools/fit-lab/web/lab.js b/tools/fit-lab/web/lab.js index c3dae33..e7b55cf 100644 --- a/tools/fit-lab/web/lab.js +++ b/tools/fit-lab/web/lab.js @@ -304,7 +304,7 @@ function soloRender(item, mode) { // mode: 'mask' | 'poke' | 'look' const rigid = fitFor(item.info).bind === 'rigid', meshes = rigid ? item.rigid : item.skinned; meshes.forEach(m => { m.visible = true; if (mode !== 'look') m.material = GREEN; }); if (mode === 'poke') { // the renderer's holdout rule for this item, see poke-rules.mjs - const rule = pokeRule(fitFor(item.info), item.info); + const rule = pokeRule(fitFor(item.info), partOf(item.info).studio_part); bodyMeasure.geometry.setIndex(rule.measure ? (rule.faces === 'all' ? bodyIndex(new Set(), null) : bodyIndex(hiddenFor(item), coveredFor(item))) : []); bodyMeasure.material = pokeMaterial(rule.allowance); bodyMeasure.visible = rule.measure; } diff --git a/tools/fit-lab/web/poke-rules.mjs b/tools/fit-lab/web/poke-rules.mjs index f74dfa8..6f56309 100644 --- a/tools/fit-lab/web/poke-rules.mjs +++ b/tools/fit-lab/web/poke-rules.mjs @@ -8,11 +8,13 @@ export const HOLDOUT_MARGIN = 0.01; export const PUSH_OUT_GAP = 0.006; const NO_PUSH_OUT = new Set(['helm', 'weapon', 'shield', 'bow', 'quiver']); -export function pokeRule(fit, info) { +// studioPart is the mapping part's `studio_part`, the value builds.py hands the renderer as spec.part (not the pack +// part code or the layer name). A part without one is treated as pushed. +export function pokeRule(fit, studioPart) { const mode = fit.occlusion || 'clothing'; if (mode === 'none') return { measure: false, mode, allowance: 0, faces: 'none' }; // renderer skips the holdout const tolerance = mode === 'clothing' && fit.hide?.enabled ? (fit.hide.inward ?? 0.01) : 0; - const pushed = fit.bind !== 'rigid' && !NO_PUSH_OUT.has(info.part) && !NO_PUSH_OUT.has(info.slot); + const pushed = fit.bind !== 'rigid' && !NO_PUSH_OUT.has(studioPart); return { measure: true, mode, allowance: Math.max(HOLDOUT_MARGIN, tolerance) + (pushed ? PUSH_OUT_GAP : 0), // clothing: limb/head faces under the item (the torso never holds out); body: the whole body, hide-body ignored diff --git a/tools/fit-lab/web/poke-rules.test.mjs b/tools/fit-lab/web/poke-rules.test.mjs index 579783f..233f77e 100644 --- a/tools/fit-lab/web/poke-rules.test.mjs +++ b/tools/fit-lab/web/poke-rules.test.mjs @@ -1,28 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { pokeRule, HOLDOUT_MARGIN, PUSH_OUT_GAP } from './poke-rules.mjs'; const fit = (o = {}) => ({ occlusion: 'clothing', bind: 'skinned', hide: { enabled: true, outward: .02, inward: .01 }, ...o }); -const info = (o = {}) => ({ slot: 'chest', part: 'chest', ...o }); +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-12, `${a} != ${b}`); test('clothing: 1 cm margin plus 6 mm push-out for a skinned garment', () => { - const r = pokeRule(fit(), info()); + const r = pokeRule(fit(), 'chest'); assert.equal(r.measure, true); assert.equal(r.faces, 'under'); - assert.ok(Math.abs(r.allowance - (HOLDOUT_MARGIN + PUSH_OUT_GAP)) < 1e-12); + near(r.allowance, HOLDOUT_MARGIN + PUSH_OUT_GAP); +}); +test('a part with no studio_part is treated as pushed', () => { + near(pokeRule(fit(), undefined).allowance, HOLDOUT_MARGIN + PUSH_OUT_GAP); }); test('hide-body inward larger than the margin is the contact allowance; off falls back to the margin', () => { - assert.ok(Math.abs(pokeRule(fit({ hide: { enabled: true, inward: .03 } }), info()).allowance - .036) < 1e-12); - assert.ok(Math.abs(pokeRule(fit({ hide: { enabled: false, inward: .03 } }), info()).allowance - .016) < 1e-12); + near(pokeRule(fit({ hide: { enabled: true, inward: .03 } }), 'chest').allowance, .036); + near(pokeRule(fit({ hide: { enabled: false, inward: .03 } }), 'chest').allowance, .016); +}); +test('rigid items get no push-out whatever the studio part', () => { + assert.equal(pokeRule(fit({ bind: 'rigid' }), 'chest').allowance, HOLDOUT_MARGIN); }); -test('rigid items and helm/weapon/shield/bow/quiver get no push-out', () => { - assert.equal(pokeRule(fit({ bind: 'rigid' }), info()).allowance, HOLDOUT_MARGIN); +test('helm, weapon, shield, bow and quiver studio parts get no push-out', () => { for (const part of ['helm', 'weapon', 'shield', 'bow', 'quiver']) - assert.equal(pokeRule(fit(), info({ part, slot: part })).allowance, HOLDOUT_MARGIN); + assert.equal(pokeRule(fit(), part).allowance, HOLDOUT_MARGIN); +}); +test('pack part codes and layer names do not decide the push-out', () => { + for (const code of ['hood', 'weapon-sword', 'shield-round', 'backpack', 'OneHanded', 'Helm']) + near(pokeRule(fit(), code).allowance, HOLDOUT_MARGIN + PUSH_OUT_GAP); }); test('body mode uses the whole body and ignores the hide-body tolerance', () => { - const r = pokeRule(fit({ occlusion: 'body' }), info()); - assert.equal(r.faces, 'all'); assert.ok(Math.abs(r.allowance - .016) < 1e-12); + const r = pokeRule(fit({ occlusion: 'body' }), 'chest'); + assert.equal(r.faces, 'all'); near(r.allowance, .016); }); test('none mode measures nothing, like the renderer', () => { - assert.equal(pokeRule(fit({ occlusion: 'none' }), info()).measure, false); + assert.equal(pokeRule(fit({ occlusion: 'none' }), 'chest').measure, false); +}); +test('cc0 starter mapping: every part gets the allowance the renderer implies', () => { + const mapping = JSON.parse(readFileSync(new URL('../../../examples/cc0-starter/outfit-mapping.json', import.meta.url))); + const plain = ['hood', 'hair', 'beard', 'weapon-sword', 'shield-round', 'backpack']; + const pushedCodes = ['shirt', 'pants', 'cloak']; + for (const p of mapping.parts) { + const rigid = p.bind === 'rigid', free = ['helm', 'weapon', 'shield', 'bow', 'quiver'].includes(p.studio_part); + const r = pokeRule(fit({ bind: p.bind }), p.studio_part); + near(r.allowance, rigid || free ? .01 : .016); + if (plain.includes(p.code)) near(r.allowance, .01); + if (pushedCodes.includes(p.code)) near(r.allowance, .016); + } + assert.ok(plain.concat(pushedCodes).every(c => mapping.parts.some(p => p.code === c))); });