diff --git a/.claude/skills/uo-fit-lab/SKILL.md b/.claude/skills/uo-fit-lab/SKILL.md index 9dca818..a687374 100644 --- a/.claude/skills/uo-fit-lab/SKILL.md +++ b/.claude/skills/uo-fit-lab/SKILL.md @@ -10,8 +10,11 @@ live in the local sidecar (`SPRITEMOTION_SIDECAR`, default `../SpriteMotion-Side `python tools/fit-lab/run.py export --pack ` and `python tools/fit-lab/run.py serve --pack `. 2. In the lab, change one slot at a time. Run **Measure slot** before and after: keep a change only when the slot total of poke pixels drops without the contact sheet looking worse. Check several actions and directions. -3. Adjustments autosave; confirm **Saved to disk** (or click **Save adjustments**). Undo/redo and the History list - recover earlier edits; **Last three saved versions** restores disk backups as undoable edits. Larger previews can +3. Adjustments autosave; confirm **Saved to disk** (or click **Save**). Undo/redo and the History list + recover earlier edits; **Saved versions** (History tab) restores disk backups as undoable edits. Larger previews can animate and cycle directions independently of the main viewport; pause them to compare the exact same pose. Regenerate the mapping (`make_mapping.py` merges `lab-adjustments.json`), then rebuild a preview job and compare its contact sheet. Report numbers, not impressions. +4. Check the **Blender render** pane beside the 3D view: it shows the real renderer output frame-locked to the lab + (Frame or Sprite sheet). **Render animation** (or **Auto re-render**) renders the current animation in the + background; keep a fit only when the render reads *Matches the saved fit* and looks right. diff --git a/CLAUDE.md b/CLAUDE.md index e7fb159..6b560f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,9 @@ Open work and where to start: `docs/handoff.md`. ## Layout +The audited CC0 starter models in `examples/cc0-starter` may be committed with their original +license and provenance hashes. This exception does not cover game-derived or commercial assets. + - `common/` is the shared Python package (imports as `spritemotion`); `schemas/` holds every JSON data contract. Extend a schema (and `docs/`) before emitting a new field. - `games//`: adapter, profiles, annotations, equipment slots (`equipment/layers.json`), outfit lab. @@ -38,8 +41,8 @@ headless Blender run, not only a syntax check. ## Key facts -- Canonical body: UO_Model3D v13 (`workspace/ultima-online/canonical-model/model/UO_Body_0x190.blend`), 108 bones, - rig `UO_Rig`, direction = driver `-d*pi/4` on the rig's Z rotation, UO frame i = scene frame 1 + 3i. +- Canonical body: UO_Model3D v13 (`workspace/ultima-online/canonical-model/model/UO_Body_0x190.blend`), 112 bones + (the 2026-10 update added `weapon1h.R`, `axe2h.L`, `bow.L`, `polearm.L` to v13's 108), rig `UO_Rig`, direction = driver `-d*pi/4` on the rig's Z rotation, UO frame i = scene frame 1 + 3i. - In item renders the body is a holdout: body poking through clothing cuts holes in the item sprite. Asset-pack parts can hide body faces under them (`hide_body` in the mapping, tuned in `tools/fit-lab`). - Asset packs are mapped 1:1 per pack: `docs/asset-packs.md`. diff --git a/README.md b/README.md index 3095357..609f0eb 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ On other platforms, create and activate a Python environment, run `python -m pip install -e ".[test]"`, then use the same Python setup command and `python tools/uo-content/studio.py`. +## Try the CC0 starter equipment + +[Bundled CC0 examples](examples/cc0-starter/README.md) cover all 25 UO layer routes, +with animated wearables, jewelry source/paperdoll examples and mount guidance. +After the Python, Blender and local model setup above, run +`launchers/editor/workbench.bat cc0-starter` on Windows or +`sh launchers/editor/workbench.sh cc0-starter` on Linux. The workbench prepares the +starter lab export when needed and starts Studio and Fit Lab. +See [all launchers](launchers/editor/README.md). + ## Open Fit Lab Fit Lab needs a prepared asset-pack mapping, item list and Blender export. @@ -85,8 +95,8 @@ The instructions above describe the public `main` branch. More recent fitting and rendering changes may be on development branches or in [open pull requests](https://github.com/DatMoshu/SpriteMotion/pulls). -Scoped animation/direction corrections and updated masking are being integrated -through [the fitting improvements PR](https://github.com/DatMoshu/SpriteMotion/pull/1). +Fit Lab supports scoped animation/direction corrections, Blender render review, +body holdouts, front/back depth and independent left/right item offsets. A shared Fit Lab inside [GUO](https://github.com/DatMoshu/GodotUO) has been tested locally; its distribution and fresh-install onboarding are still in development. Do not assume a SpriteMotion checkout installs the GUO integration. @@ -94,7 +104,8 @@ Do not assume a SpriteMotion checkout installs the GUO integration. ## What you download **Included:** Python and Blender tools, the web editors, game adapters, schemas, -annotation data, agent instructions, and a redistributable procedural sample. +annotation data, agent instructions, a redistributable procedural sample and +audited CC0 starter equipment with licenses and provenance hashes. **Not included:** UO client files, extracted sprites, the canonical body scene, commercial asset packs, private mappings, or generated game content. Work files diff --git a/docs/asset-packs.md b/docs/asset-packs.md index 2873ad8..bacf3d6 100644 --- a/docs/asset-packs.md +++ b/docs/asset-packs.md @@ -34,6 +34,8 @@ repository data; only put one there if its pack's licence allows it. | `target` | Target-rig bone that receives this bone's vertex weights | | `end` | Present on **aligned** bones: the source bone at the far end of the chain (`null`: use `fit.missing_end_offset`) | | `keep_orientation` | Aligned bone that is only moved, not rotated or scaled (spine, head: FBX bone roll is unrelated to anatomy) | +| `target_end` | Optional target bone whose head ends the aligned chain. Use a knuckle for a wrist-to-knuckle source chain, rather than a hand bone that extends to the fingertip. | +| `end_from_parent` | With `end: null`, extrapolate the previous joint segment for a terminal finger. Imported FBX bone tails may point across the anatomy and must not be used as finger tips. | | `follows` | For a non-aligned bone: the aligned ancestor it moves with | | `proposed_target` | A better target the rig offers that the fitter does not use yet (twist bones, fingers, the shield bone) | @@ -42,6 +44,10 @@ its length, clamped to `fit.scale_clamp`. Every vertex is moved by the weighted its weights are folded into the target bones. A bone that is not aligned (twist, finger, socket, a part's own cloth or hair bones) uses its nearest aligned ancestor (`fit.unmapped: nearest_mapped_ancestor`). +To animate fingers, explicitly align each finger segment with its target and next source joint. Give terminal +segments `end: null, end_from_parent: true`. `proposed_target` remains informational; it never silently changes +existing bindings. Paired rigid pieces retain one anchor **per mesh**, both in the lab and Blender. + `dynamic_chains` lists cloth and hair bones that ship inside part files rather than the base skeleton (matched by name prefix), with the socket they hang from and, where the rig has one, a better target such as the rig's cloak or skirt chains. `attach_points` lists the pack's sockets and the layers they serve. @@ -59,7 +65,9 @@ One entry per part **type** (a helmet type, a left-hand type, and so on): | `studio_part` | Fit template in the studio (`helm`, `chest`, `arms`, `gloves`, `legs`, `boots`, `robe`, `cloak`, `skirt`, `weapon`, `shield`, `bow`, `quiver`) | | `bind` | `skinned` (deforms with its bones) or `rigid` (moves with one bone; metal must not stretch) | | `offset` | Rest-pose offset in metres, applied before binding (for example helmet crown clearance) | +| `surface_clearance` | Optional rest-pose clearance in metres for close-fitting shells. Vertices within 5 cm of the body that penetrate its surface are projected outward before binding. Does not replace joint alignment or pose-time collision checks. | | `rotate`, `scale` | Rest-pose rotation (degrees, XYZ about the item centre) and uniform scale | +| `depth_scale` | Front-to-back multiplier on Blender Y about the item centre, before rotation. Defaults to 1; changes depth without changing width or height. Saved slot values replace the mapping default; scoped corrections multiply it. | | `hide_body` | `{enabled, outward, inward}`: CC4-style hiding of body faces under the part (metres along each face normal, rest pose), so the body cannot poke through or hold out holes in the sprite | | `weighted_bones` | Bones the pack's parts of this type are weighted to (sampled) | @@ -77,3 +85,12 @@ Tune `offset`, `rotate`, `scale`, `bind` and `hide_body` per slot in the [fit la (an empty list means valid). 4. Build a preview job with `source_files`, `palette` (if the pack uses a palette texture) and `pack_mapping` in its settings, and review the fit on the contact sheet before running full builds. + +### Independent left/right item offsets + +The Fit tab provides Left/Right X, Y and Z offsets for an individual item, useful for paired gloves and boots. +These add to the shared fit in Blender XYZ metres before animation. Left/right refer to the character, +not the screen; mirrored UO directions mirror the fitted result. Target-rig bone weights select each side, +including meshes containing both limbs. Neutral bones are unchanged. Each side can be reset separately; +autosave, backups and undo/redo include these offsets. Existing saves default to zero. +The adjustment document stores these as `items..sides.left` and `.right` three-number vectors. diff --git a/docs/body-occlusion.md b/docs/body-occlusion.md new file mode 100644 index 0000000..b698875 --- /dev/null +++ b/docs/body-occlusion.md @@ -0,0 +1,43 @@ +# Body occlusion in final equipment renders + +The complete posed body hides equipment behind it without appearing in the exported equipment image. +`tools/uo-content/occlusion.py` supplies the final Blender depth holdout through `fit_runtime.py`. +It does not modify the separately installed upstream renderer. + +The old clothing policy excluded the torso and globally exempted every anatomical part worn by any item. +It also discarded left/right suffixes. Paired bracers could consequently show through a torso or the opposite arm. + +## Geometry and contact + +The renderer now keeps a pristine body proxy with the body's animation modifiers. It is never rendered as color. +The proxy remains complete when the fitting mesh has faces removed by Hide body under clothes. Push-out continues +to use the fitting mesh and its existing collision regions; changing visibility does not change that solver. + +For every pixel, compare nearest body depth with nearest item depth. Body closer by more than the normal holdout +margin removes the item pixel. Clothing mode can extend that margin to the saved hide-body inward distance only +when the nearest body and item surfaces belong to the same anatomical region. Left and right remain distinct; +twist groups normalize to their limb while preserving the side. This is a finite penetration tolerance, not an +unlimited exemption. A far-side surface on the same limb still disappears when it exceeds the tolerance. + +Body mode has no garment-specific allowance. None bypasses body masking. The upstream original-sprite outline +correction and mounted horse masking remain in place. The final mask follows the original body's silhouette; +the model supplies front/back depth, so inaccurate source geometry can still require fit corrections. + +## Recovery and validation + +No saved adjustment format changes. New renders use the new policy. Renderer fingerprints include the occlusion +module, so old builds cannot be selectively mixed with new masking; create a fresh build. Old jobs remain available. +The lab's live geometry preview is still approximate and is not changed by this renderer patch. + +Regression coverage includes torso blocking, opposite limbs, small own-surface penetration, deep own-surface +occlusion, a rear garment trying to exempt a different front item, and empty depth pixels. Actual Blender acceptance +uses before/after forearm renders and regression renders of other equipment. Local evidence stays in ignored workspace. + +Acceptance run on 2026-10-02: the reported forearm item rendered all 35 actions and five stored directions +(175 blocks, 1,050 frames), with a passing independent VD alpha/anchor round trip and no clipped or empty frames. +Chest, leg and back-item checks each covered actions 0/4/9/22/25 in all five stored directions (125 frames each), +with the same file-validation result. Representative composites were visually inspected; this is not an in-game +equip test or exhaustive visual approval of every frame. No cloak item was present in the sampled catalog. +The screenshot's action 0, direction 3, frame index 1 now hides the far bracer behind the torso and keeps the near bracer. +Evidence: `workspace/forearm-occlusion-detail.png`, `workspace/forearm-occlusion-comparison.png`, +`workspace/forearm-full-job.txt`, `workspace/occlusion-regression.json` and `workspace/occlusion-regression.png`. diff --git a/docs/fit-lab-service.md b/docs/fit-lab-service.md new file mode 100644 index 0000000..a200e98 --- /dev/null +++ b/docs/fit-lab-service.md @@ -0,0 +1,29 @@ +# Fit Lab editor connection, version 1 + +GUO and the standalone browser connect to one loopback Fit Lab process. Editors first GET `/api/service` and +require `schema: spritemotion.fit-lab-service`, `schema_version: 1` and the configured `pack`. A mismatch is an +error, not permission to start another server on that port. Service discovery contains no machine paths. + +| Route | Contract | +|---|---| +| GET `/api/service` | `schemas/fit-lab-service.schema.json`; capability and pack negotiation | +| GET `/data/manifest.json` | Available item IDs, slots, actions, GLB paths and camera | +| GET `/api/mapping` | Local pack defaults; never publish this response | +| GET `/api/state` | Adjustment document, opaque revision and saved backups | +| POST `/api/adjustments` | `{adjustments, base_revision}`; 409 preserves a concurrent editor's changes | +| GET `/api/backups/` | A prior document; restoring it is another revision-checked save | +| GET, POST `/api/build` | Build state / `schemas/fit-lab-build.schema.json` request | +| GET `/api/renders?item=` | Completed jobs, newest first, with action coverage | +| GET `/builds//review/manifest.json` | Final sprite sequences, frame counts, playback FPS and anchor | + +Start with `python tools/fit-lab/run.py serve --pack --port --no-browser`. The configured checkout +and Python executable belong in the consuming editor's local settings. Pack exports must already exist. Closing +an editor view detaches from the process; it must not kill shared builds or start duplicate workers after reload. + +Native controls and embedded web controls use these same saved documents. GUI undo is distinct from undoing a +published game asset. Preserve unknown adjustment fields, stable item IDs, and all unrelated scoped corrections. +Do not treat a live 3D preview as a final sprite export. Native consumers must preserve nearest-neighbour pixels, +alpha, anchors, action/direction identities and the explicit mirror mapping in the review artifact. + +Version 1 does not provide a GUO staging importer, cross-process worker locking, or a native 3D viewport contract. +These remain integration work; service discovery alone does not mean an editor integration is complete. diff --git a/docs/guo-integration-plan.md b/docs/guo-integration-plan.md new file mode 100644 index 0000000..0c46f46 --- /dev/null +++ b/docs/guo-integration-plan.md @@ -0,0 +1,120 @@ +# SpriteMotion integration into GUO + +Draft implementation plan, 2026-10-02. Based on SpriteMotion `eea8169` and the local GUO checkout at `8735dff`, plus the source files noted below. Claude is reworking the fit lab concurrently: this plan changes no fit-lab implementation and its API inventory must be reconciled after that work lands. Companion: [tooling research](related-tools-research.md). + +Integrate SpriteMotion as GUO's content-authoring backend, with a GUO editor workspace for fitting, rendering, review and staged game testing. Keep one implementation of fit rules and final rendering. Let GUO own UO IDs, data staging, client previews and shard deployment. First prove that one new wearable travels through the entire pipeline; then bring the editing experience inside GUO. + +## What is already available + +| Area | Inspected evidence | Consequence | +|---|---|---| +| SpriteMotion fit lab | `tools/fit-lab/run.py`, its README, adjustment/build schemas | Local web UI, scoped corrections, revision-aware saves, backups and item builds already exist. Wrap these capabilities instead of copying fit logic into C#. | +| SpriteMotion builds | `tools/uo-content/studio.py`, `pipeline.py`, `rebuild.py` | Completed jobs and selective rebuilds provide a revision boundary. Final Blender frames are the import source; live preview screenshots are not. | +| GUO editor | `godot/GUO/addons/guo_editor/GuoEditorPlugin.cs`, `Panels/BulkPanel.cs`, ADR-0010 | Existing C# editor addon, tool subprocess UI, and assembly-reload lifecycle. Add authoring UI beside the existing tools and preserve its `#if TOOLS` boundary. | +| GUO data authoring | `tools/uopack/README.md`, ADR-0022 | PNG/JSON animation groups already feed staged MUL/UOP writers with read-back verification and ID ranges. Reuse this path. | +| Existing SpriteMotion bridge | `tools/uopack/outfit.py`, `run.py` | `from-outfit-lab` handles an older 2D atlas workflow, hardcodes selected wearables and skips backpack/familiar overlays. It is not an importer for current 3D studio jobs. Keep legacy behavior and add a general adapter. | +| Original-art overlays | ADR-0020 | Existing art/gump/hue overlays do not add equipment animation. New animations go through the staged-data route. | +| Private shard and world tooling | ADR-0012, ADR-0014, tools named in the editor wiki | Reuse private-shard setup and equip/world-object tests. The editor wiki says world objects are unstarted, but ADR-0014 records a verified slice; use detailed source/ADR evidence instead of that stale summary. | +| Asset Store | ADR-0019 | Current store kinds are not equipment-content distribution. Do not silently put UO animation payloads into a background/theme pack. | + +GUO's documentation records successful staged-item and client checks for an earlier shield. Those are useful precedents, not fresh validation of SpriteMotion's current renderer. This research did not launch GUO, modify its repository or rerun its recorded tests. + +## Recommended architecture + +```mermaid +flowchart LR + A[GUO authoring workspace] --> B[SpriteMotion local service] + W[Shared fit lab UI] --> B + B --> C[Blender worker] + C --> D[Immutable validated render revision] + D --> E[GUO uopack adapter] + E --> F[GUO staged data and ID registry] + F --> G[GUO client and private shard test] + G --> H[Versioned release with rollback] +``` + +SpriteMotion owns source imports, model/action profiles, fitting, masks, build fingerprints and render artifacts. GUO owns equipment metadata, range allocation, classic file encoding/staging, game preview and shard integration. Imported assets keep stable logical IDs; GUO assigns numeric item, animation and gump IDs for the chosen shard. Saved fit keys must not change when a shard assigns a different numeric ID. + +The worker remains an optional desktop tool. Exported Windows, Android, Deck and web game clients consume the finished assets without Python, Blender, licensed source meshes or editor code. GUO's existing classic presenter stays authoritative. Optional normal/depth lighting belongs to a later rendering proposal, not this integration. + +## Fit lab inside the editor + +There are two stages to the UI integration: + +1. **Working bridge:** a SpriteMotion section in the GUO Assets dock opens the shared lab, submits/reconnects to jobs, imports successful revisions and launches the GUO review/test workflow. This initially opens the lab in a browser and must be labelled as such. +2. **Integrated workspace:** a SpriteMotion main-screen tab contains the lab and final-render review. First perform a Windows webview feasibility test for the existing web UI: WebGL support, keyboard focus, Ctrl+Z, local file selection, startup/shutdown and C# assembly reload. No webview dependency was found in the inspected GUO addon/project files; embedding is not assumed to work today. If that spike fails, build a native Godot fit UI against the same service. Do not maintain independent Python, JavaScript and C# fit-resolution rules. + +Godot supports [main-screen editor plugins](https://docs.godotengine.org/en/stable/tutorials/plugins/editor/making_main_screen_plugins.html), and GUO already uses one for UO World. The exact API and any browser addon must be tested against GUO's pinned engine. Preserve the existing editor plugin's reload handling: reconnect by project/session/job identity after rebuild, and never start duplicate workers simply because the editor reconstructed a dock. + +The final workspace should expose Assets, Fit, Animation, Render Review and Game Test. Fit retains per-item/group/slot/pack and per-action/direction corrections; independent animation, original/3D/off bases; undo/redo; immediate recovery cache; server autosave and three prior saves. Game Test uses decoded final frames through GUO, including layer ordering and hue behavior. A 3D GLB inspection viewport remains a separate view of the source geometry. + +## Service and artifact contracts + +Create schemas and a short ADR before implementing the bridge. Everything in this section is proposed. + +**Service lifecycle.** Introduce a versioned adapter over the existing APIs, initially loopback-only. It reports its API version, supported operations, project identity, renderer/model hashes and dependency readiness. The current lab has `/api/state`, `/api/adjustments` and `/api/build`; the studio has its own build/job routes. These are not yet a unified stable integration API. Use one owner for worker scheduling so lab and studio builds cannot race over shared resources. + +**Projects and jobs.** A request identifies the source item, immutable adjustment revision, requested blocks and model/render profile. Return a persistent job ID immediately. Proposed states are queued, running, cancelling, cancelled, failed and complete. Closing a dock detaches; cancellation is explicit. Reconnect after a crash and distinguish an interrupted worker from a completed job. Retrying the same request token must not enqueue duplicate work. A completed job records exactly the fit revision it used. + +**Edits.** The service owns atomic saves and optimistic revisions. Preserve HTTP 409 conflicts and three backups. GUI undo changes the adjustment document; it does not silently undo an installed shard release. Keep editing history, immutable render revisions and deployed release history distinct. Native controls must not double-handle the lab's keyboard undo events. If undo across desktop/browser sessions is required, add a persisted edit journal; the current browser history alone cannot guarantee that. + +**Transfer artifact.** A completed build export should include: + +| Field family | Required information | +|---|---| +| Identity | Schema version, project ID, stable item ID, source job/revision, logical equipment slot and intended body profile | +| Reproducibility | Model and renderer fingerprints, input hashes, fit snapshot/hash, Blender/tool versions | +| Animation | Numeric action, stored direction, frame index/count, timeline sampling, explicit mirror map and coverage classification | +| Pixels | PNG paths/hashes, canvas, alpha convention, palette or quantization policy, crop rectangle and anchor/centres | +| Equipment | Item art, male/female paperdoll support declarations, tiledata/layer requirements; no invented placeholder completeness | +| Acceptance | Validation report, known failures, manual-review status and intended client/shard compatibility | +| Provenance | Source attribution and redistribution classification, separate for editable source and rendered output | + +Use relative artifact paths and reject traversal or missing/hash-mismatched files. Local dependency paths belong in private configuration. Continue to use `SPRITEMOTION_SIDECAR`; GUO should not ingest or publish the sidecar. + +**Pixel contract.** Preserve UO's 256×256 authoring canvas and anchor (128,192). For a crop with bounds `(left, top, right, bottom)`, the current GUO bridge uses `center_x = 128 - left`, `center_y = 192 - bottom`. Prove this with an asymmetric synthetic sprite, empty frames and negative centres. Preserve stored directions 0–4 and the explicit 5→3, 6→2, 7→1 mirror mapping. Do not inherit the legacy outfit atlas's facing-label convention accidentally. Quantize once per animation group's chosen palette; read back decoded pixels and centres. Keep playback timing separate from the Blender sampling interval. + +## Milestones and acceptance + +| Milestone | Implementation | Exit evidence | +|---|---|---| +| 0. Freeze the boundary | Reconcile Claude's revised lab; specify service, export manifest and GUO compatibility version. Record ADR status explicitly. | Shared procedural fixture accepted by both sides; no dependency on lab DOM structure. | +| 1. Import one real build | Add a generic SpriteMotion-job exporter and a GUO adapter alongside `from-outfit-lab`; use existing uopack/stage/ID machinery. | One wearable's full 35-action, five-stored-direction output decodes with matching pixels/anchors; all eight facings reviewed. Original install hashes unchanged. | +| 2. Prove it in the game | Complete item art, tiledata, intended paperdoll/body support and the ModernUO item definition. Launch the private stage and equip it. | Walk, attack, cast, die, mount and turn; capture GUO evidence. Verify layer/hue behavior and a control run without the new content. | +| 3. Connect the editor | Add service discovery, dependency doctor, item selection, Open Fit Lab, job progress/cancel, final review and import buttons. | Editor reload/crash reconnects; unsaved conflicts are visible; cancel leaves no promoted partial artifact. Closing the editor does not corrupt a build. | +| 4. Embed the fit workflow | Complete the webview spike and select embedded shared UI or native client. Add explicit geometry/live/final views. | Fit, Ctrl+Z, redo, save/recovery and build/review all work from the GUO workspace. Approximate masks never masquerade as final output. | +| 5. Broaden acceptance | Three representative items per supported slot; complete outfit tests; action/direction exceptions and selective rebuilds. | All required blocks present; no unexplained clipping/empty frames; unchanged blocks remain identical after a scoped correction. Body/sex limits stated. | +| 6. Release authoring tools | Clean setup, procedural CI fixtures, pinned dependencies, compatibility matrix and private-shard tutorial. | A second machine completes create → fit → render → import → equip → rollback without developer paths or bundled game data. | + +Milestones 1–2 are the first useful deliverable. They should precede a large UI rewrite. Embedding the lab can follow after Claude's UI has settled and the import contract is stable. + +## Reuse GUO game tooling + +- **Assets and animation inspection:** extend the existing Assets/Bulk panels with item/job context; use GUO's loaders for final-frame checks rather than displaying a PNG as proof of runtime correctness. +- **World and shard editing:** hand selected content into the existing world-project and shard-object workflow. Distinguish a wearable item definition from placing a static decoration or spawning an NPC. +- **Data writing:** let `tools/uopack` and `tools/uodata_write` remain the integration's single staging path. Retain SpriteMotion's standalone VD/client export for users outside GUO. +- **ID management:** GUO's range policy owns numeric IDs and collisions. Never reuse an example shield's IDs for every item. Existing shard saves make item IDs durable; a rebuilt appearance must retain its allocation. +- **Testing:** extend GUO's editor smoke/reload, uopack fixture, data verify and private-shard equip checks. Run Python/JavaScript fit parity tests in SpriteMotion; add C# consumer contract tests in GUO. +- **Other tooling later:** add the Sprite Pose Editor, 2D outfit-lab round trips, pixel editing, motion import, props, creatures, tiles and VFX as separate supported profiles. Equipment assumptions such as 35 actions and five stored directions must not silently become universal game-asset rules. + +## Deployment and recovery + +Stage a new immutable content version and verify it before activation. Activation selects the matching client data override and private-shard data directory together; preserve the prior version and its allocation registry. Assume client/shard restart or reconnect for animation data initially. GUO's live map-edit transport does not prove animation-cache hot reload exists. + +Rollback restores the previous client/shard content version and preserves saved-world identity. Never remove an allocated item ID still referenced by a shard save. Serialize writes to a stage or use one temporary stage per release; one failed item must not leave a partially active outfit. Desktop publication and remote distribution are separate operations from autosaving a fit. + +## Public distribution + +Keep SpriteMotion independently usable and versioned. GUO should reference a pinned compatible release or configured local checkout, with an updateable compatibility manifest; avoid copying the entire source into GUO. Publish code, schemas, documentation and procedural fixtures. Local UO data, canonical scenes and commercial source packs stay outside both public repositories. + +An equipment distribution system is a later GUO Asset Store contract extension. Specify permitted payloads, attribution, hashes, dependency/base-install compatibility, ID allocation, installation and rollback before enabling it. Full staged MUL/UOP files can contain copied proprietary base content, so they are not public upload artifacts. A distributable delta must contain only content whose redistribution is allowed and be applied locally to the user's installation. No automatic upload follows a successful render. + +## Proposed implementation locations + +| Repository | Proposed change | +|---|---| +| SpriteMotion | New bridge/export schemas under `schemas/`; service adapter and export helper under a dedicated `tools/` job directory; reuse `tools/uo-content` builds and `tools/fit-lab` adjustments. | +| GUO | New SpriteMotion client/panel code under `godot/GUO/addons/guo_editor/`; generic importer alongside `tools/uopack/outfit.py`; orchestration under `tools/guo` or a dedicated tool following GUO conventions. | +| Both | Synthetic contract fixtures, compatibility tests and a recorded end-to-end acceptance recipe. | + +These are proposed locations, not files implemented by this plan. No runtime refactor, GUO change, third-party installation, public push or message to another developer was performed for this research. diff --git a/docs/handoff.md b/docs/handoff.md index 5501327..a90e137 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -30,6 +30,59 @@ pixels: eight items improved, six worsened, forty unchanged. Two runs reproduced sheet chooses each item's largest content-pixel difference and labels the pose. These results use the saved fits at run time (included in the JSON); they are not final Blender render validation or a reason to change rig defaults. +## Scoped corrections, body masking and lab builds + +Latest finish checks (2026-10-02): backpack masking off/on rendered 125 frames each across actions 0/4/9/22/25 +and five stored directions on the updated renderer. Both exports passed alpha/anchor validation with no clipped +or empty frames. After separating holdout policy from push-out, the masked render only removes covered pixels +(zero newly visible pixels). Rigid items no longer receive mesh-deforming push-out. Evidence: +`workspace/back-occlusion-jobs.json`, `workspace/back-occlusion-stats.json`, `workspace/back-occlusion-comparison.png`. +A skinned chest run (actions 4/9) exercised pack, slot, group, legacy item offset and pose-specific rotation together +with body hiding; see `workspace/scoped-chest-job.txt`. A current-renderer two-block rebuild changed only (4,3), kept +(4,0) byte-identical and left the original job untouched; see `workspace/scoped-rebuild-result.txt`. +Partial rebuilds now track renderer/source fingerprints and refuse a mixed-version rebuild after model, renderer, +mesh or palette changes. The release/acceptance plan is [render-release-roadmap.md](render-release-roadmap.md). +Final regression run: 86 pytest tests passed, one client-dependent test skipped; 15 outfit-lab tests and two Node +history tests passed. The production adjustment file's hash was unchanged across the final lab restart. + +`lab-adjustments.json` gained optional `groups` and `corrections` (pack/slot/group/item, optionally per action and/or +stored direction; mirrored directions share). Lab and build resolve them with the same rules +(`tools/fit-lab/fit_rules.py`, `web/fit-rules.mjs`, parity-tested). `occlusion` (clothing/body/none) picks which body +parts hide an item; back/quiver default to whole-body. The lab can **Build item** and **Rebuild changed blocks** +(`tools/uo-content/rebuild.py`); see `tools/fit-lab/README.md` and `tools/uo-content/README.md`. The preview's Euler +order now matches Blender (XYZ), so saved multi-axis rotations preview differently than before — correctly. + +Verified 2026-10-02 against a scratch copy of the adjustments: item+pose correction, Ctrl+Z / Ctrl+Shift+Z (undo +removes it from disk, redo restores it), mirrored direction 5 showing direction 3's correction and 4 not, a named +group from checked items with a per-action group correction. Lab build of the Elven back item, action 9: the scene +report shows +5 cm only in direction 3 and the group's +2 cm in all five; item-only frames keep just the parts outside +the body except in the back view. Changing only the direction-3 correction and rebuilding re-rendered block (9,3) +alone; the other four VD blocks were byte-identical and the revision validated. + +Not verified: a full 35-action lab build or a rebuild whose base slot fit changed. Each rebuild leaves its patch job +in `jobs/` (listed as a partial job by the studio). Studio pack +jobs started outside the lab need `fit_item` when the saved adjustments hold non-zero item offsets or non-pack +corrections. + +## UO_Model3D 2026-10 update (installed) + +The upstream update (unpacked under `workspace/uo-model-review/v2/`, installed with `pipeline.py setup`) changes the +body `.blend` (same 13,380-vertex topology, four new weapon bones under the hands: 112 bones), `render_uo_layer.py`, +`uo_bind_item.py`, `vdtool.py`, and adds body shape/pose fitting, lighting and weapon tooling. Its renderer has a +native 256x256 canvas with anchor (128,192) and cuts items along the original body outline itself. +`blender_build.py` now skips its canvas padding when the renderer defines `CANVAS` and accepts 108 or 112 bones; +`fit_runtime.py` rebuilds `HIDER_TRIS` (the holdout mask, minus parts the item is skinned to) and `TORSO_TRIS` as +well as `OCCLUDER_TRIS` per block, because hidden body faces change the triangle count. Body masking now includes +the clavicles in the torso set. The fit lab was re-exported from the new model (reference, body, 72 items). + +Verified 2026-10-02 on the new model: the Elven back item (action 9, scoped test corrections) built and validated with +the same canvas/anchor and per-block fits as on v13, frames within two pixels of the v13 build; the Elven torso +(actions 0/4, saved fits, hide-body on) hid 655 faces per block and validated, frames within ±2% pixels of a v13 +build of the same job (shading differs: new lighting); a non-pack template chest (actions 4/9) built and validated. +Not verified: full 35-action builds, mounted actions, cloaks (`TORSO_TRIS`), weapons on the new weapon bones. +Rollback: `pipeline.py setup --source workspace/uo-model-review/main`, and restore the lab export from +`workspace/uo-model-review/fitlab-synty-sidekick-v13/`. + ## Verified in the takeover check - **Studio build with pack fit.** `blender_build.py` reads `pack_mapping` + `pack_part` from job settings, applies the @@ -45,8 +98,8 @@ at run time (included in the JSON); they are not final Blender render validation ## Not yet used by builds -- **Lab → mapping.** The sidecar's mapping generator merges `lab-adjustments.json` slot fits. Item overrides - (`items` in that file) are saved by the lab but not used by builds. +- **Lab → mapping.** The sidecar's mapping generator merges `lab-adjustments.json` slot fits. Item overrides and + scoped corrections are used by fit-aware builds (lab builds, or studio jobs with `fit_item`), not by the generator. ## Known gaps diff --git a/docs/related-tools-research.md b/docs/related-tools-research.md new file mode 100644 index 0000000..437bea3 --- /dev/null +++ b/docs/related-tools-research.md @@ -0,0 +1,44 @@ +# Sprite content and animation tooling research + +Research date: 2026-10-02. This is a source and architecture review, not a benchmark or installation report. Recommendations below are our assessment; capabilities are attributed to the linked upstream projects. Companion: [GUO integration plan](guo-integration-plan.md). + +The most useful near-term additions are a GUO importer for completed SpriteMotion jobs, a pixel-editor round trip, and a motion-retargeting experiment. The projects reviewed provide useful components, but none demonstrated the complete combination we need: UO body matching, equipment holdouts, scoped fits, classic animation encoding, and verified client/server deployment. + +## Candidates ranked for this project + +| Candidate | What upstream provides | Recommended use and limitation | +|---|---|---| +| [HardRockTech 3D to 2D pipeline](https://github.com/HardRockTech/3d-to-2d-sprite-pipeline) | Blender and Unity capture scripts, eight directions, equipment masks, normal/height/shadow passes, and frame metadata. MIT. | **First code experiment.** Evaluate mask capture, foot pinning and animation synchronization on a procedural fixture. The author says the project moved to full 3D; the reviewed repository has one commit. Treat it as reference code needing tests, not a supported replacement. Its generic camera/crop policy must not replace UO's fixed anchor. | +| [Pixelorama](https://github.com/Orama-Interactive/Pixelorama) | Godot-based pixel editor with layered animation, onion skinning, indexed color, backups, CLI export, project metadata and 3D layers. MIT. | **Best editor companion.** Open exported frames for paint correction, then reimport using a manifest that preserves action/direction/frame identity. Begin with a separate application. Being built with Godot does not make the entire editor a drop-in GUO dock. | +| [SpriteSheetMaker](https://github.com/ManasMakde/SpriteSheetMaker) | Blender sprite capture, optional pixelation, imported/exported settings, consistent sizing and restart after failure. MIT. | **Useful batch and UX reference.** Compare resumable jobs and reusable capture presets. Retain our fixed camera, native timeline and palette rules. Generic automatic framing would break equipment alignment. | +| [Blender Spritesheet Renderer](https://github.com/chrishayesmu/Blender-Spritesheet-Renderer) | Animation/material/direction iteration and JSON metadata. MIT; README says tested on Blender 2.9. | **Reference only initially.** Good design ideas for multiple render passes; compatibility with our installed Blender must be proved before use. | +| [Rokoko Blender plugin](https://github.com/rokoko/rokoko-studio-live-blender) | Bone mapping, pose alignment and animation retargeting, plus optional Studio streaming. | **Motion import experiment.** Retarget one licensed clip onto a copy of the canonical rig. Its README badge says MIT while GitHub identifies `LICENSE.md` as LGPL-3.0; resolve that discrepancy at a pinned revision before redistributing code. Separate plugin terms from Studio and motion-asset terms. | +| [GVHMR](https://github.com/zju3dv/GVHMR) | World-grounded human motion recovery from video; folder inference and static-camera mode. | **Research track.** Useful for recording new human motion, not established recovery of tiny UO sprites. The [license](https://raw.githubusercontent.com/zju3dv/GVHMR/main/LICENSE) limits use to educational, research and nonprofit purposes and requires separate commercial permission. Keep it out of the default public production dependency set. | +| [APES](https://zhan-xu.github.io/parts/) | CVPR 2022 research extracting articulated 2D parts from several sprite poses. | **Closest match to the sprite-reconstruction problem.** Test whether proposed head/torso/limb masks reduce annotation work. This is 2D part decomposition, not proof of 3D geometry or a UO-compatible rig. Code availability, dependency compatibility and redistribution terms need a separate check. | +| [Laigter](https://github.com/azagaya/laigter) | Normal, parallax, specular and occlusion maps from 2D images, with a CLI. GPL-3.0-or-later. | **Optional rendering experiment.** Compare inferred maps with maps rendered from known geometry. Useful only if a GUO rendering feature consumes them; they do not solve backpack/body occlusion or improve classic MUL compatibility by themselves. | +| [UOFiddler](https://github.com/polserver/UOFiddler) | Viewing and editing UO client data, built on Ultima SDK; Beerware source license. | **Independent inspection tool.** Cross-check animation groups, art, gumps and tiledata. GUO already has staging writers; avoid introducing a competing ID allocator or another mandatory export route. | +| [OpenRA](https://github.com/OpenRA/OpenRA) | A classic RTS engine, mod SDK, asset utilities and modding documentation. GPL-3.0-or-later. | **Architecture reference.** Study separation of engine, game rules, tooling and content. It is not a UO fitting or motion-recovery library. | +| [openage data](https://github.com/SFTtech/openage-data) | A project creating replacement assets for classic RTS games, with Blender among its authoring tools. Artwork is CC BY-SA 4.0-or-later; code GPLv3-or-later. | **Content-production reference.** Separate source models, generated outputs, attribution and runtime packages. Asset availability does not imply a suitable UO art style or permissive relicensing. | +| [sprite-gen](https://github.com/aldegad/sprite-gen) | An Apache-2.0 Python workflow for generated sprite rows or video loops, alpha cleanup, atlas manifests and manual curation. | **Prototype/VFX track.** Evaluate curation and frame extraction. The project itself acknowledges direction detection can be wrong. Generated loops still need verified pose timing, silhouettes and all required UO facings. Provider/model/output terms are separate. | +| [Hunyuan3D 2.1](https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1) | Image-conditioned geometry and PBR texture generation. Upstream reports substantial GPU-memory requirements and supplies a [custom license](https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1/blob/main/LICENSE). | **Optional asset source.** Suitable for a mesh-generation experiment followed by cleanup and fitting. Generating a mesh is not retargeting it or reproducing an existing animation. Do not make GPU inference a GUO installation requirement. | + +## What to adopt first + +1. **Pixel editing with preserved metadata.** Export one item as frames plus an immutable identity manifest. Paint it in Pixelorama; reimport into a new revision. Demonstrate that an unchanged export round-trips exactly and that an edited frame changes only its designated block. Keep paint corrections separate from 3D fit corrections, and flag them stale when their source render changes. +2. **Capture utilities on a procedural scene.** Compare HardRockTech's equipment-mask and animation-synchronization approaches with our current Blender output. Measure anchor drift, silhouette stability and failure recovery. A normal-map pass is optional; successful UO masking is the gate. +3. **One retargeted animation.** Use a licensed walk or attack clip, explicit source-to-target bone mapping, foot/grip checks, and preservation of animated bone scale. Render all stored directions and mirrored views. Existing equipment that must align to the original UO body should continue using its original matched motions; new motions need matching body and equipment output. +4. **APES annotation pilot.** Compare its proposed masks with hand-labelled low-resolution frames, including crossed limbs, weapons and occlusion. Measure correction time and mask quality on held-out poses. Only promote it if it saves work without hiding uncertainty. + +No exact runtimes or quality gains are claimed before these experiments. Pin candidate versions and record input hashes, outputs, dependencies and actual license files when running them. + +## Distinguish the different problems + +**3D to sprites** is comparatively direct: sample a known rig through a fixed camera. Generic sprite exporters already automate much of this. **New animation to our rig** requires retargeting, contacts and timing. **Old sprites to 3D** is underdetermined: a hidden limb or unseen surface cannot be recovered uniquely from pixels. Research can suggest a fit, but original-view comparisons and human corrections remain necessary. + +For SpriteMotion, the scarce part is reliable compatibility with the old game: exact anchors, action IDs, equipment ordering, body masking, palettes, mirroring and a playable staged import. Replacing the renderer with a generic exporter would leave those problems unsolved. + +## Tools to keep optional + +[Aseprite](https://github.com/aseprite/aseprite) is another useful external pixel editor, but its [EULA](https://github.com/aseprite/aseprite/blob/main/EULA.txt) is not an MIT-style redistribution license. Support interchange files without bundling it. Similarly, keep video reconstruction, image-to-3D models and commercial motion services optional adapters. Public source code, model weights, training data and generated assets can each have different terms. + +For a later native GUO authoring workspace, Pixelorama identifies [godot-dockable-container](https://github.com/gilzoide/godot-dockable-container) and [Keychain](https://github.com/Orama-Interactive/Keychain) as UI dependencies. Evaluate them only if GUO's existing docks and shortcuts prove insufficient. diff --git a/docs/render-release-roadmap.md b/docs/render-release-roadmap.md new file mode 100644 index 0000000..2285765 --- /dev/null +++ b/docs/render-release-roadmap.md @@ -0,0 +1,54 @@ +# Reliable UO renders and public releases + +Status: 2026-10-02. The code repository is public at +[DatMoshu/SpriteMotion](https://github.com/DatMoshu/SpriteMotion). Public source availability is not a claim that +every outfit, animation and client combination is production-ready. + +## What works now + +- Fit history, autosave and three recoverable disk versions. +- Pack, slot, named-group and item corrections, filtered by animation, direction, or both. Mirrored facings share + the corresponding stored direction. Corrections reach both the lab and fit-aware Blender builds. +- Torso-aware back-attachment masking and separate clothing masking. Original-sprite previews now apply depth masks. +- Lab buttons to build an item and rebuild only its changed existing blocks into a new validated revision. + Untouched VD blocks stay byte-identical; the original job remains available. +- Partial rebuilds reject changes to the model, renderer, source meshes or palette; a fresh build is required. +- The October 2026 model/renderer update is supported, including its 112-bone base rig and native 256×256 canvas. + +Local acceptance evidence includes 125-frame backpack masking comparisons (five representative actions, five +stored directions), a skinned chest build exercising combined correction scopes, and a partial rebuild proving +an unaffected block remained byte-identical. These are targeted checks, not full equipment coverage or in-game proof. + +## Render acceptance gates, in order + +| Gate | Work | Exit evidence | +|---|---|---| +| 1. Make final renders authoritative | Compare the new renderer's worn-part rules, original-outline correction, torso mask, push-out and mounted holdout against the live lab. Use the rendered review for export decisions. | Reproducible side-by-side cases for front/back/side views; no unexplained mask disagreement. Preview differences are either fixed or clearly labelled. | +| 2. Cover equipment and every animation | Test at least three representative items per slot, all 35 actions, five stored directions plus three mirrored views. Include large weapons, shields, cloaks, skirts, hair, falls and mounted poses. | Correct block/frame IDs; fixed 256×256 canvas and (128,192) anchor; no unexplained clipping or empty frames; visual review of crossings and silhouettes. | +| 3. Fix exceptions locally | Apply action/direction/item/group corrections where base fitting fails. Improve twist/finger/weapon/cloth targets where a transform cannot fix deformation. | A/B images improve the failing poses; regression checks show unrelated actions/items remain unchanged. Head stabilization stays experimental until original-sprite matching supports it. | +| 4. Validate a complete outfit | Render combined outfit layers and exercise body hiding, straps, back items, weapons, paired pieces and garment intersections together. | No double hiding, missing limbs, front/back swaps or unexpected layer ordering across the full action set. | +| 5. Prove client compatibility | Validate full VD output, stage classic-client imports on copies, handle animation/static IDs and body/equipment conversions, then equip in the target client/server. | In-game screenshots/video of walking, combat, casting, death, mounting and turning. Confirm intended body/sex support. This gate is not complete until an actual client test passes. | + +Pure depth masking cannot correct a misaligned rig or an item that needs a different silhouette. Likewise, a lower +poke count is not proof of better UO artwork. Keep numerical validation and visual acceptance separate. + +## Public-release path + +1. **Developer preview:** publish reviewed code and documentation; keep the current status and unsupported cases + visible. Bundle only redistributable procedural examples. Game data, canonical scenes, commercial pack files, + mappings and generated art remain local. The sidecar is never pushed. +2. **Reproducible setup:** test a clean checkout, document supported Python/Blender versions, make missing local + prerequisites actionable, and verify the sample workflow without developer-specific paths. Document upstream + tool/model provenance and permitted installation separately from the repository's MIT code license. +3. **Automated release checks:** run unit/schema tests, browser/build fit-parity checks, save/recovery tests and + public-file scanning in CI. Add a procedural Blender regression scene so masking and selective rebuild checks + can run without distributing UO or commercial assets. +4. **Alpha release:** tag a release with setup instructions, a tested compatibility matrix, known limitations and + permitted screenshots/examples. Have an outside tester complete installation and a sample export. +5. **Beta release:** finish gates 1–5 above on the supported client/body combinations. Require full-action regressions, + recoverable saves, reproducible bug reports and successful in-game outfit tests before advertising reliable UO + equipment production. + +The next highest-value work is a full-action, three-items-per-slot acceptance matrix on the updated renderer, +followed by one complete outfit imported into the actual target client. More preview controls are lower priority +than those two proofs. diff --git a/examples/cc0-starter/License-Quaternius.txt b/examples/cc0-starter/License-Quaternius.txt new file mode 100644 index 0000000..455da5e --- /dev/null +++ b/examples/cc0-starter/License-Quaternius.txt @@ -0,0 +1,20 @@ +This is the standard FREE version of the the Modular Character Outfits - Fantasy, which only contains +a portion of the models. Get all remaining outfits and separate parts by buying the Source version. + +You can get the other versions from the website https://quaternius.com + +------------------------------------------------------- +License: +CC0 1.0 Universal (CC0 1.0) +Public Domain Dedication +https://creativecommons.org/publicdomain/zero/1.0/ + +------------------------------------------------------ +Models by @Quaternius +Consider supporting me on Patreon! + +https://www.patreon.com/quaternius + +------------------------------------------------------- +Join the Discord Server: +https://discord.gg/vJqnRUYRfT diff --git a/examples/cc0-starter/License.txt b/examples/cc0-starter/License.txt new file mode 100644 index 0000000..e2d8942 --- /dev/null +++ b/examples/cc0-starter/License.txt @@ -0,0 +1,28 @@ + + + Mini Dungeon (2.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 29-07-2026 10:35 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You can use this content for personal, educational, and commercial purposes. + + Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement) + + ------------------------------ + + • Website : www.kenney.nl + • Donate : www.kenney.nl/donate + + • Patreon : patreon.com/kenney + + Follow on social media for updates: + + • Twitter: twitter.com/KenneyNL + • BlueSky: kenney.bsky.social + • Instagram: instagram.com/kenney_nl \ No newline at end of file diff --git a/examples/cc0-starter/README.md b/examples/cc0-starter/README.md new file mode 100644 index 0000000..fe3d4c6 --- /dev/null +++ b/examples/cc0-starter/README.md @@ -0,0 +1,86 @@ +# CC0 equipment starter + +The catalog covers every entry in SpriteMotion's 25-layer UO equipment table: + +- **20 animated slots:** weapons/shields, shoes, pants, shirt, helm, gloves, neck, + hair, waist, inner torso, face, facial hair, middle torso, arms, cloak, backpack, + outer torso, outer legs and inner legs. +- **Four paperdoll-only slots:** ring, talisman, bracelet and earrings include source GLBs + for artwork authoring. They deliberately do not generate a body-animation VD. +- **Mount:** documented as the server's internal mount item. It requires creature animations + and mounted rider actions, not an equipment mesh; no sample horse or automatic mount importer is claimed. + +Sources are [Kenney Mini Dungeon](https://kenney.nl/assets/mini-dungeon), +[Quaternius Modular Character Outfits — Fantasy (free standard)](https://quaternius.com/packs/modularcharacteroutfitsfantasy.html), +and simple original accessories dedicated to CC0. The original accessories are basic fitting examples, +not a commercial-quality outfit collection. See [Kenney's license](License.txt), +[Quaternius's license](License-Quaternius.txt), [supplement license](supplement-license.txt), +and [per-file provenance](provenance.json). + +Every GLB includes its textures. No account or asset download is required. Source clothing has exposed +skin faces removed; no geometry from the UO body is bundled. Shirts and tunics intentionally reuse the +same example shape on different layers. These are starting fits, not approved game-ready exports. + +## Try a supplied asset + +After the normal SpriteMotion Python, Blender and separately supplied UO model setup, open Content Studio, +choose **Bundled CC0 starter**, and click **Build item**. No asset download or file selection is needed. +Restart Content Studio after updating to load the new backend. + +Alternatively, run: + +```sh +python tools/starter-assets/run.py weapon-sword +``` + +Run `python tools/starter-assets/run.py --list` for every slot and its route. +For example: `boots`, `shirt`, `hood`, `gloves`, `cloak`, `backpack`, `robe`, `ring`. +Add `--smoke` for a quick idle/direction-0 render. Default previews include walk, idle, +attack, fall and mounted actions. Jobs appear in Content Studio at http://127.0.0.1:8772. +Review placement and occlusion before exporting a full animation; automatic placement is only a starting point. + +## Open the starter Fit Lab + +```sh +python tools/starter-assets/run.py --prepare-lab +python tools/fit-lab/run.py serve --pack cc0-starter +``` + +Or use `launchers/editor/workbench.bat cc0-starter` on Windows, or +`sh launchers/editor/workbench.sh cc0-starter` on Linux. The combined launcher prepares a missing +starter lab automatically, then starts both tools. An existing different lab on port 8774 must +be stopped first; it is never silently replaced. + +Preparation requires Blender and your separately supplied canonical UO model. All generated body, +reference sprites, fitted GLBs, renders and saved adjustments stay in ignored `workspace/`. +The 20 animated slots appear in the lab. Content Studio exposes all slots and source downloads; +paperdoll/internal entries explain their separate route. Saved starter lab fits are used by starter builds. + +## Rebuild the distributable sources + +Download/extract the creator's free standard outfit archive, then run Blender with +`tools/starter-assets/prepare_blender.py -- examples/cc0-starter`. +This script reads only CC0 source files and creates original primitive accessories; it never reads the UO model. +It emits `sources.json` with hashes. Update the matching provenance entries after reviewing regenerated files. + +## Add your own assets + +1. Start `launchers/editor/content-studio.bat` or `python tools/uo-content/studio.py`. +2. Select the equipment type and upload a self-contained GLB (recommended), FBX, STL or BLEND. + Include only the item meshes. Pack textures into the GLB before uploading. +3. Build a preview, inspect the item over the reference body, and adjust placement. +4. Render the full animation once the preview is correct. Use the resulting review and import package. + +For OBJ/glTF with adjacent resources use the CLI instead: + +```sh +python tools/uo-content/pipeline.py build --spec examples/cc0-starter/weapon-sword.json --asset path/to/your-model.glb +``` + +Fit Lab's **Load fitted GLBs from a folder** accepts already fitted models with the canonical +bone names/weights; it is not a raw model importer. Modular clothing packs need a skeleton/slot +mapping and lab export: see [asset packs](../../docs/asset-packs.md). +GUO's embedded Fit Lab uses that same service and has the same input requirements. + +The starter models can also be imported directly into Godot or Blender without UO data. +The UO body, animation backend and client art are not bundled or licensed by this CC0 notice. diff --git a/examples/cc0-starter/arms.glb b/examples/cc0-starter/arms.glb new file mode 100644 index 0000000..9980b21 Binary files /dev/null and b/examples/cc0-starter/arms.glb differ diff --git a/examples/cc0-starter/backpack.glb b/examples/cc0-starter/backpack.glb new file mode 100644 index 0000000..4846959 Binary files /dev/null and b/examples/cc0-starter/backpack.glb differ diff --git a/examples/cc0-starter/beard.glb b/examples/cc0-starter/beard.glb new file mode 100644 index 0000000..7385274 Binary files /dev/null and b/examples/cc0-starter/beard.glb differ diff --git a/examples/cc0-starter/belt.glb b/examples/cc0-starter/belt.glb new file mode 100644 index 0000000..b7c67a9 Binary files /dev/null and b/examples/cc0-starter/belt.glb differ diff --git a/examples/cc0-starter/boots.glb b/examples/cc0-starter/boots.glb new file mode 100644 index 0000000..3b23843 Binary files /dev/null and b/examples/cc0-starter/boots.glb differ diff --git a/examples/cc0-starter/bracelet.glb b/examples/cc0-starter/bracelet.glb new file mode 100644 index 0000000..1bef495 Binary files /dev/null and b/examples/cc0-starter/bracelet.glb differ diff --git a/examples/cc0-starter/catalog.json b/examples/cc0-starter/catalog.json new file mode 100644 index 0000000..0d30ebb --- /dev/null +++ b/examples/cc0-starter/catalog.json @@ -0,0 +1,249 @@ +{ + "schema": "spritemotion.starter-catalog", + "schema_version": 1, + "items": [ + { + "id": "weapon-sword", + "name": "OneHanded \u2014 weapon sword", + "layer": 1, + "kind": "animated", + "asset": "starter-sword.glb", + "part": "weapon", + "mapping": "outfit-mapping.json" + }, + { + "id": "shield-round", + "name": "TwoHanded \u2014 shield round", + "layer": 2, + "kind": "animated", + "asset": "starter-shield.glb", + "part": "shield", + "mapping": "outfit-mapping.json" + }, + { + "id": "boots", + "name": "Shoes \u2014 boots", + "layer": 3, + "kind": "animated", + "asset": "boots.glb", + "part": "boots", + "mapping": "outfit-mapping.json" + }, + { + "id": "pants", + "name": "Pants \u2014 pants", + "layer": 4, + "kind": "animated", + "asset": "pants.glb", + "part": "legs", + "mapping": "outfit-mapping.json" + }, + { + "id": "shirt", + "name": "Shirt \u2014 shirt", + "layer": 5, + "kind": "animated", + "asset": "shirt.glb", + "part": "chest", + "mapping": "outfit-mapping.json" + }, + { + "id": "hood", + "name": "Helm \u2014 hood", + "layer": 6, + "kind": "animated", + "asset": "hood.glb", + "part": "helm", + "mapping": "outfit-mapping.json" + }, + { + "id": "gloves", + "name": "Gloves \u2014 gloves", + "layer": 7, + "kind": "animated", + "asset": "gloves.glb", + "part": "gloves", + "mapping": "outfit-mapping.json" + }, + { + "id": "ring", + "name": "Ring \u2014 ring", + "layer": 8, + "kind": "paperdoll", + "asset": "ring.glb", + "part": "helm", + "mapping": "outfit-mapping.json", + "note": "Source model for inventory/paperdoll artwork; no body-animation VD is generated." + }, + { + "id": "talisman", + "name": "Talisman \u2014 talisman", + "layer": 9, + "kind": "paperdoll", + "asset": "talisman.glb", + "part": "helm", + "mapping": "outfit-mapping.json", + "note": "Source model for inventory/paperdoll artwork; no body-animation VD is generated." + }, + { + "id": "gorget", + "name": "Neck \u2014 gorget", + "layer": 10, + "kind": "animated", + "asset": "gorget.glb", + "part": "helm", + "mapping": "outfit-mapping.json" + }, + { + "id": "hair", + "name": "Hair \u2014 hair", + "layer": 11, + "kind": "animated", + "asset": "hair.glb", + "part": "helm", + "mapping": "outfit-mapping.json" + }, + { + "id": "belt", + "name": "Waist \u2014 belt", + "layer": 12, + "kind": "animated", + "asset": "belt.glb", + "part": "skirt", + "mapping": "outfit-mapping.json" + }, + { + "id": "chest", + "name": "InnerTorso \u2014 chest", + "layer": 13, + "kind": "animated", + "asset": "chest.glb", + "part": "chest", + "mapping": "outfit-mapping.json" + }, + { + "id": "bracelet", + "name": "Bracelet \u2014 bracelet", + "layer": 14, + "kind": "paperdoll", + "asset": "bracelet.glb", + "part": "helm", + "mapping": "outfit-mapping.json", + "note": "Source model for inventory/paperdoll artwork; no body-animation VD is generated." + }, + { + "id": "mask", + "name": "Face \u2014 mask", + "layer": 15, + "kind": "animated", + "asset": "mask.glb", + "part": "helm", + "mapping": "outfit-mapping.json" + }, + { + "id": "beard", + "name": "FacialHair \u2014 beard", + "layer": 16, + "kind": "animated", + "asset": "beard.glb", + "part": "helm", + "mapping": "outfit-mapping.json" + }, + { + "id": "tunic", + "name": "MiddleTorso \u2014 tunic", + "layer": 17, + "kind": "animated", + "asset": "tunic.glb", + "part": "chest", + "mapping": "outfit-mapping.json" + }, + { + "id": "earrings", + "name": "Earrings \u2014 earrings", + "layer": 18, + "kind": "paperdoll", + "asset": "earrings.glb", + "part": "helm", + "mapping": "outfit-mapping.json", + "note": "Source model for inventory/paperdoll artwork; no body-animation VD is generated." + }, + { + "id": "arms", + "name": "Arms \u2014 arms", + "layer": 19, + "kind": "animated", + "asset": "arms.glb", + "part": "arms", + "mapping": "outfit-mapping.json" + }, + { + "id": "cloak", + "name": "Cloak \u2014 cloak", + "layer": 20, + "kind": "animated", + "asset": "cloak.glb", + "part": "cloak", + "mapping": "outfit-mapping.json" + }, + { + "id": "backpack", + "name": "Backpack \u2014 backpack", + "layer": 21, + "kind": "animated", + "asset": "backpack.glb", + "part": "quiver", + "mapping": "outfit-mapping.json" + }, + { + "id": "robe", + "name": "OuterTorso \u2014 robe", + "layer": 22, + "kind": "animated", + "asset": "robe.glb", + "part": "robe", + "mapping": "outfit-mapping.json" + }, + { + "id": "skirt", + "name": "OuterLegs \u2014 skirt", + "layer": 23, + "kind": "animated", + "asset": "skirt.glb", + "part": "skirt", + "mapping": "outfit-mapping.json" + }, + { + "id": "leg-armor", + "name": "InnerLegs \u2014 leg armor", + "layer": 24, + "kind": "animated", + "asset": "leg-armor.glb", + "part": "legs", + "mapping": "outfit-mapping.json" + }, + { + "id": "mount", + "name": "Mount \u2014 mount", + "layer": 25, + "kind": "internal", + "note": "Internal server mount item, not an equipment sprite. Use a separately animated creature and mounted rider actions; no wearable mesh is appropriate." + }, + { + "id": "weapon-spear", + "name": "Kenney weapon-spear", + "layer": 1, + "kind": "animated", + "asset": "weapon-spear.glb", + "part": "weapon" + }, + { + "id": "shield-rectangle", + "name": "Kenney shield-rectangle", + "layer": 2, + "kind": "animated", + "asset": "shield-rectangle.glb", + "part": "shield" + } + ] +} diff --git a/examples/cc0-starter/chest.glb b/examples/cc0-starter/chest.glb new file mode 100644 index 0000000..11453d1 Binary files /dev/null and b/examples/cc0-starter/chest.glb differ diff --git a/examples/cc0-starter/cloak.glb b/examples/cc0-starter/cloak.glb new file mode 100644 index 0000000..312bcbf Binary files /dev/null and b/examples/cc0-starter/cloak.glb differ diff --git a/examples/cc0-starter/earrings.glb b/examples/cc0-starter/earrings.glb new file mode 100644 index 0000000..ea9ecff Binary files /dev/null and b/examples/cc0-starter/earrings.glb differ diff --git a/examples/cc0-starter/gloves.glb b/examples/cc0-starter/gloves.glb new file mode 100644 index 0000000..6ab143c Binary files /dev/null and b/examples/cc0-starter/gloves.glb differ diff --git a/examples/cc0-starter/gorget.glb b/examples/cc0-starter/gorget.glb new file mode 100644 index 0000000..7b0a6a7 Binary files /dev/null and b/examples/cc0-starter/gorget.glb differ diff --git a/examples/cc0-starter/hair.glb b/examples/cc0-starter/hair.glb new file mode 100644 index 0000000..a049e4a Binary files /dev/null and b/examples/cc0-starter/hair.glb differ diff --git a/examples/cc0-starter/hood.glb b/examples/cc0-starter/hood.glb new file mode 100644 index 0000000..cfb321e Binary files /dev/null and b/examples/cc0-starter/hood.glb differ diff --git a/examples/cc0-starter/leg-armor.glb b/examples/cc0-starter/leg-armor.glb new file mode 100644 index 0000000..30a4d84 Binary files /dev/null and b/examples/cc0-starter/leg-armor.glb differ diff --git a/examples/cc0-starter/mask.glb b/examples/cc0-starter/mask.glb new file mode 100644 index 0000000..ac2c886 Binary files /dev/null and b/examples/cc0-starter/mask.glb differ diff --git a/examples/cc0-starter/outfit-mapping.json b/examples/cc0-starter/outfit-mapping.json new file mode 100644 index 0000000..1dc48ce --- /dev/null +++ b/examples/cc0-starter/outfit-mapping.json @@ -0,0 +1,762 @@ +{ + "schema": "spritemotion.asset-pack", + "schema_version": 1, + "id": "cc0-starter", + "title": "CC0 starter outfit", + "game": "ultima-online", + "target_rig": "uo-model3d-v13", + "source": { + "env": "SPRITEMOTION_CC0_STARTER", + "license": "CC0-1.0; bundled files and provenance in examples/cc0-starter" + }, + "fit": { + "scale_clamp": [ + 0.5, + 1.8 + ], + "missing_end_offset": [ + 0, + 0, + 0.08 + ], + "unmapped": "nearest_mapped_ancestor" + }, + "bones": { + "root": { + "parent": null, + "role": "root", + "target": "pelvis", + "follows": "pelvis" + }, + "pelvis": { + "parent": "root", + "role": "deform", + "target": "pelvis", + "end": "spine_01", + "keep_orientation": true + }, + "spine_01": { + "parent": "pelvis", + "role": "deform", + "target": "spine", + "end": "spine_02", + "keep_orientation": true + }, + "spine_02": { + "parent": "spine_01", + "role": "deform", + "target": "chest", + "end": "spine_03", + "keep_orientation": true + }, + "spine_03": { + "parent": "spine_02", + "role": "deform", + "target": "chest", + "end": "neck_01", + "keep_orientation": true + }, + "neck_01": { + "parent": "spine_03", + "role": "deform", + "target": "neck", + "end": "Head", + "keep_orientation": true + }, + "Head": { + "parent": "neck_01", + "role": "deform", + "target": "head", + "end": null, + "keep_orientation": true + }, + "clavicle_l": { + "parent": "spine_03", + "role": "deform", + "target": "clavicle.L", + "end": "upperarm_l" + }, + "upperarm_l": { + "parent": "clavicle_l", + "role": "deform", + "target": "upper_arm.L", + "end": "lowerarm_l" + }, + "lowerarm_l": { + "parent": "upperarm_l", + "role": "deform", + "target": "forearm.L", + "end": "hand_l" + }, + "hand_l": { + "parent": "lowerarm_l", + "role": "deform", + "target": "hand.L", + "end": "middle_01_l", + "target_end": "finger3-1.L" + }, + "index_01_l": { + "parent": "hand_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "index_02_l": { + "parent": "index_01_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "index_03_l": { + "parent": "index_02_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "index_04_leaf_l": { + "parent": "index_03_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "middle_01_l": { + "parent": "hand_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "middle_02_l": { + "parent": "middle_01_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "middle_03_l": { + "parent": "middle_02_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "middle_04_leaf_l": { + "parent": "middle_03_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "pinky_01_l": { + "parent": "hand_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "pinky_02_l": { + "parent": "pinky_01_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "pinky_03_l": { + "parent": "pinky_02_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "pinky_04_leaf_l": { + "parent": "pinky_03_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "ring_01_l": { + "parent": "hand_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "ring_02_l": { + "parent": "ring_01_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "ring_03_l": { + "parent": "ring_02_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "ring_04_leaf_l": { + "parent": "ring_03_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "thumb_01_l": { + "parent": "hand_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "thumb_02_l": { + "parent": "thumb_01_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "thumb_03_l": { + "parent": "thumb_02_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "thumb_04_leaf_l": { + "parent": "thumb_03_l", + "role": "finger", + "target": "hand.L", + "follows": "hand_l" + }, + "clavicle_r": { + "parent": "spine_03", + "role": "deform", + "target": "clavicle.R", + "end": "upperarm_r" + }, + "upperarm_r": { + "parent": "clavicle_r", + "role": "deform", + "target": "upper_arm.R", + "end": "lowerarm_r" + }, + "lowerarm_r": { + "parent": "upperarm_r", + "role": "deform", + "target": "forearm.R", + "end": "hand_r" + }, + "hand_r": { + "parent": "lowerarm_r", + "role": "deform", + "target": "hand.R", + "end": "middle_01_r", + "target_end": "finger3-1.R" + }, + "index_01_r": { + "parent": "hand_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "index_02_r": { + "parent": "index_01_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "index_03_r": { + "parent": "index_02_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "index_04_leaf_r": { + "parent": "index_03_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "middle_01_r": { + "parent": "hand_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "middle_02_r": { + "parent": "middle_01_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "middle_03_r": { + "parent": "middle_02_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "middle_04_leaf_r": { + "parent": "middle_03_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "pinky_01_r": { + "parent": "hand_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "pinky_02_r": { + "parent": "pinky_01_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "pinky_03_r": { + "parent": "pinky_02_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "pinky_04_leaf_r": { + "parent": "pinky_03_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "ring_01_r": { + "parent": "hand_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "ring_02_r": { + "parent": "ring_01_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "ring_03_r": { + "parent": "ring_02_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "ring_04_leaf_r": { + "parent": "ring_03_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "thumb_01_r": { + "parent": "hand_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "thumb_02_r": { + "parent": "thumb_01_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "thumb_03_r": { + "parent": "thumb_02_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "thumb_04_leaf_r": { + "parent": "thumb_03_r", + "role": "finger", + "target": "hand.R", + "follows": "hand_r" + }, + "thigh_l": { + "parent": "pelvis", + "role": "deform", + "target": "thigh.L", + "end": "calf_l" + }, + "calf_l": { + "parent": "thigh_l", + "role": "deform", + "target": "shin.L", + "end": "foot_l" + }, + "foot_l": { + "parent": "calf_l", + "role": "deform", + "target": "foot.L", + "end": "ball_l" + }, + "ball_l": { + "parent": "foot_l", + "role": "deform", + "target": "toe.L", + "end": "ball_leaf_l" + }, + "ball_leaf_l": { + "parent": "ball_l", + "role": "finger", + "target": "toe.L", + "follows": "ball_l" + }, + "thigh_r": { + "parent": "pelvis", + "role": "deform", + "target": "thigh.R", + "end": "calf_r" + }, + "calf_r": { + "parent": "thigh_r", + "role": "deform", + "target": "shin.R", + "end": "foot_r" + }, + "foot_r": { + "parent": "calf_r", + "role": "deform", + "target": "foot.R", + "end": "ball_r" + }, + "ball_r": { + "parent": "foot_r", + "role": "deform", + "target": "toe.R", + "end": "ball_leaf_r" + }, + "ball_leaf_r": { + "parent": "ball_r", + "role": "finger", + "target": "toe.R", + "follows": "ball_r" + } + }, + "parts": [ + { + "code": "weapon-sword", + "name": "OneHanded", + "status": "uo", + "uo_layer": 1, + "studio_part": "weapon", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "shield-round", + "name": "TwoHanded", + "status": "uo", + "uo_layer": 2, + "studio_part": "shield", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "boots", + "name": "Shoes", + "status": "uo", + "uo_layer": 3, + "studio_part": "boots", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "pants", + "name": "Pants", + "status": "uo", + "uo_layer": 4, + "studio_part": "legs", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "shirt", + "name": "Shirt", + "status": "uo", + "uo_layer": 5, + "studio_part": "chest", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "depth_scale": 1.85 + }, + { + "code": "hood", + "name": "Helm", + "status": "uo", + "uo_layer": 6, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "gloves", + "name": "Gloves", + "status": "uo", + "uo_layer": 7, + "studio_part": "gloves", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "ring", + "name": "Ring", + "status": "uo", + "uo_layer": 8, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "talisman", + "name": "Talisman", + "status": "uo", + "uo_layer": 9, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "gorget", + "name": "Neck", + "status": "uo", + "uo_layer": 10, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "offset": [ + 0, + 0, + 0.075 + ] + }, + { + "code": "hair", + "name": "Hair", + "status": "uo", + "uo_layer": 11, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "offset": [ + 0, + 0, + 0.12 + ] + }, + { + "code": "belt", + "name": "Waist", + "status": "uo", + "uo_layer": 12, + "studio_part": "skirt", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "chest", + "name": "InnerTorso", + "status": "uo", + "uo_layer": 13, + "studio_part": "chest", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "depth_scale": 1.45 + }, + { + "code": "bracelet", + "name": "Bracelet", + "status": "uo", + "uo_layer": 14, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "mask", + "name": "Face", + "status": "uo", + "uo_layer": 15, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "offset": [ + 0, + 0, + 0.11 + ] + }, + { + "code": "beard", + "name": "FacialHair", + "status": "uo", + "uo_layer": 16, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "offset": [ + 0, + 0, + 0.17 + ] + }, + { + "code": "tunic", + "name": "MiddleTorso", + "status": "uo", + "uo_layer": 17, + "studio_part": "chest", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "depth_scale": 1.65 + }, + { + "code": "earrings", + "name": "Earrings", + "status": "uo", + "uo_layer": 18, + "studio_part": "helm", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "arms", + "name": "Arms", + "status": "uo", + "uo_layer": 19, + "studio_part": "arms", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "cloak", + "name": "Cloak", + "status": "uo", + "uo_layer": 20, + "studio_part": "cloak", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "backpack", + "name": "Backpack", + "status": "uo", + "uo_layer": 21, + "studio_part": "quiver", + "bind": "rigid", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "robe", + "name": "OuterTorso", + "status": "uo", + "uo_layer": 22, + "studio_part": "robe", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + }, + "depth_scale": 1.3 + }, + { + "code": "skirt", + "name": "OuterLegs", + "status": "uo", + "uo_layer": 23, + "studio_part": "skirt", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + }, + { + "code": "leg-armor", + "name": "InnerLegs", + "status": "uo", + "uo_layer": 24, + "studio_part": "legs", + "bind": "skinned", + "hide_body": { + "enabled": true, + "outward": 0.02, + "inward": 0.01 + } + } + ] +} diff --git a/examples/cc0-starter/pants.glb b/examples/cc0-starter/pants.glb new file mode 100644 index 0000000..0042df6 Binary files /dev/null and b/examples/cc0-starter/pants.glb differ diff --git a/examples/cc0-starter/provenance.json b/examples/cc0-starter/provenance.json new file mode 100644 index 0000000..52a1b29 --- /dev/null +++ b/examples/cc0-starter/provenance.json @@ -0,0 +1,167 @@ +{ + "creator": "Kenney", + "pack": "Mini Dungeon 2.0", + "license": "CC0-1.0", + "source": "https://kenney.nl/assets/mini-dungeon", + "archive_sha256": "19c4648680cb1d2e8836cade96cbf9781c0c1f45fbc6d2ce41cee8239a3ec4d8", + "changes": "Embedded original colormap PNG into each GLB; geometry unchanged.", + "files": { + "weapon-sword.glb": { + "source": "Models/GLB format/weapon-sword.glb", + "source_sha256": "8e69eb27977f8c84a9b7cd423cd5830eddcf0d07ac1d7fed1969931387015b28", + "sha256": "9a78c1d3543a0e3bf376971dfabb68f962c8c9d4e8bc4771b6ffdcff4c9a2833" + }, + "weapon-spear.glb": { + "source": "Models/GLB format/weapon-spear.glb", + "source_sha256": "991fbb818c13226981d8c53b2a709c98bcc594b6cb895eea06478f3630814574", + "sha256": "bd0c5efa9eb4fd2e83e4cd4568be5f28c9f2e8befa21c0648ea0aaf7f9b8f5f0" + }, + "shield-round.glb": { + "source": "Models/GLB format/shield-round.glb", + "source_sha256": "1527cc9a27891efe1019e22cf68d99c98f6fce9a45b3d6e1e4cf034956e08e54", + "sha256": "a291d4364b97aa2030ec33321766a7c1b890a9db2372df3f16f05b03a38e8f35" + }, + "shield-rectangle.glb": { + "source": "Models/GLB format/shield-rectangle.glb", + "source_sha256": "16e3d0131666e7ad9bdad313253ff5dcc70cddfb86c28864b3b965ee5c65ac3b", + "sha256": "8a25ad83d3e80a5fd78b1d1f0985f39c68059cc5b4c6fd0d00c39dc5731a9b60" + }, + "boots.glb": { + "sha256": "99aa5c436c000575d87046591634f2f4f61d42b98a3ec42afc860121f5f4779c", + "source": "Male_Ranger_Feet_Boots.fbx", + "source_sha256": "e60d0f0eae8a9791dce407767991cc3b759f2f7660cd8eae5018e61f11e1aaef" + }, + "pants.glb": { + "sha256": "fd23e5dd1638328234aa1964c3646b105a42c74bfb43246463f5c4639db45a2a", + "source": "Male_Peasant_Legs.fbx", + "source_sha256": "547c90e8c7440cc149ebc5164ec0fda2cf2be0470cdf53816401545be0337abd" + }, + "shirt.glb": { + "sha256": "111d69e533431a06f653b6c2f494390c9d3a122661cc256f6f4a1a5e5a9bf52f", + "source": "Male_Peasant_Body.fbx", + "source_sha256": "d875b268f63ddf884257a6f7c7a8c20b48f3aafa30363755a6115963b1a17349" + }, + "hood.glb": { + "sha256": "fb09ae4e5e4c712c0c86c296290c396eebad7166faf6c70b48389a0fd38a5264", + "source": "Male_Ranger_Head_Hood.fbx", + "source_sha256": "c8b6f4e01b569176a5d91a7255bfc0eb45e83529b7e97eb4640c75db25f847f2" + }, + "chest.glb": { + "sha256": "453c3749e18eea67ce9a8a65034e01064455ef8ecc56b81a0ec743f695ae3bef", + "source": "Male_Ranger_Body.fbx", + "source_sha256": "9f5b7866170daa1d32a4652c0574188379d93b9889792e13edb3f2492fed444c" + }, + "tunic.glb": { + "sha256": "111d69e533431a06f653b6c2f494390c9d3a122661cc256f6f4a1a5e5a9bf52f", + "source": "Male_Peasant_Body.fbx", + "source_sha256": "d875b268f63ddf884257a6f7c7a8c20b48f3aafa30363755a6115963b1a17349" + }, + "arms.glb": { + "sha256": "baed4beb8e103190635332b5217505a6ee9f4c624db9e3391863a0c6e76eaa9b", + "source": "Male_Ranger_Arms.fbx", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "leg-armor.glb": { + "sha256": "0dcfcd172650be25ef934a5a3f8b9d905b5709321efa1f19f33832bd257cbce6", + "source": "Male_Ranger_Legs.fbx", + "source_sha256": "f1e30b2e4210086496ab2e18d107d4c163c8bde7ce72b10cd309cf5125540f63" + }, + "gorget.glb": { + "sha256": "730ca2fd8c38a452b7805295b9ede7067718dfa8e67a3fe7dd42df13ee7724ff", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "hair.glb": { + "sha256": "d2e1776c473500ae1c4b807b1df150268c1f90c202821758949228f46adbf260", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "belt.glb": { + "sha256": "4b80d09ed278fecd84776a58d6b426941a6a79a920d90e670e19af3c89ad6649", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "mask.glb": { + "sha256": "9f91b4bf9e907b4c4d4535cda84b41649e0e0cfc711b7466b625b87eab78ca1f", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "beard.glb": { + "sha256": "8d928e0e3b1ce2791c55fb5b9e8c91753903682ab5289a653d956ee043103e7e", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "cloak.glb": { + "sha256": "7ead79dea2710d0efdf0412add38e2099448dbfef4e873184d5ab58794c5b8e7", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "backpack.glb": { + "sha256": "37ad5992109486d64efcecd4c21cb71edfbdabff97b237642a2ac9e8e21643c4", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "robe.glb": { + "sha256": "5402b4ddfa739595319e1b691886149415353ec4ddad9cf4a2191a67e6de0f53", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "skirt.glb": { + "sha256": "ab8239fb510559d77ee1354f8224b270ee72e1b376c2e27e391d5b5881ccd00b", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "gloves.glb": { + "sha256": "629c7b91ce8ef714dd2fe5da457e7b80be410778a0588ada33e64f01ad228391", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "ring.glb": { + "sha256": "f64d4aec95adc55d33e3cfaa0be6ff0ec7ddb4a0684edb4cc374b42af35e1798", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "bracelet.glb": { + "sha256": "c02974355bba5a2d732fe37733b3a67a20e1803e321a98f2f280c1c2fe8caf40", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "earrings.glb": { + "sha256": "687e026f6ec8e4a3119d4fea9533835847897f3778f52d1b6de9949705094fa3", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "talisman.glb": { + "sha256": "fa16d1b0ec8e57f78d74d57ecc4b61c1ffcd4c063126b406cc931acf1f90804e", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "starter-sword.glb": { + "sha256": "46d36fde626f74c590754265cd391ac7f6b656da967683084e3fd7084e9f38e4", + "source": "Kenney weapon-sword.glb mounted on CC0 Quaternius rig", + "source_sha256": "9a78c1d3543a0e3bf376971dfabb68f962c8c9d4e8bc4771b6ffdcff4c9a2833" + }, + "starter-shield.glb": { + "sha256": "ef974610f6a8df8a7cf06a852aecec6d36a64dd6845219069606d17a414e884f", + "source": "Kenney shield-round.glb mounted on CC0 Quaternius rig", + "source_sha256": "a291d4364b97aa2030ec33321766a7c1b890a9db2372df3f16f05b03a38e8f35" + } + }, + "additional_sources": [ + { + "creator": "Quaternius", + "source": "https://quaternius.com/packs/modularcharacteroutfitsfantasy.html", + "license": "CC0-1.0", + "license_file": "License-Quaternius.txt", + "archive_sha256": "c3468b18871cc8c8f05ab14df7712baf22cb9f389cbd870babf130e595187f70", + "changes": "Selected male modular parts; removed exposed skin material faces; packed textures; rotated authoring rig 180 degrees; exported GLB." + }, + { + "creator": "SpriteMotion", + "source": "tools/starter-assets/prepare_blender.py", + "license": "CC0-1.0", + "license_file": "supplement-license.txt", + "changes": "Original primitive accessory and cloth shapes on the CC0 authoring rig. No UO model geometry used." + } + ] +} diff --git a/examples/cc0-starter/ring.glb b/examples/cc0-starter/ring.glb new file mode 100644 index 0000000..012d426 Binary files /dev/null and b/examples/cc0-starter/ring.glb differ diff --git a/examples/cc0-starter/robe.glb b/examples/cc0-starter/robe.glb new file mode 100644 index 0000000..8e04e34 Binary files /dev/null and b/examples/cc0-starter/robe.glb differ diff --git a/examples/cc0-starter/shield-rectangle.glb b/examples/cc0-starter/shield-rectangle.glb new file mode 100644 index 0000000..0a72a97 Binary files /dev/null and b/examples/cc0-starter/shield-rectangle.glb differ diff --git a/examples/cc0-starter/shield-rectangle.json b/examples/cc0-starter/shield-rectangle.json new file mode 100644 index 0000000..cc03cd2 --- /dev/null +++ b/examples/cc0-starter/shield-rectangle.json @@ -0,0 +1,6 @@ +{ + "name": "Kenney shield rectangle", + "part": "shield", + "mode": "preview", + "fit": "auto" +} diff --git a/examples/cc0-starter/shield-round.glb b/examples/cc0-starter/shield-round.glb new file mode 100644 index 0000000..e8c39ba Binary files /dev/null and b/examples/cc0-starter/shield-round.glb differ diff --git a/examples/cc0-starter/shield-round.json b/examples/cc0-starter/shield-round.json new file mode 100644 index 0000000..79cf427 --- /dev/null +++ b/examples/cc0-starter/shield-round.json @@ -0,0 +1,6 @@ +{ + "name": "Kenney shield round", + "part": "shield", + "mode": "preview", + "fit": "auto" +} diff --git a/examples/cc0-starter/shirt.glb b/examples/cc0-starter/shirt.glb new file mode 100644 index 0000000..91b2e37 Binary files /dev/null and b/examples/cc0-starter/shirt.glb differ diff --git a/examples/cc0-starter/skirt.glb b/examples/cc0-starter/skirt.glb new file mode 100644 index 0000000..c0c8087 Binary files /dev/null and b/examples/cc0-starter/skirt.glb differ diff --git a/examples/cc0-starter/source-bones.json b/examples/cc0-starter/source-bones.json new file mode 100644 index 0000000..dfdbaab --- /dev/null +++ b/examples/cc0-starter/source-bones.json @@ -0,0 +1,197 @@ +{ + "root": { + "parent": null + }, + "pelvis": { + "parent": "root" + }, + "spine_01": { + "parent": "pelvis" + }, + "spine_02": { + "parent": "spine_01" + }, + "spine_03": { + "parent": "spine_02" + }, + "neck_01": { + "parent": "spine_03" + }, + "Head": { + "parent": "neck_01" + }, + "clavicle_l": { + "parent": "spine_03" + }, + "upperarm_l": { + "parent": "clavicle_l" + }, + "lowerarm_l": { + "parent": "upperarm_l" + }, + "hand_l": { + "parent": "lowerarm_l" + }, + "index_01_l": { + "parent": "hand_l" + }, + "index_02_l": { + "parent": "index_01_l" + }, + "index_03_l": { + "parent": "index_02_l" + }, + "index_04_leaf_l": { + "parent": "index_03_l" + }, + "middle_01_l": { + "parent": "hand_l" + }, + "middle_02_l": { + "parent": "middle_01_l" + }, + "middle_03_l": { + "parent": "middle_02_l" + }, + "middle_04_leaf_l": { + "parent": "middle_03_l" + }, + "pinky_01_l": { + "parent": "hand_l" + }, + "pinky_02_l": { + "parent": "pinky_01_l" + }, + "pinky_03_l": { + "parent": "pinky_02_l" + }, + "pinky_04_leaf_l": { + "parent": "pinky_03_l" + }, + "ring_01_l": { + "parent": "hand_l" + }, + "ring_02_l": { + "parent": "ring_01_l" + }, + "ring_03_l": { + "parent": "ring_02_l" + }, + "ring_04_leaf_l": { + "parent": "ring_03_l" + }, + "thumb_01_l": { + "parent": "hand_l" + }, + "thumb_02_l": { + "parent": "thumb_01_l" + }, + "thumb_03_l": { + "parent": "thumb_02_l" + }, + "thumb_04_leaf_l": { + "parent": "thumb_03_l" + }, + "clavicle_r": { + "parent": "spine_03" + }, + "upperarm_r": { + "parent": "clavicle_r" + }, + "lowerarm_r": { + "parent": "upperarm_r" + }, + "hand_r": { + "parent": "lowerarm_r" + }, + "index_01_r": { + "parent": "hand_r" + }, + "index_02_r": { + "parent": "index_01_r" + }, + "index_03_r": { + "parent": "index_02_r" + }, + "index_04_leaf_r": { + "parent": "index_03_r" + }, + "middle_01_r": { + "parent": "hand_r" + }, + "middle_02_r": { + "parent": "middle_01_r" + }, + "middle_03_r": { + "parent": "middle_02_r" + }, + "middle_04_leaf_r": { + "parent": "middle_03_r" + }, + "pinky_01_r": { + "parent": "hand_r" + }, + "pinky_02_r": { + "parent": "pinky_01_r" + }, + "pinky_03_r": { + "parent": "pinky_02_r" + }, + "pinky_04_leaf_r": { + "parent": "pinky_03_r" + }, + "ring_01_r": { + "parent": "hand_r" + }, + "ring_02_r": { + "parent": "ring_01_r" + }, + "ring_03_r": { + "parent": "ring_02_r" + }, + "ring_04_leaf_r": { + "parent": "ring_03_r" + }, + "thumb_01_r": { + "parent": "hand_r" + }, + "thumb_02_r": { + "parent": "thumb_01_r" + }, + "thumb_03_r": { + "parent": "thumb_02_r" + }, + "thumb_04_leaf_r": { + "parent": "thumb_03_r" + }, + "thigh_l": { + "parent": "pelvis" + }, + "calf_l": { + "parent": "thigh_l" + }, + "foot_l": { + "parent": "calf_l" + }, + "ball_l": { + "parent": "foot_l" + }, + "ball_leaf_l": { + "parent": "ball_l" + }, + "thigh_r": { + "parent": "pelvis" + }, + "calf_r": { + "parent": "thigh_r" + }, + "foot_r": { + "parent": "calf_r" + }, + "ball_r": { + "parent": "foot_r" + }, + "ball_leaf_r": { + "parent": "ball_r" + } +} diff --git a/examples/cc0-starter/sources.json b/examples/cc0-starter/sources.json new file mode 100644 index 0000000..cfd1933 --- /dev/null +++ b/examples/cc0-starter/sources.json @@ -0,0 +1,122 @@ +{ + "boots.glb": { + "sha256": "99aa5c436c000575d87046591634f2f4f61d42b98a3ec42afc860121f5f4779c", + "source": "Male_Ranger_Feet_Boots.fbx", + "source_sha256": "e60d0f0eae8a9791dce407767991cc3b759f2f7660cd8eae5018e61f11e1aaef" + }, + "pants.glb": { + "sha256": "fd23e5dd1638328234aa1964c3646b105a42c74bfb43246463f5c4639db45a2a", + "source": "Male_Peasant_Legs.fbx", + "source_sha256": "547c90e8c7440cc149ebc5164ec0fda2cf2be0470cdf53816401545be0337abd" + }, + "shirt.glb": { + "sha256": "111d69e533431a06f653b6c2f494390c9d3a122661cc256f6f4a1a5e5a9bf52f", + "source": "Male_Peasant_Body.fbx", + "source_sha256": "d875b268f63ddf884257a6f7c7a8c20b48f3aafa30363755a6115963b1a17349" + }, + "hood.glb": { + "sha256": "fb09ae4e5e4c712c0c86c296290c396eebad7166faf6c70b48389a0fd38a5264", + "source": "Male_Ranger_Head_Hood.fbx", + "source_sha256": "c8b6f4e01b569176a5d91a7255bfc0eb45e83529b7e97eb4640c75db25f847f2" + }, + "chest.glb": { + "sha256": "453c3749e18eea67ce9a8a65034e01064455ef8ecc56b81a0ec743f695ae3bef", + "source": "Male_Ranger_Body.fbx", + "source_sha256": "9f5b7866170daa1d32a4652c0574188379d93b9889792e13edb3f2492fed444c" + }, + "tunic.glb": { + "sha256": "111d69e533431a06f653b6c2f494390c9d3a122661cc256f6f4a1a5e5a9bf52f", + "source": "Male_Peasant_Body.fbx", + "source_sha256": "d875b268f63ddf884257a6f7c7a8c20b48f3aafa30363755a6115963b1a17349" + }, + "arms.glb": { + "sha256": "baed4beb8e103190635332b5217505a6ee9f4c624db9e3391863a0c6e76eaa9b", + "source": "Male_Ranger_Arms.fbx", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "leg-armor.glb": { + "sha256": "0dcfcd172650be25ef934a5a3f8b9d905b5709321efa1f19f33832bd257cbce6", + "source": "Male_Ranger_Legs.fbx", + "source_sha256": "f1e30b2e4210086496ab2e18d107d4c163c8bde7ce72b10cd309cf5125540f63" + }, + "gorget.glb": { + "sha256": "730ca2fd8c38a452b7805295b9ede7067718dfa8e67a3fe7dd42df13ee7724ff", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "hair.glb": { + "sha256": "d2e1776c473500ae1c4b807b1df150268c1f90c202821758949228f46adbf260", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "belt.glb": { + "sha256": "4b80d09ed278fecd84776a58d6b426941a6a79a920d90e670e19af3c89ad6649", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "mask.glb": { + "sha256": "9f91b4bf9e907b4c4d4535cda84b41649e0e0cfc711b7466b625b87eab78ca1f", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "beard.glb": { + "sha256": "8d928e0e3b1ce2791c55fb5b9e8c91753903682ab5289a653d956ee043103e7e", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "cloak.glb": { + "sha256": "7ead79dea2710d0efdf0412add38e2099448dbfef4e873184d5ab58794c5b8e7", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "backpack.glb": { + "sha256": "37ad5992109486d64efcecd4c21cb71edfbdabff97b237642a2ac9e8e21643c4", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "robe.glb": { + "sha256": "5402b4ddfa739595319e1b691886149415353ec4ddad9cf4a2191a67e6de0f53", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "skirt.glb": { + "sha256": "ab8239fb510559d77ee1354f8224b270ee72e1b376c2e27e391d5b5881ccd00b", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "gloves.glb": { + "sha256": "629c7b91ce8ef714dd2fe5da457e7b80be410778a0588ada33e64f01ad228391", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "ring.glb": { + "sha256": "f64d4aec95adc55d33e3cfaa0be6ff0ec7ddb4a0684edb4cc374b42af35e1798", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "bracelet.glb": { + "sha256": "c02974355bba5a2d732fe37733b3a67a20e1803e321a98f2f280c1c2fe8caf40", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "earrings.glb": { + "sha256": "687e026f6ec8e4a3119d4fea9533835847897f3778f52d1b6de9949705094fa3", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "talisman.glb": { + "sha256": "fa16d1b0ec8e57f78d74d57ecc4b61c1ffcd4c063126b406cc931acf1f90804e", + "source": "Original primitive geometry; CC0 Quaternius rig", + "source_sha256": "1f8171fa29e8ad0d4290f2d9c7d99b6dead8a79987140db3b220c3b62ee8cd0b" + }, + "starter-sword.glb": { + "sha256": "46d36fde626f74c590754265cd391ac7f6b656da967683084e3fd7084e9f38e4", + "source": "Kenney weapon-sword.glb mounted on CC0 Quaternius rig", + "source_sha256": "9a78c1d3543a0e3bf376971dfabb68f962c8c9d4e8bc4771b6ffdcff4c9a2833" + }, + "starter-shield.glb": { + "sha256": "ef974610f6a8df8a7cf06a852aecec6d36a64dd6845219069606d17a414e884f", + "source": "Kenney shield-round.glb mounted on CC0 Quaternius rig", + "source_sha256": "a291d4364b97aa2030ec33321766a7c1b890a9db2372df3f16f05b03a38e8f35" + } +} diff --git a/examples/cc0-starter/starter-shield.glb b/examples/cc0-starter/starter-shield.glb new file mode 100644 index 0000000..7b4a917 Binary files /dev/null and b/examples/cc0-starter/starter-shield.glb differ diff --git a/examples/cc0-starter/starter-sword.glb b/examples/cc0-starter/starter-sword.glb new file mode 100644 index 0000000..ca0e9e0 Binary files /dev/null and b/examples/cc0-starter/starter-sword.glb differ diff --git a/examples/cc0-starter/supplement-license.txt b/examples/cc0-starter/supplement-license.txt new file mode 100644 index 0000000..e89b0cb --- /dev/null +++ b/examples/cc0-starter/supplement-license.txt @@ -0,0 +1,10 @@ +SpriteMotion original starter accessories + +The original primitive-based accessory geometry generated by +tools/starter-assets/prepare_blender.py is dedicated to the public domain under +CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ + +These are original sample shapes, not extracted or sampled from UO models or art. +Their authoring skeleton comes from Quaternius's CC0 standard outfit pack. +See License-Quaternius.txt for that source's dedication. +This notice does not license any UO model, game data, or third-party commercial asset. diff --git a/examples/cc0-starter/talisman.glb b/examples/cc0-starter/talisman.glb new file mode 100644 index 0000000..7775203 Binary files /dev/null and b/examples/cc0-starter/talisman.glb differ diff --git a/examples/cc0-starter/tunic.glb b/examples/cc0-starter/tunic.glb new file mode 100644 index 0000000..91b2e37 Binary files /dev/null and b/examples/cc0-starter/tunic.glb differ diff --git a/examples/cc0-starter/weapon-spear.glb b/examples/cc0-starter/weapon-spear.glb new file mode 100644 index 0000000..c142480 Binary files /dev/null and b/examples/cc0-starter/weapon-spear.glb differ diff --git a/examples/cc0-starter/weapon-spear.json b/examples/cc0-starter/weapon-spear.json new file mode 100644 index 0000000..7435eb4 --- /dev/null +++ b/examples/cc0-starter/weapon-spear.json @@ -0,0 +1,6 @@ +{ + "name": "Kenney weapon spear", + "part": "weapon", + "mode": "preview", + "fit": "auto" +} diff --git a/examples/cc0-starter/weapon-sword.glb b/examples/cc0-starter/weapon-sword.glb new file mode 100644 index 0000000..d9b53e2 Binary files /dev/null and b/examples/cc0-starter/weapon-sword.glb differ diff --git a/examples/cc0-starter/weapon-sword.json b/examples/cc0-starter/weapon-sword.json new file mode 100644 index 0000000..c1db5a4 --- /dev/null +++ b/examples/cc0-starter/weapon-sword.json @@ -0,0 +1,6 @@ +{ + "name": "Kenney weapon sword", + "part": "weapon", + "mode": "preview", + "fit": "auto" +} diff --git a/launchers/editor/README.md b/launchers/editor/README.md new file mode 100644 index 0000000..da13abb --- /dev/null +++ b/launchers/editor/README.md @@ -0,0 +1,34 @@ +# Content tools + +Start both services and open Content Studio: + +```bat +launchers\editor\workbench.bat +``` + +```sh +sh launchers/editor/workbench.sh +``` + +Use `cc0-starter` for the bundled examples, or the name of an existing private pack export. +Without an argument, `SPRITEMOTION_FIT_PACK` takes precedence; otherwise the bundled starter +is preferred, or the sole existing export. A missing starter export is prepared automatically +from the bundled assets and your supplied UO model. Private packs still require their own export. It reuses matching services and refuses a port occupied by another pack +or application. Ctrl+C stops only services it started; finish saves/renders first. + +Content Studio and Fit Lab have **Open Fit Lab** / **Open Content Studio** links in their +headers. These open another tab so unsaved work in the current tool remains available. +The links use the standard local ports, 8772 and 8774; start both with the workbench launcher. + +Individual Windows launchers: `content-studio.bat`, `fit-lab.bat `. +Linux/macOS equivalents: + +```sh +sh launchers/editor/content-studio.sh +sh launchers/editor/fit-lab.sh +``` + +The shell launchers use `.venvs/spritemotion/bin/python`, then `python3`. +Set `SPRITEMOTION_PYTHON` to override. Install the repository's Python dependencies first; +UO builds also require Blender and the supplied canonical model. The Linux scripts can be +run with `sh` without changing executable permissions. diff --git a/launchers/editor/content-studio.bat b/launchers/editor/content-studio.bat index bd72787..1107849 100644 --- a/launchers/editor/content-studio.bat +++ b/launchers/editor/content-studio.bat @@ -1,2 +1,4 @@ @echo off +call "%~dp0..\_shared\common.bat" || exit /b 1 powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0content-studio.ps1" +exit /b %errorlevel% diff --git a/launchers/editor/content-studio.sh b/launchers/editor/content-studio.sh new file mode 100644 index 0000000..85ebb58 --- /dev/null +++ b/launchers/editor/content-studio.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu +SM_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +SM_PYTHON=${SPRITEMOTION_PYTHON:-"$SM_ROOT/.venvs/spritemotion/bin/python"} +if [ ! -x "$SM_PYTHON" ]; then SM_PYTHON=${SPRITEMOTION_PYTHON:-python3}; fi +cd "$SM_ROOT" +exec "$SM_PYTHON" tools/uo-content/studio.py "$@" diff --git a/launchers/editor/fit-lab.bat b/launchers/editor/fit-lab.bat index ca6cba0..00a59e8 100644 --- a/launchers/editor/fit-lab.bat +++ b/launchers/editor/fit-lab.bat @@ -13,4 +13,4 @@ if not exist "workspace\ultima-online\fit-lab\%SM_PACK%\manifest.json" ( pause exit /b 1 ) -"%SPRITEMOTION_PYTHON%" tools\fit-lab\run.py serve --pack %SM_PACK% +"%SPRITEMOTION_PYTHON%" tools\fit-lab\run.py serve --pack "%SM_PACK%" diff --git a/launchers/editor/fit-lab.sh b/launchers/editor/fit-lab.sh new file mode 100644 index 0000000..c460794 --- /dev/null +++ b/launchers/editor/fit-lab.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu +SM_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +SM_PYTHON=${SPRITEMOTION_PYTHON:-"$SM_ROOT/.venvs/spritemotion/bin/python"} +if [ ! -x "$SM_PYTHON" ]; then SM_PYTHON=${SPRITEMOTION_PYTHON:-python3}; fi +SM_PACK=${1:-${SPRITEMOTION_FIT_PACK:-}} +if [ -z "$SM_PACK" ]; then echo 'Usage: fit-lab.sh (or set SPRITEMOTION_FIT_PACK)' >&2; exit 1; fi +if [ "$#" -gt 0 ]; then shift; fi +cd "$SM_ROOT" +if [ ! -f "workspace/ultima-online/fit-lab/$SM_PACK/manifest.json" ]; then + echo "No Fit Lab export for $SM_PACK. See tools/fit-lab/README.md." >&2; exit 1 +fi +exec "$SM_PYTHON" tools/fit-lab/run.py serve --pack "$SM_PACK" "$@" diff --git a/launchers/editor/workbench.bat b/launchers/editor/workbench.bat new file mode 100644 index 0000000..d5761e5 --- /dev/null +++ b/launchers/editor/workbench.bat @@ -0,0 +1,3 @@ +@echo off +call "%~dp0..\_shared\common.bat" || exit /b 1 +"%SPRITEMOTION_PYTHON%" tools\workbench\run.py %* diff --git a/launchers/editor/workbench.sh b/launchers/editor/workbench.sh new file mode 100644 index 0000000..7c423f8 --- /dev/null +++ b/launchers/editor/workbench.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu +SM_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +SM_PYTHON=${SPRITEMOTION_PYTHON:-"$SM_ROOT/.venvs/spritemotion/bin/python"} +if [ ! -x "$SM_PYTHON" ]; then SM_PYTHON=${SPRITEMOTION_PYTHON:-python3}; fi +cd "$SM_ROOT" +exec "$SM_PYTHON" tools/workbench/run.py "$@" diff --git a/schemas/asset-pack.schema.json b/schemas/asset-pack.schema.json index 4ddfd18..627c16a 100644 --- a/schemas/asset-pack.schema.json +++ b/schemas/asset-pack.schema.json @@ -174,6 +174,14 @@ "type": "boolean", "description": "Translate only; keep the source orientation and scale (spine and head)." }, + "target_end": { + "type": "string", + "description": "Use this target bone's head as the chain endpoint instead of the target tail (for example wrist to knuckle)." + }, + "end_from_parent": { + "type": "boolean", + "description": "For a terminal aligned bone with null end, extrapolate the previous joint segment instead of using missing_end_offset." + }, "follows": { "type": "string", "description": "Aligned ancestor this bone moves with (informational, derived)." @@ -313,6 +321,12 @@ "$ref": "#/$defs/vec3", "description": "Rest-pose offset in metres applied before binding." }, + "surface_clearance": { + "type": "number", + "minimum": 0, + "maximum": 0.03, + "description": "Optional rest-pose body clearance in metres. Push nearby penetrating vertices out to the body surface before binding; does not move vertices more than 5 cm from the surface." + }, "candidate_slot": { "type": "object", "required": [ @@ -361,6 +375,11 @@ "minimum": 0 } } + }, + "depth_scale": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Front-to-back multiplier along rest-pose Blender Y about the item center, before rotation; width and height are unchanged." } } } diff --git a/schemas/fit-adjustments.schema.json b/schemas/fit-adjustments.schema.json index 4ccd746..41cb8d1 100644 --- a/schemas/fit-adjustments.schema.json +++ b/schemas/fit-adjustments.schema.json @@ -2,23 +2,200 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Fit lab adjustments (also used by each backup)", "type": "object", - "required": ["parts", "items"], + "required": [ + "parts", + "items" + ], "additionalProperties": false, "properties": { - "parts": {"type": "object", "additionalProperties": {"$ref": "#/$defs/fit"}}, - "items": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": false, "properties": {"offset": {"$ref": "#/$defs/vector"}}}} + "groups": { + "type": "object", + "additionalProperties": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + }, + "corrections": { + "type": "array", + "items": { + "$ref": "#/$defs/correction" + } + }, + "parts": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/fit" + } + }, + "items": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "offset": { + "$ref": "#/$defs/vector" + }, + "sides": { + "type": "object", + "additionalProperties": false, + "properties": { + "left": { + "$ref": "#/$defs/vector" + }, + "right": { + "$ref": "#/$defs/vector" + } + } + } + } + } + } }, "$defs": { - "vector": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + "correction": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "fit" + ], + "properties": { + "target": { + "enum": [ + "pack", + "slot", + "group", + "item" + ] + }, + "key": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "integer", + "minimum": 0, + "maximum": 34 + }, + "direction": { + "type": "integer", + "minimum": 0, + "maximum": 4 + }, + "fit": { + "type": "object", + "additionalProperties": false, + "properties": { + "offset": { + "$ref": "#/$defs/vector" + }, + "rotate": { + "$ref": "#/$defs/vector" + }, + "scale": { + "type": "number", + "exclusiveMinimum": 0 + }, + "occlusion": { + "enum": [ + "clothing", + "body", + "none" + ] + }, + "depth_scale": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "target": { + "enum": [ + "slot", + "group", + "item" + ] + } + } + }, + "then": { + "required": [ + "key" + ] + } + } + ] + }, + "vector": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number" + } + }, "fit": { - "type": "object", "additionalProperties": false, + "type": "object", + "additionalProperties": false, "properties": { - "offset": {"$ref": "#/$defs/vector"}, "rotate": {"$ref": "#/$defs/vector"}, - "scale": {"type": "number", "exclusiveMinimum": 0}, - "bind": {"enum": ["skinned", "rigid"]}, - "hide_body": {"type": "object", "additionalProperties": false, "required": ["enabled", "outward", "inward"], "properties": { - "enabled": {"type": "boolean"}, "outward": {"type": "number", "minimum": 0}, "inward": {"type": "number", "minimum": 0} - }} + "offset": { + "$ref": "#/$defs/vector" + }, + "rotate": { + "$ref": "#/$defs/vector" + }, + "scale": { + "type": "number", + "exclusiveMinimum": 0 + }, + "bind": { + "enum": [ + "skinned", + "rigid" + ] + }, + "occlusion": { + "enum": [ + "clothing", + "body", + "none" + ] + }, + "hide_body": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "outward", + "inward" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "outward": { + "type": "number", + "minimum": 0 + }, + "inward": { + "type": "number", + "minimum": 0 + } + } + }, + "depth_scale": { + "type": "number", + "exclusiveMinimum": 0 + } } } } diff --git a/schemas/fit-build.schema.json b/schemas/fit-build.schema.json new file mode 100644 index 0000000..18c4c6a --- /dev/null +++ b/schemas/fit-build.schema.json @@ -0,0 +1,21 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Fit-aware build settings extension", + "type":"object", + "properties":{ + "render_fingerprint":{"type":"string"}, + "source_fingerprints":{"type":"object","additionalProperties":{"type":"string"}}, + "rebuild":{"type":"object","required":["source_job","patch_job","blocks","unchanged_vd_blocks"],"properties":{ + "source_job":{"type":"string"},"patch_job":{"type":"string"},"blocks":{"type":"array"}, + "unchanged_vd_blocks":{"type":"object","additionalProperties":{"type":"string"}} + }}, + "fit_blocks":{"type":"object","additionalProperties":{"type":"object","required":["fit","occlusion","hidden_body_faces"],"properties":{ + "fit":{"type":"object"},"occlusion":{"enum":["clothing","body","none"]},"hidden_body_faces":{"type":"integer","minimum":0} + }}}, + "fit_adjustments":{"$ref":"fit-adjustments.schema.json"}, + "fit_item":{"type":"object","required":["id","slot","part"],"properties":{"id":{"type":"string"},"slot":{"type":"string"},"part":{"type":"string"}}}, + "actions":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"integer","minimum":0,"maximum":34}}, + "blocks":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"array","minItems":2,"maxItems":2,"prefixItems":[{"type":"integer","minimum":0,"maximum":34},{"type":"integer","minimum":0,"maximum":4}]}}, + "occlusion":{"enum":["clothing","body","none"]} + } +} diff --git a/schemas/fit-lab-build.schema.json b/schemas/fit-lab-build.schema.json new file mode 100644 index 0000000..91c12ec --- /dev/null +++ b/schemas/fit-lab-build.schema.json @@ -0,0 +1,23 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Local lab build state", + "type":"object", "required":["state"], + "properties":{ + "state":{"enum":["idle","building","complete","failed"]},"item":{"type":"string"}, + "job":{"type":"string"},"review":{"type":"string"},"error":{"type":"string"},"unchanged":{"type":"boolean"}, + "mode":{"enum":["build","rebuild"]},"started":{"type":"number","description":"Unix time the build started"}, + "progress":{"type":"object","required":["done","total"],"additionalProperties":false,"description":"Frames written by the running Blender job","properties":{ + "done":{"type":"integer","minimum":0},"total":{"type":"integer","minimum":1}}} + },"additionalProperties":false, + "$defs":{ + "request":{"type":"object","required":["item","mode","coverage","action"],"additionalProperties":false,"properties":{ + "item":{"type":"string"},"mode":{"enum":["build","rebuild"]},"coverage":{"enum":["preview","action","full"]},"action":{"type":"integer","minimum":0,"maximum":34} + }}, + "latest":{"type":"object","additionalProperties":{"type":"string","pattern":"^[a-zA-Z0-9]+$"}}, + "renders":{"type":"object","required":["renders"],"additionalProperties":false,"description":"GET /api/renders?item=, newest first","properties":{ + "renders":{"type":"array","items":{"type":"object","required":["job","item","actions","finished"],"additionalProperties":false,"properties":{ + "job":{"type":"string","pattern":"^[a-zA-Z0-9]+$"},"item":{"type":"string"}, + "actions":{"type":"array","items":{"type":"integer","minimum":0,"maximum":34}},"finished":{"type":"number"}}}} + }} + } +} diff --git a/schemas/fit-lab-service.schema.json b/schemas/fit-lab-service.schema.json new file mode 100644 index 0000000..d4619c6 --- /dev/null +++ b/schemas/fit-lab-service.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spritemotion.local/schemas/fit-lab-service.schema.json", + "title": "SpriteMotion Fit Lab service discovery", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "pack", "capabilities"], + "properties": { + "schema": {"const": "spritemotion.fit-lab-service"}, + "schema_version": {"const": 1}, + "pack": {"type": "string", "minLength": 1}, + "capabilities": {"type": "array", "uniqueItems": true, "items": {"enum": ["revision-saves", "three-backups", "scoped-fits", "blender-builds", "render-index"]}} + } +} diff --git a/schemas/starter-catalog.schema.json b/schemas/starter-catalog.schema.json new file mode 100644 index 0000000..6c1820b --- /dev/null +++ b/schemas/starter-catalog.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spritemotion.local/schemas/starter-catalog.schema.json", + "title": "Bundled starter equipment catalog", + "type": "object", + "required": ["schema", "schema_version", "items"], + "additionalProperties": false, + "properties": { + "schema": {"const": "spritemotion.starter-catalog"}, + "schema_version": {"const": 1}, + "items": {"type": "array", "items": { + "type": "object", "additionalProperties": false, + "required": ["id", "name", "layer", "kind"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "name": {"type": "string"}, + "layer": {"type": "integer", "minimum": 1, "maximum": 25}, + "kind": {"enum": ["animated", "paperdoll", "internal"]}, + "asset": {"type": "string", "pattern": "^[a-z0-9-]+\\.glb$"}, + "part": {"type": "string"}, + "mapping": {"type": "string", "pattern": "^[a-z0-9-]+\\.json$"}, + "note": {"type": "string"} + } + }} + } +} diff --git a/tests/integration/blender_pack_surface_check.py b/tests/integration/blender_pack_surface_check.py new file mode 100644 index 0000000..f3644ed --- /dev/null +++ b/tests/integration/blender_pack_surface_check.py @@ -0,0 +1,25 @@ +"""Run with Blender --background --factory-startup --python . No external assets needed.""" +from pathlib import Path +import sys +import bpy +from mathutils import Matrix + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'tools/uo-content')) +from pack_fit import clear_surface + +bpy.ops.mesh.primitive_cube_add(size=2) +body = bpy.context.object +rig = bpy.data.objects.new('Target', None) +bpy.context.collection.objects.link(rig) +# The algorithm must use rig space, independently of the body's object transform. +body.matrix_world = Matrix.Translation((2, 0, 0)) +rig.matrix_world = Matrix.Translation((2, 0, 0)) +mesh = bpy.data.meshes.new('Shell') +mesh.from_pydata([(0.98, 0, 0), (1.1, 0, 0), (0, 0, 0)], [], []) +shell = bpy.data.objects.new('Shell', mesh) +bpy.context.collection.objects.link(shell) +clear_surface([shell], body, rig, .004) +assert abs(mesh.vertices[0].co.x - 1.004) < 1e-5, 'Nearby penetration must clear the body' +assert abs(mesh.vertices[1].co.x - 1.1) < 1e-5, 'Outside geometry must stay unchanged' +assert mesh.vertices[2].co.length < 1e-5, 'Distant geometry must not snap to another body region' +print('PASS: surface clearance, coordinate spaces, outside and distant geometry') diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index ac32fe2..2f3e137 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -4,6 +4,8 @@ to the folder holding anim.mul / anim.idx. """ import os +import hashlib +import json import re import subprocess import sys @@ -45,10 +47,16 @@ def test_bundled_uo_annotations_keep_the_approved_corrections(): def test_no_game_assets_or_local_paths_are_published(): files = tracked_files() + starter = REPO / 'examples/cc0-starter' + approved = json.loads((starter / 'provenance.json').read_text())['files'] for path in files: rel = path.relative_to(REPO).as_posix() assert not rel.startswith("workspace/") or rel == "workspace/README.md", rel - assert path.suffix.lower() not in {".mul", ".uop", ".idx", ".blend", ".fbx", ".glb"}, rel + if path.parent == starter and path.name in approved: + assert path.suffix == '.glb' + assert hashlib.sha256(path.read_bytes()).hexdigest() == approved[path.name]['sha256'], rel + else: + assert path.suffix.lower() not in {".mul", ".uop", ".idx", ".blend", ".fbx", ".glb"}, rel if rel.startswith("games/") and path.suffix.lower() in {".png", ".bmp", ".gif"}: pytest.fail(f"image under games/ (game art must never be committed): {rel}") if path.suffix.lower() in {".py", ".gd", ".json", ".md", ".bat", ".toml", ".cfg", ".godot", ".tscn", diff --git a/tests/unit/test_body_occlusion.py b/tests/unit/test_body_occlusion.py new file mode 100644 index 0000000..da382df --- /dev/null +++ b/tests/unit/test_body_occlusion.py @@ -0,0 +1,65 @@ +"""Crossing surfaces, bounded contact and hidden-face independence of holdout.""" +import importlib.util +from pathlib import Path +import numpy as np + +spec = importlib.util.spec_from_file_location('body_occlusion', Path(__file__).resolve().parents[2] / 'tools/uo-content/occlusion.py') +occlusion = importlib.util.module_from_spec(spec) +spec.loader.exec_module(occlusion) + + +def test_side_identity_and_twist_aliases(): + assert occlusion.region('forearm_twist.L') == 'forearm.L' + assert occlusion.region('forearm.R') != occlusion.region('forearm.L') + assert occlusion.region('finger_index.R') == 'hand.R' + + +def test_full_body_depth_and_contact_limit(): + # Visible, torso occlusion, own-surface penetration, own far side, + # opposite arm, uncovered background, exact tolerance boundary. + body = np.array([2., 2., 2., 2., 2., np.inf, 2.]) + item = np.array([1.9, 2.3, 2.015, 2.3, 2.015, 2.3, 2.02]) + own_body = np.array([2., np.inf, 2., 2., np.inf, np.inf, 2.]) + own_item = item.copy() + assert occlusion.blocked_pixels(body, item, .01, [(own_body, own_item)], .02).tolist() == [False, True, False, True, True, False, False] + + +def test_rear_garment_cannot_exempt_front_item(): + body, item = np.array([2.]), np.array([2.015]) + assert occlusion.blocked_pixels(body, item, .01, [(body, np.array([2.1]))], .2)[0] + + +def test_strict_body_mode_and_unknown_contact(): + body, item = np.array([2., np.inf]), np.array([2.015, np.inf]) + assert occlusion.blocked_pixels(body, item, .01).tolist() == [True, False] + + +def test_zero_margin_retains_fractional_contact_tolerance(): + body, item = np.array([2.]), np.array([2.015]) + assert not occlusion.blocked_pixels(body, item, 0, [(body, item)], .02)[0] + + +def test_render_masks_color_without_drawing_body_and_none_is_unchanged(): + from types import SimpleNamespace + holdout = occlusion.BodyHoldout.__new__(occlusion.BodyHoldout) + holdout.body = object() # Pristine proxy, not the fitting body from env. + item = SimpleNamespace(hide_render=False) + holdout.items = [(item, np.array(['forearm.L']))] + holdout.body_regions = np.array(['chest']) + original = np.ones((1, 2, 4)) + original[..., :3] = [.8, .4, .1] + def raster(objects, mask=None): + if objects[0] is holdout.body: + return np.array([[True, True]]), np.array([[2., 2.]]) + assert objects[0] is item + return np.array([[True, True]]), np.array([[2.3, 1.9]]) + env = {'raster': raster, 'body': object()} + holdout.configure('clothing', {'enabled': True, 'inward': .02}) + result, coverage = holdout.render(env, original, .01) + assert not result[0, 0].any() + np.testing.assert_array_equal(result[0, 1], original[0, 1]) + assert original[0, 0, 3] == 1 + assert coverage.all() + holdout.configure('none', {}) + result, _ = holdout.render(env, original, .01) + np.testing.assert_array_equal(result, original) diff --git a/tests/unit/test_fit_lab_renders.py b/tests/unit/test_fit_lab_renders.py new file mode 100644 index 0000000..3ea3a78 --- /dev/null +++ b/tests/unit/test_fit_lab_renders.py @@ -0,0 +1,42 @@ +import importlib.util +import json +import os +from pathlib import Path +import time + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('fit_lab_renders', ROOT / 'tools/fit-lab/renders.py') +renders = importlib.util.module_from_spec(spec); spec.loader.exec_module(renders) + + +def job(root, name, item, state='complete', actions=(4,), **extra): + d = root / name; (d / 'review').mkdir(parents=True) + (d / 'status.json').write_text(json.dumps({'state': state})) + (d / 'job.json').write_text(json.dumps({'name': item, 'fit_item': {'id': item}, 'actions': list(actions), **extra})) + (d / 'review/manifest.json').write_text(json.dumps({'sequences': [{'action': a, 'dir': d} for a in actions for d in range(5)]})) + return d + + +def test_renders_list_complete_jobs_for_one_item_newest_first(tmp_path): + old = job(tmp_path, 'aaa', 'cloak', actions=(4, 9)); os.utime(old / 'status.json', (1, 1)) + job(tmp_path, 'bbb', 'cloak', actions=(0,)) + job(tmp_path, 'ccc', 'other') + job(tmp_path, 'ddd', 'cloak', state='failed') + index = renders.RenderIndex(tmp_path) + found = index.renders('cloak') + assert [r['job'] for r in found] == ['bbb', 'aaa'] and found[1]['actions'] == [4, 9] + running = job(tmp_path, 'eee', 'cloak', state='building') + assert [r['job'] for r in index.renders('cloak')] == ['bbb', 'aaa'] + (running / 'status.json').write_text('{"state": "complete"}') + assert index.renders('cloak')[0]['job'] == 'eee' + + +def test_progress_counts_frames_of_the_running_job(tmp_path): + started = time.time() + d = job(tmp_path, 'run', 'cloak', state='building', actions=(4, 9)) + frames = d / 'render/clothing/frames/09_x/dir0'; frames.mkdir(parents=True) + for i in range(3): (frames / f'{i:02d}.png').write_bytes(b'') + assert renders.RenderIndex(tmp_path).progress('cloak', started, {4: 1, 9: 7}) == {'done': 3, 'total': 40} + patch = job(tmp_path, 'patch', 'other', state='building', actions=(9,), blocks=[[9, 3]]) + assert renders.RenderIndex(tmp_path).progress('other', started, {9: 7}) == {'done': 0, 'total': 7} + assert renders.RenderIndex(tmp_path).progress('missing', started, {}) is None diff --git a/tests/unit/test_fit_rebuild.py b/tests/unit/test_fit_rebuild.py new file mode 100644 index 0000000..bc6854e --- /dev/null +++ b/tests/unit/test_fit_rebuild.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path +import sys + +import pytest + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tools/uo-content')) +from rebuild import changed_blocks +from pipeline import normalize, snapshot_fit + + +def test_changed_blocks_only_include_affected_stored_direction(tmp_path): + mapping=tmp_path/'mapping.json';mapping.write_text(json.dumps({'parts':[{'code':'BACK'}]})) + spec={'pack_mapping':str(mapping),'fit_item':{'id':'bag','slot':'back','part':'BACK'},'fit_adjustments':{'parts':{},'items':{}}} + doc={'parts':{},'items':{},'corrections':[{'target':'item','key':'bag','action':9,'direction':3,'fit':{'offset':[.05,0,0]}}]} + blocks=[(a,d) for a in (0,4,9) for d in range(5)] + assert changed_blocks(spec,doc,blocks)==[[9,3]] + assert changed_blocks(dict(spec,fit_adjustments=doc),doc,blocks)==[] + doc['corrections'][0]['key']='someone-else' + assert changed_blocks(spec,doc,blocks)==[] + + +def test_explicit_actions_and_blocks_survive_normalization(): + result=normalize({'actions':[9], 'blocks':[[9,3]]}) + assert result['actions']==[9] and result['blocks']==[[9,3]] + with pytest.raises(ValueError): normalize({'actions':[4], 'blocks':[[9,3]]}) + with pytest.raises(ValueError): normalize({'actions':[9], 'blocks':[[9,5]]}) + + +def test_item_scopes_require_unambiguous_build_identity(): + with pytest.raises(ValueError,match='fit_item'): + snapshot_fit({'fit_adjustments':{'parts':{},'items':{'bag':{'offset':[0,0,.01]}}}}) + + +@pytest.mark.parametrize('change,reason',[('model','canonical model'),('renderer','renderer'),('source','Source meshes')]) +def test_partial_rebuild_rejects_changed_inputs(tmp_path,monkeypatch,change,reason): + import pipeline + from rebuild import rebuild_job + import hashlib + (tmp_path/'model').mkdir(); (tmp_path/'model/UO_Body_0x190.blend').write_bytes(b'model') + source=tmp_path/'source.mesh';source.write_bytes(b'original') + monkeypatch.setattr(pipeline,'BACKEND',tmp_path) + monkeypatch.setattr(pipeline,'render_fingerprint',lambda:'renderer') + spec={'backend_sha256':pipeline.sha(tmp_path/'model/UO_Body_0x190.blend'), + 'render_fingerprint':'renderer','source_fingerprints':{str(source):pipeline.sha(source)}} + if change=='model': spec['backend_sha256']='old' + elif change=='renderer': spec['render_fingerprint']='old' + else: source.write_bytes(b'changed') + (tmp_path/'job.json').write_text(json.dumps(spec)) + with pytest.raises(ValueError,match=reason): rebuild_job(tmp_path,{'parts':{},'items':{}}) diff --git a/tests/unit/test_fit_rules.py b/tests/unit/test_fit_rules.py new file mode 100644 index 0000000..9ec1a0e --- /dev/null +++ b/tests/unit/test_fit_rules.py @@ -0,0 +1,86 @@ +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tools/fit-lab')) +from fit_rules import resolve +from adjustments import validate + + +def test_depth_is_independent_and_hiding_defaults_on_without_overriding_saved_choices(): + item={'id':'shirt','part':'shirt','slot':'Shirt'} + doc={'parts':{'shirt':{'depth_scale':1.5,'hide_body':{'enabled':False,'outward':.02,'inward':.01}}}, + 'items':{},'corrections':[{'target':'item','key':'shirt','action':9,'fit':{'depth_scale':1.2}}]} + validate(doc) + assert resolve({'parts':{},'items':{}},{},item)['hide_body']['enabled'] is True + base=resolve(doc,{'depth_scale':2},item,0,1) + attack=resolve(doc,{'depth_scale':2},item,9,1) + assert base['depth_scale']==1.5 and attack['depth_scale']==pytest.approx(1.8) + assert base['scale']==attack['scale']==1 + assert base['hide_body']['enabled'] is False + for invalid in (0,-1,float('nan'),float('inf'),True): + with pytest.raises(ValueError):validate({'parts':{'shirt':{'depth_scale':invalid}},'items':{}}) + + +def test_layers_mirroring_and_unaffected_poses(): + doc={'parts':{'BACK':{'offset':[.1,0,0]}},'items':{'one':{'offset':[0,.2,0]}},'groups':{'pair':['one','two']}, + 'corrections':[{'target':'pack','fit':{'scale':1.1}}, + {'target':'group','key':'pair','action':9,'fit':{'offset':[0,0,.3]}}, + {'target':'item','key':'one','action':9,'direction':3,'fit':{'offset':[.4,0,0],'occlusion':'none'}}]} + validate(doc) + item={'id':'one','part':'BACK','slot':'back'} + assert resolve(doc,{},item,0,3)['offset']==[.1,.2,0] + assert resolve(doc,{},item,9,3)['offset']==[.5,.2,.3] + assert resolve(doc,{},item,9,5)==resolve(doc,{},item,9,3) + assert resolve(doc,{},item,9,4)['occlusion']=='body' + assert resolve(doc,{},dict(item,id='two'),9,3)['offset']==[.1,0,.3] + assert resolve(doc,{},dict(item,id='other'),9,3)['offset']==[.1,0,0] + + +@pytest.mark.parametrize('rules',[ + [{'target':'item','fit':{}}], + [{'target':'pack','direction':5,'fit':{}}], + [{'target':'pack','action':True,'fit':{}}], + [{'target':'group','key':'missing','fit':{}}], + [{'target':'pack','fit':{}},{'target':'pack','fit':{}}], + [{'target':'pack','fit':{'scale':0}}], +]) +def test_invalid_corrections_rejected(rules): + with pytest.raises(ValueError): validate({'parts':{},'items':{},'corrections':rules}) + + +def test_browser_resolver_matches_build_resolver(tmp_path): + node=shutil.which('node') + if not node: pytest.skip('Node is required for browser/build parity') + doc={'parts':{'P':{'rotate':[1,2,3], 'depth_scale':1.4}},'items':{'i':{'offset':[.02,0,0], 'sides':{'left':[.01,.02,.03],'right':[-.02,0,0]}}},'groups':{'g':['i']}, + 'corrections':[{'target':target,**({'key':key} if key else {}),**filters,'fit':{'offset':[.1,.2,.3],'scale':1.05,'depth_scale':1.1,'occlusion':'none'}} + for target,key,filters in [('pack',None,{}),('slot','back',{'direction':3}),('group','g',{'action':9}),('item','i',{'action':9,'direction':3})]]} + cases=[{'document':doc,'mapping':{'offset':[.01,0,0]},'item':{'id':item,'part':'P','slot':'back'},'action':a,'direction':d} + for item in ('i','other') for a in (0,9) for d in range(8)] + doc['groups'].update({'Z':['i'],'a':['i']}) + doc['corrections'].extend([{'target':'group','key':'Z','fit':{'occlusion':'none'}}, + {'target':'group','key':'a','fit':{'occlusion':'body'}}]) + fixture=tmp_path/'cases.json';fixture.write_text(json.dumps(cases)) + runner=tmp_path/'run.mjs' + runner.write_text("import fs from 'node:fs'; import {resolveFit} from "+json.dumps((ROOT/'tools/fit-lab/web/fit-rules.mjs').as_uri())+"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(process.argv[2],'utf8')).map(c=>resolveFit(c.document,c.mapping,c.item,c.action,c.direction))));") + result=subprocess.run([node,str(runner),str(fixture)],capture_output=True,text=True,check=True) + assert json.loads(result.stdout)==[resolve(**case) for case in cases] + + +def test_side_offsets_are_per_item_and_validated(): + document={'parts':{},'items':{'gloves':{'sides':{'left':[.01,0,.03]}}}} + validate(document) + item={'id':'gloves','part':'hands','slot':'Gloves'} + for direction in range(8): + result=resolve(document,{},item,0,direction) + assert result['sides']=={'left':[.01,0,.03]} + result['sides']['left'][0]=9 + assert resolve(document,{},dict(item,id='boots'))['sides']=={} + for bad in ({'screenLeft':[0,0,0]}, {'left':[0,0]}, {'left':[True,0,0]}, {'right':[float('nan'),0,0]}, []): + with pytest.raises(ValueError): validate({'parts':{},'items':{'gloves':{'sides':bad}}}) diff --git a/tests/unit/test_rigid_binding.py b/tests/unit/test_rigid_binding.py new file mode 100644 index 0000000..5de65f8 --- /dev/null +++ b/tests/unit/test_rigid_binding.py @@ -0,0 +1,24 @@ +import json +from pathlib import Path +import shutil +import subprocess +import pytest + + +def test_paired_meshes_keep_independent_weighted_anchors(tmp_path): + node = shutil.which('node') + if not node: + pytest.skip('Node required for browser rigid binding') + module = Path(__file__).resolve().parents[2] / 'tools/fit-lab/web/rigid-binding.mjs' + runner = tmp_path / 'check.mjs' + runner.write_text('import {dominantJoint} from ' + json.dumps(module.as_uri()) + '''; +import assert from 'node:assert/strict'; +const attr = rows => ({count:rows.length,getComponent:(i,j)=>rows[i][j]}); +const weights=attr([[.8,.2,0,0],[.7,.3,0,0]]); +const left=attr([[3,4,0,0],[3,4,0,0]]), right=attr([[8,9,0,0],[8,9,0,0]]); +assert.equal(dominantJoint(left,weights),3); +assert.equal(dominantJoint(right,weights),8); +assert.equal(dominantJoint(attr([[0,4,0,0]]),attr([[0,1,0,0]])),4); +assert.equal(dominantJoint(attr([[0,0,0,0]]),attr([[0,0,0,0]])),undefined); +''') + subprocess.run([node,str(runner)],check=True,capture_output=True,text=True) diff --git a/tests/unit/test_starter_assets.py b/tests/unit/test_starter_assets.py new file mode 100644 index 0000000..321becf --- /dev/null +++ b/tests/unit/test_starter_assets.py @@ -0,0 +1,54 @@ +import importlib.util +import json +from pathlib import Path +import struct + +import jsonschema +import pytest + +ROOT=Path(__file__).resolve().parents[2] +ASSETS=ROOT/'examples/cc0-starter' +loader=importlib.util.spec_from_file_location('starter_catalog',ROOT/'tools/uo-content/starters.py') +starters=importlib.util.module_from_spec(loader);loader.loader.exec_module(starters) + + +def test_every_layer_has_an_appropriate_starter_route(): + document=json.loads((ASSETS/'catalog.json').read_text()) + jsonschema.validate(document,json.loads((ROOT/'schemas/starter-catalog.schema.json').read_text())) + items=document['items'] + layers=json.loads((ROOT/'games/ultima-online/equipment/layers.json').read_text())['layers'] + assert {i['layer'] for i in items}=={i['id'] for i in layers} + assert len({i['id'] for i in items})==len(items) + for layer in layers: + matching=[i for i in items if i['layer']==layer['id']] + assert all(i['kind']==('internal' if layer['kind']=='internal' else 'animated' if layer['animated'] else 'paperdoll') for i in matching) + for item in items: + if item['kind']=='animated': + spec,asset=starters.settings(item['id']) + assert asset.is_file() and spec['part']==item['part'] + else: + with pytest.raises(ValueError):starters.settings(item['id']) + + +def test_bundled_sources_are_self_contained_and_mappings_validate(): + mapping=json.loads((ASSETS/'outfit-mapping.json').read_text()) + jsonschema.validate(mapping,json.loads((ROOT/'schemas/asset-pack.schema.json').read_text())) + for path in ASSETS.glob('*.glb'): + raw=path.read_bytes() + magic,version,size=struct.unpack_from('` (or set `SPRITEMOTION_FIT_PACK`). Data ## History, recovery and previews -Adjustments autosave after 800 ms of inactivity; **Save adjustments** saves immediately. Each edit is also cached in +Adjustments autosave after 800 ms of inactivity; **Save** saves immediately. Each edit is also cached in this browser for crash recovery. A slider drag is one history step. **Ctrl+Z** undoes; **Ctrl+Y** or **Ctrl+Shift+Z** redoes (Command also works on macOS). The History list jumps to any of the last 100 steps. Editing after undo discards the redo branch. Selection, playback and camera changes are not fit edits. Browser history is scoped to this pack and @@ -38,40 +38,82 @@ independently of the main viewport; **Cycle directions** advances every two seco Choosing a direction stops cycling and selects it for both views. Scrubbing the main frame also positions the preview frame. These are live fit-lab camera renders, not final Blender output; the same holdout-metric limitations apply. +## Blender render pane + +Beside the live 3D view, **Blender render** shows the selected item's real renderer output for the current animation, +locked to the main frame and direction (5–7 are mirrored from 3–1, as in the client). **Sprite sheet** shows every +frame of the 5 stored directions; click a cell to jump the lab to that pose, **Download sheet** saves it as a PNG +(item over the original UO body when **UO body** is on), and **Import package** downloads the job's VD package. +The pane uses the newest finished lab render that contains the animation, and compares that job's frozen fit with +the current saved fit: the status reads *Matches the saved fit* or *Fit changed since this render*. +**Render animation** saves, then builds the current animation (coverage `action`) in the background; progress counts +rendered frames. **Auto re-render** (remembered per browser) does this once the saved fit has been still for 3 s and +the shown render is stale or missing, once per fit and pose so a failing build is not retried in a loop. +`GET /api/renders?item=` lists finished renders for an item, newest first (`$defs.renders` in +`schemas/fit-lab-build.schema.json`); while building, `GET /api/build` adds `mode`, `started` and `progress`. + ## In the lab +### Scoped corrections and masking + +The optional `groups` and `corrections` fields extend the adjustment document without changing old saves. +Groups map names to item IDs. Each correction has `target` (pack/slot/group/item), `key` (except pack), optional +`action` (0–34), optional stored `direction` (0–4), and `fit`. Missing filters mean all poses. Offsets and Euler +angles add to the base fit; scales multiply. Resolution order is pack, slot, named groups, individual item; +within each target: all poses, action, direction, action+direction. Groups at equal specificity use name order. +Each selector is unique. Directions 5/6/7 share stored corrections with 3/2/1 respectively. +Corrections are applied to rest geometry before animation, in Blender XYZ coordinates (metres/degrees). +Use **Scoped correction** to edit deltas; the original **Slot fit** remains the base for every pose. +Choosing a pose scope pauses playback/cycling. Groups are created from checked visible items in the current slot. + +`occlusion` is clothing (limbs/head only), body (also torso), or none. Back/quiver slots default to body. +Preview overlays use a depth-tested body mask with a 1 cm margin, clipped to the original sprite alpha when present. +Content-only mode uses the same cutout; the 3D view remains a geometry inspection view. The preview still lacks +the final renderer's push-out and exact-outline edge correction, so validate final rendered frames as well. + +**Build item** renders the selected mapped source with a snapshot of saved adjustments, using preview, current-action +or full coverage. **Rebuild changed blocks** compares against that item's last successful lab build and produces a +new validated revision. Only blocks present in that build are considered; use Build item for wider coverage. +Neither operation changes the source build. Group corrections affect each member's next build; this button builds +one selected item, not an entire group. Directory-only imports are not build sources. +`POST /api/build` takes `{item, mode: build|rebuild, coverage: preview|action|full, action}`. `GET /api/build` +returns idle/building/complete/failed status, with item, job/review on success, or error on failure. `unchanged` +marks a rebuild with no changed existing blocks. `lab-builds.json` maps item IDs to last-successful job IDs. +Both contracts are described in `schemas/fit-lab-build.schema.json`. Successful reviews are served at `/builds/`. + Preview **Base** selects the original UO sprite, the animated 3D body, or transparent content only. The original is extracted from the canonical model's embedded original frames; it is a visual reference, not a final holdout render. `reference.json` follows `schemas/fit-reference.schema.json`, indexes `reference.png` by `action,frame,stored-direction`, and stays in ignored workspace data. Mirroring applies to the complete composite. Poke highlighting is separately switchable. -**Stabilize head** holds the head's local rotation and position at the selected action's first frame. +**Steady head** holds the head's local rotation and position at the selected action's first frame. Neck and body movement remains. This preview-only experiment defaults off and does not change saved fits, -the canonical rig, or builds. **Head A/B: 3 per slot** compares the first three item IDs in each slot, +the canonical rig, or builds. **Run A/B** (Measure tab) compares the first three item IDs in each slot, over every frame of checked actions and all five stored directions. The table reports poke counts; lower counts alone do not establish a better match to original artwork. Download the report for provenance. The downloadable contact sheet shows the pose with the largest content-pixel change for each item (before/after pairs); original reference pixels are composited underneath when available. JSON frame indices are zero-based, and printed contact-sheet frame numbers are one-based. Reports follow `schemas/fit-head-ab.schema.json`. -**Load asset directory** accepts a local directory of already fitted, self-contained GLBs. Select the +**Load fitted GLBs from a folder** accepts a local directory of already fitted, self-contained GLBs. Select the destination slot first. Files must have skin weights using the canonical body's bone names; raw FBX and unrigged objects must go through the pack export/fitting workflow first. Files are copied into an ignored local import cache; source files are never changed. Imports are session-only and must be loaded again after reload. `POST /api/assets` takes `{directory, slot, part}` and returns `{items, skipped}` using the existing manifest item format; `file` paths point into the cache. Limits: 100 GLBs and 50 MB each. -- **Left:** slot and its items. Tick items to show them in 3D; click one to edit its slot. -- **Centre:** the body through the UO camera (drag to orbit, **UO camera** resets), action, direction 0–7 - (5–7 mirrored like the client), frame, play. Below it, scroll across enlarged 136×120 previews of every item in the +- **Top:** pack, save status, undo/redo, save. +- **Left:** slot and its items. Tick items to show them in 3D; click one (or its preview card) to select it. +- **Centre:** the live 3D body through the UO camera (drag to orbit, **UO camera** resets) beside the Blender render + pane; below them action, direction ring 0–7 (dashed 5–7 are mirrored like the client), play and frame. Below it, scroll across enlarged 136×120 previews of every item in the slot, with independent animation and direction cycling. Magenta marks body pixels poking through. -- **Right:** +- **Right**, in tabs (Fit, Corrections, Render, Measure, History): - Slot fit: offset, rotation, scale, skinned or rigid binding, plus an offset for one item only. - Hide body under clothes: on/off, outward and inward distance. - **Measure slot:** poke pixels over every frame of the checked actions in 5 directions. The first measurement is the baseline, and each new run shows the change. - - **Save adjustments** writes `/packs//lab-adjustments.json`. + - **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 @@ -86,4 +128,30 @@ don't read them as absolutes. with `hide_body` enabled, deletes the covered body faces before rendering (`pack_fit.hide_body_under`, the lab's rule). The count is written to `scene-report.json`. -Item overrides (`items` in `lab-adjustments.json`) are not used by builds yet. +Builds snapshot item overrides and scoped corrections from `lab-adjustments.json` at job creation. + +Rigid binding selects a separate dominant bone for each mesh, including each half of paired elbow and knee +pieces. Finger articulation requires explicit aligned finger mappings; a hand-only mapping cannot close a glove. +Use `target_end` to match wrist-to-knuckle chains and optional `surface_clearance` for close-fitting shells +(see `docs/asset-packs.md`). Both the exporter and Blender builder use these settings. After a mapping change, +regenerate the GLBs and create new render jobs; existing jobs retain their frozen mapping and fits. + +## Garment depth and body hiding + +**Front/back depth** changes only the rest-pose front-to-back dimension (Blender Y), before rotation. +Use it for a shallow torso shell instead of increasing uniform Scale and widening the shoulders. +Corrections also have a depth multiplier for individual items, actions and directions. +The same transform is applied in the live preview, initial Blender scene and selective rebuilds. + +Body hiding defaults to on when no explicit mapping/saved choice is present. CC0 starter mappings +also enable it by default. An explicitly saved off value remains off. Hiding changes the fitting +body; the complete invisible body still controls camera occlusion in final renders. + +### Independent left/right item offsets + +The Fit tab provides Left/Right X, Y and Z offsets for an individual item, useful for paired gloves and boots. +These add to the shared fit in Blender XYZ metres before animation. Left/right refer to the character, +not the screen; mirrored UO directions mirror the fitted result. Target-rig bone weights select each side, +including meshes containing both limbs. Neutral bones are unchanged. Each side can be reset separately; +autosave, backups and undo/redo include these offsets. Existing saves default to zero. +The adjustment document stores these as `items..sides.left` and `.right` three-number vectors. diff --git a/tools/fit-lab/adjustments.py b/tools/fit-lab/adjustments.py index 0d03710..310ad69 100644 --- a/tools/fit-lab/adjustments.py +++ b/tools/fit-lab/adjustments.py @@ -19,27 +19,57 @@ def validate(data): def number(value): return type(value) in (int, float) and math.isfinite(value) - if not isinstance(data, dict) or set(data) != {'parts', 'items'}: + if not isinstance(data, dict) or not {'parts', 'items'} <= set(data) or not set(data) <= {'parts', 'items', 'groups', 'corrections'}: raise ValueError('Adjustments need parts and items objects.') - for section, allowed in [('parts', {'offset', 'rotate', 'scale', 'bind', 'hide_body'}), ('items', {'offset'})]: + for section, allowed in [('parts', {'offset', 'rotate', 'scale', 'depth_scale', 'bind', 'hide_body', 'occlusion'}), ('items', {'offset', 'sides'})]: if not isinstance(data[section], dict): raise ValueError(f'{section} must be an object.') for name, fit in data[section].items(): if not isinstance(name, str) or not isinstance(fit, dict) or not set(fit) <= allowed: raise ValueError('Invalid fit fields.') for key, value in fit.items(): - if key in ('offset', 'rotate'): + if key == 'sides': + valid = (isinstance(value, dict) and set(value) <= {'left', 'right'} and + all(isinstance(v, list) and len(v) == 3 and all(number(n) for n in v) for v in value.values())) + elif key in ('offset', 'rotate'): valid = isinstance(value, list) and len(value) == 3 and all(number(n) for n in value) - elif key == 'scale': + elif key in ('scale', 'depth_scale'): valid = number(value) and value > 0 elif key == 'bind': valid = value in ('skinned', 'rigid') + elif key == 'occlusion': + valid = value in ('clothing', 'body', 'none') else: valid = (isinstance(value, dict) and set(value) == {'enabled', 'outward', 'inward'} and type(value['enabled']) is bool and all(number(value[k]) and value[k] >= 0 for k in ('outward', 'inward'))) if not valid: raise ValueError(f'Invalid {key} value.') + groups = data.get('groups', {}) + if not isinstance(groups, dict) or any(not isinstance(k, str) or not isinstance(v, list) or + any(not isinstance(i, str) for i in v) or len(v) != len(set(v)) for k, v in groups.items()): + raise ValueError('Groups must contain unique item IDs.') + rules = data.get('corrections', []) + if not isinstance(rules, list): raise ValueError('Corrections must be a list.') + seen = set() + for rule in rules: + if not isinstance(rule, dict) or not {'target', 'fit'} <= set(rule) or not set(rule) <= {'target','key','action','direction','fit'}: + raise ValueError('Invalid correction fields.') + if rule['target'] not in ('pack','slot','group','item'): raise ValueError('Invalid correction target.') + if rule['target'] != 'pack' and (not isinstance(rule.get('key'), str) or not rule['key']): + raise ValueError('Correction target requires a key.') + if rule['target'] == 'group' and rule['key'] not in groups: raise ValueError('Unknown correction group.') + if rule['target'] == 'pack' and 'key' in rule: raise ValueError('Pack corrections do not have a key.') + for field, maximum in [('action',34),('direction',4)]: + if field in rule and (type(rule[field]) is not int or not 0 <= rule[field] <= maximum): + raise ValueError('Invalid correction ' + field) + identity = (rule['target'], rule.get('key'), rule.get('action'), rule.get('direction')) + if identity in seen: raise ValueError('Duplicate correction selector.') + seen.add(identity) + fit = rule['fit'] + if not isinstance(fit, dict) or not set(fit) <= {'offset','rotate','scale','depth_scale','occlusion'}: + raise ValueError('Invalid correction fit.') + validate({'parts': {'correction': fit}, 'items': {}}) return data diff --git a/tools/fit-lab/builds.py b/tools/fit-lab/builds.py new file mode 100644 index 0000000..46c04b8 --- /dev/null +++ b/tools/fit-lab/builds.py @@ -0,0 +1,73 @@ +"""One local build at a time, using frozen saved fits and durable last-successful job references.""" +import json +from pathlib import Path +import sys +import time +from threading import Lock, Thread + +from adjustments import atomic_write +from renders import RenderIndex + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tools/uo-content')) +import pipeline +from rebuild import rebuild_job + + +class Builds: + def __init__(self, data, store): + self.data, self.store = data, store + self.path = data/'lab-builds.json' + self.lock = Lock() + self.status = {'state':'idle'} + self.renders = RenderIndex(pipeline.HOME/'jobs') + + def state(self): + with self.lock: status = dict(self.status) + if status['state']=='building': + manifest = json.loads((self.data/'manifest.json').read_text()) + frames = {a['id']:a['frames'] for a in manifest['actions']} + progress = self.renders.progress(status['item'],status['started'],frames) + if progress: status['progress'] = progress + return status + + def start(self, request): + if not isinstance(request,dict) or set(request) != {'item','mode','coverage','action'}: + raise ValueError('Expected item, mode, coverage and action.') + if request['mode'] not in ('build','rebuild') or request['coverage'] not in ('preview','full','action'): + raise ValueError('Invalid build mode.') + if type(request['action']) is not int or not 0<=request['action']<=34: raise ValueError('Invalid action.') + catalog = json.loads((self.data/'lab-items.json').read_text()) + item = next((i for i in catalog['items'] if i['id']==request['item']),None) + if item is None: raise ValueError('Builds require a mapped source item; directory imports are preview-only.') + document = self.store.state()['adjustments'] + last = json.loads(self.path.read_text()) if self.path.exists() else {} + parent = last.get(item['id']) + if request['mode']=='rebuild' and not parent: raise ValueError('Build this item once before rebuilding changed blocks.') + with self.lock: + if self.status['state']=='building': raise ValueError('A lab build is already running.') + self.status = {'state':'building','item':item['id'],'mode':request['mode'],'started':time.time()} + Thread(target=self.run,args=(request,catalog,item,document,last,parent),daemon=True).start() + return self.state() + + def run(self, request, catalog, item, document, last, parent): + try: + if request['mode']=='rebuild': + if not isinstance(parent,str) or not parent.isalnum(): raise ValueError('Invalid saved job ID.') + job = rebuild_job(pipeline.HOME/'jobs'/parent,document) + else: + mapping = json.loads(Path(catalog['mapping']).read_text()) + part = next(p for p in mapping['parts'] if p['code']==item['part']) + mode = 'full' if request['coverage']=='full' else 'preview' + spec = {'name':item['id'], 'part':part['studio_part'], 'mode':mode, 'fit':'preserve', + 'source_files':item['files'], 'palette':item.get('palette'), 'pack_mapping':catalog['mapping'], + 'pack_part':item['part'], 'fit_item':{k:item[k] for k in ('id','slot','part')}, 'fit_adjustments':document} + if request['coverage']=='action': spec['actions']=[request['action']] + job = pipeline.create_job(spec,item['files'][0]); pipeline.run_job(job) + last[item['id']]=job.name + atomic_write(self.path,json.dumps(last,indent=2).encode()) + status={'state':'complete','item':item['id'],'job':job.name,'review':f'/builds/{job.name}/review/index.html', + 'unchanged':job.name==parent} + except Exception as error: + status={'state':'failed','item':item['id'],'error':str(error)} + with self.lock: self.status=status diff --git a/tools/fit-lab/export_blender.py b/tools/fit-lab/export_blender.py index 3cb9697..87ec5bc 100644 --- a/tools/fit-lab/export_blender.py +++ b/tools/fit-lab/export_blender.py @@ -84,7 +84,8 @@ def export(path, objects, animations): target = OUT / 'items' / f"{item['id']}.glb" if target.exists() and not FORCE and item['id'] in known: continue - objects = pack_fit.import_fitted({'source_files': item['files'], 'palette': item.get('palette')}, rig, pack) + objects = pack_fit.import_fitted({'source_files': item['files'], 'palette': item.get('palette'), + 'pack_part': item['part']}, rig, pack, body) pack_fit.bind(objects, rig, False) totals = {} for o in objects: diff --git a/tools/fit-lab/fit_rules.py b/tools/fit-lab/fit_rules.py new file mode 100644 index 0000000..8e7ec79 --- /dev/null +++ b/tools/fit-lab/fit_rules.py @@ -0,0 +1,34 @@ +"""Deterministic rest-pose fit resolution shared by lab builds and selective rebuilds.""" +from copy import deepcopy + + +def stored_direction(direction): + return direction if direction <= 4 else 8 - direction + + +def resolve(document, mapping, item, action=None, direction=None): + base = {**mapping, **document.get('parts', {}).get(item['part'], {})} + result = {'offset': list(base.get('offset', [0, 0, 0])), 'rotate': list(base.get('rotate', [0, 0, 0])), + 'scale': base.get('scale', 1), 'depth_scale': base.get('depth_scale', 1), 'bind': base.get('bind', 'skinned'), + 'hide_body': deepcopy(base.get('hide_body', {'enabled': True, 'outward': .02, 'inward': .01})), + 'occlusion': base.get('occlusion', 'body' if item.get('slot') in ('back', 'quiver') else 'clothing')} + result['sides'] = deepcopy(document.get('items', {}).get(item['id'], {}).get('sides', {})) + legacy = document.get('items', {}).get(item['id'], {}).get('offset', [0, 0, 0]) + result['offset'] = [a + b for a, b in zip(result['offset'], legacy)] + ranks = {'pack': 0, 'slot': 1, 'group': 2, 'item': 3} + rules = sorted(document.get('corrections', []), key=lambda r: (ranks[r['target']], + int('action' in r) + int('direction' in r), int('direction' in r), r.get('key', ''))) + for rule in rules: + target, key = rule['target'], rule.get('key') + if target == 'slot' and key != item.get('slot'): continue + if target == 'item' and key != item['id']: continue + if target == 'group' and item['id'] not in document.get('groups', {}).get(key, []): continue + if 'action' in rule and rule['action'] != action: continue + if 'direction' in rule and (direction is None or rule['direction'] != stored_direction(direction)): continue + delta = rule['fit'] + for field in ('offset', 'rotate'): + result[field] = [a + b for a, b in zip(result[field], delta.get(field, [0, 0, 0]))] + result['scale'] *= delta.get('scale', 1) + result['depth_scale'] *= delta.get('depth_scale', 1) + if 'occlusion' in delta: result['occlusion'] = delta['occlusion'] + return result diff --git a/tools/fit-lab/renders.py b/tools/fit-lab/renders.py new file mode 100644 index 0000000..758bc6f --- /dev/null +++ b/tools/fit-lab/renders.py @@ -0,0 +1,51 @@ +"""Finished Blender renders per lab item, and progress of the one running, read from content-studio job folders.""" +import json +import os +from pathlib import Path +from threading import Lock + + +def read_json(path): + try: return json.loads(Path(path).read_text(encoding='utf-8')) + except (OSError, ValueError): return None + + +class RenderIndex: + """Job id -> (item id, actions, finished time) for complete lab jobs. Job folders never change once complete.""" + + def __init__(self, jobs): + self.jobs, self.lock, self.known, self.seen = Path(jobs), Lock(), {}, set() + + def scan(self): + try: entries = [e for e in os.scandir(self.jobs) if e.is_dir()] + except FileNotFoundError: return + with self.lock: + for entry in entries: + if entry.name in self.seen: continue + status = read_json(Path(entry.path, 'status.json')) + if not status or status.get('state') not in ('complete', 'failed'): continue # retry while running + self.seen.add(entry.name) + spec, review = read_json(Path(entry.path, 'job.json')), read_json(Path(entry.path, 'review/manifest.json')) + if status['state'] != 'complete' or not spec or not review or not spec.get('fit_item'): continue + self.known[entry.name] = {'job': entry.name, 'item': spec['fit_item']['id'], + 'actions': sorted({s['action'] for s in review['sequences']}), + 'finished': Path(entry.path, 'status.json').stat().st_mtime} + + def renders(self, item): + """Newest first: the render panel shows the newest job that contains the requested action.""" + self.scan() + with self.lock: found = [dict(r) for r in self.known.values() if r['item'] == item] + return sorted(found, key=lambda r: -r['finished']) + + def progress(self, item, started, frames): + """Frames written by the newest job for item created after started; frames maps action -> frame count.""" + try: entries = [e for e in os.scandir(self.jobs) if e.is_dir() and e.stat().st_mtime >= started - 1] + except FileNotFoundError: return None + for entry in sorted(entries, key=lambda e: -e.stat().st_mtime): + spec = read_json(Path(entry.path, 'job.json')) + if not spec or spec.get('name') != item or 'rebuild' in spec: continue + blocks = spec.get('blocks') or [[a, d] for a in spec.get('actions', []) for d in range(5)] + total = sum(frames.get(a, 0) for a, _ in blocks) + done = sum(1 for _ in Path(entry.path, 'render/clothing/frames').rglob('*.png')) + return {'done': min(done, total), 'total': total} if total else None + return None diff --git a/tools/fit-lab/run.py b/tools/fit-lab/run.py index c373cfb..105009b 100644 --- a/tools/fit-lab/run.py +++ b/tools/fit-lab/run.py @@ -16,9 +16,11 @@ import sys import webbrowser from pathlib import Path -from urllib.parse import unquote, urlsplit +from urllib.parse import parse_qs, unquote, urlsplit from adjustments import AdjustmentStore, ConflictError from assets import import_directory +from builds import Builds +from service import describe HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] @@ -49,8 +51,10 @@ def export(args): def serve(args): d = data_dir(args.pack) - adjust = sidecar() / 'packs' / args.pack / 'lab-adjustments.json' + adjust = (d / 'lab-adjustments.json' if args.pack == 'cc0-starter' + else sidecar() / 'packs' / args.pack / 'lab-adjustments.json') store = AdjustmentStore(adjust) + builds = Builds(d,store) class Handler(http.server.SimpleHTTPRequestHandler): extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.mjs': 'text/javascript'} @@ -62,11 +66,19 @@ def translate_path(self, path): path = unquote(urlsplit(path).path) root = d if path.startswith('/data/') else HERE / 'web' relative = path[6:] if path.startswith('/data/') else path.lstrip('/') or 'index.html' + if path.startswith('/builds/'): + root = REPO/'workspace/ultima-online/content-studio/jobs' + relative = path.removeprefix('/builds/') target = (root / relative).resolve() return str(target) if target.is_relative_to(root.resolve()) else str(root / '__not_found__') def do_GET(self): path = urlsplit(self.path).path + if path == '/api/service': return self.reply(200, json.dumps(describe(args.pack)).encode()) + if path == '/api/build': return self.reply(200,json.dumps(builds.state()).encode()) + if path == '/api/renders': + item = parse_qs(urlsplit(self.path).query).get('item', [''])[0] + return self.reply(200, json.dumps({'renders': builds.renders.renders(item)}).encode()) if path == '/api/mapping': manifest = json.loads((d / 'manifest.json').read_text()) mapping = Path(manifest.get('mapping') or '') @@ -86,7 +98,7 @@ def do_GET(self): def do_POST(self): path = urlsplit(self.path).path - if path not in ('/api/adjustments', '/api/assets'): return self.reply(404, b'{}') + if path not in ('/api/adjustments', '/api/assets', '/api/build'): return self.reply(404, b'{}') # JSON-only requests prevent cross-origin forms from changing local fits. if self.headers.get_content_type() != 'application/json': return self.reply(415, b'{"error":"Reload the lab before saving."}') @@ -94,6 +106,7 @@ def do_POST(self): size = int(self.headers.get('Content-Length', '0')) if not 0 < size <= 2_000_000: raise ValueError('Invalid request size.') data = json.loads(self.rfile.read(size)) + if path == '/api/build': return self.reply(202,json.dumps(builds.start(data)).encode()) if path == '/api/assets': if not isinstance(data, dict) or set(data) != {'directory', 'slot', 'part'} or not all(isinstance(v, str) for v in data.values()): raise ValueError('Expected directory, slot and part strings') diff --git a/tools/fit-lab/service.py b/tools/fit-lab/service.py new file mode 100644 index 0000000..d81fc02 --- /dev/null +++ b/tools/fit-lab/service.py @@ -0,0 +1,10 @@ +"""Small, stable discovery boundary for desktop editors and webview hosts.""" + + +def describe(pack): + return { + 'schema': 'spritemotion.fit-lab-service', + 'schema_version': 1, + 'pack': pack, + 'capabilities': ['revision-saves', 'three-backups', 'scoped-fits', 'blender-builds', 'render-index'], + } diff --git a/tools/fit-lab/web/fit-rules.mjs b/tools/fit-lab/web/fit-rules.mjs new file mode 100644 index 0000000..ba138dc --- /dev/null +++ b/tools/fit-lab/web/fit-rules.mjs @@ -0,0 +1,31 @@ +export const storedDirection = direction => direction <= 4 ? direction : 8 - direction; +function compareKeys(a,b) { + const left=Array.from(a, c=>c.codePointAt(0)), right=Array.from(b, c=>c.codePointAt(0)); + for(let i=0;i v + (document.items?.[item.id]?.offset?.[i] || 0)); + const ranks = {pack:0, slot:1, group:2, item:3}; + const rules = [...(document.corrections || [])].sort((a,b) => ranks[a.target]-ranks[b.target] || + (('action' in a)+('direction' in a))-(('action' in b)+('direction' in b)) || + ('direction' in a)-('direction' in b) || compareKeys(a.key || '',b.key || '')); + for (const rule of rules) { + if (rule.target === 'slot' && rule.key !== item.slot) continue; + if (rule.target === 'item' && rule.key !== item.id) continue; + if (rule.target === 'group' && !document.groups?.[rule.key]?.includes(item.id)) continue; + if ('action' in rule && rule.action !== action) continue; + if ('direction' in rule && (direction === null || rule.direction !== storedDirection(direction))) continue; + for (const field of ['offset','rotate']) result[field] = result[field].map((v,i) => v + (rule.fit[field]?.[i] || 0)); + result.scale *= rule.fit.scale ?? 1; + result.depth_scale *= rule.fit.depth_scale ?? 1; + if (rule.fit.occlusion) result.occlusion = rule.fit.occlusion; + } + return result; +} diff --git a/tools/fit-lab/web/index.html b/tools/fit-lab/web/index.html index ed7245f..df6228b 100644 --- a/tools/fit-lab/web/index.html +++ b/tools/fit-lab/web/index.html @@ -4,6 +4,8 @@ Fit Lab + + +
+ +
+

Loading saved adjustments…

+ + +
+
+ -
-
+ +
+
+
+

Live 3D

Drag to orbit + +
+
+
+
+

Blender render

+
+ +
+ Not rendered
+
+ +
+ + +
+
+ + + + + + +
+
+
+
- - + +
+ - 0 - - - + 0 +
-
-

Live slot previews

-
- - - - - - - + +
+
+

Slot previews

+
+ + + + + + + +
-

-

Head stabilization holds its local first-frame pose; neck/body motion remains. Preview only.

-

Live UO-camera fit preview · magenta = body poke pixels · final Blender rendering may differ

+

Live camera previews; magenta marks body poke-through. The Blender render above is the final word.

-
+
+ diff --git a/tools/uo-content/occlusion.py b/tools/uo-content/occlusion.py new file mode 100644 index 0000000..1a8d5c3 --- /dev/null +++ b/tools/uo-content/occlusion.py @@ -0,0 +1,103 @@ +"""Camera-depth holdout with bounded, side-specific garment contact tolerance. + +The occluder is a pristine animated body, independent of hide-body geometry. +Only the nearest garment surface can claim a contact exemption, and only +against the same anatomical region within the configured penetration depth. +""" +import numpy as np + + +def region(name): + base, dot, side = name.partition('.') + base = {'upper_arm_twist': 'upper_arm', 'forearm_twist': 'forearm', + 'toe': 'foot'}.get(base, base) + if base.startswith('finger'): + base = 'hand' + return base + (dot + side if dot else '') + + +def triangle_regions(obj): + """Dominant summed skin weight per triangle, preserving left/right identity.""" + mesh = obj.data + mesh.calc_loop_triangles() + names = [region(g.name) for g in obj.vertex_groups] + result = [] + for tri in mesh.loop_triangles: + weights = {} + for index in tri.vertices: + for group in mesh.vertices[index].groups: + name = names[group.group] + weights[name] = weights.get(name, 0.0) + group.weight + result.append(max(weights, key=weights.get) if weights else '') + return np.asarray(result) + + +def blocked_pixels(body_depth, item_depth, margin, contacts=(), tolerance=0.0): + """A contact is a pair of depth maps for ONE matching body/item region. + + Maps belonging to a hidden rear item must not exempt a nearer visible item. + Infinite background depths never establish contact. + """ + allowance = np.full(body_depth.shape, margin, dtype=float) + for own_body, own_item in contacts: + same_surface = (np.isfinite(own_body) & np.isfinite(own_item) + & np.isclose(own_body, body_depth, rtol=0, atol=1e-6) + & np.isclose(own_item, item_depth, rtol=0, atol=1e-6)) + allowance[same_surface] = max(margin, tolerance) + return np.isfinite(body_depth) & (body_depth + allowance < item_depth) + + +class BodyHoldout: + def __init__(self, body, pristine_mesh, objects): + import bpy + self.body = body.copy() + self.body.data = pristine_mesh + self.body.name = 'SpriteMotion_DepthBody' + bpy.context.scene.collection.objects.link(self.body) + self.body.hide_render = True + self.body_regions = triangle_regions(self.body) + self.items = [(obj, triangle_regions(obj)) for obj in objects] + self.mode = 'clothing' + self.tolerance = 0.0 + + def configure(self, mode, hide): + self.mode = mode + self.tolerance = float(hide.get('inward', .01)) if hide.get('enabled') else 0.0 + + def render(self, env, free, margin): + raster = env['raster'] + coverage, body_depth = raster([self.body]) + if self.mode == 'none': + return free.copy(), coverage + visible = [(obj, labels) for obj, labels in self.items if not obj.hide_render] + _, item_depth = raster([obj for obj, _ in visible]) + # Match the renderer's treatment of antialiased edge samples missed by + # pixel-centre rasterization. Missing samples receive no own-part exemption. + seen = free[..., 3] >= .5 + for _ in range(2): + missing = seen & ~np.isfinite(item_depth) + if not missing.any(): + break + h, w = item_depth.shape + pad = np.pad(item_depth, 1, constant_values=np.inf) + neighbours = np.minimum.reduce([pad[y:y+h, x:x+w] for y in range(3) for x in range(3)]) + item_depth = np.where(missing, neighbours, item_depth) + + def contacts(): + if self.mode != 'clothing' or self.tolerance <= margin: + return + for name in sorted(set(self.body_regions) - {''}): + matching = [(obj, labels == name) for obj, labels in visible if np.any(labels == name)] + if not matching: + continue + _, own_body = raster([self.body], self.body_regions == name) + own_item = np.full(item_depth.shape, np.inf) + for obj, mask in matching: + _, depth = raster([obj], mask) + own_item = np.minimum(own_item, depth) + yield own_body, own_item + + blocked = blocked_pixels(body_depth, item_depth, margin, contacts(), self.tolerance) + hold = free.copy() + hold[blocked] = 0 + return hold, coverage diff --git a/tools/uo-content/pack_fit.py b/tools/uo-content/pack_fit.py index 7c9b20f..eea11b1 100644 --- a/tools/uo-content/pack_fit.py +++ b/tools/uo-content/pack_fit.py @@ -14,7 +14,7 @@ def load_pack(path): return json.loads(Path(path).read_text(encoding='utf-8-sig')) -def import_fitted(spec, rig, pack): +def import_fitted(spec, rig, pack, body=None): bones = pack['bones'] aligned = {name: b for name, b in bones.items() if 'end' in b} mapping = {name: b['target'] for name, b in aligned.items()} @@ -25,7 +25,10 @@ def import_fitted(spec, rig, pack): result = [] for filename in spec['source_files']: before = set(bpy.data.objects) - bpy.ops.import_scene.fbx(filepath=filename) + if Path(filename).suffix.lower() in ('.glb', '.gltf'): + bpy.ops.import_scene.gltf(filepath=filename) + else: + bpy.ops.import_scene.fbx(filepath=filename) imported = set(bpy.data.objects)-before arm = next(o for o in imported if o.type == 'ARMATURE') arm.data.pose_position = 'REST' @@ -40,8 +43,17 @@ def resolve(name): start = arm.matrix_world@arm.data.bones[source].head_local endname = aligned[source]['end'] endpoint = (arm.matrix_world@arm.data.bones[endname].head_local) if endname and endname in arm.data.bones else start+missing_end + if not endname and aligned[source].get('end_from_parent'): + parent = arm.data.bones[source].parent + if parent is None: + raise ValueError(f'{source}: end_from_parent requires a parent joint') + endpoint = start + (start - arm.matrix_world @ parent.head_local) dest = rig.data.bones[target] - a = endpoint-start; b = dest.tail_local-dest.head_local + target_end = aligned[source].get('target_end') + finish = rig.data.bones[target_end].head_local if target_end else dest.tail_local + a = endpoint-start; b = finish-dest.head_local + if a.length < 1e-8 or b.length < 1e-8: + raise ValueError(f'{source}: aligned chain has zero length') # Keep torso/head vertical orientation; FBX bone roll is unrelated to anatomy. if source in keep: rotation = Matrix.Identity(4); scale = 1.0 @@ -49,7 +61,9 @@ def resolve(name): rotation = a.rotation_difference(b).to_matrix().to_4x4() scale = max(low, min(high, b.length/a.length)) transforms[source] = Matrix.Translation(dest.head_local)@rotation@Matrix.Scale(scale, 4)@Matrix.Translation(-start) - for ob in [o for o in imported if o.type == 'MESH']: + # glTF creates mesh objects used only as bone display shapes; they are not equipment. + bone_shapes = {p.custom_shape for p in arm.pose.bones if p.custom_shape is not None} + for ob in [o for o in imported if o.type == 'MESH' and o not in bone_shapes]: # The base shape is the input; source morphs use the old coordinate system. if ob.data.shape_keys: ob.shape_key_clear() groups = {g.index: resolve(g.name) for g in ob.vertex_groups} @@ -85,9 +99,28 @@ def resolve(name): for image in list(bpy.data.images): if image.source == 'FILE' and not image.packed_file and image.filepath and not Path(bpy.path.abspath(image.filepath)).exists(): bpy.data.images.remove(image) + part = next((p for p in pack.get('parts', []) if p['code'] == spec.get('pack_part')), {}) + if part.get('surface_clearance') is not None: + if body is None: + raise ValueError('surface_clearance requires a rest-pose body mesh') + clear_surface(result, body, rig, part['surface_clearance']) return result +def clear_surface(objects, body, rig, clearance): + """Project nearby penetrations onto the unposed body, in target-rig coordinates.""" + from mathutils.bvhtree import BVHTree + transform = rig.matrix_world.inverted() @ body.matrix_world + points = [transform @ v.co for v in body.data.vertices] + tree = BVHTree.FromPolygons(points, [list(p.vertices) for p in body.data.polygons]) + for ob in objects: + for vertex in ob.data.vertices: + hit, normal, _, distance = tree.find_nearest(vertex.co) + if hit is not None and distance <= .05 and (vertex.co - hit).dot(normal) < clearance: + vertex.co = hit + normal * clearance + ob.data.update() + + def bind(objects, rig, rigid=False): if rigid: for o in objects: diff --git a/tools/uo-content/pipeline.py b/tools/uo-content/pipeline.py index 821ab9e..3cfd442 100644 --- a/tools/uo-content/pipeline.py +++ b/tools/uo-content/pipeline.py @@ -30,6 +30,13 @@ def sha(path): h.update(b) return h.hexdigest() + +def render_fingerprint(): + paths = [HERE/name for name in ('blender_build.py','fit_runtime.py','occlusion.py','pack_fit.py')] + paths += [ROOT/'tools/fit-lab/fit_rules.py'] + paths += [BACKEND/'pipeline'/name for name in ('render_uo_layer.py','uo_bind_item.py','uo_vd_writer.py')] + return hashlib.sha256(''.join(sha(path) for path in paths).encode()).hexdigest() + def setup(source): source = Path(source).resolve() files = ['model/UO_Body_0x190.blend', 'pipeline/uo_bind_item.py', @@ -70,7 +77,16 @@ def normalize(spec): if mode not in ('preview', 'full'): raise ValueError('Build mode must be preview or full.') spec['mode'] = mode - spec['actions'] = [0, 4, 9, 22, 25] if mode == 'preview' else list(range(35)) + spec.setdefault('actions', [0, 4, 9, 22, 25] if mode == 'preview' else list(range(35))) + if not isinstance(spec['actions'], list) or not spec['actions'] or any(type(a) is not int or not 0 <= a <= 34 for a in spec['actions']) or len(set(spec['actions'])) != len(spec['actions']): + raise ValueError('Actions must be unique IDs from 0 to 34.') + if 'blocks' in spec: + blocks = spec['blocks'] + if not isinstance(blocks, list) or not blocks or any(not isinstance(b, list) or len(b)!=2 or + type(b[0]) is not int or b[0] not in spec['actions'] or type(b[1]) is not int or not 0<=b[1]<=4 for b in blocks): + raise ValueError('Blocks must be action/stored-direction pairs within the selected actions.') + if len(set(map(tuple,blocks))) != len(blocks): raise ValueError('Duplicate blocks.') + if spec.get('occlusion', 'clothing') not in ('clothing','body','none'): raise ValueError('Invalid occlusion mode.') for field, default, low, high in [('scale', 1., .01, 100.), ('offset_x', 0., -3., 3.), ('offset_y', 0., -3., 3.), ('offset_z', 0., -3., 3.), ('rotate_x', 0., -360., 360.), ('rotate_y', 0., -360., 360.), ('rotate_z', 0., -360., 360.)]: @@ -92,12 +108,44 @@ def normalize(spec): spec['color'] = color return spec +def snapshot_fit(spec): + """Freeze local adjustments at job creation, never let a running job read changing UI saves.""" + sys.path.insert(0, str(ROOT / 'tools/fit-lab')) + from adjustments import validate + if 'fit_adjustments' in spec: + validate(spec['fit_adjustments']) + elif spec.get('pack_mapping'): + saved = Path(spec['pack_mapping']).parent / 'lab-adjustments.json' + if saved.exists(): spec['fit_adjustments'] = validate(json.loads(saved.read_text())) + document = spec.get('fit_adjustments', {'parts':{},'items':{}}) + if 'fit_item' not in spec and spec.get('pack_mapping'): + sources = {str(Path(p).resolve()).casefold() for p in spec.get('source_files', [])} + matches = [] + for path in (ROOT / 'workspace/ultima-online/fit-lab').glob('*/lab-items.json'): + catalog = json.loads(path.read_text()) + if Path(catalog.get('mapping','')).resolve() != Path(spec['pack_mapping']).resolve(): continue + matches.extend(i for i in catalog['items'] if sources and sources == {str(Path(p).resolve()).casefold() for p in i['files']}) + if len(matches) == 1: spec['fit_item'] = {k: matches[0][k] for k in ('id','slot','part')} + item = spec.get('fit_item') + if item is not None and (not isinstance(item,dict) or any(not isinstance(item.get(k),str) or not item[k] for k in ('id','slot','part'))): + raise ValueError('fit_item requires id, slot and part strings.') + # Zero item offsets are left behind by the lab's item sliders; only real per-item/slot data needs an identity. + item_offsets = any(any(v.get('offset', [0, 0, 0])) for v in document.get('items', {}).values()) + if item is None and (item_offsets or any(r['target']!='pack' for r in document.get('corrections',[]))): + raise ValueError('Provide fit_item so item/group/slot corrections can be applied reliably.') + + def create_job(spec, asset=None): spec = normalize(spec) + snapshot_fit(spec) if not (BACKEND / 'provenance.json').exists(): raise ValueError('Install the v13 backend first: pipeline.py setup --source ') job = HOME / 'jobs' / uuid.uuid4().hex[:12] job.mkdir(parents=True) + if spec.get('pack_mapping'): + frozen = job / 'pack-mapping.json' + shutil.copy2(spec['pack_mapping'], frozen) + spec['pack_mapping'] = str(frozen) if asset: asset = Path(asset).resolve() if asset.suffix.lower() not in MODELS | IMAGES: @@ -120,6 +168,9 @@ def create_job(spec, asset=None): 'procedural item template configured by text and controls') spec['backend'] = str(BACKEND) spec['backend_sha256'] = sha(BACKEND / 'model/UO_Body_0x190.blend') + spec['render_fingerprint'] = render_fingerprint() + spec['source_fingerprints'] = {str(Path(path).resolve()):sha(path) for path in + [*spec.get('source_files',[]), *([spec['palette']] if spec.get('palette') else [])]} spec['job'] = str(job) write_json(job / 'job.json', spec) write_json(job / 'status.json', dict(state='queued', name=spec['name'])) @@ -155,7 +206,7 @@ def finish(job): vd = job / 'item.vd' counts = inspect_vd(vd) expected = json.loads((job / 'scene-report.json').read_text(encoding='utf-8'))['actions'] - intended = {(a, d): expected[str(a)] for a in spec['actions'] for d in range(5)} + intended = {(a, d): expected[str(a)] for a,d in spec.get('blocks', [(a,d) for a in spec['actions'] for d in range(5)])} actual = {(b['action'], b['dir']): len(b['frames']) for b in meta['blocks'] if b['frames']} if actual != intended or counts != intended: raise ValueError('Animation identities/frame counts differ between scene, PNGs and VD.') @@ -265,11 +316,16 @@ def main(): s = sub.add_parser('setup'); s.add_argument('--source', required=True) b = sub.add_parser('build'); b.add_argument('--spec', required=True); b.add_argument('--asset') f = sub.add_parser('finish'); f.add_argument('job') + r = sub.add_parser('rebuild'); r.add_argument('job'); r.add_argument('--adjustments',required=True) args = p.parse_args() if args.command == 'setup': print(setup(args.source)) elif args.command == 'finish': finish(Path(args.job).resolve()) + elif args.command == 'rebuild': + sys.path.insert(0,str(ROOT/'tools/fit-lab')) + from rebuild import rebuild_job + print(rebuild_job(args.job,json.loads(Path(args.adjustments).read_text())),flush=True) else: job = create_job(json.loads(Path(args.spec).read_text(encoding='utf-8')), args.asset) print(job, flush=True) diff --git a/tools/uo-content/rebuild.py b/tools/uo-content/rebuild.py new file mode 100644 index 0000000..1908ed6 --- /dev/null +++ b/tools/uo-content/rebuild.py @@ -0,0 +1,88 @@ +"""Render changed blocks into a new validated revision; never modify the source job.""" +import hashlib +import json +from pathlib import Path +import shutil +import struct +import uuid + + +def merge_vd(source, patch, output): + from client_import import vd_blocks + original, replacement = vd_blocks(source), vd_blocks(patch) + if not set(replacement) <= set(original): raise ValueError('Patch contains blocks missing from the source build.') + blocks = {**original, **replacement} + header = bytearray(struct.pack('. Available: ' + ', '.join(packs)) + if not args.pack or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' for c in args.pack): + parser.error('Invalid pack name.') + if not (catalogs / args.pack / 'manifest.json').is_file(): + if args.pack != 'cc0-starter': + parser.error('This pack needs a Fit Lab export first. See tools/fit-lab/README.md.') + print('Preparing bundled starters for Fit Lab (requires your supplied UO model and Blender).',flush=True) + subprocess.run([sys.executable,str(ROOT/'tools/starter-assets/run.py'),'--prepare-lab'],cwd=ROOT,check=True) + services = [ + (8772, '/api/config', lambda d: isinstance(d, dict) and 'ready' in d and 'parts' in d, + ['tools/uo-content/studio.py']), + (8774, '/api/service', lambda d: isinstance(d, dict) and d.get('schema') == 'spritemotion.fit-lab-service' and d.get('pack') == args.pack, + ['tools/fit-lab/run.py', 'serve', '--pack', args.pack, '--no-browser']), + ] + children = [] + try: + # Verify both ports before starting anything. + running = [probe(port, endpoint, match) for port, endpoint, match, _ in services] + for active, (port, endpoint, match, command) in zip(running, services): + if not active: + process = subprocess.Popen([sys.executable, *command], cwd=ROOT) + children.append(process) + for _ in range(100): + if process.poll() is not None: + raise RuntimeError(f'Service on port {port} exited during startup.') + if probe(port, endpoint, match): + break + time.sleep(.1) + else: + raise RuntimeError(f'Service on port {port} did not become ready.') + print('Content Studio: http://127.0.0.1:8772/\nFit Lab: http://127.0.0.1:8774/\nCtrl+C stops services started by this launcher.', flush=True) + if not args.no_browser: + webbrowser.open('http://127.0.0.1:8772/') + while children: + if any(child.poll() is not None for child in children): + raise RuntimeError('A workbench service stopped. See its output above.') + time.sleep(.5) + except KeyboardInterrupt: + return 0 + finally: + for child in children: + if child.poll() is None: + child.terminate() + for child in children: + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except RuntimeError as exc: + sys.exit(str(exc))