Skip to content
Open
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
20 changes: 20 additions & 0 deletions schemas/fit-lab-view.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://spritemotion.local/schemas/fit-lab-view.schema.json",
"title": "SpriteMotion Fit Lab outfit view (browser-local)",
"description": "Per-browser view state stored in localStorage key fit-lab:outfit:<origin>:<pack>. Not a fit edit; never written to lab-adjustments.json. See tools/fit-lab/README.md, 'Whole outfit while fitting one slot'.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "schema_version", "show", "measure_outfit", "worn"],
"properties": {
"schema": {"const": "spritemotion.fit-lab-view"},
"schema_version": {"const": 1},
"show": {"type": "boolean", "description": "Show outfit: kept slots render beside the edited slot."},
"measure_outfit": {"type": "boolean", "description": "Body hiding and Measure slot include kept items."},
"worn": {
"type": "object",
"description": "Kept slots: slot id to the manifest item id it wears.",
"additionalProperties": {"type": "string", "minLength": 1}
}
}
}
69 changes: 69 additions & 0 deletions tests/unit/test_fit_lab_outfit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json
from pathlib import Path
import shutil
import subprocess
import pytest

ROOT = Path(__file__).resolve().parents[2]


def run_node(tmp_path, body):
node = shutil.which('node')
if not node:
pytest.skip('Node required for the browser outfit logic')
module = ROOT / 'tools/fit-lab/web/outfit.mjs'
runner = tmp_path / 'check.mjs'
runner.write_text('import * as O from ' + json.dumps(module.as_uri()) + ";\nimport assert from 'node:assert/strict';\n"
+ body)
subprocess.run([node, str(runner)], check=True, capture_output=True, text=True)


ITEMS = '''const items=[
{id:'a-vest',slot:'MiddleTorso',family:'A'},{id:'b-vest',slot:'MiddleTorso',family:'B'},
{id:'a-boots',slot:'Shoes',family:'A'},{id:'c-helm',slot:'Helm',family:'C'}];
'''


def test_stored_outfit_drops_unknown_slots_items_and_versions(tmp_path):
run_node(tmp_path, ITEMS + '''
const doc={schema:O.SCHEMA,schema_version:1,show:false,measure_outfit:true,
worn:{MiddleTorso:'b-vest',Shoes:'gone',Helm:'a-vest',Cloak:'x'}};
const out=O.parseOutfit(JSON.stringify(doc),items);
assert.deepEqual(out.worn,{MiddleTorso:'b-vest'});
assert.equal(out.show,false); assert.equal(out.measure_outfit,true);
assert.deepEqual(O.parseOutfit('{not json',items),O.emptyOutfit());
assert.deepEqual(O.parseOutfit(null,items),O.emptyOutfit());
assert.deepEqual(O.parseOutfit({...doc,schema_version:2},items),O.emptyOutfit());
''')


def test_kept_items_exclude_the_edited_slot_and_follow_show(tmp_path):
run_node(tmp_path, ITEMS + '''
let o=O.keep(O.keep(O.emptyOutfit(),'MiddleTorso','a-vest'),'Shoes','a-boots');
assert.deepEqual(O.keptIds(o,'MiddleTorso'),['a-boots']);
assert.deepEqual(O.keptIds(o,'Helm').sort(),['a-boots','a-vest']);
assert.deepEqual(O.measuredKept(o,'Helm'),[]);
assert.deepEqual(O.measuredKept({...o,measure_outfit:true},'Helm').sort(),['a-boots','a-vest']);
assert.deepEqual(O.keptIds({...o,show:false},'Helm'),[]);
o=O.select(o,'MiddleTorso','b-vest'); assert.equal(o.worn.MiddleTorso,'b-vest');
assert.equal(O.select(o,'Helm','c-helm').worn.Helm,undefined);
o=O.release(o,'Shoes'); assert.deepEqual(Object.keys(o.worn),['MiddleTorso']);
assert.deepEqual(O.clearOutfit(o).worn,{});
''')


def test_wear_set_keeps_the_family_and_leaves_other_slots(tmp_path):
run_node(tmp_path, ITEMS + '''
const start=O.keep(O.emptyOutfit(),'Helm','c-helm');
const o=O.wearSet(start,'A',items);
assert.deepEqual(o.worn,{Helm:'c-helm',MiddleTorso:'a-vest',Shoes:'a-boots'});
assert.deepEqual(start.worn,{Helm:'c-helm'});
assert.deepEqual(O.families(items),[{family:'A',slots:2},{family:'B',slots:1},{family:'C',slots:1}]);
''')


def test_view_document_matches_its_schema():
jsonschema = pytest.importorskip('jsonschema')
schema = json.loads((ROOT / 'schemas/fit-lab-view.schema.json').read_text(encoding='utf-8'))
jsonschema.validate({'schema': 'spritemotion.fit-lab-view', 'schema_version': 1, 'show': True,
'measure_outfit': False, 'worn': {'Shoes': 'a-boots'}}, schema)
36 changes: 36 additions & 0 deletions tools/fit-lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,42 @@ rest) show in front of it. Those are the holes the renderer's body holdout would
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.

