diff --git a/schemas/fit-lab-view.schema.json b/schemas/fit-lab-view.schema.json new file mode 100644 index 0000000..c57a194 --- /dev/null +++ b/schemas/fit-lab-view.schema.json @@ -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::. 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} + } + } +} diff --git a/tests/unit/test_fit_lab_outfit.py b/tests/unit/test_fit_lab_outfit.py new file mode 100644 index 0000000..895666e --- /dev/null +++ b/tests/unit/test_fit_lab_outfit.py @@ -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) diff --git a/tools/fit-lab/README.md b/tools/fit-lab/README.md index 28efd6e..b0effc3 100644 --- a/tools/fit-lab/README.md +++ b/tools/fit-lab/README.md @@ -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::` as the document in `schemas/fit-lab-view.schema.json`: +`{"schema": "spritemotion.fit-lab-view", "schema_version": 1, "show": true, "measure_outfit": false, +"worn": {"": ""}}`. 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`, diff --git a/tools/fit-lab/web/index.html b/tools/fit-lab/web/index.html index df6228b..cc007df 100644 --- a/tools/fit-lab/web/index.html +++ b/tools/fit-lab/web/index.html @@ -26,7 +26,14 @@