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..f84eb01 100644 --- a/tools/fit-lab/README.md +++ b/tools/fit-lab/README.md @@ -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 `/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`), 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 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..e7b55cf 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), 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') { @@ -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..6f56309 --- /dev/null +++ b/tools/fit-lab/web/poke-rules.mjs @@ -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' }; +} 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..233f77e --- /dev/null +++ b/tools/fit-lab/web/poke-rules.test.mjs @@ -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))); +});