### Whole outfit while fitting one slot

The rules live in `web/outfit.mjs` (pure logic, tested from pytest with Node in `tests/unit/test_fit_lab_outfit.py`)
and are wired into `lab.js`.

By default the 3D view shows only the slot being edited. **Keep visible** (a checkbox beside the Slot selector)
adds that slot to the *outfit*: its worn item stays rendered in the 3D view when another slot is selected.
The worn item is the slot's selected item; selecting another item in a kept slot changes what it wears.
**Show outfit** (top of the left column) turns every kept slot on or off at once without forgetting them,
so solo and whole-outfit views are one click apart. **Wear set** lists the pack's families (for Sidekick,
one character's modular parts) and keeps, in every slot that has that family, the family's item; slots without
it are left as they were. **Clear outfit** forgets every kept slot.

Rules:

- The edited slot always behaves exactly as today: its ticked items render, the selected item is the one the
sliders, corrections, Render tab and **Measure slot** act on. A kept slot that is also the edited slot adds
nothing extra.
- Kept items render with their saved fit (`resolveFit`, current action and direction), are not selectable from
the 3D view, and do not appear in the item list, preview cards, A/B or builds.
- **Body hiding and poke measurement apply only to the edited slot** (the body hides faces under the edited
slot's ticked items only, so kept items may show poke-through in the 3D view). **Measure outfit** (Measure tab,
off by default) changes that: the 3D body hides faces under every shown item, and **Measure slot** counts each
edited-slot item against the body with all kept items' hidden faces removed and adds one row per kept item
(kept rows have no baseline until measured once with the option on). Turning it off drops the kept rows.
- Slot preview cards stay per item (solo renders). Outfit compositing in previews is out of scope for now.
- None of this is a fit edit: no history step, no autosave, no change to `lab-adjustments.json`, no effect on
builds or renders. Keyboard undo/redo never changes the outfit.

The outfit is remembered per browser, per server origin and pack, in `localStorage` key
`fit-lab:outfit:<origin>:<pack>` as the document in `schemas/fit-lab-view.schema.json`:
`{"schema": "spritemotion.fit-lab-view", "schema_version": 1, "show": true, "measure_outfit": false,
"worn": {"<slot>": "<item id>"}}`. On load, entries whose slot or item is not in the manifest (removed imports,
re-exports) are dropped; an unreadable or wrong-version document means an empty outfit. Storage failures
(private window, blocked storage) leave the outfit working for the session only, like the other view toggles.

## From the lab to builds

1. The pack's mapping generator merges `lab-adjustments.json` into the part types (`offset`, `rotate`, `scale`,
Expand Down
8 changes: 8 additions & 0 deletions tools/fit-lab/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
</header>

<aside id="left" inert>
<section id="outfit" aria-label="Whole outfit">
<div class="row"><label class="check" title="Show kept slots beside the slot you are editing"><input id="outfitShow" type="checkbox" checked> Show outfit</label>
<span id="outfitCount" class="hint"></span></div>
<div class="row"><label class="field inline">Wear set <select id="wearSet"><option value="">Choose a set…</option></select></label>
<button id="clearOutfit">Clear outfit</button></div>
</section>
<label class="field">Slot <select id="slot"></select></label>
<label class="check" title="Keep this slot's selected item visible while you edit other slots"><input id="keepSlot" type="checkbox"> Keep visible</label>
<p class="hint" id="slotInfo"></p>
<div id="items" aria-label="Items in this slot"></div>
<details class="tuck"><summary>Load fitted GLBs from a folder</summary>
Expand Down Expand Up @@ -140,6 +147,7 @@ <h3>Render the selected item</h3>
<h3>Poke-through</h3>
<p class="hint">Counts body pixels that show through the item, over every frame of the checked animations in 5 directions. The first run is the baseline.</p>
<div id="measureActions" aria-label="Animations to measure"></div>
<label class="check" title="Body hiding and counts include every kept item. Off: only the edited slot."><input id="measureOutfit" type="checkbox"> Measure outfit</label>
<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>
Expand Down
5 changes: 5 additions & 0 deletions tools/fit-lab/web/lab.css
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,8 @@ td:first-child, th:first-child { text-align: left; max-width: 140px; overflow: h
#saveStatus { display: none; }
}
@media (prefers-reduced-motion: reduce) { *, *::before { animation: none !important; transition: none !important; } }

/* Whole outfit: kept slots stay visible while another slot is edited (view state only). */
#outfit { padding-bottom: 6px; margin-bottom: 4px; border-bottom: 1px solid var(--line); }
#outfit .row { align-items: center; margin: 8px 0; }
#outfit .field.inline select { max-width: 150px; }
Loading
Loading