Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
21 changes: 17 additions & 4 deletions tools/fit-lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,23 @@ existing manifest item format; `file` paths point into the cache. Limits: 100 GL
the baseline, and each new run shows the change.
- **Save** writes `<sidecar>/packs/<pack>/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`), 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
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

Expand Down
2 changes: 1 addition & 1 deletion tools/fit-lab/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ <h3>Poke-through</h3>
<div id="measureActions" aria-label="Animations to measure"></div>
<div class="row"><button id="measure" class="primary">Measure slot</button></div>
<table id="results"></table>
<details class="tuck"><summary>How poke pixels are counted</summary><p class="hint">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.</p></details>
<details class="tuck"><summary>How poke pixels are counted</summary><p class="hint">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.</p></details>
<h3>Head steadying A/B</h3>
<p class="hint">Compares original motion with a steadied head on the first three items of every slot.</p>
<div class="row"><button id="headAB">Run A/B</button><button id="cancelAB" hidden>Stop A/B</button></div>
Expand Down
20 changes: 16 additions & 4 deletions tools/fit-lab/web/lab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <project_vertex>',
'#include <project_vertex>\ngl_Position.z += ' + (2 * .01 / (uoCam.far - uoCam.near)).toFixed(8) + ' * gl_Position.w;');
'#include <project_vertex>\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); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Give each allowance shader a distinct program cache key

When measurements use different allowances—for example, a 1.6 cm skinned garment followed by a 1 cm rigid item—all callbacks returned by pushBack() have the same string representation. Three.js r160 uses onBeforeCompile.toString() as the default custom program cache key, so it reuses the first compiled shader, including that shader's embedded distance literal, for subsequent materials. Measurements after the first allowance therefore use the wrong depth offset; define a customProgramCacheKey containing the allowance or pass the distance through a uniform.

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -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), 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;
}
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') {
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions tools/fit-lab/web/poke-rules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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']);

// 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(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
faces: mode === 'body' ? 'all' : 'under' };
}
51 changes: 51 additions & 0 deletions tools/fit-lab/web/poke-rules.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +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 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(), 'chest');
assert.equal(r.measure, true); assert.equal(r.faces, 'under');
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', () => {
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('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(), 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' }), 'chest');
assert.equal(r.faces, 'all'); near(r.allowance, .016);
});
test('none mode measures nothing, like the renderer', () => {
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)));
});
Loading