diff --git a/.agents/skills/blacksite-build-storage/SKILL.md b/.agents/skills/blacksite-build-storage/SKILL.md new file mode 100644 index 0000000..47b2778 --- /dev/null +++ b/.agents/skills/blacksite-build-storage/SKILL.md @@ -0,0 +1,23 @@ +--- +name: blacksite-build-storage +description: Keep Blacksite Cargo and package build artifacts within a configured disk budget by selecting stable build lanes, measuring target/build directories, pruning only verified disposable caches, and preserving warm development performance. Use before or after heavy builds, when disk use grows, or when compile performance degrades from artifact accumulation. +--- + +# Blacksite build storage + +Read `.codex/workflow.toml` and [the storage policy](references/build-storage-policy.md). Use `scripts/codex/cargo_lane.py` for every Cargo invocation and `scripts/codex/build_storage.py` for measurement or cleanup. + +Trigger this Skill for candidate, all-feature, all-target, package, hot-reload, full-debug, cache-growth, low-space, unexplained rebuild, and cleanup requests. + +- Keep one persistent `dev` lane. Candidate, hot-reload, full-debug, cross-target, and package lanes are exceptional and disposable. +- Use stable separate `CARGO_BUILD_BUILD_DIR` when supported; otherwise use bounded external `CARGO_TARGET_DIR` lanes. +- Account for repository final targets, external intermediates, package caches, Cargo timings/docs, free space, and an already-enabled `sccache`. +- Run storage preflight/postflight around heavy gates. When configured, pressure may prune only + expired disposable lanes after their complete checked plan is printed and flushed. +- Require valid sentinels, canonical workspace identity, no symlink traversal, and no active Cargo/rustc/linker/packager before deletion. +- Delete whole managed lanes only. Never delete individual Cargo internal files and never install cleanup/cache tools without explicit approval. +- Every apply prints and flushes its complete byte-counted checked plan before deletion begins. +- Preserve candidate binaries/packages and evidence outside the disposable lane, then establish the + generation-bound marker with `build_storage.py mark-candidate-preserved`; an absent, stale, or + hash-mismatched marker blocks candidate deletion. +- Report reclaimed bytes plus expected cold-build impact. diff --git a/.agents/skills/blacksite-build-storage/references/build-storage-policy.md b/.agents/skills/blacksite-build-storage/references/build-storage-policy.md new file mode 100644 index 0000000..9a3956c --- /dev/null +++ b/.agents/skills/blacksite-build-storage/references/build-storage-policy.md @@ -0,0 +1,27 @@ +# Build-storage policy + +The tracked limits live in `.codex/workflow.toml`. Defaults are a 55 GiB soft total, 80 GiB hard total, at least 40 GiB or 15% free space, a 40 GiB persistent-lane ceiling, and a 35 GiB ceiling per disposable lane. + +The canonical workspace path is hashed so `/home/.../Bevy` and `/mnt/Fedora/.../Bevy` share one cache identity. Managed lanes carry a sentinel with workspace path/hash, lane, toolchain, feature/profile signature, target/build directories, and timestamps. + +Enforcement order: + +1. Report total/per-lane size and largest children. +2. Under storage pressure, print the checked byte plan and prune expired marked disposable lanes. +3. Prune stale package intermediates after preserving published output. +4. Remove non-evidence Cargo timing/doc output. +5. Block a new heavy gate if the hard limit or free-space floor still fails. +6. Reset the persistent lane only at a safe slice boundary, after a dry run and verification-ledger update. + +`enforce --phase pre|post` automatically prunes only when tracked policy enables it, storage pressure +exists, and a disposable lane has expired. Automatic and explicit prune/reset paths first print and +flush the complete byte-counted plan, including sentinel, path, process, and preservation checks, +before deleting any lane. A candidate lane additionally requires +`.codex/session/candidate-prune-safe.json`, created only after preserved evidence and at least one +distinct binary/package file have been hashed with `mark-candidate-preserved`; any later candidate +lane use makes that generation-bound marker stale. + +Never prune an active lane, an unmarked directory, a symlink, another workspace, `/`, home, Cargo +home, the workspace root, `.git`, or a parent of any protected path. `cargo clean` is not a routine +operation; the one-time legacy-target migration is the sole exception documented by the workflow +installation record. diff --git a/.agents/skills/blacksite-doc-integrity/SKILL.md b/.agents/skills/blacksite-doc-integrity/SKILL.md new file mode 100644 index 0000000..f5a4d58 --- /dev/null +++ b/.agents/skills/blacksite-doc-integrity/SKILL.md @@ -0,0 +1,21 @@ +--- +name: blacksite-doc-integrity +description: Prevent stale or contradictory Blacksite documentation from becoming design context by enforcing documentation authority, archiving superseded plans, comparing changed behavior with canonical docs and accepted ADRs, and running the documentation audit. +--- + +# Blacksite documentation integrity + +Read `.codex/workflow.toml`, `docs/authority.toml`, and [the authority reference](references/documentation-authority.md) before treating repository prose as design guidance. + +Trigger this Skill for user-visible behavior, schema/command/component renames, architectural changes, candidate preparation, documentation requests, or discovered contradictions. + +1. Identify the changed public behavior from code and focused tests. +2. Load only the canonical docs for its topic and relevant accepted ADRs. +3. Read the exact Gitea acceptance criteria when tracked. +4. Search historical material only for contradictions, never as authority. +5. Write a bounded `.codex/session/doc-contradictions.md` listing claim, source, current truth, and action. +6. Update the smallest canonical owner at the stable slice boundary, within the same task. +7. Mark completed plans historical and freeze their substantive contents. Add required banners and replacement links. +8. Run `python scripts/codex/docs_audit.py --changed`, then the full audit at a documentation slice gate. + +Keep README as an overview. Do not duplicate detailed contracts across guides, plans, ADRs, and tracker bodies. Coordinate a dry-run Gitea scope/status update when tracker truth has drifted. diff --git a/.agents/skills/blacksite-doc-integrity/references/documentation-authority.md b/.agents/skills/blacksite-doc-integrity/references/documentation-authority.md new file mode 100644 index 0000000..fc70f79 --- /dev/null +++ b/.agents/skills/blacksite-doc-integrity/references/documentation-authority.md @@ -0,0 +1,16 @@ +# Documentation authority + +`docs/authority.toml` is the machine-readable registry. Roles mean: + +- `canonical`: current user or developer behavior. +- `architecture`: accepted decisions and constraints. +- `overview`: a high-level entry point linking to canonical owners. +- `active-plan`: approved incomplete work. +- `evidence`: dated acceptance or evaluation, not product specification. +- `historical`: preserved record no longer used as guidance. +- `superseded`: replaced material that links to its replacement. +- `draft`: non-authoritative work. + +Code, schemas, tests, and deterministic output define actual behavior. Accepted ADRs define architecture. Gitea defines accepted current scope and work state. Canonical docs describe shipped/current workflows. When they conflict, identify and reconcile the conflict; never silently combine old and new claims. + +Historical, evidence, and superseded files require a visible warning and current canonical link. Active plans require an incomplete-work banner and shipped-behavior link. Session state and raw logs are never documentation. diff --git a/.agents/skills/blacksite-editor-ux/SKILL.md b/.agents/skills/blacksite-editor-ux/SKILL.md new file mode 100644 index 0000000..f37a6fb --- /dev/null +++ b/.agents/skills/blacksite-editor-ux/SKILL.md @@ -0,0 +1,14 @@ +--- +name: blacksite-editor-ux +description: Design and evaluate Blacksite editor UI/UX changes using a production editor interaction rubric, explicit state matrices, existing visual language, and native acceptance criteria. Use for Content Browser, Inspector, viewport, dialogs, material UI, and other interactive editor work. +--- + +# Blacksite editor UX + +Read [the editor UX rubric](references/editor-ux-rubric.md). Before implementing meaningful UI behavior, record a compact interaction-state matrix in session state or the active plan. + +Cover only relevant states, but explicitly consider empty, single/multi-selection, narrow/resized, invalid/broken, inherited/read-only/built-in, loading, confirmation, cancel/failure, undo/redo, and restart persistence. + +Keep egui rendering thin and reuse established components, spacing, icons, cards, badges, drag/drop feedback, and editor language. Move reusable models and transactions to the lightest owning module/crate. Keep IDs, fingerprints, provenance, and implementation-owned data out of primary editing surfaces. + +Create named native scenarios and hand them to `blacksite-native-qa`. Unit tests and screenshots alone are not acceptance; exercise the interaction. If the user says behavior still does not work properly, return to acceptance-in-progress and record a scope delta rather than treating it as unrelated by default. diff --git a/.agents/skills/blacksite-editor-ux/references/editor-ux-rubric.md b/.agents/skills/blacksite-editor-ux/references/editor-ux-rubric.md new file mode 100644 index 0000000..18a47dc --- /dev/null +++ b/.agents/skills/blacksite-editor-ux/references/editor-ux-rubric.md @@ -0,0 +1,29 @@ +# Blacksite editor UX rubric + +## Product intent + +- What familiar editor or file-manager interaction is being matched? +- What is the primary action and what must be obvious without documentation? +- What happens on invalid input? + +## Interaction matrix + +Include relevant rows for mouse click, double click, right click, shortcut, drag start/hover/drop, multi-selection, empty-space action, narrow panel, modal/review, cancel, undo/redo, and restart. + +## Visual design + +- Reuse current typography, spacing, icons, cards, and status badges. +- Provide visible hover, drop, focus, invalid, and disabled states. +- Avoid duplicated controls and competing presentations. +- Hide managed/internal data unless diagnosing a failure. +- Support resizing and narrow layouts. +- Distinguish inherited/default/read-only state from explicit editable state. + +## Acceptance + +- Focused logic tests in the lightest owning crate. +- Focused UI wiring tests when editor-owned state matters. +- Native interaction against the intended build/profile. +- Targeted screenshot or recording of the relevant state. +- Cancel/failure proof and saved-state restart proof when applicable. +- Canonical documentation update after the behavior stabilizes. diff --git a/.agents/skills/blacksite-gitea-sync/SKILL.md b/.agents/skills/blacksite-gitea-sync/SKILL.md new file mode 100644 index 0000000..9728ed7 --- /dev/null +++ b/.agents/skills/blacksite-gitea-sync/SKILL.md @@ -0,0 +1,20 @@ +--- +name: blacksite-gitea-sync +description: Read and synchronize exact Blacksite Gitea issues, epics, and milestones with current scope, implementation state, verification evidence, canonical documentation, and release-candidate status while avoiding duplicate issues and noisy updates. +--- + +# Blacksite Gitea synchronization + +Read `.codex/workflow.toml` and [the state model](references/gitea-state-model.md). Use the configured Gitea connector; do not scrape or invent tracker state. + +Supported modes are `read`, `plan`, `status`, `scope-delta`, `candidate`, and `close`. + +1. Read exact known issue, parent epic, milestone, and recent relevant comments first. Search broadly only to locate an unknown item or prevent a duplicate. +2. Summarize acceptance locally and create a dry-run mutation plan before every write. +3. Follow configured permissions. Creation and scope/milestone changes require explicit authority; closure is candidate-only; delete/reopen is explicit. +4. Add at most one meaningful status comment per completed slice. +5. Distinguish working-tree implementation from engineering-complete, acceptance-in-progress, candidate-ready, and closure evidence. +6. Mark acceptance only when code, focused tests, and required native/package evidence exist and remain valid. +7. On failure, re-read the exact item, detect partial success, and retry only the missing operation. + +Never push or close as a side effect. Record remote mutations and their readback in session state. diff --git a/.agents/skills/blacksite-gitea-sync/references/gitea-state-model.md b/.agents/skills/blacksite-gitea-sync/references/gitea-state-model.md new file mode 100644 index 0000000..14c700e --- /dev/null +++ b/.agents/skills/blacksite-gitea-sync/references/gitea-state-model.md @@ -0,0 +1,14 @@ +# Gitea state model + +Gitea owns accepted scope, criteria, priority, dependencies, work state, candidate/evidence links, blockers, and milestone composition. It does not replace code, tests, ADRs, canonical docs, or native evidence. + +States: + +- `Implementing`: active local work. +- `Engineering-complete`: relevant engineering gates pass; native/product acceptance may remain. +- `Acceptance-in-progress`: native or stakeholder validation is active or exposed a gap. +- `Candidate-ready`: stable scope and a nominated tree are ready for remaining release gates. +- `Blocked`: an external condition prevents progress. +- `Closed`: candidate evidence and every criterion are accepted. + +User-discovered UX gaps return an item to acceptance-in-progress and become scope deltas, not automatic new issues. Close children, final signoff, epic, then milestone. Never close a milestone with open assigned issues unless the user explicitly changes its definition. diff --git a/.agents/skills/blacksite-native-qa/SKILL.md b/.agents/skills/blacksite-native-qa/SKILL.md new file mode 100644 index 0000000..c842f0b --- /dev/null +++ b/.agents/skills/blacksite-native-qa/SKILL.md @@ -0,0 +1,18 @@ +--- +name: blacksite-native-qa +description: Run deterministic named Blacksite editor or packaged-runtime scenarios on the actual desktop session, target the exact window, capture concise evidence, and restore test fixtures. Use only when visual or interactive acceptance is required. +--- + +# Blacksite native QA + +Use only when the user requests native/visual QA or the active acceptance target explicitly requires it. Read `.codex/workflow.toml` and [the scenario format](references/scenario-format.md), then use `scripts/codex/native_qa.sh`. + +- Build through `cargo_lane.py` in the named lane and resolve binary, intermediate `deps`, runtime environment, and PID from lane JSON. Never hard-code `target/debug/deps`. +- Target the exact PID/window/class/workspace. Bring offscreen dialogs on-screen and prefer accessibility or window-relative input over desktop coordinates. +- Capture only the target window or relevant crop under `.codex/evidence/`, with a hash and result record. +- Reuse one editor session while code is unchanged. +- Record fixture mutations and restore/trash them transactionally. +- Close processes spawned by the scenario. +- Keep full compositor/accessibility dumps out of the task unless they explain a failure. + +Do not claim acceptance from a screenshot alone. Exercise the named steps, invalid/cancel path, and persistence where required. diff --git a/.agents/skills/blacksite-native-qa/references/scenario-format.md b/.agents/skills/blacksite-native-qa/references/scenario-format.md new file mode 100644 index 0000000..078676b --- /dev/null +++ b/.agents/skills/blacksite-native-qa/references/scenario-format.md @@ -0,0 +1,41 @@ +# Named native scenario format + +Scenario files use these top-level fields: + +```yaml +name: +purpose: +preconditions: +build_lane: +fixture: +steps: +assertions: +evidence: +cleanup: +invalidates: +``` + +Keep steps deterministic and phrased as user actions. Assertions describe visible behavior, state transitions, persistence, and failure recovery. Evidence names the target window/crop and expected files. Cleanup must identify every spawned process and fixture mutation. + +The runner exposes the scenario instead of pretending manual interactions are automated: + +```bash +bash scripts/codex/native_qa.sh plan +bash scripts/codex/native_qa.sh launch +bash scripts/codex/native_qa.sh attach +bash scripts/codex/native_qa.sh capture +bash scripts/codex/native_qa.sh record pass|fail [note] +bash scripts/codex/native_qa.sh status +bash scripts/codex/native_qa.sh close +bash scripts/codex/native_qa.sh restore --dry-run +bash scripts/codex/native_qa.sh restore --apply +``` + +`launch` snapshots a workspace-contained regular-file fixture before the editor starts; `attach` +does the same while reusing an existing editor and never closes that user-owned process. `restore --apply` +prints and flushes the baseline/current hash plan, refuses to run while the recorded process +is alive, validates the snapshot hash, and restores atomically. A scenario is accepted only when all +named assertions are recorded as passing and the required evidence exists; visual interaction remains +user-controlled unless explicitly delegated. + +Recurring scenario families include Content Browser item/empty-space menus, multi-selection, destination-first import, move/reference repair, duplicate/undo/derived cleanup, material-slot assignment/inheritance, DefaultGrid Forward/Solari, and package launch. diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml new file mode 100644 index 0000000..9e15714 --- /dev/null +++ b/.agents/skills/blacksite-native-qa/references/scenarios/content-browser-card-layout.yaml @@ -0,0 +1,20 @@ +name: content-browser-card-layout +purpose: Verify production-quality grid/list card layout and model-thumbnail identity at wide and narrow browser sizes. +preconditions: The running editor has the project Content Browser available with long asset names and the Poly Haven desk imported. +build_lane: dev +fixture: +steps: + - Open the Content Browser grid and navigate to the Office model folder. + - Inspect long Material, Texture, and model names with clean and dirty status states. + - Resize the Content Browser and Details splitter through narrow and wide layouts. + - Switch to list view and compare status markers and column alignment. + - Inspect the Poly Haven metal office desk card and one mesh-subasset preview. +assertions: + - Grid labels stay inside clipped two-line footers with ellipsis and no status overlap. + - Dirty, processing, and error state use the fixed top-right status rail rather than text over the label. + - List status markers occupy fixed space and do not shift or truncate neighboring columns unpredictably. + - Toolbar, Details splitter, and cards remain usable without clipped controls at narrow sizes. + - The office desk and mesh-subasset thumbnails show rendered geometry, never an albedo texture substitute. +evidence: Target-window captures for grid-wide, grid-narrow, list, and desk geometry states plus hashes. +cleanup: Close only a runner-spawned editor; this scenario does not mutate project fixtures. +invalidates: Asset-card, Content Browser layout, status model, or thumbnail-routing changes. diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/material-slot-live-edit.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/material-slot-live-edit.yaml new file mode 100644 index 0000000..efe1bf3 --- /dev/null +++ b/.agents/skills/blacksite-native-qa/references/scenarios/material-slot-live-edit.yaml @@ -0,0 +1,26 @@ +name: material-slot-live-edit +purpose: Verify persistent live Material edits, explicit save separation, and the unified responsive slot widget. +preconditions: editor_scene contains a primitive and static mesh using a project Material; no editor process is running. +build_lane: dev +fixture: assets/materials/pebble_bricks.ron +steps: + - Select the primitive using pebble_bricks and expand its material parameters. + - Drag a scalar control, release it, and observe the surface for at least five seconds. + - Switch selection away and back, then compare the scalar value and rendered surface. + - Narrow and widen the Inspector around the responsive threshold. + - Save the active Material document and observe status and processing indicators. + - Select a static mesh material slot and compare its slot presentation. + - Leave the expanded material card idle for five minutes while sampling the editor process GPU memory and terminal diagnostics. + - Close and relaunch the editor, then reopen the same Material slot. +assertions: + - The surface changes continuously, does not flicker back after release, and the editor remains responsive. + - Selection changes preserve the live value and the compact amber dirty indicator without label overlap. + - Scalar save becomes clean without a PROCESSING state or a long-running UI operation. + - Primitive and static-mesh slots use the same compact header and schema-driven parameter layout. + - Narrow and wide layouts contain every control without overlap, clipping, garbled rows, or redundant diagnostics prose. + - No Apply, Revert, Ctrl+S hint, inline Explicit label, or inline Source & Diagnostics section appears. + - GPU memory remains bounded, no repeated egui multipass ID warning appears, and the editor stays alive without a renderer Out of Memory error. + - Relaunch preserves the saved Material value and rendered result. +evidence: Target-window captures for dirty-wide, dirty-narrow, saved, and relaunched states plus hashes. +cleanup: Close the spawned editor and atomically restore pebble_bricks.ron from the runner snapshot. +invalidates: Material UI, material persistence, or slot-resolution code changes. diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml new file mode 100644 index 0000000..71fbba8 --- /dev/null +++ b/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml @@ -0,0 +1,27 @@ +name: penpot-material-inspector +purpose: Verify the final Penpot Materials list and material-slot panel at its 640 px and 420 px Inspector reference widths. +preconditions: editor_scene contains primitive, static-mesh, and skinned-mesh material users; no editor process is running. +build_lane: dev +fixture: assets/materials/pebble_bricks.ron +steps: + - Launch the editor on the real desktop and size the Inspector to the 640 px reference width. + - Select a primitive, static mesh, and skinned mesh in turn and expand each material panel. + - Compare Materials ownership, expanded slot header, PARAMETERS spine, Surface, six Inputs rows, UV, and Advanced geometry with the current Penpot reference. + - Narrow the Inspector to the 420 px floor and verify every value, channel, texture, Locate, and Clear control reflows. + - Open the material picker, dismiss it without selection, then assign a valid project Material and inspect inherited and direct states. + - Drop a Texture onto the material assignment target and inspect the invalid state without assigning it. + - Open Base Color and exercise Wheel, Sliders, Presets, Apply, Cancel, Escape, and outside dismissal. + - Edit a scalar and color, release the controls, switch actors, then save and relaunch the editor. + - Inspect imported-source, DefaultGrid, and broken-reference presentations through the available fixture states. +assertions: + - The 640 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 592 px parameter sections, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift. + - Primitive, static-mesh, and skinned-mesh slots use the same panel geometry and have no redundant second slot presentation. + - At the 420 px Inspector floor, six 56 px input rows put paired texture controls on the second line with no horizontal scrolling, clipping, compressed labels, or missing actions. + - Direct assignments have no Explicit badge; inherited, dirty, processing, failed, read-only, imported-source, and built-in states retain one header geometry. + - Picker dismissal and invalid Texture drops make no assignment or source save; valid Material or Instance drops use the blue accent state. + - Wheel, Sliders, and Presets preview live; Apply retains the dirty in-memory value and Cancel, Escape, or outside dismissal restores the exact pre-open value. + - Material controls perform no pointer-release write or scalar/color processing job and keep the existing live GPU handle visible across actor selection. + - Save and relaunch preserve the authored value, while broken explicit references retain failed identity and visible DefaultGrid fallback. +evidence: Target-window captures for 640 px primitive/static/skinned, narrow reflow, color modes, invalid drop, dirty state, and relaunch plus hashes. +cleanup: Close the spawned editor and atomically restore pebble_bricks.ron from the runner snapshot. +invalidates: Design tokens, fonts, material panel geometry, material picker, color picker, authored documents, or material resolver changes. diff --git a/.agents/skills/blacksite-release-candidate/SKILL.md b/.agents/skills/blacksite-release-candidate/SKILL.md new file mode 100644 index 0000000..dd94c08 --- /dev/null +++ b/.agents/skills/blacksite-release-candidate/SKILL.md @@ -0,0 +1,17 @@ +--- +name: blacksite-release-candidate +description: Nominate and verify one Blacksite candidate tree or commit, reuse still-valid selective verification evidence, run the remaining full release gates once, produce an evidence manifest, and hand results to Gitea synchronization. +--- + +# Blacksite release candidate + +Read `.codex/workflow.toml` and [the release gates](references/release-gates.md). Require stable scope, no pending major delta, and `Candidate-ready` session state. + +1. Record the candidate SHA or dirty-tree digest. +2. Read the verification ledger and reuse every gate whose relevant input digest and lane signature remain valid. +3. Run only missing/invalid release gates via `verify.py candidate` and managed candidate/package lanes. +4. Preserve final binaries/package output and required native evidence before disposable-lane pruning. +5. Write `.codex/session/candidate-evidence.json` with commands, digests, results, artifacts, screenshots, and documentation status. +6. Run documentation integrity and hand a dry-run/authorized candidate update to `blacksite-gitea-sync`. + +Do not push, close, or change tracker scope outside direct or configured authority. Run full workspace/all-feature tests and strict lint only once per candidate code digest. diff --git a/.agents/skills/blacksite-release-candidate/references/release-gates.md b/.agents/skills/blacksite-release-candidate/references/release-gates.md new file mode 100644 index 0000000..6d2d7e5 --- /dev/null +++ b/.agents/skills/blacksite-release-candidate/references/release-gates.md @@ -0,0 +1,14 @@ +# Candidate release gates + +A nominated Blacksite candidate requires, when relevant: + +- Full workspace/all-feature tests. +- Strict all-target/all-feature Clippy with warnings denied. +- Deterministic content processing and read-only check parity. +- Level/sample/project validators and migration idempotence. +- Packaging, packaged artifact validation, and launch. +- Named native editor/runtime scenarios against candidate binaries. +- Documentation authority/contradiction audit. +- Clean candidate-tree or nominated dirty-tree digest evidence. + +Do not precede workspace tests with a redundant workspace check unless a target is otherwise uncovered. Documentation-only changes do not invalidate code gates when recorded inputs are unchanged. Closure additionally requires every issue criterion, epic checklist synchronization, and configured Gitea evidence. diff --git a/.agents/skills/blacksite-selective-verify/SKILL.md b/.agents/skills/blacksite-selective-verify/SKILL.md new file mode 100644 index 0000000..f147855 --- /dev/null +++ b/.agents/skills/blacksite-selective-verify/SKILL.md @@ -0,0 +1,18 @@ +--- +name: blacksite-selective-verify +description: Select and run the smallest valid Blacksite formatting, compile, test, lint, processing, validation, packaging, and native gates based on changed files and the current task state. Use after edits or before slice/candidate completion; do not default to full workspace gates. +--- + +# Blacksite selective verification + +Read `.codex/workflow.toml` and [the verification matrix](references/verification-matrix.md), then invoke `python scripts/codex/verify.py`. + +- `plan` explains selected and skipped gates without executing them. +- `fast` runs the smallest useful edit loop. +- `slice` adds affected-package tests, strict Clippy, domain checks, docs integrity, and named native requirements. +- `candidate` is permitted only for a nominated Candidate-ready state and runs missing release gates once. +- `status` reads the digest ledger; `invalidate --reason ...` invalidates only when inputs or acceptance change. + +Every Cargo command must go through `cargo_lane.py`. Do not run overlapping Cargo processes. Reuse a PASS when its relevant input digest, command, lane signature, toolchain, features, and environment are unchanged. Store complete output under `.codex/logs/` and show only the compact result or the first bounded actionable failure. + +Do not append workspace tests, workspace Clippy, processing, validators, packaging, or native QA to an ordinary fast loop. Prefer focused tests in the lightest crate that owns the invariant. diff --git a/.agents/skills/blacksite-selective-verify/references/verification-matrix.md b/.agents/skills/blacksite-selective-verify/references/verification-matrix.md new file mode 100644 index 0000000..0c9417a --- /dev/null +++ b/.agents/skills/blacksite-selective-verify/references/verification-matrix.md @@ -0,0 +1,19 @@ +# Blacksite verification matrix + +| Changed area | Fast loop | Slice gate | Candidate impact | +|---|---|---|---| +| Markdown only | docs audit | docs audit | Rust gates remain valid | +| `AGENTS.md`, Skills, workflow scripts | script self-tests/dry run | docs audit and workflow acceptance | No Rust gate unless Rust changed | +| `crates/content_pipeline/**` | pipeline check and focused tests | pipeline tests and strict Clippy; processing if output changed | Full gate later | +| `crates/shared/**` schemas/types | shared check and focused tests | shared tests, dependent checks, migration dry run if needed | Full gate later | +| `crates/editor/src/ui/**` | editor library check; focused wiring tests | editor tests/Clippy and named native scenario | Full gate later | +| `crates/blacksite_surface/**`, shaders | surface check/tests | surface Clippy and relevant renderer scenarios | Full gate later | +| `xtask/**` packaging | focused xtask/package test | repeat package test and package dry run | Package candidate required | +| Registry/catalog inputs | processing check | validators and deterministic write/check | Package/native as relevant | +| Migration/schema versions | focused migration tests/dry run | fixture apply, idempotence, rollback | Full gate later | +| Gitea only | No Cargo | Tracker readback | No code gate | +| Session/evidence only | No Cargo | Evidence integrity | No code gate | + +Fast checks normally use the `dev` lane. Candidate, package, hot-reload, and full-debug work use their named disposable lanes. A docs-only edit never invalidates a code gate whose recorded input digest is unchanged. + +Rust gate digests fingerprint the owning package and its local dependency closure, relevant Cargo and workflow configuration, the exact gate/lane, tool versions, and build-affecting environment. Documentation and unrelated packages are excluded, so their edits preserve valid focused Rust evidence; changes to a dependency, relevant configuration, toolchain, or environment invalidate it. diff --git a/.agents/skills/blacksite-task-state/SKILL.md b/.agents/skills/blacksite-task-state/SKILL.md new file mode 100644 index 0000000..405c537 --- /dev/null +++ b/.agents/skills/blacksite-task-state/SKILL.md @@ -0,0 +1,19 @@ +--- +name: blacksite-task-state +description: Maintain resumable Blacksite task state, scope deltas, verification validity, and exact next actions during multi-step implementation, user steering, reconnects, or context compaction. Use for long or multi-subsystem work; do not use for trivial one-file edits. +--- + +# Blacksite task state + +Read `.codex/workflow.toml`, then use `python scripts/codex/state.py` to create or update the ignored `.codex/session/STATE.md`. + +Use this Skill for `continue`, `resume`, plan implementation, multi-crate work, material scope changes, native acceptance discoveries, reconnects, and path remapping. + +1. Verify the canonical repository path, branch, HEAD, dirty summary, and relevant live processes. +2. Resume an existing valid state instead of rediscovering the repository. +3. Keep the active goal, slice, acceptance target, non-goals, intentional files, decisions, evidence validity, and one exact next action current. +4. Record steering with `state.py scope-delta`. Classify it as an active-slice refinement, newly discovered blocker, added acceptance criterion, or separate follow-up. +5. State which evidence remains valid and invalidate only affected gates with `verify.py invalidate`. +6. Update state after a completed slice, native checkpoint, or remote mutation. + +Keep the file below the configured line limit. Never copy logs, issue bodies, large plans, or desktop dumps into it. diff --git a/.cargo/config.toml b/.cargo/config.toml index 1750233..423163d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,9 +5,9 @@ linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] [alias] -clean-target = "run -p xtask --bin clean-target --" validate-levels = "run -p xtask --features validate-levels --bin validate-levels --" validate-samples = "run -p xtask --features validate-samples --bin validate-samples --" bake-navigation = "run -p xtask --features validate-levels --bin bake-navigation --" package-project = "run -p xtask --features validate-levels --bin package-project --" upgrade-project = "run -p xtask --features validate-levels --bin upgrade-project --" +process-assets = "run -p xtask --features content-pipeline --bin process-assets --" diff --git a/.codex/ARCHITECTURE.md b/.codex/ARCHITECTURE.md new file mode 100644 index 0000000..a5755b3 --- /dev/null +++ b/.codex/ARCHITECTURE.md @@ -0,0 +1,14 @@ +# Architecture debt ratchet + +Blacksite keeps extension seams sustainable at the point of change. New behavior belongs in the +smallest domain module that owns it; an already-large file is not permission to add more behavior +there. + +`architecture.toml` is the machine-readable policy. `scripts/codex/architecture_audit.py` enforces +three default nonblank-line budgets: 500 for UI shells, 800 for other UI modules, and 1,200 for +other production Rust modules. Existing files above those budgets have frozen ceilings and may +shrink but never grow. + +An exception is temporary and must name a tracker issue, rationale, hard maximum, extraction +target, and expiry milestone. Passing the audit means the debt did not regress; it does not mean an +exception is complete. Candidate review must reject expired exceptions. diff --git a/.codex/architecture.toml b/.codex/architecture.toml new file mode 100644 index 0000000..3a33122 --- /dev/null +++ b/.codex/architecture.toml @@ -0,0 +1,34 @@ +version = 1 + +[limits] +ui_shell = 500 +ui_module = 800 +production_rust_module = 1200 + +[shells] +"crates/editor/src/ui/asset_browser/panel.rs" = 500 +"crates/editor/src/ui/inspector.rs" = 500 + +# Frozen baselines are debt ceilings, not targets. They may shrink but never grow. +[baselines] +"crates/scene/src/project_validation.rs" = 4685 +"crates/editor/src/assets/prefab_overrides.rs" = 4238 +"crates/editor/src/scene/scene_io.rs" = 4196 +"crates/editor/src/history/mod.rs" = 3651 +"crates/editor/src/assets/catalog.rs" = 2077 +"crates/scene/src/navigation.rs" = 2346 +"crates/shared/src/components.rs" = 1831 +"crates/game/src/animation.rs" = 1690 +"crates/editor/src/project/collaboration.rs" = 1649 +"crates/editor/src/ui/viewport_chrome.rs" = 1575 +"crates/content_pipeline/src/transaction.rs" = 1477 +"crates/content_pipeline/src/import.rs" = 1417 +"crates/editor/src/viewport/brush_edit.rs" = 1286 +"crates/editor/src/viewport/material_drop.rs" = 1269 +"crates/editor/src/viewport/visualizers.rs" = 1221 +"crates/scene/src/upgrade.rs" = 1203 +"crates/editor/src/ui/navigation_inspector.rs" = 1210 +"crates/editor/src/ui/hierarchy.rs" = 1149 +"crates/editor/src/ui/material_library.rs" = 1011 +"crates/editor/src/ui/animation_inspector.rs" = 971 +"crates/editor/src/ui/component_registry.rs" = 864 diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..5cb54df --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +approval_policy = "on-request" +workspace_root = "/home/Rbanh/Documents/Bevy" diff --git a/.codex/templates/GITEA_STATUS_COMMENT.md b/.codex/templates/GITEA_STATUS_COMMENT.md new file mode 100644 index 0000000..33b7834 --- /dev/null +++ b/.codex/templates/GITEA_STATUS_COMMENT.md @@ -0,0 +1,19 @@ +## Codex status — + +**Scope completed** +- ... + +**Verification** +- `` — PASS +- Native scenario `` — PASS/NOT RUN + +**Documentation** +- Canonical docs updated: ... +- Contradiction audit: PASS/remaining ... + +**Candidate** +- Commit: working tree only | `` +- This is/is not closure evidence. + +**Remaining** +- ... diff --git a/.codex/templates/SCOPE_DELTA.md b/.codex/templates/SCOPE_DELTA.md new file mode 100644 index 0000000..0f709a1 --- /dev/null +++ b/.codex/templates/SCOPE_DELTA.md @@ -0,0 +1,7 @@ +### Delta N — title +- Source: +- Classification: active-slice refinement | newly discovered blocker | added acceptance criterion | separate follow-up +- Added/changed requirement: +- Prior evidence still valid: +- Invalidated gates: +- Tracker sync required: yes | no diff --git a/.codex/templates/SESSION_STATE.md b/.codex/templates/SESSION_STATE.md new file mode 100644 index 0000000..1b59a78 --- /dev/null +++ b/.codex/templates/SESSION_STATE.md @@ -0,0 +1,36 @@ +# Codex session state + +## Repository +- Root: +- Real path: +- Branch: +- HEAD: +- Worktree status summary: +- Active processes/windows: + +## Active task +- Goal: +- Current state: Implementing +- Gitea issue(s): +- Gitea milestone: +- Active slice: +- Non-goals: + +## Acceptance target +- [ ] Criterion + +## Scope deltas + +## Files intentionally changed + +## Verification ledger summary + +## Native evidence + +## Decisions + +## Remote actions already performed +- None. + +## Exact next action +- One concrete next step. diff --git a/.codex/workflow.toml b/.codex/workflow.toml new file mode 100644 index 0000000..d20c799 --- /dev/null +++ b/.codex/workflow.toml @@ -0,0 +1,81 @@ +version = 1 + +[session] +state_file = ".codex/session/STATE.md" +verification_file = ".codex/session/verification.json" +max_state_lines = 180 + +[output] +log_dir = ".codex/logs" +evidence_dir = ".codex/evidence" +max_failure_lines = 120 +progress_mode = "meaningful-checkpoints" + +[verification] +default_tier = "fast" +candidate_requires_explicit_state = true +reuse_by_input_digest = true +offline_after_bootstrap = true +cargo_lane_wrapper = "scripts/codex/cargo_lane.py" +persistent_lane = "dev" +candidate_lane = "candidate" +hot_reload_lane = "hot-reload" +full_debug_lane = "full-debug" +package_lane = "package" +parallel_cargo = false +run_workspace_check_before_workspace_test = false + +[build_storage] +enabled = true +strategy = "separate-build-dir-when-supported" +cache_root = "workspace-parent-cache" +workspace_partition = "canonical-path-hash" +dev_target_dir = "target" +exceptional_target_root = "target/lanes" +soft_total_gib = 55 +hard_total_gib = 80 +min_free_gib = 40 +min_free_percent = 15 +persistent_lane_max_gib = 40 +disposable_lane_max_gib = 35 +candidate_retention_hours = 24 +hot_reload_retention_hours = 24 +full_debug_retention_hours = 12 +failed_lane_retention_hours = 24 +auto_prune_disposable = true +persistent_reset = "safe-slice-boundary-after-dry-run" +delete_only_marked_lanes = true +preflight = true +postflight = true + +[git] +large_task_branch = true +local_checkpoint_commits = true +push = "explicit" +force_push = false +dirty_main_policy = "preserve-and-isolate-when-safe" + +[gitea] +repository = "Falling-Metal-Interactive/Blacksite" +read_exact_items_first = true +routine_status_comments = true +sync_acceptance_checkboxes = true +sync_epic_checklists = true +create_issues = "explicit" +change_scope_or_milestone = "explicit" +close_issues = "candidate-only" +close_milestones = "candidate-only" +delete_or_reopen = "explicit" +max_status_comments_per_slice = 1 + +[documentation] +authority_manifest = "docs/authority.toml" +classify_all_docs = true +historical_banner_required = true +canonical_topic_unique = true +completed_plans_become_historical = true + +[native_qa] +reuse_running_session = true +capture_target_window_only = true +restore_fixtures = true diff --git a/.cursor/plans/animation_authoring_2026-07-11.plan.md b/.cursor/plans/animation_authoring_2026-07-11.plan.md index 74f0834..08c026c 100644 --- a/.cursor/plans/animation_authoring_2026-07-11.plan.md +++ b/.cursor/plans/animation_authoring_2026-07-11.plan.md @@ -1,5 +1,7 @@ # Skeletal Animation Import, Preview, And Authoring +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea issue [`#46`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46). This is the minimum production animation loop required by the M7 content-production milestone. diff --git a/.cursor/plans/audio_authoring_2026-07-10.plan.md b/.cursor/plans/audio_authoring_2026-07-10.plan.md index 69807ab..7a5166d 100644 --- a/.cursor/plans/audio_authoring_2026-07-10.plan.md +++ b/.cursor/plans/audio_authoring_2026-07-10.plan.md @@ -1,5 +1,7 @@ # Audio Authoring And Runtime Parity +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea issue [`#47`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47). This is the minimum complete audio loop required by the M7 content-production milestone. diff --git a/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md index a3ad794..c94481c 100644 --- a/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md +++ b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md @@ -1,5 +1,7 @@ # Blacksite Editor Production Readiness Program +> **Active plan — desired scope and acceptance, not implementation truth.** Current shipped behavior is indexed in the [canonical documentation](../../docs/README.md). + Working milestone plan for the production expansion tracked by Gitea epic [`#1`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/1). The existing Jackdaw-inspired M0-M5 roadmap remains the authoring/tool foundation. This plan adds diff --git a/.cursor/plans/collider_diagnostics_2026-07-12.plan.md b/.cursor/plans/collider_diagnostics_2026-07-12.plan.md index a1a5a25..37d4ec6 100644 --- a/.cursor/plans/collider_diagnostics_2026-07-12.plan.md +++ b/.cursor/plans/collider_diagnostics_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Collider Authoring Diagnostics - 2026-07-12 +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Issue: Gitea #26 (`BS-JD-305`) ## Outcome diff --git a/.cursor/plans/component_system_refactor_2026-06-05.plan.md b/.cursor/plans/component_system_refactor_2026-06-05.plan.md index d6e9f3f..e3db88d 100644 --- a/.cursor/plans/component_system_refactor_2026-06-05.plan.md +++ b/.cursor/plans/component_system_refactor_2026-06-05.plan.md @@ -1,5 +1,7 @@ # Component System Refactor Plan - 2026-06-05 +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + ## Goal Align the actor inspector and static mesh authoring model with diff --git a/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md new file mode 100644 index 0000000..7961624 --- /dev/null +++ b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md @@ -0,0 +1,456 @@ +# Content Workspace And Import Authoring Refactor + +> **Active plan — desired scope and acceptance, not implementation truth.** Current shipped behavior is documented in the [Content Workspace guide](../../docs/editor/content-workspace.md). + +Date: 2026-07-13 + +## Status + +Active acceptance plan for Gitea milestone **M2 - Content workspace and asset pipeline**. Current +implementation state, user steering, invalidated evidence, and the exact next action live in the +ignored `.codex/session/STATE.md`; accepted scope and closure state live in Gitea #59 and #65. The +local candidate tree passed the complete candidate gate on 2026-07-17; its exact commit is assigned +at publication, so this plan must not be read as remote completion evidence yet. It remains the +desired acceptance target until M2 is accepted, then becomes a frozen historical record. + +## Gitea Tracking + +- Milestone: [M2 - Content workspace and asset pipeline](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/milestone/12) +- Epic: [#59 BS-JD-211 - Content workspace and import authoring refactor](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/59) +- Contract and catalog: [#60 BS-JD-212](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/60), [#62 BS-JD-214](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/62) +- File operations and import: [#20 BS-JD-205](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/20), [#61 BS-JD-213](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/61) +- Model materials: [#64 BS-JD-215](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/64), [#63 BS-JD-216](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/63) +- Final gate: [#65 BS-JD-217](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/65) +- Engine fallback: [#66 BS-JD-218](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/66) +- Responsive authored documents: [#67 BS-JD-219](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/67) +- Modular editor architecture: [#68 BS-JD-220](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/68) + +## Goal + +The active corrective slice also requires one overlay-first Material handle authority, conditional +ARM/ORM-only derived processing, registry-only Inspector dispatch, shared Material/asset-card UI, +and the ADR 0048 architecture ratchet. Both large UI files are now <=500-line shells; #68 remains +open until selective verification, critical native acceptance, and publication approval are +complete. + +The current #68 refinement adopts the Penpot **Inspector Material Slot** as the first Blacksite +design-system reference. Penpot owns geometry, typography, and interaction-state references; +Blacksite's editor-owned semantic palette owns color and is published into Penpot Assets / Colors. +The current palette is the default, with preference selection deferred behind the same palette +seam. Shared semantic tokens, Source Sans Pro typography, action-returning +material panels, responsive property grids, and custom asset/color pickers must match the 640 px +and 420 px references. The Inspector has a hard 420 px floor and one actor-body scroll region. +Runtime material authority and explicit-save behavior remain +owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice. + +Make the Content Browser behave like a project file manager and make imported models behave like +editable engine assets: + +- users organize source and authored assets in any folder beneath the project `assets/` content + root; +- importing copies into the current or explicitly chosen project folder instead of sorting by file + extension; +- static and skinned model assets expose stable material slots whose defaults can use source + materials, existing project Materials/Material Instances, or extracted editable Materials; +- move, rename, reimport, validation, packaging, and headless processing preserve stable identity + and never silently overwrite authored material work. + +This matches the portable project-content model used by Unity and Unreal. Arbitrary absolute paths +outside the project are not part of this milestone: external linked assets require a separate +virtual-filesystem, packaging, and source-control decision. + +## Existing Foundation To Reuse + +- `AssetRegistry` owns UUIDs, source fingerprints, import settings, and generated-manifest paths. +- The Content Browser already scans nested folders below `assets/`, renders thumbnails and model + subassets, stages import settings, reimports, and moves deleted files to `assets/.trash/`. +- Static and skinned renderers already share stable material-slot IDs, imported-source fallback, + explicit scene-level project Material assignments, and orphan preservation. +- Project Materials and direct-base Material Instances are editable, schema-driven assets with a + dedicated Material Library. Parameter edits update shared runtime handles every frame and mark an + asset-keyed document dirty; pointer release performs no persistence or processing. Explicit Save + publishes guarded source and queues affected-only derived processing in the background. +- Imported-source fingerprints are content-addressed, and FBX dependency bundles already use a + sandboxed transactional copy. + +The refactor must extend these contracts rather than add a second catalog, material type, or model +loader. + +## Implementation Snapshot + +- [Implemented] Destination-first Import Here/Import To, in-project adoption, exact-path review, + dependency/source fingerprint guards, dependency-bundle preflight, and whole-batch rollback. +- [Implemented] Folder-independent registry-v3 classification and one `content_pipeline` engine for + editor transactions, watcher refresh, validation, headless processing, and packaging catalogs. +- [Implemented] File-manager selection, folders, item/batch/visible-empty-space context menus, + content-scoped keyboard operations, protected managed folders, safe navigation and subasset + semantics, guarded undo, transaction review, collaboration state, trash, stable-reference + rewrites, hidden-selection clearing when filters change, consistent right-click targeting, and + restart-safe explicit identity selection for ambiguous out-of-editor moves. +- [Implemented] Stable per-slot model defaults, orphan reconciliation, six-layer material + precedence, project fallback, immutable DefaultGrid, and reviewed exact-slot + MaterialPropertyBlock promotion with transactional rollback. +- [Implemented] Transactional glTF/GLB/FBX material extraction with provenance, explicit + diff/apply/create-new re-extraction, plus loose-texture PBR grouping. +- [Implemented 2026-07-15] Scene schema v6 and actor-only `MaterialSlot` ownership for + primitives/static/skinned renderers; primitive-local `MaterialDesc` hydration and the synthetic + Authoring Material card are removed. The unified slot widget supports valid/invalid drag state, + inherited effective status, guarded shared parameter editing, direct-base instance creation, + actor-exact source extraction, and property-block promotion. +- [Implemented 2026-07-14] Typed thumbnail keys separate textures, models, mesh subassets, and + source materials. Models always queue geometry rendering and static draw previews request their + exact mesh subasset; the Poly Haven metal office desk regression is unit-covered. +- [Implemented 2026-07-15] Thumbnail cache replacement, retry, and catalog invalidation release the + last Egui image registration for each key. Repeated studio renders no longer retain hidden GPU + render targets after the visible cache entry is gone. +- [Implemented 2026-07-14] Asset-keyed authored documents separate live Material/Instance and + import-setting edits from explicit source publication. Context Save, Save All, recovery, + conflict state, separate Git/processing badges, and revisioned background processing remove + pointer-release writes and synchronous content work from material controls. +- [Implemented 2026-07-17] Penpot-led design-system foundations and the unified + primitive/static/skinned material panel are in the local worktree. The revised handoff adds + field-click texture pickers with Locate/Clear, Separate/ORM presentation, and shared UV + Offset/Tiling controls. Blacksite's default semantic palette now replaces the panel-private + prototype colors. Exact 640/420 px native acceptance and responsive resize/deep-scroll scenarios + pass on the final geometry. +- [Implemented 2026-07-17] #67/#68 corrective sampler and Penpot v2.3 slice: project textures use + catalog-authored Repeat/Clamp/Mirror sampling across Standard and Surface paths; the shared panel + has one Shader selector, UV before Advanced, typed texture thumbnails, and a disabled Advanced + preview with no authored/runtime state. +- [Implemented 2026-07-17] #68 Penpot v2.3.1 corrective pass: the design component and every state + reference remove Shading Model and UV/Advanced overlap; the shared Material asset zone owns + assignment and its current/recent/project picker; Surface, Packed Maps, inputs, UV, and Advanced + use the locked reference heights; and the actor-salted Inspector body is clipped below its fixed + identity header. Managed verification and Codex-owned native visual acceptance pass. +- [Implemented 2026-07-17] Direct exports of the default, expanded Advanced, Material picker, and + color picker are now the numeric baseline. The exact pass removes the invented drop cue and Blend + label, restores the fixed Inputs columns, boxed ORM guidance, `UV` label, Advanced summary, compact + Material menu, paired color/HEX values, normal strength, and the seventh Emissive Intensity row. +- [Implemented 2026-07-17] The final #68 micro-geometry pass makes the exported 420 x 350 color + overlay Inspector-centered and modal, locks its value/alpha/recents/footer coordinates, keeps the + eyedropper as a disabled planned preview, replaces stock material sliders with the 130 px Penpot + scalar control, and scroll-reveals the complete expanded Advanced body. Geometry and popup + interaction tests plus native captures pass. +- [Implemented 2026-07-17] Native Inspector splitter endurance exposed a viewport-target ownership + leak: every intermediate width allocated another full-resolution HDR Image and Egui retained each + strong registration until Solari terminated on GPU OutOfMemory. The corrective path resizes one + stable Image asset in place, unregisters exceptional replacements/absent targets, and requires a + repeated wide/narrow drag endurance pass before #68 visual acceptance resumes. +- [Implemented 2026-07-17] Native top-level window resizing exposed a distinct NVIDIA Wayland + swapchain-acquire timeout loop after the target-ownership leak was removed. The editor host now + prefers an Immediate/Mailbox presentation path while packaged game windows retain VSync; repeated + native-window resize remains the focused acceptance gate. +- [Implemented 2026-07-17] Native narrow/tall Inspector captures exposed a universal overflow + boundary defect: solid scrollbar allocation changed responsive widths and nested component cards + could replace the actor-body clip. The shared Inspector now keeps a stable floating scroll rail, + intersects every touched child clip, and ready texture fields omit the redundant image glyph. +- [Implemented 2026-07-17] The final #68 Penpot boards supersede the earlier v2.2/v2.3 slot + geometry. Inspectors render a 32 px **Materials** heading and ordered 82/52 px collapsible slots, + a nested PARAMETERS spine, 616/396 px slot references, 592/372 px parameter bodies, and one + 420 px Inspector floor. Surface owns Separate/ORM; Inputs has six 30/56 px primary rows, with + independently stored Emissive Intensity presented as the Emissive color companion. +- [Implemented 2026-07-17] The exact geometry correction replaces flow-derived Surface, input, + texture-action, and UV placement with clipped owning-rectangle models for the exported 592/372 px + sections. Texture names can no longer move row actions, UV labels no longer share coordinates, + and transient sub-minimum widths use a bounded overflow fallback. Focused engineering verification + and 640/420/short-height native captures pass. +- [Candidate gate passed 2026-07-17; pending publication] The complete all-feature test and strict + lint gates, deterministic processing, level/sample validators, QA package, architecture audit, + and documentation audit pass on the local candidate tree. Native runtime-injected property-block + promotion, skeletal import, explicit scene-slot assignment, and broken project-fallback behavior + also pass. Exact-commit clean-tree readback, evidence publication, and Gitea closure remain. +- [Corrective acceptance in progress 2026-07-17] The current Penpot README and active 01–30 boards + supersede the earlier 640/420 material-only references and invalidate the prior candidate's UI + acceptance. M2 now requires the shared 50 px Inspector Header, renderer Array Header/panels, + foundation controls, overlays, and current 620/420 Material references across the complete + Inspector. The new wide/narrow Material canvases are 596/396 px with 569/369 px parameter + sections, 32/58 px rows, and 54/94 px UV sections. Candidate publication and issue closure remain + blocked until refreshed engineering and Codex-owned native evidence pass on one exact tree. + +## Target Contracts + +### Project content boundary + +- User-managed content may live in any normal nested folder under `assets/`. +- Folder names are organizational only and never determine asset type. +- `assets/.index/`, `assets/.trash/`, thumbnail storage, and UUID-keyed derived artifacts remain + editor-managed and protected from ordinary file operations. +- The storage ADR decides whether existing generated directories remain in place or migrate behind + `.index`; either way, they stay hidden and are never presented as imported user content. + +### Registry and type authority + +- `AssetId` remains authoritative identity; the current project-relative path is mutable metadata. +- Extension plus parsed document schema/registry kind determines type. Moving a Material beside a + model does not turn it into a level. +- All editor, hydration, validation, and packaging paths resolve current locations through stable + identity before consulting cached path hints. +- Existing projects migrate without replacing UUIDs or rewriting semantically unchanged files. + +### Filesystem operations + +- Create Folder, Rename, Move, Duplicate, Cut/Copy/Paste, drag-to-folder, trash, and restore work on + assets and folders. +- Ref-affecting operations show collisions, dependencies, collaboration state, and affected authored + files before commit. They use guarded, staged filesystem transactions with rollback on failure. +- Reversible filesystem operations create guarded content-undo entries. Undo is refused after an + external fingerprint change; trash/restore remains the recovery model for deletion. +- External filesystem changes reconcile by stable ID/fingerprint where unambiguous and otherwise + produce an explicit repair flow. + +### Destination-first import + +- **Import Here** targets the selected Content Browser folder. **Import To...** chooses another + folder beneath `assets/` without changing the browser location. The native picker opens only + after the destination is pinned, then a second review previews every dependency and final path. +- External files are copied into the chosen project folder; files already inside project content + are registered/processed in place instead of copied again. +- Import never silently creates visible `models/`, `textures/`, `audio/`, or `materials/` folders. +- Dependency-relative bundles, overwrite conflicts, collaboration guards, staging, and rollback + are resolved before registry publication. +- Registry records retain optional source provenance and a deterministic reimport recipe without + making an external absolute path a runtime dependency. + +### Model asset material defaults + +Material resolution uses the strongest valid authored layer in this order: + +1. runtime `MaterialPropertyBlock`; +2. placed renderer/prefab slot assignment; +3. model-asset per-slot default; +4. imported source material; +5. project default Material/Instance; +6. immutable engine DefaultGrid. + +Mappings are keyed by stable imported slot/subasset IDs, never display names. Changing a model-asset +default updates existing and future placements that do not carry a scene override; clearing a scene +override returns to the next intentional layer. A configured-but-broken explicit reference reports +a diagnostic and renders DefaultGrid instead of exposing a weaker assignment. Static and skinned +renderers use the same resolution rules without sharing geometry/hydration components. + +Reimport reconciles by stable slot ID. Removed mappings become visible orphans and are never moved to +a similarly named slot automatically. + +### Editable imported materials + +- A model import can keep source fallback, map a slot to an existing project material, extract a + supported source material into an editable project Material, or intentionally select Default. +- Extraction previews destination paths, naming collisions, external texture channels, render + state, provenance, and supported PBR parameters before writing. Embedded texture payloads remain + source-owned and are not misrepresented as extracted project textures. +- Extracted Materials are ordinary project assets and may live beside the model or anywhere else + under `assets/`. +- Source provenance is recorded, but reimport never overwrites an edited Material silently. Updating + an extracted material requires an explicit diff/apply decision. +- Unsupported source features produce diagnostics and preserve a predictable source/fallback path; + partial conversion is never presented as lossless. + +## Milestone Work Packages + +### Epic — Content workspace and import authoring refactor + +Tracks the milestone contract, dependencies, sequencing, and exit evidence. It is not an +implementation substitute for the focused issues below. + +### Architecture — Project content and derived-artifact contract + +- [Implemented] Add ADR 0045 covering the content root, registry/type authority, mutable paths, + protected managed directories, import provenance, and the six-layer material resolution contract. +- [Implemented] Define explicit migrations for registry v1, project settings, legacy cached refs, + whole-model material policy, and mesh material overrides. +- [Implemented] Replace fixed-folder consumers across editor, shared hydration, scene validation, project + settings, and packaging. + +### Existing BS-JD-205 — Content Browser file operations and reference-safe moves + +Expand the existing rename/move repair ticket instead of duplicating it: + +- [Implemented] create folder, rename, move, duplicate, cut/copy/paste, drag-to-folder, and + transactional trash/restore with stable-ID manifests and collision guards; +- [Implemented] file-manager selection for assets and folders (single, Ctrl/Cmd toggle, Shift range, select all), + batch operations, keyboard shortcuts, and scoped context menus on assets, folders, and unused + content space; +- [Implemented] asset/folder collision, collaboration, external-fingerprint, and dependency-impact preview; +- [Implemented] atomic registry/reference repair and external-change reconciliation; +- [Implemented] selection, breadcrumb, thumbnail, and Material Library continuity after moves. + +### Destination-first transactional import + +- [Implemented] Replace extension-to-directory routing with current-folder/explicit-folder import. +- [Implemented] Register files already under project content in place. +- [Implemented] Preserve FBX/glTF relative dependencies safely and reject collisions before any + selected source mutates the destination. +- [Implemented] Roll back the complete multi-source publication batch on failure; cancellation + remains non-mutating. + +### Path-agnostic catalog, loading, validation, and packaging + +- [Implemented] Remove folder-name asset classification and fixed material/shader/model discovery paths. +- [Implemented] Resolve stable IDs to current paths in editor and runtime consumers. +- [Implemented] Make the live editor watcher, headless processing, validators, and build manifests + share the same catalog rules. Live refresh debounces changes, ignores generated/managed noise, + suppresses editor transactions, republishes registry/runtime catalog, and invalidates thumbnails. +- [Implemented] Migrate representative registry/model records while preserving UUIDs and + semantically unchanged user-authored bytes. + +### Built-in DefaultGrid and project fallback + +- [Implemented] Provide one immutable, UV-independent engine material with forward/Solari parity and a shared + emergency StandardMaterial handle. +- [Implemented] Let Project Settings select a registry-backed project Material/Instance fallback; Clear or an + invalid reference returns to DefaultGrid with diagnostics. +- [Implemented] Apply the fallback to unresolved static/skinned slots, primitives without an active material, + unassigned brush faces, and model/mesh previews. Terrain keeps its specialized layer fallback. + +### Unified actor material slots and previews + +- [Implemented] `MaterialSlot`/`MaterialSlotSet` are the primary contracts; schema-v4 renderer names + remain compatibility aliases for one cycle. `Primitive.surface` always uses + `slot:primitive:surface` and new placements begin unassigned. +- [Implemented] Hydration binds the exact primitive/static/skinned slot to one cached emergency + handle, then resolves explicit/model source/project default/DefaultGrid without actor-local + material creation. Component-form `MaterialDesc` remains only for the brush fallback path. +- [Implemented] One inspector widget exposes the effective Material sphere/name/status, + Browse/Locate/Clear, a full-width Material/Instance target, red explanatory Texture rejection, + expandable guarded shared parameters, exact-slot instance creation, imported-source extraction, + and property-block information/promotion. +- [Implemented] Scene schema v6 stores actor-only slot assignments; the explicit upgrader converts primitive, mesh-wide, and per-slot legacy values + into saved slots and deterministic deduplicated project Materials. Normal loading remains + non-writing and validation diagnoses unmigrated actor components. +- [Implemented] Model cards always use the offscreen scene studio; texture loading is texture-only. + Typed cache keys prevent cross-category collisions and static mesh inspector previews request + exact mesh-subasset renders. + +### Model-asset material slot mapping + +- [Implemented] Persist model-default mappings by stable slot/subasset ID. +- [Implemented] Add Content Browser model-details controls to Browse, Assign, Locate, Clear, and inspect orphans. +- [Implemented] Apply the six-layer resolution contract to static and skinned hydration. +- [Implemented] Reimport without rewriting scene overrides or resetting skinned animation state. + +### Editable source-material extraction and import-policy UX + +- [Implemented] Convert supported glTF/GLB/FBX PBR source values and external textures into guarded + `MaterialAsset` documents. +- [Implemented] Let each slot choose Source, Existing Project Material, Extract Editable, or Default. +- [Implemented] Integrate PBR texture-set detection from BS-JD-202 without conflating filename + grouping with model source-material conversion. The folder action now previews confidence, + supports manual roles and target merge/split, and publishes no-overwrite Material batches through + the shared transaction engine. +- [Implemented] Preview converted values, destinations, collisions, provenance, and render state. + Provenance-matched re-extraction requires an explicit diff-backed Apply or Create New choice; + Apply is fingerprint-guarded and byte-restoring, while unrelated provenance is never overwritten. + +### Migration and acceptance signoff + +Use a representative project workflow: + +1. Create `assets/Props/Office/` in the Content Browser. +2. Import a static model and a skeletal model directly into that folder. +3. Extract one valid source material, map another slot to an existing Material Instance, and keep a + third slot on source fallback. +4. Place both models, add one scene-level material override, save, close, and reopen. +5. Move/rename the containing folder, reimport both models, and resolve a removed-slot orphan. +6. Verify unchanged UUIDs, model defaults, scene override precedence, editable Material contents, + thumbnails, selection, PIE/runtime rendering, headless validation, and release packaging. +7. Verify a failed/cancelled import or move leaves the exact pre-operation filesystem and registry. + +No M2 milestone issue of any priority may remain open at signoff. + +## Sequencing + +1. Epic and ADR/storage contract. +2. Path-agnostic registry/type resolution foundation. +3. BS-JD-205 file operations and destination-first import. +4. DefaultGrid/project fallback and model-asset material mapping. +5. Editable extraction plus BS-JD-202 PBR grouping integration. +6. [Implemented] BS-JD-204 live refresh and BS-JD-206 headless parity. +7. Migration/native/headless/package acceptance. + +[Implemented] BS-JD-208 runtime MaterialPropertyBlocks applies after resolved bases with cached +per-owner/slot handles and exact-slot transactional promotion. +Solari dynamic-deformation work remains independent and is not a prerequisite for this refactor. + +## Documentation On Implementation + +- [Implemented] Create and index ADR 0045; update ADR 0035 and ADR 0044 where contracts changed. +- [Implemented] Add `docs/editor/content-workspace.md` as the canonical workflow/reference document and index it + from `docs/README.md` and `docs/editor/README.md`. +- [Implemented] Update root README controls, troubleshooting, and implementation checklist. +- [Implemented] Add the M2 Content Workspace feature-level native evaluation record with capture + checksums and headless/package evidence links. +- [Pending signoff] Upload the native PNGs to Gitea and rerun the record from the exact committed + implementation revision. + +## Verification Matrix + +- Unit tests: path/type resolution, collision planning, slot mapping precedence, orphan + reconciliation, conversion fidelity, provenance, and migration. +- Transaction fixtures: recursive move/rename, bundle import rollback, collaboration conflict, + cancelled extraction, and external filesystem moves. +- Editor tests: file-manager actions, exact model-slot mapping, static/skinned parity, save/reopen, + and no scene dirtiness from previews. +- [Passed 2026-07-13] Focused unit/transaction/editor suites; `cargo process-assets` write followed + by read-only check over 42 assets; project validation over 97 dependencies with 0 blockers. +- [Passed 2026-07-13] Five sample projects validate with 0 blockers; repeated QA packaging reuses + unchanged files, includes/fingerprints the runtime catalog, and the native executable remains + healthy through a bounded launch smoke test. +- [Passed 2026-07-13] Development packaging publishes after accepting an equivalent legacy lock + output with a trailing separator; the regression is unit-covered, the packaged runtime catalog + is byte-identical to the headless catalog, and an eight-second native launch leaves no process + behind. +- [Passed 2026-07-13] `cargo fmt --all -- --check`, strict workspace/all-target/all-feature Clippy, + and the complete workspace/all-feature unit and documentation test suite. +- [Passed 2026-07-13] Native Content Browser selection, batch and empty-space menus, basic item + actions, managed-folder hiding, background/item interaction priority, and Details resizing; see + `docs/editor/evaluations/content-workspace-m2/`. +- [Passed 2026-07-14] All-feature workspace tests, strict all-target/all-feature Clippy, 48-asset + write/check parity, 116/121-dependency validators with no blockers, byte-identical packaged + catalog, and live mapped editor/package windows. +- [Passed locally 2026-07-14] Destination-first skeletal import and placement, broken project + fallback to DefaultGrid, and exact-slot skinned scene override. The registry was restored to the + built-in default before the final deterministic refresh. +- [Passed locally 2026-07-14] Focused Import To/review tests prove pinned destinations, no mutation + before confirmation, collision refusal, source/dependency fingerprint conflicts, and complete + textual-glTF dependency tracking. Focused promotion tests prove direct-base/exact-slot + publication, one history edit, cancel/conflict/stale-input non-mutation, schema validation, and + byte-exact file/catalog rollback after a failed scene edit. +- [Passed natively 2026-07-14] Import To selected `assets/Furniture/Office` while the browser stayed + at `assets`, the second review showed the exact target for a temporary PNG, and Cancel published + no file. Capture hashes are recorded in the M2 evaluation. +- [Passed 2026-07-14] The material-slot consistency follow-through migrated the active project to + the then-current scene schema, processed 71 assets with write/check parity, passed 330 editor tests plus the + complete all-feature workspace suite and strict Clippy, published the development package, and + kept both the live editor and packaged runtime healthy through bounded Wayland launches. Native + inspection confirmed the primitive-owned Surface Material slot and expandable shared parameters; + typed thumbnail tests confirm the Poly Haven office desk queues geometry renders rather than its + albedo texture. +- [Corrected 2026-07-14] Assigned Standard Materials now detach from the singleton emergency + handle during resolution. The regression begins with a neutral-grey hydrated handle, verifies + that fallback remains unchanged, and checks the resolved project asset's base color, metallic, + and roughness on its reference-scoped cached handle. +- [Passed natively 2026-07-17] Editor-only BRP injected a two-parameter + `MaterialPropertyBlock` into Ground's `slot:primitive:surface`; the Inspector promoted one sparse + direct-base Material Instance, assigned the exact slot through one history edit, and removed the + runtime block only after successful publication. Undo restored the prior slot, and the temporary + instance, registry, catalog, scene, and Pebble fixture were restored byte-for-byte afterward. +- [Passed on candidate tree 2026-07-17; exact-commit readback pending] Refresh/validation of + unchanged content writes no files (`process-assets --check`: 78 discovered, zero changes). +# Material authoring UI and Texture processing completion (2026-07-14) + +- [x] Replace duplicated Standard fields with schema-v2 `MaterialInputSchema` and + `MaterialInputSet` documents; keep Material Instance overrides sparse. +- [x] Use one compact primitive/static/skinned material-slot widget with an embedded sphere, + assignment status/actions, responsive parameters, and diagnostics outside parameter rows. +- [x] Pair Standard Lit multipliers with textures and expose ARM/ORM, Separate Maps, and custom + channel selection. +- [x] Add registry-v3 typed Texture properties and a shared deterministic UASTC/KTX2 processor. +- [x] Canonicalize AO/Roughness/Metallic selections to R/G/B ARM and publish runtime-catalog v2 + paths for Forward and Surface/Solari consumers. +- [x] Extend the explicit transactional upgrader for registry v2 and Material/Instance/Shader + Schema v1 documents; current normal loading remains non-writing. +- [ ] Codex-owned critical native acceptance covers persistent live edits, scalar Save without + processing, ARM background processing, final wide/narrow material cards, long Content Browser + labels/status, clean-value recovery retirement, restart persistence, and Forward/Solari + presentation. The user explicitly delegated this QA; low-quality UI is a failure, not + acceptable evidence. diff --git a/.cursor/plans/deterministic_asset_fingerprints_2026-07-13.plan.md b/.cursor/plans/deterministic_asset_fingerprints_2026-07-13.plan.md index 41cc021..31ac6ac 100644 --- a/.cursor/plans/deterministic_asset_fingerprints_2026-07-13.plan.md +++ b/.cursor/plans/deterministic_asset_fingerprints_2026-07-13.plan.md @@ -1,5 +1,7 @@ # Deterministic Imported-Asset Fingerprints +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + **Status:** Complete at `cbd380a9413411667a83f74b66c5a2feda5e1af7`; see [`docs/editor/evaluations/deterministic-asset-fingerprints/`](../../docs/editor/evaluations/deterministic-asset-fingerprints/). diff --git a/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md b/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md index 505e937..6e7fb96 100644 --- a/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md +++ b/.cursor/plans/editor_sample_regression_pack_2026-07-13.plan.md @@ -1,5 +1,7 @@ # Editor Sample Regression Pack +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Date: 2026-07-13 Issue: BS-JD-501 / Gitea #32 Milestone: M5 - Regression, docs, and first-hour UX diff --git a/.cursor/plans/fbx_external_texture_dependencies_2026-07-13.plan.md b/.cursor/plans/fbx_external_texture_dependencies_2026-07-13.plan.md index 8a705ee..a2cb162 100644 --- a/.cursor/plans/fbx_external_texture_dependencies_2026-07-13.plan.md +++ b/.cursor/plans/fbx_external_texture_dependencies_2026-07-13.plan.md @@ -1,5 +1,7 @@ # FBX External Texture Dependencies +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + **Issue:** Gitea #58 (`BS-JD-210`) ## Goal diff --git a/.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md b/.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md index 949b5d6..8fc21dd 100644 --- a/.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md +++ b/.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md @@ -1,5 +1,7 @@ # Guarded Shutdown And History Savepoints +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Date: 2026-07-13 Issue: BS-PR-709 / Gitea #55 Milestone: M7 - Production Readiness diff --git a/.cursor/plans/jackdaw_feature_roadmap_2026-06-06.plan.md b/.cursor/plans/jackdaw_feature_roadmap_2026-06-06.plan.md index 6c2fb29..5af4d90 100644 --- a/.cursor/plans/jackdaw_feature_roadmap_2026-06-06.plan.md +++ b/.cursor/plans/jackdaw_feature_roadmap_2026-06-06.plan.md @@ -1,5 +1,7 @@ # Blacksite Editor Roadmap: Jackdaw-Inspired Feature Tickets +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) and exact current Gitea scope instead of its original ticket drafts. + Date: 2026-06-06 Target repository: `Falling-Metal-Interactive/Blacksite` on `main`. @@ -15,7 +17,7 @@ Blacksite already has the hard foundation pieces: an editor crate, ordered plugi Recommended sequence: - **M0 - Foundation and polish seams:** Make the sandbox easy to start, easy to extend internally, and safe to mutate. This is the prerequisite for bigger tools. - **M1 - Brush-based blockout:** Add the Jackdaw-inspired level-blockout workflow: brush schema, draw/edit modes, CSG, per-face materials. -- **M2 - Materials and asset pipeline:** Make assets feel live and production-like: material browser, PBR grouping, drag/drop assignment, reference repair, headless processing. +- **M2 - Content workspace and asset pipeline:** Make project content user-organized and production-like: file-manager operations, destination-first import, model-asset material mapping and extraction, live refresh, reference repair, and headless processing. The expanded implementation contract lives in [`content_workspace_and_import_authoring_2026-07-13.plan.md`](content_workspace_and_import_authoring_2026-07-13.plan.md). - **M3 - Terrain and physics placement:** Add larger-world authoring and practical prop placement: terrain sculpting plus physics drop/settle. - **M4 - Extensibility and runtime integration:** Turn Blacksite from a single editor crate into a future-game editor platform through static extensions and narrow remote prototypes. - **M5 - Regression, docs, and first-hour UX:** Lock in quality: samples, tests, profiling, docs, and first-run guidance. @@ -40,6 +42,7 @@ Recommended sequence: | Operator framework | Blacksite has EditorHistory and EditorCommandRegistry. | Unify all mutating actions behind preview/commit/cancel/undo operators. | BS-JD-004 | | Brushes and CSG | Blacksite has primitives/static meshes, no native brush geometry yet. | Add authoring brush data, draw/edit modes, CSG, per-face material/UV. | BS-JD-101 to BS-JD-106 | | Material browser and PBR auto-detection | Blacksite has MaterialAsset, material overrides, asset browser, thumbnails. | Add dedicated Material Browser, texture-set detection, drag/drop material assignment. | BS-JD-201 to BS-JD-203 | +| Content workspace and model import authoring | The browser scans nested folders, but imports use fixed type directories and imported model defaults are not editable project mappings. | Make folders organizational, import to a chosen destination, add safe file operations, and expose editable model material defaults/extraction. | BS-JD-205, BS-JD-211 to BS-JD-217 | | Terrain sculpting | No native terrain authoring found in inspected Blacksite editor modules. | Add heightmap/chunk terrain MVP and brush tools. | BS-JD-301 to BS-JD-303 | | Physics placement tool | Blacksite has Avian, ColliderDesc/RigidBodyDesc, visualizers, PIE sim. | Add selected-body drop/settle/commit tool and collider diagnostics. | BS-JD-304 to BS-JD-305 | | Dylib extensions | Blacksite has EditorPlugin, command palette, ActorInspectorSection, optional game hot reload. | Stabilize static extension API first; treat dylib as later experimental. | BS-JD-401 to BS-JD-403 | @@ -71,16 +74,27 @@ Add the Jackdaw-inspired level-blockout workflow: brush schema, draw/edit modes, - **BS-JD-105 [P1] Per-face material and UV controls** - Allow brush faces to reference textures/materials and edit UV offset, scale, and rotation. - **BS-JD-106 [P2] Brush validation, repair, and diagnostics** - Add robust error handling for degenerate brushes, inverted normals, zero-area faces, and non-manifold results. -### M2: Materials and asset pipeline +### M2: Content workspace and asset pipeline -Make assets feel live and production-like: material browser, PBR grouping, drag/drop assignment, reference repair, headless processing. +Make project content user-organized and production-like. The original material browser, PBR grouping, +drag/drop, watcher, reference-repair, and headless-processing slices remain; destination-first import, +path-agnostic asset typing, model-asset material defaults, and editable source-material extraction are +specified in +[`content_workspace_and_import_authoring_2026-07-13.plan.md`](content_workspace_and_import_authoring_2026-07-13.plan.md). - **BS-JD-201 [P0] Material Browser and shared material catalog** - Create a dedicated Material Browser panel for named PBR materials, scene-local materials, and project-wide material assets. - **BS-JD-202 [P1] PBR texture-set auto-detection** - Detect texture sets from filenames and generate draft materials automatically. - **BS-JD-203 [P0] Drag/drop material and texture application** - Make texture/material drag/drop work consistently for static mesh actors, primitive actors, and brush faces. - **BS-JD-204 [P1] Live asset watcher and refresh** - Watch assets/ and refresh asset catalog, thumbnails, and material registry without manual refresh. -- **BS-JD-205 [P1] Asset rename/move reference repair** - Track asset moves/renames and repair scene/material/prefab references. +- **BS-JD-205 [P0] Content Browser file operations and reference-safe moves** - Add file-manager operations with stable identity, reference repair, and transactional rollback. - **BS-JD-206 [P2] Headless asset processing command** - Add xtask/cargo-style command to process assets without opening the editor UI. +- **BS-JD-211 [P0] Content workspace and import authoring refactor** - Coordinate the expanded M2 product contract and acceptance path. +- **BS-JD-212 [P0] Project content and derived-artifact contract** - Fix the storage, identity, typing, provenance, and material-resolution contract before implementation. +- **BS-JD-213 [P0] Destination-first transactional asset import** - Import or adopt content in the current or explicitly selected project folder. +- **BS-JD-214 [P0] Path-agnostic asset catalog, loading, validation, and packaging** - Remove conventional-folder semantics from every asset consumer. +- **BS-JD-215 [P0] Static and skinned model-asset material slot mappings** - Persist project Material defaults by stable imported slot ID. +- **BS-JD-216 [P1] Editable imported-material extraction and per-slot import policy** - Extract supported source materials as ordinary editable project assets without silent reimport overwrite. +- **BS-JD-217 [P0] M2 content-workspace migration and acceptance signoff** - Gate migration, native editor, headless, and packaged behavior with one representative workflow. ### M3: Terrain and physics placement @@ -763,38 +777,43 @@ Lock in quality: samples, tests, profiling, docs, and first-run guidance. **Docs to update:** - docs/editor/assets.md. -### BS-JD-205 - Asset rename/move reference repair +### BS-JD-205 - Content Browser file operations and reference-safe moves **Milestone:** M2 -**Priority:** P1 +**Priority:** P0 **Area:** Assets -**Summary:** Track asset moves/renames and repair scene/material/prefab references. +**Summary:** Turn the Content Browser into a safe project file manager while preserving stable asset identity and authored references. -**Why this matters:** General editors need robust asset references. Jackdaw has project-wide catalog concepts; Blacksite already has stable UUIDs in AssetRegistry, so this is a natural improvement. +**Why this matters:** Users need to organize content entirely inside the editor without losing scene, prefab, material, model-slot, thumbnail, or registry continuity. **Implementation notes:** - Treat AssetId as source of truth and path as mutable metadata. -- On file watcher rename or Asset Browser rename, update AssetRecord.path and all EditorAssetRef paths where asset_id matches. -- Add a missing-reference resolver dialog for files changed outside the editor. -- Add Asset Browser actions: Rename, Move, Duplicate, Delete with reference impact preview. +- Add Create Folder, Rename, Move, Duplicate, Cut/Copy/Paste, drag-to-folder, trash, and restore. +- Use guarded staged filesystem transactions with collision and dependency previews. +- Repair cached paths across scenes, prefabs, Materials/Instances, model material mappings, generated manifests, thumbnails, and registry metadata. +- Reconcile unambiguous external moves by stable identity and send ambiguous cases through an explicit repair flow. +- Protect editor-managed index, trash, thumbnail, and derived-artifact locations. **Acceptance criteria:** -- Renaming a material or model inside editor updates scene refs and registry. -- Opening a scene with missing path but matching AssetId repairs path if registry knows it. -- Deleting an asset warns about usages before commit. +- Arbitrary nested folders and all scoped file operations work entirely in the Content Browser. +- Moving supported authored/imported assets or a containing folder retains UUID identity and valid references after save/reopen. +- Recursive operations preview collisions and reference impact before commit. +- Cancelled or failed operations leave the filesystem and registry byte-for-byte unchanged. +- Trash/restore retains identity; ambiguous external moves are never guessed. **Polish details:** - Reference impact dialog lists affected scenes/entities/material slots. -- Offer 'Keep broken reference' for deliberate missing assets. -- Add undo for in-editor rename/move where practical. +- Selection, breadcrumbs, thumbnails, Material Library usage, validation, and PIE remain coherent after moves. +- Offer an explicit broken-reference choice when retaining a missing dependency is deliberate. **Tests:** -- Registry path update tests. -- Scene ref repair tests with missing asset path. +- Recursive transaction/rollback and registry-path tests. +- Reference-repair fixtures across scenes, prefabs, Materials, and model mappings. +- Native drag-to-folder, restart, and external-move acceptance. **Docs to update:** -- docs/editor/assets.md reference model. +- Canonical content-workspace guide defined by the expanded M2 plan. ### BS-JD-206 - Headless asset processing command diff --git a/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md b/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md index 7ab7374..2ef45d4 100644 --- a/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md +++ b/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Material Library And Targeted Viewport Drop +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea issues [`#16`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/16) and [`#18`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/18). diff --git a/.cursor/plans/navigation_authoring_2026-07-11.plan.md b/.cursor/plans/navigation_authoring_2026-07-11.plan.md index 539dc48..49e57bb 100644 --- a/.cursor/plans/navigation_authoring_2026-07-11.plan.md +++ b/.cursor/plans/navigation_authoring_2026-07-11.plan.md @@ -1,5 +1,7 @@ # Navigation Mesh Authoring, Bake Diagnostics, And Path Preview +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea issue [`#48`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48). This is the navigation production loop required by the M7 content-production milestone. diff --git a/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md b/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md index 6b743b5..2cdcf44 100644 --- a/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md +++ b/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Non-Blocking Native Dialogs +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for the production-readiness defect discovered during live acceptance of collaborative file safety. Opening an `rfd` picker synchronously from an egui/Bevy system stops window event processing long enough for Hyprland to report Blacksite as unresponsive. diff --git a/.cursor/plans/operator_invariants_completion_2026-07-12.plan.md b/.cursor/plans/operator_invariants_completion_2026-07-12.plan.md index c131a0d..5d3c2f5 100644 --- a/.cursor/plans/operator_invariants_completion_2026-07-12.plan.md +++ b/.cursor/plans/operator_invariants_completion_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Operator Invariants Completion +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working plan for Gitea [`#33`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33). diff --git a/.cursor/plans/physics_placement_2026-07-12.plan.md b/.cursor/plans/physics_placement_2026-07-12.plan.md index 4388188..3d76e8c 100644 --- a/.cursor/plans/physics_placement_2026-07-12.plan.md +++ b/.cursor/plans/physics_placement_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Physics Placement Tool - 2026-07-12 +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Issue: Gitea #25 (`BS-JD-304`) ## Outcome diff --git a/.cursor/plans/production_readiness_acceptance_2026-07-12.plan.md b/.cursor/plans/production_readiness_acceptance_2026-07-12.plan.md index 3d53ad8..aa595f0 100644 --- a/.cursor/plans/production_readiness_acceptance_2026-07-12.plan.md +++ b/.cursor/plans/production_readiness_acceptance_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Production-Readiness Acceptance Gate +> **Active plan — desired scope and acceptance, not implementation truth.** Current shipped behavior is indexed in the [canonical documentation](../../docs/README.md). + Working plan for Gitea issue [`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50). This gate is broader than the M7 feature list: it proves the complete daily authoring, content, diff --git a/.cursor/plans/project_roadmap_8a452d43.plan.md b/.cursor/plans/project_roadmap_8a452d43.plan.md index 95444b0..4c16789 100644 --- a/.cursor/plans/project_roadmap_8a452d43.plan.md +++ b/.cursor/plans/project_roadmap_8a452d43.plan.md @@ -71,6 +71,8 @@ todos: isProject: false --- +> **Superseded plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) and exact current Gitea scope. + ## Project Roadmap (Plan of Plans) This is the top-level strategy. Each milestone (M0-M8) becomes its own detailed sub-plan when started. Milestone M1 (Editor Build-Out) is fully contained inline in the "M1 detail" section below; later milestones get their detailed plans spun out as they begin. @@ -212,4 +214,4 @@ M1 out of scope (later): per-field inspector undo, multi-scene tabs, full PBR ma This roadmap is the index, and M1's full detail is contained inline above. Starting a later milestone expands its detailed scope/files/todos the same way (either inline or as a spun-out plan). M0 and M2 are the immediate next planning targets; M3 begins with a time-boxed Lightyear spike before committing. ### Out of scope for now -- Concrete release dates/staffing (cadence assumptions only), console ports, peer-to-peer topology, and full anti-cheat beyond server authority. \ No newline at end of file +- Concrete release dates/staffing (cadence assumptions only), console ports, peer-to-peer topology, and full anti-cheat beyond server authority. diff --git a/.cursor/plans/renderer_foundation_acceptance_2026-07-12.plan.md b/.cursor/plans/renderer_foundation_acceptance_2026-07-12.plan.md index 2b22393..8588b1a 100644 --- a/.cursor/plans/renderer_foundation_acceptance_2026-07-12.plan.md +++ b/.cursor/plans/renderer_foundation_acceptance_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Renderer Foundation Acceptance Split - 2026-07-12 +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Issue: Gitea #51 (`BS-JD-207`) ## Outcome diff --git a/.cursor/plans/renderer_material_component_foundation_2026-07-11.plan.md b/.cursor/plans/renderer_material_component_foundation_2026-07-11.plan.md index a16352b..b59f7e7 100644 --- a/.cursor/plans/renderer_material_component_foundation_2026-07-11.plan.md +++ b/.cursor/plans/renderer_material_component_foundation_2026-07-11.plan.md @@ -1,5 +1,7 @@ # Renderer, material, and component foundation +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + ## Goal Separate static and skinned renderer ownership, restore deterministic skinned edit poses, add shared diff --git a/.cursor/plans/rendering_unification_2026-06-05.plan.md b/.cursor/plans/rendering_unification_2026-06-05.plan.md index 2a78878..6e72e1c 100644 --- a/.cursor/plans/rendering_unification_2026-06-05.plan.md +++ b/.cursor/plans/rendering_unification_2026-06-05.plan.md @@ -1,5 +1,7 @@ # Rendering Unification Plan +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + ## Summary Unify the Bevy 0.18 rendering framework around a single effective render-stack contract. Preserve diff --git a/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md b/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md index 39ee0d2..fcae1bd 100644 --- a/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md +++ b/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Source-Control Status And Collaborative File Safety +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea issue [`#49`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49). This is the final M7 implementation slice before the production-readiness acceptance gate. diff --git a/.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md b/.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md index 50d9f7b..49968c9 100644 --- a/.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md +++ b/.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md @@ -1,5 +1,7 @@ # Static Mesh Asset Refactor Plan +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Date: 2026-06-05 ## Scope diff --git a/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md b/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md index ea13d30..fb81f8d 100644 --- a/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md +++ b/.cursor/plans/terrain_authoring_foundation_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Terrain Authoring Foundation +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + Working implementation plan for Gitea [`#22`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22). This establishes the persistent and hydrated terrain contract required by sculpting `#23`, layer diff --git a/.cursor/plans/terrain_material_layers_2026-07-12.plan.md b/.cursor/plans/terrain_material_layers_2026-07-12.plan.md index 36b79a8..8ff84ff 100644 --- a/.cursor/plans/terrain_material_layers_2026-07-12.plan.md +++ b/.cursor/plans/terrain_material_layers_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Terrain Material Layers And Weight Painting (#24) +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + ## Scope - Persist up to four shared Material/Material Instance references on each `TerrainDesc`. diff --git a/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md b/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md index 2e88bf7..1097447 100644 --- a/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md +++ b/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md @@ -1,5 +1,7 @@ # Terrain Sculpt Tools (#23) +> **Historical plan — not current implementation guidance.** Use the [documentation index](../../docs/README.md) for current behavior and architecture. + ## Scope - Add a modal terrain sculpt operator for raise, lower, flatten, smooth, and deterministic noise. diff --git a/.cursor/rules/documentation.mdc b/.cursor/rules/documentation.mdc index e823627..81cfb7f 100644 --- a/.cursor/rules/documentation.mdc +++ b/.cursor/rules/documentation.mdc @@ -1,42 +1,13 @@ --- -description: Keep project documentation current—ADRs, mission, editor docs, README checklist +description: Follow Blacksite repository and documentation authority rules alwaysApply: true --- -# Documentation Maintenance +# Repository instructions -When changing behavior, architecture, or user-facing workflows, **update docs in the same task**—do not defer. +Follow the root [`AGENTS.md`](../../AGENTS.md) and the closest subtree `AGENTS.md`. Documentation +work also follows [`docs/AGENTS.md`](../../docs/AGENTS.md), with document status and authority read +from [`docs/authority.toml`](../../docs/authority.toml). -## Doc map (single source of truth: [docs/README.md](../../docs/README.md)) - -| Change type | Update | -|-------------|--------| -| Architecture / crate boundary / sim-network split | New or updated ADR in `docs/adr/` | -| Editor framework intent, principles, non-goals | `docs/mission.md` | -| Editor feature design or phase status | `docs/editor/` (see its README) | -| User controls, run commands, troubleshooting | Root `README.md` | -| Shipped feature completion | Root `README.md` Implementation Checklist | -| Bevy upgrade policy | ADR 0002 + migration notes in `docs/adr/` | -| Milestone scope (pre-implementation) | `.cursor/plans/` plan file | - -## ADRs - -Create `docs/adr/NNNN-short-title.md` when a decision is **hard to reverse** or affects multiple crates (rendering path, scene format, PIE semantics, settings schema, netcode choice). - -Format: Status, Context, Decision, Consequences. Number sequentially. Link from `docs/README.md` and root README. - -## Coherence rules - -- **One home per fact.** Link across docs; do not copy paragraphs between README, mission, and ADRs. -- **Intent vs implementation:** mission/ADRs = *why*; README = *how to use*; crate module docs = *local API*. -- **Plans vs permanent docs:** `.cursor/plans/` holds working roadmaps; promote stable decisions into `docs/` when implemented. -- **Stale docs are bugs.** If code contradicts docs, fix both or fix docs in the same PR/task. -- **New subsystems** get a short entry in `docs/README.md` and, if editor-facing, `docs/editor/README.md`. - -## Agent checklist (end of relevant tasks) - -1. Did behavior or UX change? → README controls / checklist -2. Did architecture or policy change? → ADR or mission -3. Did editor workflow change? → `docs/editor/` -4. Added a new doc file? → index in `docs/README.md` -5. Left a deliberate gap? → "Future work" in README or an ADR Consequences section—not silent omission +This Cursor rule is intentionally only a pointer so workflow and documentation policy have one +maintained source. diff --git a/.gitattributes b/.gitattributes index 6f9142e..1eceb98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -20,6 +20,7 @@ *.tga filter=lfs diff=lfs merge=lfs -text *.exr filter=lfs diff=lfs merge=lfs -text *.hdr filter=lfs diff=lfs merge=lfs -text +*.basis filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text *.ogg filter=lfs diff=lfs merge=lfs -text *.mp3 filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea051e..694cef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: - name: Check formatting run: cargo fmt --all --check + - name: Check architecture debt ratchet + run: python scripts/codex/architecture_audit.py check + - name: Check workspace run: cargo check --workspace --all-targets diff --git a/.gitignore b/.gitignore index 8a93262..a869d60 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ /target/ /dist/ -# Local automation and assistant state -/.agents/ -/.codex/ +# Local automation and assistant state. Repository instructions, Skills, +# configuration, and templates remain tracked. +/.codex/session/ +/.codex/logs/ +/.codex/cache/ +/.codex/evidence/ # Local environment files .env @@ -17,6 +20,8 @@ /.blacksite/backups/ # Backup, temporary, and log files +__pycache__/ +*.py[cod] **/*.rs.bk Cargo.lock.bak *.bak diff --git a/.vscode/launch.json b/.vscode/launch.json index bd081d5..bd2e9b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,35 +43,6 @@ "sourceLanguages": ["rust"], "gracefulShutdown": "SIGINT" }, - { - "type": "lldb", - "request": "launch", - "name": "Editor: Hot reload", - "cargo": { - "args": [ - "build", - "-p", - "editor", - "--bin", - "editor", - "--features", - "dev,hot-reload" - ], - "filter": { "name": "editor", "kind": "bin" }, - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }, - "problemMatcher": "$rustc" - }, - "args": [], - "env": { - "RUST_BACKTRACE": "1", - "WGPU_VALIDATION": "0", - "LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}" - }, - "cwd": "${workspaceFolder}", - "terminal": "integrated", - "sourceLanguages": ["rust"], - "gracefulShutdown": "SIGINT" - }, { "type": "lldb", "request": "launch", @@ -174,35 +145,6 @@ "sourceLanguages": ["rust"], "gracefulShutdown": "SIGINT" }, - { - "type": "lldb", - "request": "launch", - "name": "Game: Hot reload", - "cargo": { - "args": [ - "build", - "-p", - "game", - "--bin", - "game", - "--features", - "dev,hot-reload" - ], - "filter": { "name": "game", "kind": "bin" }, - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" }, - "problemMatcher": "$rustc" - }, - "args": [], - "env": { - "RUST_BACKTRACE": "1", - "WGPU_VALIDATION": "0", - "LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}" - }, - "cwd": "${workspaceFolder}", - "terminal": "integrated", - "sourceLanguages": ["rust"], - "gracefulShutdown": "SIGINT" - }, { "type": "lldb", "request": "launch", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3692069..80b1ce3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -35,9 +35,15 @@ }, { "label": "cargo clippy (launch feature matrix)", - "type": "cargo", - "command": "clippy", + "type": "shell", + "command": "python", "args": [ + "scripts/codex/cargo_lane.py", + "exec", + "hot-reload", + "--", + "cargo", + "clippy", "-p", "editor", "-p", @@ -101,52 +107,34 @@ "group": "build" }, { - "label": "clean dev-link crates", - "type": "cargo", - "command": "clean", - "args": ["-p", "editor", "-p", "game", "-p", "game_hot", "-p", "shared"], - "options": { - "cwd": "${workspaceFolder}", - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } - }, - "problemMatcher": [] - }, - { - "label": "clean build editor (dev)", - "dependsOrder": "sequence", - "dependsOn": ["clean dev-link crates", "build editor (dev fast-link)"], - "problemMatcher": [], - "group": "build" - }, - { - "label": "target cleanup (dry run)", + "label": "build storage status", "type": "shell", - "command": "cargo clean-target", + "command": "python", + "args": ["scripts/codex/build_storage.py", "status"], "options": { - "cwd": "${workspaceFolder}", - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } + "cwd": "${workspaceFolder}" }, "problemMatcher": [], "group": "build" }, { - "label": "target cleanup (safe apply)", + "label": "build storage prune (dry run)", "type": "shell", - "command": "cargo clean-target --apply", + "command": "python", + "args": ["scripts/codex/build_storage.py", "prune", "--dry-run"], "options": { - "cwd": "${workspaceFolder}", - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } + "cwd": "${workspaceFolder}" }, "problemMatcher": [], "group": "build" }, { - "label": "target cleanup (deep stale, 3 days)", + "label": "build storage prune (apply)", "type": "shell", - "command": "cargo clean-target --include-artifacts --days 3 --apply", + "command": "python", + "args": ["scripts/codex/build_storage.py", "prune", "--apply"], "options": { - "cwd": "${workspaceFolder}", - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } + "cwd": "${workspaceFolder}" }, "problemMatcher": [], "group": "build" @@ -185,9 +173,15 @@ }, { "label": "run editor (hot reload)", - "type": "cargo", - "command": "run", + "type": "shell", + "command": "python", "args": [ + "scripts/codex/cargo_lane.py", + "exec", + "hot-reload", + "--", + "cargo", + "run", "-p", "editor", "--bin", @@ -199,8 +193,7 @@ "cwd": "${workspaceFolder}", "env": { "RUST_BACKTRACE": "1", - "WGPU_VALIDATION": "0", - "CARGO_TARGET_DIR": "${workspaceFolder}/target" + "WGPU_VALIDATION": "0" } }, "problemMatcher": ["$rustc"], @@ -208,15 +201,27 @@ }, { "label": "run game (hot reload)", - "type": "cargo", - "command": "run", - "args": ["-p", "game", "--bin", "game", "--features", "dev,hot-reload"], + "type": "shell", + "command": "python", + "args": [ + "scripts/codex/cargo_lane.py", + "exec", + "hot-reload", + "--", + "cargo", + "run", + "-p", + "game", + "--bin", + "game", + "--features", + "dev,hot-reload" + ], "options": { "cwd": "${workspaceFolder}", "env": { "RUST_BACKTRACE": "1", - "WGPU_VALIDATION": "0", - "CARGO_TARGET_DIR": "${workspaceFolder}/target" + "WGPU_VALIDATION": "0" } }, "problemMatcher": ["$rustc"], @@ -259,10 +264,23 @@ { "label": "watch game_hot (hot reload)", "type": "shell", - "command": "cargo watch -w crates/game_hot -w crates/sim -x \"build -p game_hot --features dylib\"", + "command": "python", + "args": [ + "scripts/codex/cargo_lane.py", + "exec", + "hot-reload", + "--", + "cargo", + "watch", + "-w", + "crates/game_hot", + "-w", + "crates/sim", + "-x", + "build -p game_hot --features dylib" + ], "options": { - "cwd": "${workspaceFolder}", - "env": { "CARGO_TARGET_DIR": "${workspaceFolder}/target" } + "cwd": "${workspaceFolder}" }, "problemMatcher": ["$rustc"], "group": "build", diff --git a/AGENTS.md b/AGENTS.md index 0881f6d..2369eec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,35 +1,174 @@ -# Agent Instructions +# Blacksite Codex operating rules -## Documentation Maintenance +## Sources of truth -When changing behavior, architecture, or user-facing workflows, update documentation in the same task. Do not defer documentation updates for implemented work. +For desired scope and acceptance: -Use `docs/README.md` as the documentation map and keep each fact in one home: +1. The user's latest instruction. +2. The current Gitea issue, epic, and milestone scope, including newer explicit scope-change comments. +3. The active implementation plan, when one exists. -| Change type | Update | -|-------------|--------| -| Architecture, crate boundaries, or sim/network split | New or updated ADR in `docs/adr/` | -| Editor framework intent, principles, or non-goals | `docs/mission.md` | -| Editor feature design or phase status | `docs/editor/` | -| User controls, run commands, or troubleshooting | Root `README.md` | -| Shipped feature completion | Root `README.md` implementation checklist | -| Bevy upgrade policy | ADR 0002 and migration notes in `docs/adr/` | -| Milestone scope before implementation | `.cursor/plans/` plan file | +For actual implemented behavior: -Create an ADR in `docs/adr/NNNN-short-title.md` when a decision is hard to reverse or affects multiple crates, such as rendering path, scene format, PIE semantics, settings schema, or netcode choice. Use this format: Status, Context, Decision, Consequences. Number ADRs sequentially and link new ADRs from `docs/README.md` and the root README when relevant. +1. Current source code and schemas. +2. Current tests and deterministic generated outputs. +3. Current native acceptance evidence. +4. Canonical current documentation. -Coherence rules: +For architecture: -- One home per fact. Link across docs; do not copy paragraphs between README, mission, and ADRs. -- Intent vs implementation: mission/ADRs explain why, README explains how to use, crate module docs explain local API. -- Plans vs permanent docs: `.cursor/plans/` holds working roadmaps; promote stable decisions into `docs/` when implemented. -- Stale docs are bugs. If code contradicts docs, fix both or fix docs in the same PR/task. -- New subsystems get a short entry in `docs/README.md` and, if editor-facing, `docs/editor/README.md`. +1. Accepted ADRs. +2. Current public types and dependency boundaries. +3. Canonical architecture documentation. -End-of-task checklist for relevant implementation work: +Before using repository documentation as design guidance, read `docs/authority.toml`. Historical +plans, evaluations, session logs, archived docs, and superseded docs are not current implementation +guidance. When sources conflict, do not silently blend them. Reconcile current code, canonical docs, +active tracker scope, and the user's latest direction. -1. Did behavior or UX change? Update README controls or checklist. -2. Did architecture or policy change? Update or create an ADR or mission note. -3. Did an editor workflow change? Update `docs/editor/`. -4. Added a new doc file? Index it in `docs/README.md`. -5. Left a deliberate gap? Record it as future work in README or an ADR Consequences section. +## Work modes and remote authority + +- "Plan", "audit", "investigate", "review", and "propose" are read-only unless the user explicitly + authorizes mutation. +- "Implement", "fix", "apply", and equivalent wording authorize local repository edits. +- Do not push, force-push, close tracker items, create tracker items, or change milestone scope without + authority from `.codex/workflow.toml` or a direct user instruction. +- Routine Gitea comments and checklist synchronization may occur only through + `blacksite-gitea-sync` and within its configured permissions. + +## Start of task + +1. Read `.codex/session/STATE.md` when it exists. +2. Verify repository root, canonical real path, branch, HEAD, dirty state, and relevant active + processes. +3. Read the closest applicable `AGENTS.md` files. +4. Read only canonical docs for the affected topic as classified by `docs/authority.toml`. +5. Read exact linked Gitea items when the task is tracked. +6. Establish the goal, acceptance criteria, non-goals, affected subsystem, and verification tier. + +Do not repeat broad repository discovery when valid session state already exists. + +## User steering and scope deltas + +User steering is authoritative. When the target changes materially: + +1. Record a scope delta in `.codex/session/STATE.md`. +2. Classify it as an active-slice refinement, discovered blocker, added acceptance criterion, or + separate follow-up. +3. Record which evidence remains valid and which gates are invalidated. +4. Synchronize active tracker scope/status through the configured workflow when appropriate. +5. Continue without rerunning unrelated gates or rediscovering the repository. + +A missing product behavior found during native acceptance is not unrelated merely because it was +absent from the first prompt. + +## Token and context discipline + +- Search before reading large files; use targeted ranges and do not reread unchanged files. +- Keep raw command output in `.codex/logs/`; report concise results and bounded actionable excerpts. +- Never dump full issue lists, accessibility trees, desktop state, or build logs when exact queries + are available. +- Keep `.codex/session/STATE.md` current for compaction, reconnects, and path remapping. +- Use subagents only for narrow independent work that does not duplicate repository context. + +## Architecture for fast iteration + +- Minimize technical debt at the point of change. New behavior belongs in the smallest owning + domain module; an already-large file is not permission to grow it. +- Keep Inspector and Content Browser panel shells thin and dispatch through registered/domain + extension seams. Run `python scripts/codex/architecture_audit.py check` for production changes. +- Architecture-audit exceptions must name a tracker issue, rationale, hard cap, extraction target, + and expiry milestone; passing with an exception is not completion evidence. +- Keep UI-independent logic out of the heavy editor UI crate when practical. +- Prefer `shared`, `content_pipeline`, or another lightweight core crate for schemas, transactions, + classification, import planning, validation, and deterministic processing. +- The editor renders state and dispatches operations; it should not own reusable headless logic. +- `xtask` and headless processors must not depend on the editor crate. +- Put non-rendering tests in the lightest crate that owns the invariant. +- Do not add Bevy rendering dependencies to headless code without a proven requirement. + +## Verification + +Use `blacksite-selective-verify` and its verification matrix. + +- **Fast loop:** formatting as needed, affected-package check, and focused tests. +- **Slice gate:** affected-package tests and Clippy plus relevant domain checks. +- **Candidate gate:** full workspace/all-feature tests and lint, deterministic content checks, + validators, packaging, and named native scenarios. + +Do not run the candidate gate during ordinary iteration. Do not run a full workspace check directly +before a full workspace test unless it covers an otherwise-uncompiled target. Do not rerun a passed +gate when its inputs are unchanged. Never use unscoped `cargo clean`; scoped lane deletion is allowed +only through the build-storage workflow. Keep toolchain, features, profile, target, build directory, +and `RUSTFLAGS` stable within a lane. + +## Build artifacts and disk budget + +- Every Codex Cargo invocation runs through `scripts/codex/verify.py` or + `scripts/codex/cargo_lane.py`. +- Do not invent ad hoc target directories. +- Use one persistent ordinary-development cache. Candidate, all-feature/hot-reload, full-debug, + cross-target, and package caches are exceptional lanes with explicit retention limits. +- Run `scripts/codex/build_storage.py enforce --phase pre` before a heavy build and `--phase post` + afterward. +- Do not contaminate the persistent lane with a different feature set, profile, target, wrapper, + linker configuration, or `RUSTFLAGS`. +- Preserve required binaries/evidence before pruning a disposable lane. +- Never delete individual files from Cargo's `deps`, `.fingerprint`, `build`, or `incremental` + layouts by age. Delete only a complete verified workflow-managed lane. +- At the hard limit or free-space floor, stop starting heavy builds, prune safe expired lanes, and + schedule a persistent-lane reset at a safe slice boundary if still required. + +## Documentation maintenance + +Update documentation in the same task when behavior, architecture, or user workflows change, but +publish canonical updates at a stable slice boundary rather than after every tiny edit. Use +`blacksite-doc-integrity`; classify every document in `docs/authority.toml`; and treat historical +plans and evidence as records, not current requirements. + +| Change type | Canonical home | +|-------------|----------------| +| Architecture or crate boundary | Accepted ADR in `docs/adr/` | +| Editor framework intent or non-goals | `docs/mission.md` | +| Editor feature workflow | `docs/editor/` | +| User controls, commands, troubleshooting | Root `README.md` | +| Shipped feature completion | Root README implementation checklist | +| Bevy upgrade policy | ADR 0002 and its migration notes | +| Milestone scope before implementation | An active `.cursor/plans/` plan | + +Keep one home per fact. Link instead of copying contracts across README, guides, plans, ADRs, and +tracker bodies. New subsystems must be indexed in `docs/README.md` and, when editor-facing, +`docs/editor/README.md`. Record deliberate gaps as future work in the owning canonical doc or an ADR +Consequences section. + +## Gitea + +- Read exact linked issues, epics, and milestones; avoid broad unfiltered listing. +- Distinguish Implementing, Engineering-complete, Acceptance-in-progress, Candidate-ready, and + Closed. +- Add at most one meaningful tracker update per completed slice or material scope change. +- Do not mark criteria complete without code/test/native evidence, and do not call an issue complete + from a dirty worktree. +- Closure requires a nominated commit and configured release evidence. +- Keep epic checklists and milestone composition aligned with material child-scope changes. + +## Native editor quality + +Use `blacksite-editor-ux` for editor UX changes and `blacksite-native-qa` for interactive evidence. +Automated tests are not user acceptance. For UI work, exercise the rendered interaction flow and +relevant empty, selection, invalid, read-only, narrow-layout, cancel, undo, and restart states. Honor +an explicit user instruction that they will perform visual QA. + +## Completion states + +Use these states precisely: + +- Implementing +- Engineering-complete +- Acceptance-in-progress +- Candidate-ready +- Accepted/closed + +Do not say "complete" when only engineering checks pass. Once the current acceptance target is met, +stop broad adjacent auditing unless a shared invariant requires a bounded sibling audit or the user +asks for more. diff --git a/Cargo.lock b/Cargo.lock index fa1d369..6347913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -214,6 +232,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arbitrary-chunks" version = "0.4.1" @@ -240,6 +264,17 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -258,6 +293,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -390,6 +434,40 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + [[package]] name = "avian3d" version = "0.7.0" @@ -428,6 +506,15 @@ dependencies = [ "syn", ] +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.13.1" @@ -446,6 +533,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basis-universal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555fb05709f4e12fa2f6b93a480facf167eb0ecb2558ba41f610f588e77cbd14" +dependencies = [ + "basis-universal-sys", + "bitflags 1.3.2", + "lazy_static", +] + +[[package]] +name = "basis-universal-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd9bde5e9547958fb0e77d79fc7879edcf91d5e0c8e372ef8959916cf35e8506" +dependencies = [ + "cc", +] + [[package]] name = "bevy" version = "0.19.0" @@ -1126,6 +1233,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37bc41f69a0c6ade2e7961602517545b39ab8e42bc8c4a729bd24ffee3bcd904" dependencies = [ + "basis-universal", "bevy_app", "bevy_asset", "bevy_color", @@ -2201,6 +2309,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -2217,6 +2331,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blacksite_surface" version = "0.1.0" @@ -2294,6 +2417,12 @@ dependencies = [ "serde", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -2542,6 +2671,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -2629,6 +2764,26 @@ dependencies = [ "const_soft_float", ] +[[package]] +name = "content_pipeline" +version = "0.1.0" +dependencies = [ + "basis-universal", + "bevy", + "bevy_ufbx", + "blake3", + "gltf", + "image", + "notify", + "ron 0.8.1", + "serde", + "serde_json", + "shared", + "ufbx", + "uuid", + "walkdir", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -3068,7 +3223,9 @@ dependencies = [ "bevy_egui", "bevy_solari", "bevy_ufbx", + "blacksite_surface", "blake3", + "content_pipeline", "egui_dock", "egui_phosphor_icons", "game", @@ -3271,6 +3428,26 @@ version = "0.34.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8970033a4282a7bcf899b38b5ed3a58b732fe093d03785d58648515d8d309da" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3334,6 +3511,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3695,6 +3889,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gilrs" version = "0.11.2" @@ -4327,14 +4531,38 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "2.14.0" @@ -4384,6 +4612,17 @@ dependencies = [ "libc", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "inventory" version = "0.3.24" @@ -4620,6 +4859,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "lewton" version = "0.10.2" @@ -4637,6 +4882,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4720,6 +4975,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4754,6 +5018,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.2" @@ -4893,6 +5167,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" @@ -4905,6 +5185,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -4921,12 +5210,27 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonmax" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "normpath" version = "1.5.1" @@ -5003,6 +5307,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -5645,6 +5950,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "peniko" version = "0.6.1" @@ -5921,6 +6232,19 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] [[package]] name = "protocol" @@ -5930,12 +6254,44 @@ dependencies = [ "serde", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -5985,10 +6341,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -6009,6 +6375,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -6018,6 +6394,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -6040,6 +6425,65 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6112,6 +6556,12 @@ dependencies = [ "font-types", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "rectangle-pack" version = "0.4.2" @@ -6223,6 +6673,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "robust" version = "1.2.0" @@ -6279,7 +6735,7 @@ dependencies = [ "const_format", "derive_more 0.99.20", "macro_rules_attribute", - "nom", + "nom 7.1.3", "unicode-ident", ] @@ -6381,6 +6837,7 @@ dependencies = [ "serde", "settings", "shared", + "uuid", ] [[package]] @@ -6506,6 +6963,7 @@ dependencies = [ "ron 0.8.1", "serde", "settings", + "uuid", ] [[package]] @@ -6560,6 +7018,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -7313,6 +7780,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -8432,6 +8910,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "blake3", + "content_pipeline", "libc", "ron 0.8.1", "scene", @@ -8442,6 +8921,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yazi" version = "0.2.1" @@ -8565,6 +9050,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index d6f5f20..037d3db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/blacksite_surface", + "crates/content_pipeline", "crates/game", "crates/game_hot", "crates/shared", @@ -29,6 +30,8 @@ avian3d = { version = "0.7", default-features = false, features = [ ] } bevy = { version = "0.19", features = [ "serialize", + "basis-universal", + "ktx2", "jpeg", "vorbis", "wav", @@ -38,6 +41,7 @@ bevy = { version = "0.19", features = [ bevy_core_pipeline = "0.19" bevy_solari = "0.19" blacksite_surface = { path = "crates/blacksite_surface" } +content_pipeline = { path = "crates/content_pipeline" } bevy_ufbx = "0.18.1-rc.1" bevy_egui = "0.40" bevy-inspector-egui = "0.37" diff --git a/README.md b/README.md index f4f1e16..c54bdc7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,33 @@ native Bevy scene save/load, and BRP support for external tooling. - A Vulkan-capable GPU + drivers (developed against an NVIDIA RTX 3080 Ti) - Linux build deps for `winit`/`wgpu` (ALSA, udev, etc.) if not already present +## Repository Workflow + +[`docs/authority.toml`](docs/authority.toml) classifies current documentation, active plans, +evidence, historical records, and superseded material. The complete workflow and policy index is +[`docs/workflow/codex-workflow.md`](docs/workflow/codex-workflow.md). Validate documentation changes +with `python scripts/codex/docs_audit.py`. + +Codex compile/test work uses `scripts/codex/verify.py` and `scripts/codex/cargo_lane.py` so focused +checks reuse one stable development lane and broad candidate gates run only when explicitly +nominated. The direct Cargo examples below remain contributor commands, not an instruction to rerun +the full set after every edit. + +| Workflow action | Command | +|-----------------|---------| +| Start a resumable task | `python scripts/codex/state.py init --goal "..." --accept "..." --slice "..." --next "..."` | +| Resume | `python scripts/codex/state.py resume` | +| Preview / run focused verification | `python scripts/codex/verify.py plan` / `python scripts/codex/verify.py fast` | +| Run a stable slice gate | `python scripts/codex/verify.py slice` | +| Inspect build storage | `python scripts/codex/build_storage.py status` | +| Run storage preflight / postflight | `python scripts/codex/build_storage.py enforce --phase pre` / `python scripts/codex/build_storage.py enforce --phase post` | +| Preview / apply safe pruning | `python scripts/codex/build_storage.py prune --dry-run` / `python scripts/codex/build_storage.py prune --apply` | +| Preserve a candidate before expiry | `python scripts/codex/build_storage.py mark-candidate-preserved --evidence --artifact ` | +| Audit documentation | `python scripts/codex/docs_audit.py` | +| Preview a named native scenario without launching | `bash scripts/codex/native_qa.sh plan material-slot-live-edit` | +| Sync tracker state | Ask Codex to use `blacksite-gitea-sync` in `read`, `plan`, or authorized `status` mode for exact issue IDs | +| Nominate / verify a candidate | `python scripts/codex/state.py nominate --commit ` then `python scripts/codex/verify.py candidate` | + ## Running ```bash @@ -29,6 +56,9 @@ cargo test --workspace cargo validate-levels cargo validate-samples cargo bake-navigation --project . --check +cargo process-assets --project . --check +# Refresh registry v3 and the stripped runtime content catalog after external content changes +cargo process-assets --project . # Validate one artifact without opening a game window cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron # Machine-readable project dependency and finding report @@ -59,7 +89,10 @@ cargo run -p editor --bin project_launcher --features dev The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard / Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor -process with that root. +process with that root. A normal desktop click launches the current managed development binary +without invoking Cargo. This keeps editor startup deterministic and prevents build-planning work +from consuming memory in the desktop session. When an explicit rebuild is required, run +`~/.local/bin/blacksite-editor --build-only`; that command uses Blacksite's managed Cargo lane. ### Hot reload (gameplay iteration) @@ -70,14 +103,17 @@ Add `--features hot-reload` for **in-process hot reload** of [`game_hot`](crates **Two-terminal workflow (hot reload):** ```bash -# Terminal 1 — rebuild the hot dylib on save -cargo watch -w crates/game_hot -w crates/sim -x "build -p game_hot --features dylib" +# Terminal 1 — rebuild the hot dylib on save in the managed hot-reload lane +python scripts/codex/cargo_lane.py exec hot-reload -- cargo watch -w crates/game_hot -w crates/sim -x "build -p game_hot --features dylib" -# Terminal 2 — run the editor once -cargo run -p editor --features dev,hot-reload +# Terminal 2 — run the editor once in the same lane +python scripts/codex/cargo_lane.py exec hot-reload -- cargo run -p editor --features dev,hot-reload ``` -Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor (hot reload)**, or choose **Editor: Hot reload** in Run and Debug. The watcher task requires `cargo-watch` (`cargo install cargo-watch --locked`). +Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor (hot reload)**. The +managed tasks intentionally replace the former CodeLLDB hot-reload launch so lane-specific runtime +paths are never guessed. The watcher task requires an existing `cargo-watch` installation; this +workflow never installs it implicitly. | Input | Action | |-------|--------| @@ -92,22 +128,25 @@ Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor ( ### Target Cache Cleanup -Cargo/Bevy debug artifacts can grow quickly. Use the workspace cleanup task before reaching for a full `cargo clean`: +Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup +before applying it: | Command | Effect | |---------|--------| -| `cargo clean-target` | Dry run: prints reclaimable `target/` cache. | -| `cargo clean-target --apply` | Safe cleanup: removes incremental and rust-analyzer flycheck cache. | -| `cargo clean-target --include-artifacts --days 3 --apply` | Deeper cleanup: also removes stale hashed `deps`, `build`, `.fingerprint`, and example artifacts older than 3 days. Cargo will rebuild anything still needed. | +| `python scripts/codex/build_storage.py status` | Measure repository output, managed lanes, package caches, and free space. | +| `python scripts/codex/build_storage.py plan` | Explain policy actions without deleting anything. | +| `python scripts/codex/build_storage.py enforce --phase pre` | Enforce budgets before a heavy command; under pressure, prune only expired verified disposable lanes after printing their checked plan. | +| `python scripts/codex/build_storage.py enforce --phase post` | Recheck after the command and apply the same bounded expired-lane policy. | +| `python scripts/codex/build_storage.py prune --dry-run` | Byte-count expired disposable lanes after path/sentinel safety checks. | +| `python scripts/codex/build_storage.py prune --apply` | Remove only the verified complete lanes shown by the dry run. | +| `python scripts/codex/build_storage.py reset-lane --dry-run` | Preview an explicit whole-lane reset. | +| `python scripts/codex/build_storage.py reset-lane --apply` | Apply that explicit whole-lane reset only after reviewing its printed dry run. | +| `python scripts/codex/build_storage.py mark-candidate-preserved --evidence --artifact ` | Hash evidence and at least one preserved artifact outside the candidate lane before that lane can expire. | -Use the safe cleanup during normal iteration and the three-day deep cleanup after Bevy upgrades, -feature-matrix builds, or large test runs. The cutoff preserves recent artifacts and avoids the full -rebuild caused by `cargo clean`. The workspace test profile keeps line tables but disables full test -debuginfo and incremental test caches, so routine test binaries stay materially smaller without -reducing normal editor debugging fidelity. The cleanup binary deliberately excludes the scene/Bevy -validation dependency; `cargo clean-target` therefore stays cheap even from a cold target. Use -`cargo validate-levels` when level and prefab-graph validation is required. VS Code tasks expose dry-run, safe, and -deep-stale variants. +Do not use an unscoped `cargo clean` as a troubleshooting reflex. Never delete individual Cargo +`deps`, `.fingerprint`, `build`, or `incremental` files by age; preserve nominated binaries/evidence +and remove only an entire verified disposable lane. See the +[build-storage policy](docs/workflow/build-storage-policy.md). ### Launch Troubleshooting @@ -115,7 +154,10 @@ deep-stale variants. - If the window maps but appears transparent on Hyprland or another Wayland compositor, launch with `BEVY_FPS_HDR=0` to force the SDR camera path while debugging monitor/compositor behavior. - Bevy 0.19 removed the prior local `bevy_render` swapchain-timeout patch; launch troubleshooting should start from current wgpu/driver/compositor logs. - Normal Debug and run configurations preserve project HDR and set `WGPU_VALIDATION=0` to suppress known Bevy/Solari Vulkan memory-model VUID noise on this stack. Use **GPU validation** when actively debugging renderer work; it forces `WGPU_VALIDATION=1` and may report those known upstream/driver messages. Use **SDR fallback** for compositor/HDR mapping failures; it additionally sets `BEVY_FPS_HDR=0`. -- If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, the incremental `target/` cache is stale. Run the VS Code task **clean build editor (dev)** or `cargo clean -p editor -p game -p game_hot -p shared && cargo build -p editor --bin editor --features dev`, then launch **Editor: Debug** again. Use `cargo run -p editor --features dev` from the terminal if you need `libbevy_dylib` on `LD_LIBRARY_PATH` automatically. +- If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, inspect the active + lane signature first. Preview a development-lane reset with + `python scripts/codex/build_storage.py reset-lane hot-reload --dry-run`, apply it only at a safe + slice boundary, then rebuild through `python scripts/codex/cargo_lane.py exec hot-reload -- cargo build -p editor --bin editor --features dev`. ## Editor Controls @@ -152,13 +194,14 @@ deep-stale variants. | Viewport selection/orientation HUD | Identify the primary selection, multi-selection count, overlapping-pick position, camera axes, shading mode, and active render path | | Click Player visualizer in Edit mode | Select or create the authored `PlayerSpawn` (`Player Start`) | | Select Project Sun | Inspect project default lighting; create a scene sun override | -| Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; audition audio; drag supported assets/submeshes into the viewport | +| Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; resize the Details pane by dragging its divider (double-click resets it); use click/Ctrl/Shift multi-selection and item/empty-space context menus; audition audio; drag supported assets/submeshes into the viewport | | Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead | -| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` | +| Asset Browser context/details actions | Preview affected IDs/reference rewrites, then create folders or Materials in the current folder; rename, duplicate, cut/copy/paste or drag-to-folder; undo an unchanged content move; use **Import Here** or **Import To...** and review every source dependency/final target before committing; review/group loose PBR textures into editable Materials; assign/locate/clear per-slot model materials and resolve preserved reimport orphans; extract editable glTF/GLB/FBX PBR materials, explicitly diff/apply or create-new on re-extraction, and atomically map their source slots; regenerate thumbnails; reimport; place assets/submeshes; or trash and collision-guarded restore complete deletion batches | +| Texture Details | Set semantic and sRGB/Linear intent, mip policy, Basis UASTC or uncompressed KTX2 output, size limit, filtering, wrapping, anisotropy, and OpenGL/DirectX normal convention; **Apply & Reprocess** publishes derived runtime data without altering the source image | | Window → Material Library | Search/filter project Materials and Material Instances, inspect scene usage and dependency health, create/edit shared assets, and drag them into the viewport | -| Drag Material/Instance onto viewport surface | Preview and assign the exact renderer slot, primitive, or brush face under the pointer; release commits one undo step, while Escape/right-click/outside restores the preview | -| Drag Texture onto viewport surface | Set a primitive base-color texture or exact brush face; renderer slots reject loose textures and direct you to a Material Instance | -| Static/Skinned Mesh Renderer material slots | Assign a shared Material/Material Instance per slot; Browse/Select/Locate the reference, or Clear the override to restore the imported source material | +| Drag Material/Instance onto viewport surface | Preview and assign the exact primitive/static/skinned slot or brush face under the pointer; release commits one undo step, while Escape/right-click/outside restores the preview | +| Drag Texture onto viewport surface | Primitive/static/skinned slots reject loose textures and explain that a Material/Instance is required; brush faces retain their specialized direct-texture path | +| Primitive/Static/Skinned material slots | Use the same Penpot-led material panel: Drop or Browse a shared Material/Instance; Clear returns to model source, project default, or DefaultGrid; expand the panel for immediate shared-handle updates that mark the asset **UNSAVED** without writing on release, then use contextual Save/Save All; use **Create Instance and Assign** for actor variation or **Extract Editable…** for one imported source slot; runtime property-block promotion remains exact-slot and transactional | | `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run | | `F7` | While paused in Play: advance one sim tick | | Shift/Ctrl + click (Hierarchy) | Additive selection | @@ -332,7 +375,8 @@ the current process without changing preferences. Clean editor exits restore the last authored scene, panel visibility, and viewport bookmarks from the versioned machine-local session document. After an abnormal exit, Blacksite opens the safe startup scene and asks whether to **Resume Last Scene** or **Continue Safe**; modal tools and dirty -preview state are never restored. Session metadata lives under +preview state are never restored. The recovery prompt is modal and blocks interaction with the +safe scene until one of those choices is made. Session metadata lives under `$XDG_STATE_HOME/blacksite-editor/session.ron` or `~/.local/state/blacksite-editor/session.ron` and contains no scene contents or credentials. See [ADR 0024](docs/adr/0024-versioned-editor-session-state.md). @@ -361,7 +405,10 @@ The `.vscode/` folder is preconfigured: - `settings.json` runs `clippy` on save, enables proc-macro/build-script support, formats on save, and excludes `target/` from search/watch. - `tasks.json` mirrors the full workspace formatting, all-target check, strict Clippy, test, level-validation, build/run, hot-reload, and target-cleanup workflows. **build editor (dev fast-link)** is the default build task. -- `launch.json` uses CodeLLDB Cargo artifact filtering for editor/game Debug, GPU-validation, hot-reload, SDR fallback, and Release configurations. Normal launches keep HDR enabled while quieting known Vulkan validation noise; dynamic-link launches set the required `LD_LIBRARY_PATH` automatically. +- `launch.json` uses CodeLLDB Cargo artifact filtering for ordinary editor/game Debug, + GPU-validation, SDR fallback, and Release configurations. Managed hot reload uses the lane-aware + tasks above so exceptional artifacts cannot overwrite or borrow the ordinary editor's runtime + dependency path. - `.github/workflows/ci.yml` mirrors local formatting, check, clippy, test, and binary build verification. ## Architecture Decisions @@ -377,6 +424,9 @@ The `.vscode/` folder is preconfigured: - [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md) - [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md) - [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md) +- [ADR 0047: Editor Authored-Asset Documents](docs/adr/0047-editor-authored-asset-documents.md) +- [ADR 0048: Modular Editor Composition and Debt Ratchet](docs/adr/0048-modular-editor-composition-and-debt-ratchet.md) +- [ADR 0049: Penpot-Led Editor Visual System](docs/adr/0049-penpot-led-editor-visual-system.md) - [ADR 0043: Content-Addressed Import Fingerprints](docs/adr/0043-content-addressed-import-fingerprints.md) - [ADR 0044: Sandboxed FBX External Texture Dependencies](docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md) @@ -460,8 +510,16 @@ crates/ - [x] Independent scene tabs + stable subscene composition, recursive validation, ownership locks, active-world PIE consistency, and per-saved-tab recovery ([ADR 0026](docs/adr/0026-stable-scene-composition-and-active-document.md)) - [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel - [x] FBX/glTF model import + normalized static/skinned renderer routing; explicit generic scene-instance load via `bevy_ufbx` / `ModelRef` -- [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`) -- [x] Shared Material/Material Instance assets, stable static/skinned renderer material slots, imported-source fallback, orphan preservation, and a persistence-excluded property-block schema; runtime block application/promotion remains tracked in [Gitea #53](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/53) ([ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md), [material-system guide](docs/editor/material-system.md)) +- [x] Typed texture/model/mesh-subasset/source-material thumbnail cache keys; model and mesh cards always render geometry in the offscreen studio instead of substituting an albedo texture (`ThumbnailState`; FBX via `FbxThumbnailSource`) +- [x] Shared Material/Material Instance assets, generalized primitive/static/skinned slots, expandable guarded shared parameters, stable model defaults, six-layer fallback through immutable DefaultGrid, exact-actor source extraction, orphan preservation, cached Standard/Surface runtime property-block application, and exact-slot transactional instance creation/promotion ([ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md), [ADR 0045](docs/adr/0045-content-workspace-and-material-fallback-contract.md), [material-system guide](docs/editor/material-system.md)) +- [x] Path-agnostic content classification, reference-safe file operations, destination-first dependency-bundle import, deterministic runtime catalog/model manifests, shared debounced watcher core, and atomic GPU-free `cargo process-assets` write/check validation ([content workspace guide](docs/editor/content-workspace.md)) +- [x] Registry v3 Texture properties, schema-v2 Material inputs, paired multiplier/texture controls, channel-selectable ARM/ORM repacking, and content-addressed UASTC/KTX2 runtime artifacts shared by editor refresh and `cargo process-assets` ([ADR 0046](docs/adr/0046-schema-driven-material-inputs-and-processed-textures.md), [material guide](docs/editor/material-system.md)) +- [x] Asset-keyed dirty documents use one overlay-first live Material authority and conditional derived processing without pointer-release writes; contextual Save/Save All, recovery, background processing, and live-handle persistence passed native acceptance ([ADR 0047](docs/adr/0047-editor-authored-asset-documents.md)) +- [x] Registry-only Inspector dispatch, shared material/asset-card UI, and the architecture debt ratchet are implemented; both Inspector and Content Browser shells are <=500 nonblank lines and pass the selective architecture gate plus native resize/deep-scroll acceptance ([ADR 0048](docs/adr/0048-modular-editor-composition-and-debt-ratchet.md)) +- [ ] Penpot-led tokens, Source Sans Pro typography, responsive Inspector/material components, asset/color pickers, and exact current 620/420 px geometry are undergoing final M2 native acceptance ([ADR 0049](docs/adr/0049-penpot-led-editor-visual-system.md)) +- [x] File-manager Content Browser selection for assets and folders, Ctrl/Cmd toggle and Shift ranges, batch clipboard/duplicate/drag/trash operations, stable-ID moves, fresh-ID copies with internal-only reference remapping, concrete destructive-reference previews, exact cancel-state restoration, restart-safe explicit ambiguous external-move identity repair, fingerprint-guarded move/create/copy/trash undo, modal-safe content-scoped keyboard shortcuts, exact-name Cut/Paste collision review, safe navigation/filter selection clearing, consistent right-click targeting for files/folders/subassets, current-folder Material creation, and right-click menus on items and empty workspace space ([content workspace guide](docs/editor/content-workspace.md)) +- [x] Transactional Content Trash with versioned batch manifests, stable-ID restore, generated model-manifest preservation, collision guards, legacy-batch discovery, and registry/runtime-catalog rollback ([content workspace guide](docs/editor/content-workspace.md)) +- [x] Transactional loose-texture PBR grouping with suffix/confidence detection, manual role correction and target merge/split, packed ORM/ARM mapping, and no-overwrite Material publication ([material-system guide](docs/editor/material-system.md)) - [x] Docked Material Library with cross-folder type/usage filters, resolved Material Instance thumbnails, shared guarded editors, and exact reversible viewport Material/Texture drops for renderer slots, primitives, and brush faces ([material-system guide](docs/editor/material-system.md); [Gitea #16](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/16), [Gitea #18](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/18)) - [x] Prefab v2 core: shared stable nested override paths, property/component/structural scopes, recursive graph validation, linked-root variants, conflict recovery, transactional source Apply, and undoable unpack/convert ([ADR 0027](docs/adr/0027-stable-prefab-ownership-and-variants.md)) - [x] Prefab v2 production acceptance: committed base/nested/variant fixtures pass workspace tests, recursive headless validation, packaged release startup, and live editor placement/inspection regression coverage ([Gitea #43](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/43)) @@ -471,7 +529,7 @@ crates/ - [x] Surface ABI v1 with shared raster/Solari evaluator dispatch, packed typed parameters/textures, exact cutout ray-candidate evaluation, last-good shader fallback, and explicit exclusion of skinned/morph-deformed Solari geometry ([ADR 0036](docs/adr/0036-surface-abi-and-solari-parity.md), [material-system guide](docs/editor/material-system.md)) - [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md)) - [x] Dedicated `SkinnedMeshRenderer`/`SkinnedMesh` actor path, skinned-part exclusion from static slots, dedicated runtime hierarchy hydration, and v2-to-v3 animated-scene migration ([ADR 0033](docs/adr/0033-dedicated-skinned-mesh-renderer.md)) -- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md)) +- [x] Componentized actor inspector with one primitive/static/skinned Material-slot widget, full-width valid/invalid drop feedback, material sphere and exact mesh-subasset previews, inherited status, Browse/Locate/Clear, guarded shared parameter foldouts, and no synthetic primitive Authoring Material card ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md), [ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md)) - [x] Stable authoring component IDs, registry-built save filtering, reflected atomic add/remove/reset/copy/paste history, independent active state, static extension registration, and derived composable `ActorKind` hints ([ADR 0034](docs/adr/0034-registry-driven-authoring-components.md), [extensibility guide](docs/editor/extensibility.md)) - [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md)) - [x] Brush draw, vertex/edge/face gizmo editing, face material/UV authoring, clip, and bounds-based CSG preview/commit workflow ([brush guide](docs/editor/brushes.md)) @@ -481,13 +539,14 @@ crates/ - Crouch lowers the camera and movement speed; the collider stays full-height for stability. - The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views, - texture and model thumbnails (glTF albedo fast-path; offscreen render studio for FBX and - untextured models), material sphere thumbnails, search/filter/sort controls, expandable model + direct texture thumbnails, offscreen geometry renders for all models and mesh subassets, + material sphere thumbnails, search/filter/sort controls, expandable model subasset shelves, and a staged details pane. **File -> Import Assets** accepts glTF/GLB and binary **FBX**. FBX import parses and transactionally preserves every safe referenced sibling `textures/` or `.fbm/` file while rejecting traversal and external absolute paths before project content changes. Missing source textures appear once in validation and Asset - Browser dependency status; **Authoring Override** deliberately permits an untextured model. + Browser dependency status; choosing **Project** or **Default** for every stable model slot + deliberately permits an untextured model, while any **Source** slot keeps the bundle required. Model assets generate normalized model manifests under `assets/meshes/generated/`; drag/drop uses **Renderable Asset (Auto)**. Unrigged sources use `StaticMeshRenderer` with imported asset refs and optional separate static mesh colliders. Skin-bound or animated sources and their subasset placement use @@ -508,7 +567,7 @@ crates/ **Unpack Layer** preserves nested links; **Convert to Local** recursively removes them. Current authoring UI coverage and production-acceptance gaps are tracked in [prefab-authoring.md](docs/editor/prefab-authoring.md). -- Material and direct-base Material Instance assets live under `assets/materials/`; shader schemas live under `assets/shaders/`. **Window > Material Library** provides cross-folder creation, editing, usage filters, and drag sources. Viewport Material drops target one exact renderer slot, primitive, or brush face and use reversible preview plus one-step history; loose Textures target primitives/brush faces and are rejected on renderer slots. Shared edits propagate through live-updated handles without reloading skinned geometry. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Runtime property-block application/promotion is tracked in [#53](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/53); dynamic skinned/morph Solari geometry is tracked in [#54](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/54). See the [material-system guide](docs/editor/material-system.md). +- Material and direct-base Material Instance assets may live anywhere under `assets/`; shader schemas are also folder-independent. **Window > Material Library** provides cross-folder creation, editing, usage filters, and drag sources. Material parameters update one live shared handle and mark the asset **UNSAVED**; releasing a control does not write or process. **Ctrl+S** saves the last edited context and **Ctrl+Shift+S** / **File > Save All** saves every dirty scene, asset, and project setting. Scalar/color/render-state Material saves finish without a processing job; ARM/ORM binding changes continue through affected-only background processing. Viewport Material drops target one exact primitive/static/skinned slot or brush face and use reversible preview plus one-step history; loose Textures are rejected by primitive/mesh slots while brushes retain their specialized face texture path. Runtime property blocks apply after resolved bases without mutating shared assets. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Dynamic skinned/morph Solari geometry remains tracked in [#54](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/54). See the [material-system guide](docs/editor/material-system.md). - Per-field reflect undo for all components remains future work; typed `shared` inspectors cover the common authoring path. - The authoring/hydration layer is intentionally small so richer asset workflows (terrain, material graphs, lighting profiles) can be added without changing the scene format foundation. diff --git a/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/76a589e4456ec3320b876209-arm.basis b/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/76a589e4456ec3320b876209-arm.basis new file mode 100644 index 0000000..6f9faa3 --- /dev/null +++ b/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/76a589e4456ec3320b876209-arm.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dbf73b7df8f3d17cb6ce961f986bf1b02902bcea30a06a0dff8dc451b8d04ae +size 22370024 diff --git a/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/c6f971e303a67229b09c1283-arm.basis b/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/c6f971e303a67229b09c1283-arm.basis new file mode 100644 index 0000000..2b458b4 --- /dev/null +++ b/assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/c6f971e303a67229b09c1283-arm.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef8d5f2ed1e3d3824b6e0f50d6106475cdbbf4cf590936def34defedce7f2bea +size 22370024 diff --git a/assets/.import-cache/runtime/materials/647a63d4-64c6-4431-ba46-9efdd515cd8f/297372611543b1f7baa1e5e3-arm.basis b/assets/.import-cache/runtime/materials/647a63d4-64c6-4431-ba46-9efdd515cd8f/297372611543b1f7baa1e5e3-arm.basis new file mode 100644 index 0000000..75c49b9 --- /dev/null +++ b/assets/.import-cache/runtime/materials/647a63d4-64c6-4431-ba46-9efdd515cd8f/297372611543b1f7baa1e5e3-arm.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f45715e7c352ac03e90a646cd66376ab9831bfbdcc40f137e427a021418814 +size 5592785 diff --git a/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/76a589e4456ec3320b876209-arm.basis b/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/76a589e4456ec3320b876209-arm.basis new file mode 100644 index 0000000..6f9faa3 --- /dev/null +++ b/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/76a589e4456ec3320b876209-arm.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dbf73b7df8f3d17cb6ce961f986bf1b02902bcea30a06a0dff8dc451b8d04ae +size 22370024 diff --git a/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/c6f971e303a67229b09c1283-arm.basis b/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/c6f971e303a67229b09c1283-arm.basis new file mode 100644 index 0000000..2b458b4 --- /dev/null +++ b/assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/c6f971e303a67229b09c1283-arm.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef8d5f2ed1e3d3824b6e0f50d6106475cdbbf4cf590936def34defedce7f2bea +size 22370024 diff --git a/assets/.import-cache/runtime/textures/0496e62e-1837-4940-a9c9-b2b4ffe9ed90/3ecc62c0eaf1672ca08fb59f/texture.basis b/assets/.import-cache/runtime/textures/0496e62e-1837-4940-a9c9-b2b4ffe9ed90/3ecc62c0eaf1672ca08fb59f/texture.basis new file mode 100644 index 0000000..a0cc0f5 --- /dev/null +++ b/assets/.import-cache/runtime/textures/0496e62e-1837-4940-a9c9-b2b4ffe9ed90/3ecc62c0eaf1672ca08fb59f/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92442c1e7ed2f768cbda8eadd162d9fa86711a93425ee8c5d0092f5d94298d76 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/25db6174-afcb-421f-8c1c-0c5ffde21b79/552b39094506da0fbf4cd647/texture.basis b/assets/.import-cache/runtime/textures/25db6174-afcb-421f-8c1c-0c5ffde21b79/552b39094506da0fbf4cd647/texture.basis new file mode 100644 index 0000000..6f9faa3 --- /dev/null +++ b/assets/.import-cache/runtime/textures/25db6174-afcb-421f-8c1c-0c5ffde21b79/552b39094506da0fbf4cd647/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dbf73b7df8f3d17cb6ce961f986bf1b02902bcea30a06a0dff8dc451b8d04ae +size 22370024 diff --git a/assets/.import-cache/runtime/textures/393414a8-3089-435f-b267-6dd3eac0285f/edf8ef1b2fc54bbe4eafc16d/texture.basis b/assets/.import-cache/runtime/textures/393414a8-3089-435f-b267-6dd3eac0285f/edf8ef1b2fc54bbe4eafc16d/texture.basis new file mode 100644 index 0000000..75c49b9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/393414a8-3089-435f-b267-6dd3eac0285f/edf8ef1b2fc54bbe4eafc16d/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f45715e7c352ac03e90a646cd66376ab9831bfbdcc40f137e427a021418814 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/481b1a35-75e4-4879-879d-ccab183ba2b7/534c0d384d22c554b9597064/texture.basis b/assets/.import-cache/runtime/textures/481b1a35-75e4-4879-879d-ccab183ba2b7/534c0d384d22c554b9597064/texture.basis new file mode 100644 index 0000000..06b9128 --- /dev/null +++ b/assets/.import-cache/runtime/textures/481b1a35-75e4-4879-879d-ccab183ba2b7/534c0d384d22c554b9597064/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d18f679a3a59aedd9dd4b3a85ea4c14fa0ac4efc686cdec87a3fd61376e464d +size 5592785 diff --git a/assets/.import-cache/runtime/textures/5212dba1-853f-4dab-98f3-e6395d05a4f7/cd1c550f437632f394bf7502/texture.basis b/assets/.import-cache/runtime/textures/5212dba1-853f-4dab-98f3-e6395d05a4f7/cd1c550f437632f394bf7502/texture.basis new file mode 100644 index 0000000..7d3f2e9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/5212dba1-853f-4dab-98f3-e6395d05a4f7/cd1c550f437632f394bf7502/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed2cf26e680b7f8e0783c8ffe2c72532589057bc723acb200ea9e74a43c12e04 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/7c5235a1-f5d7-4530-b29f-5ea075881df5/944b70940e3dd9a70e851d0e/texture.basis b/assets/.import-cache/runtime/textures/7c5235a1-f5d7-4530-b29f-5ea075881df5/944b70940e3dd9a70e851d0e/texture.basis new file mode 100644 index 0000000..238d322 --- /dev/null +++ b/assets/.import-cache/runtime/textures/7c5235a1-f5d7-4530-b29f-5ea075881df5/944b70940e3dd9a70e851d0e/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4a4bdc2381d9afe0ca11dff6a0f34178ae927c1b84f9816e8aaef9435070a77 +size 22370024 diff --git a/assets/.import-cache/runtime/textures/bddaa4d4-5097-42bf-8d51-dd9a3580442a/2276a96b1a148fd7c16977d5/texture.basis b/assets/.import-cache/runtime/textures/bddaa4d4-5097-42bf-8d51-dd9a3580442a/2276a96b1a148fd7c16977d5/texture.basis new file mode 100644 index 0000000..2eda7dd --- /dev/null +++ b/assets/.import-cache/runtime/textures/bddaa4d4-5097-42bf-8d51-dd9a3580442a/2276a96b1a148fd7c16977d5/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d28497a128f2b8b2f9a3d1f144f4beb6611cc12f89311f58caf436b370dcf44f +size 5592785 diff --git a/assets/.import-cache/runtime/textures/c4abe41c-ac88-4aec-8168-27e8a7c90583/d0ec4224c3dcb6fa565c94bb/texture.basis b/assets/.import-cache/runtime/textures/c4abe41c-ac88-4aec-8168-27e8a7c90583/d0ec4224c3dcb6fa565c94bb/texture.basis new file mode 100644 index 0000000..590c745 --- /dev/null +++ b/assets/.import-cache/runtime/textures/c4abe41c-ac88-4aec-8168-27e8a7c90583/d0ec4224c3dcb6fa565c94bb/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a27aa22c744477635fd7e39aadbd2d99dce7958814f58142a54e73c509ca7601 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/c644134e-87c7-43fb-ac30-31c4e0a78b48/534c0d384d22c554b9597064/texture.basis b/assets/.import-cache/runtime/textures/c644134e-87c7-43fb-ac30-31c4e0a78b48/534c0d384d22c554b9597064/texture.basis new file mode 100644 index 0000000..06b9128 --- /dev/null +++ b/assets/.import-cache/runtime/textures/c644134e-87c7-43fb-ac30-31c4e0a78b48/534c0d384d22c554b9597064/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d18f679a3a59aedd9dd4b3a85ea4c14fa0ac4efc686cdec87a3fd61376e464d +size 5592785 diff --git a/assets/.import-cache/runtime/textures/c661e66a-0745-4dd9-89b0-e611befab602/cd1c550f437632f394bf7502/texture.basis b/assets/.import-cache/runtime/textures/c661e66a-0745-4dd9-89b0-e611befab602/cd1c550f437632f394bf7502/texture.basis new file mode 100644 index 0000000..7d3f2e9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/c661e66a-0745-4dd9-89b0-e611befab602/cd1c550f437632f394bf7502/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed2cf26e680b7f8e0783c8ffe2c72532589057bc723acb200ea9e74a43c12e04 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/c7a227bc-2bae-4a41-857b-28cc6b432a9d/edf8ef1b2fc54bbe4eafc16d/texture.basis b/assets/.import-cache/runtime/textures/c7a227bc-2bae-4a41-857b-28cc6b432a9d/edf8ef1b2fc54bbe4eafc16d/texture.basis new file mode 100644 index 0000000..75c49b9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/c7a227bc-2bae-4a41-857b-28cc6b432a9d/edf8ef1b2fc54bbe4eafc16d/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f45715e7c352ac03e90a646cd66376ab9831bfbdcc40f137e427a021418814 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/cadad4af-c24a-4d52-bdc4-8089dac67f30/cd1c550f437632f394bf7502/texture.basis b/assets/.import-cache/runtime/textures/cadad4af-c24a-4d52-bdc4-8089dac67f30/cd1c550f437632f394bf7502/texture.basis new file mode 100644 index 0000000..7d3f2e9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/cadad4af-c24a-4d52-bdc4-8089dac67f30/cd1c550f437632f394bf7502/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed2cf26e680b7f8e0783c8ffe2c72532589057bc723acb200ea9e74a43c12e04 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/cef332e3-d0d1-41d7-adf2-87462c70f52c/edf8ef1b2fc54bbe4eafc16d/texture.basis b/assets/.import-cache/runtime/textures/cef332e3-d0d1-41d7-adf2-87462c70f52c/edf8ef1b2fc54bbe4eafc16d/texture.basis new file mode 100644 index 0000000..75c49b9 --- /dev/null +++ b/assets/.import-cache/runtime/textures/cef332e3-d0d1-41d7-adf2-87462c70f52c/edf8ef1b2fc54bbe4eafc16d/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f45715e7c352ac03e90a646cd66376ab9831bfbdcc40f137e427a021418814 +size 5592785 diff --git a/assets/.import-cache/runtime/textures/d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee/644db179a14573b2a514e68f/texture.basis b/assets/.import-cache/runtime/textures/d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee/644db179a14573b2a514e68f/texture.basis new file mode 100644 index 0000000..f55bd2d --- /dev/null +++ b/assets/.import-cache/runtime/textures/d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee/644db179a14573b2a514e68f/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:504c83749fc2b43212ccfee04f12c8f46416566891c66ac5bac20ff6688c91a9 +size 22370024 diff --git a/assets/.import-cache/runtime/textures/e5512ff1-aa00-4592-a740-4574e1059a99/534c0d384d22c554b9597064/texture.basis b/assets/.import-cache/runtime/textures/e5512ff1-aa00-4592-a740-4574e1059a99/534c0d384d22c554b9597064/texture.basis new file mode 100644 index 0000000..06b9128 --- /dev/null +++ b/assets/.import-cache/runtime/textures/e5512ff1-aa00-4592-a740-4574e1059a99/534c0d384d22c554b9597064/texture.basis @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d18f679a3a59aedd9dd4b3a85ea4c14fa0ac4efc686cdec87a3fd61376e464d +size 5592785 diff --git a/assets/.index/registry.ron b/assets/.index/registry.ron index 743f4c4..b937026 100644 --- a/assets/.index/registry.ron +++ b/assets/.index/registry.ron @@ -1,776 +1,1070 @@ -[ - ( - id: ("04a4e00e-4732-43db-8e99-9cd99b94667b"), - path: "assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron", - label: "113f74df-e39c-41d4-9b5b-e48efe541f7f.animation", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, - ), - dependencies: [], +( + schema_version: 3, + defaults: ( + default_material: None, ), - ( - id: ("4937e63f-923b-4270-bdfc-298d63bcb814"), - path: "assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron", - label: "3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + records: [ + ( + id: ("5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc"), + path: "assets/Furniture/Office/blacksite-m2-robot-expressive.glb", + label: "blacksite-m2-robot-expressive", + kind: Model, + source_fingerprint: Some(( + byte_len: 463988, + content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.animation.ron"), + default_animation_clip_id: None, + )), + dependencies: [], ), - dependencies: [], - ), - ( - id: ("28f1df44-6d24-4722-bf75-6969879995a4"), - path: "assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron", - label: "b98ef565-3500-49e7-9935-f685fa9b2594.animation", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("647a63d4-64c6-4431-ba46-9efdd515cd8f"), + path: "assets/Furniture/Office/metal_office_desk.material.ron", + label: "metal_office_desk.material", + kind: Material, + import_settings: None, + dependencies: [ + "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + ], ), - dependencies: [], - ), - ( - id: ("fe40db93-181f-4c79-86e6-26bd97464c4b"), - path: "assets/audio/Puzzle Lobby.mp3", - label: "Puzzle Lobby", - kind_tag: "AudioClip", - source_fingerprint: Some(( - byte_len: 1680820, - content_hash: "3b5a401dbaccfdbda540c60070ce259fb506d7da548f45008eb2b3fa720b4b57", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("36c51ff3-29ae-4ab1-aaca-f90f57e27966"), + path: "assets/Furniture/Office/metal_office_desk_2k.gltf", + label: "metal_office_desk_2k", + kind: Model, + source_fingerprint: Some(( + byte_len: 13500, + content_hash: "64fd91fbc603c9675f9c8b5f1291a654deff89b40aeb0a0b877b7b93f6ebf05e", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SourceHierarchy, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node1:mesh1:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node2:mesh2:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node3:mesh3:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node4:mesh4:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node5:mesh5:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node6:mesh6:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node7:mesh7:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.animation.ron"), + default_animation_clip_id: None, + )), + dependencies: [ + "assets/Furniture/Office/metal_office_desk.bin", + "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + ], ), - dependencies: [], - ), - ( - id: ("c0a7d10e-53c1-4de0-b0dc-34a66f44ba77"), - path: "assets/audio/editor_audition_tone.ogg", - label: "editor_audition_tone", - kind_tag: "AudioClip", - source_fingerprint: Some(( - byte_len: 4450, - content_hash: "8ef56184ab5b5cd135e45df152f8a95737023420947f3d08c9b3e3605e55a349", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("cef332e3-d0d1-41d7-adf2-87462c70f52c"), + path: "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 2724325, + content_hash: "0f67cb85769b10467eeea0aa3bf0649c65a2687029d49b6de76a960ab60ce00d", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], ), - dependencies: [], - ), - ( - id: ("9213efe6-12c7-45a8-8b64-9e1699f76ba3"), - path: "assets/build_profiles/development.ron", - label: "development", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("481b1a35-75e4-4879-879d-ccab183ba2b7"), + path: "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 1787420, + content_hash: "5469ef3017f5818e177c08294e9bd9e51cca6f010db09cbead11b103e7fa5e63", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], ), - dependencies: [], - ), - ( - id: ("64fea29e-c123-4987-92dc-ca25f4dc7dea"), - path: "assets/build_profiles/qa.ron", - label: "qa", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("cadad4af-c24a-4d52-bdc4-8089dac67f30"), + path: "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 490814, + content_hash: "b9847b163409459d6d052a236c483ba1c0d72a5510aa9fb067ec3c1f0f5f5bb0", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], ), - dependencies: [], - ), - ( - id: ("de76f1c9-f05d-48d3-bb69-6b7ce05f29d8"), - path: "assets/build_profiles/release.ron", - label: "release", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("fe40db93-181f-4c79-86e6-26bd97464c4b"), + path: "assets/audio/Puzzle Lobby.mp3", + label: "Puzzle Lobby", + kind: AudioClip, + source_fingerprint: Some(( + byte_len: 1680820, + content_hash: "3b5a401dbaccfdbda540c60070ce259fb506d7da548f45008eb2b3fa720b4b57", + )), + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"), - path: "assets/levels/audio_authoring_showcase.scn.ron", - label: "audio_authoring_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("c0a7d10e-53c1-4de0-b0dc-34a66f44ba77"), + path: "assets/audio/editor_audition_tone.ogg", + label: "editor_audition_tone", + kind: AudioClip, + source_fingerprint: Some(( + byte_len: 4450, + content_hash: "8ef56184ab5b5cd135e45df152f8a95737023420947f3d08c9b3e3605e55a349", + )), + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("a7ef65b4-24d6-4c3a-b637-5bb52d8e9a26"), - path: "assets/levels/collider_diagnostics_showcase.scn.ron", - label: "collider_diagnostics_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"), + path: "assets/levels/audio_authoring_showcase.scn.ron", + label: "audio_authoring_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), - path: "assets/levels/editor_scene 2.scn.ron", - label: "editor_scene 2.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("a7ef65b4-24d6-4c3a-b637-5bb52d8e9a26"), + path: "assets/levels/collider_diagnostics_showcase.scn.ron", + label: "collider_diagnostics_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"), - path: "assets/levels/editor_scene 3.scn.ron", - label: "editor_scene 3.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), + path: "assets/levels/editor_scene 2.scn.ron", + label: "editor_scene 2.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"), - path: "assets/levels/editor_scene.scn.ron", - label: "editor_scene.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"), + path: "assets/levels/editor_scene 3.scn.ron", + label: "editor_scene 3.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"), - path: "assets/levels/navigation_authoring_showcase.scn.ron", - label: "navigation_authoring_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"), + path: "assets/levels/editor_scene.scn.ron", + label: "editor_scene.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("78c6850b-5a76-4a4f-b48e-7fb00f6dd1ed"), - path: "assets/levels/physics_placement_showcase.scn.ron", - label: "physics_placement_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"), + path: "assets/levels/navigation_authoring_showcase.scn.ron", + label: "navigation_authoring_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"), - path: "assets/levels/rendering_showcase.scn.ron", - label: "rendering_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("78c6850b-5a76-4a4f-b48e-7fb00f6dd1ed"), + path: "assets/levels/physics_placement_showcase.scn.ron", + label: "physics_placement_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("bca13177-afa9-4691-9b40-288059c7a57e"), - path: "assets/levels/samples/brush_blockout.scn.ron", - label: "brush_blockout.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"), + path: "assets/levels/rendering_showcase.scn.ron", + label: "rendering_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("607864bf-fa77-4f9e-acc9-2c0906d34163"), - path: "assets/levels/samples/material_lab.scn.ron", - label: "material_lab.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("bca13177-afa9-4691-9b40-288059c7a57e"), + path: "assets/levels/samples/brush_blockout.scn.ron", + label: "brush_blockout.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("1b25d451-7b54-41d6-af00-d2e6950f502d"), - path: "assets/levels/terrain_authoring_showcase.scn.ron", - label: "terrain_authoring_showcase.scn", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("607864bf-fa77-4f9e-acc9-2c0906d34163"), + path: "assets/levels/samples/material_lab.scn.ron", + label: "material_lab.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("a57f2891-8536-47b8-b476-01c08b36ac43"), - path: "assets/materials/concrete.ron", - label: "concrete", - kind_tag: "Material", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("1b25d451-7b54-41d6-af00-d2e6950f502d"), + path: "assets/levels/terrain_authoring_showcase.scn.ron", + label: "terrain_authoring_showcase.scn", + kind: Level, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("4f684592-50b1-47e4-888c-8ba537e4c29d"), - path: "assets/materials/emissive_panel.ron", - label: "emissive_panel", - kind_tag: "Material", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("782d9f6a-e965-41af-86e1-84ce44de1cd6"), + path: "assets/materials/chrome.ron", + label: "chrome", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("d6cb4151-7124-4237-aaf9-f7f8abd5fb76"), - path: "assets/materials/surface_tint.ron", - label: "surface_tint", - kind_tag: "Material", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("a57f2891-8536-47b8-b476-01c08b36ac43"), + path: "assets/materials/concrete.ron", + label: "concrete", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("cc2769da-c9a5-4cb3-866a-cc81d0688ee2"), - path: "assets/materials/surface_tint_instance.ron", - label: "surface_tint_instance", - kind_tag: "Material", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("4f684592-50b1-47e4-888c-8ba537e4c29d"), + path: "assets/materials/emissive_panel.ron", + label: "emissive_panel", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("113f74df-e39c-41d4-9b5b-e48efe541f7f"), - path: "assets/models/RobotExpressive.glb", - label: "RobotExpressive", - kind_tag: "Model", - source_fingerprint: Some(( - byte_len: 463988, - content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: SceneInstance, - hierarchy_mode: SourceHierarchy, - material_policy: SourceMaterials, - static_mesh_manifest_path: Some("assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron"), - animation_manifest_path: Some("assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron"), - default_animation_clip_id: Some("animation:clip:8:standing"), + ( + id: ("0cb3fb85-db18-52ea-8c42-b5ab58e90965"), + path: "assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron", + label: "legacy-05a00cdd0a637f9e.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("b98ef565-3500-49e7-9935-f685fa9b2594"), - path: "assets/models/painted_wooden_chair_02_2k.fbx", - label: "painted_wooden_chair_02_2k", - kind_tag: "Model", - source_fingerprint: Some(( - byte_len: 59964, - content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: AuthoringOverride, - static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"), - animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"), - default_animation_clip_id: None, + ( + id: ("250a0db7-7108-5adf-b759-39b30c692c35"), + path: "assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron", + label: "legacy-082ba03b8cbb710b.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [ - "assets/models/textures/painted_wooden_chair_02_diff_2k.jpg", - "assets/models/textures/painted_wooden_chair_02_nor_gl_2k.exr", - "assets/models/textures/painted_wooden_chair_02_rough_2k.exr", - ], - ), - ( - id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"), - path: "assets/models/robot_expressive.glb", - label: "robot_expressive", - kind_tag: "Model", - source_fingerprint: Some(( - byte_len: 463988, - content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: Some("assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron"), - animation_manifest_path: Some("assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron"), - default_animation_clip_id: Some("animation:clip:8:standing"), + ( + id: ("5b60f0a9-53b8-56b4-8ee9-cffd2e117c55"), + path: "assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron", + label: "legacy-0a21ebb8e495bf57.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("264e2010-0693-4d74-8440-99373c2cedb3"), - path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron", - label: "navigation_showcase_humanoid.nav", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("8fd7f537-75c1-524d-9f09-6654a3718a8e"), + path: "assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron", + label: "legacy-1a259303a97c4ba9.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"), - path: "assets/post_fx/chromatic_aberration.ron", - label: "chromatic_aberration", - kind_tag: "PostProcessEffect", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("7a818722-7a73-5541-838c-cedbef0648f7"), + path: "assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron", + label: "legacy-2b86cae1d3bfacae.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("e14a9bc3-9a4e-47b0-901f-b31c004e1836"), - path: "assets/post_fx/vignette.ron", - label: "vignette", - kind_tag: "PostProcessEffect", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("c6c84742-e395-5ccb-96bc-d1bd101623b5"), + path: "assets/materials/migrated/legacy-3546762bbff37951.material.ron", + label: "legacy-3546762bbff37951.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("862530b2-8111-4ebb-9078-29723c002e08"), - path: "assets/prefabs/example_base.scn.ron", - label: "example_base.scn", - kind_tag: "Prefab", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("3adc8ea9-1387-5f1c-9404-bbe057dd910d"), + path: "assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron", + label: "legacy-454bcd95aaa4f1ca.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("682191d6-59cd-46a1-9560-6cf7bc1583b2"), - path: "assets/prefabs/example_nested.scn.ron", - label: "example_nested.scn", - kind_tag: "Prefab", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("2b67be74-d8b5-509a-9efe-dd58b8010a9a"), + path: "assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron", + label: "legacy-4f65eccbb7dac1a2.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("c14f6240-5654-4e80-9edb-2b4c44cf8d61"), - path: "assets/prefabs/example_variant.scn.ron", - label: "example_variant.scn", - kind_tag: "Prefab", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("231210a7-5d2a-5d06-b3ec-b3ee0bde636a"), + path: "assets/materials/migrated/legacy-62ef60a686be94f1.material.ron", + label: "legacy-62ef60a686be94f1.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("2c4a9866-17b5-4797-9a8f-726b2ffacdb9"), - path: "assets/rendering_profiles/cave_dark.ron", - label: "cave_dark", - kind_tag: "RenderingProfile", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("c98f2306-b6f1-5d02-b232-19e74e886e20"), + path: "assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron", + label: "legacy-77cc6503f24bb9e8.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("802471df-8295-4997-80e2-db4191850fc9"), - path: "assets/rendering_profiles/outdoor_haze.ron", - label: "outdoor_haze", - kind_tag: "RenderingProfile", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("6cc5ffc1-16fb-5652-ab6b-0bb9b4f36ae2"), + path: "assets/materials/migrated/legacy-78bc49856f47eae1.material.ron", + label: "legacy-78bc49856f47eae1.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("e42185de-cda2-4a9e-9df0-df67ccd86123"), - path: "assets/samples/editor_samples.ron", - label: "editor_samples", - kind_tag: "Level", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("069977d1-acb8-5bf2-8afa-dfe2abbd4303"), + path: "assets/materials/migrated/legacy-964101ee77293501.material.ron", + label: "legacy-964101ee77293501.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("addf6f7f-8114-45f3-8566-70d1eaa1fec3"), - path: "assets/shaders/standard_lit.shader.ron", - label: "standard_lit.shader", - kind_tag: "ShaderSchema", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("ee32fb60-4ef9-5b7f-a20d-ce216e915530"), + path: "assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron", + label: "legacy-97356bd0ac2f6198.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("a72943d6-3e9d-4629-8887-5c02d99d4f92"), - path: "assets/shaders/surface_tint.shader.ron", - label: "surface_tint.shader", - kind_tag: "ShaderSchema", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("1cc3cb7a-7ddd-55e3-ba91-71071f30dc83"), + path: "assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron", + label: "legacy-9a630ee3e4848d9d.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"), - path: "assets/shaders/unlit.shader.ron", - label: "unlit.shader", - kind_tag: "ShaderSchema", - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("81212e15-4fc8-50ca-bd7d-ac596505cb4c"), + path: "assets/materials/migrated/legacy-a74f874eafd5d224.material.ron", + label: "legacy-a74f874eafd5d224.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("0496e62e-1837-4940-a9c9-b2b4ffe9ed90"), - path: "assets/textures/metal_stool_01_arm_2k.jpg", - label: "metal_stool_01_arm_2k", - kind_tag: "Texture", - source_fingerprint: Some(( - byte_len: 3152060, - content_hash: "31b2249ed7b50d3a021312a1e299167897513710ee8ded75c5c5e56aad592e10", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("91e93c0c-4d4b-5e3f-adcd-6c15be6d0a74"), + path: "assets/materials/migrated/legacy-b278a62978900638.material.ron", + label: "legacy-b278a62978900638.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("bddaa4d4-5097-42bf-8d51-dd9a3580442a"), - path: "assets/textures/metal_stool_01_diff_2k.jpg", - label: "metal_stool_01_diff_2k", - kind_tag: "Texture", - source_fingerprint: Some(( - byte_len: 2603921, - content_hash: "e0b9dee5b09f1968e7c6ea82eb9a157d303c25d0600913b8b9c809adf50b73c8", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("0a4681e1-b5f0-57fa-8ee1-4431e14e322f"), + path: "assets/materials/migrated/legacy-c656429ce3668694.material.ron", + label: "legacy-c656429ce3668694.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), - ( - id: ("c4abe41c-ac88-4aec-8168-27e8a7c90583"), - path: "assets/textures/metal_stool_01_nor_gl_2k.jpg", - label: "metal_stool_01_nor_gl_2k", - kind_tag: "Texture", - source_fingerprint: Some(( - byte_len: 2779150, - content_hash: "8c0aa4cf4270c9bf7217fba40b2b48878972ee59cff851cb8dfa57a90f4f373b", - )), - import_settings: ( - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: StaticAsset, - hierarchy_mode: SingleActor, - material_policy: SourceMaterials, - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + ( + id: ("ba295f51-426f-5fdb-b444-a5f1cc5199da"), + path: "assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron", + label: "legacy-c6c02ef278fba7c3.material", + kind: Material, + import_settings: None, + dependencies: [], ), - dependencies: [], - ), -] + ( + id: ("6c8791ac-a746-58a2-b097-17d2fe0494dc"), + path: "assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron", + label: "legacy-c6fbbebd3e1a9943.material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("41dbf5a0-7a0a-5fac-9ec3-a0ceacb548a9"), + path: "assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron", + label: "legacy-c9d05c61e5cf348d.material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("2fb3fcd4-f7b7-5f28-bf79-2241e47c7ea8"), + path: "assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron", + label: "legacy-cc0396f5c90fb7bb.material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("c512d6e2-272b-5e86-8235-4628ed3b6e8c"), + path: "assets/materials/migrated/legacy-e9671004b101f4df.material.ron", + label: "legacy-e9671004b101f4df.material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("2db7e7f2-c0ab-51df-b71b-37f0e8c038f1"), + path: "assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron", + label: "legacy-fc132d9b30cc63d4.material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("e9bd02c0-986f-4f90-a6d5-f04388622b94"), + path: "assets/materials/new_material.ron", + label: "new_material", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("d4387c2e-3ca2-4fca-862a-d86def44e424"), + path: "assets/materials/new_material_2.ron", + label: "new_material_2", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("c07a5b05-f27d-46c6-8b8c-9de9cdd14720"), + path: "assets/materials/pebble_bricks.ron", + label: "pebble_bricks", + kind: Material, + import_settings: None, + dependencies: [ + "assets/materials/textures/pebble_bricks_arm_4k.jpg", + "assets/materials/textures/pebble_bricks_diff_4k.jpg", + "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + ], + ), + ( + id: ("bd495161-5ea8-48ef-b0db-5b1d57f33885"), + path: "assets/materials/pebble_bricks_4k.gltf", + label: "pebble_bricks_4k", + kind: Model, + source_fingerprint: Some(( + byte_len: 2788, + content_hash: "55a5374732e4174e33859cf415d22ac855cea3efe8d8ff78c6ee42c86d8fd5fb", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + sub_asset_id: "", + label: "pebble_bricks", + source_path: Some("assets/materials/pebble_bricks.ron"), + ))), + ), + ], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.animation.ron"), + default_animation_clip_id: None, + )), + dependencies: [ + "assets/materials/pebble_bricks.bin", + "assets/materials/textures/pebble_bricks_arm_4k.jpg", + "assets/materials/textures/pebble_bricks_diff_4k.jpg", + "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + ], + ), + ( + id: ("d6cb4151-7124-4237-aaf9-f7f8abd5fb76"), + path: "assets/materials/surface_tint.ron", + label: "surface_tint", + kind: Material, + import_settings: None, + dependencies: [], + ), + ( + id: ("cc2769da-c9a5-4cb3-866a-cc81d0688ee2"), + path: "assets/materials/surface_tint_instance.ron", + label: "surface_tint_instance", + kind: MaterialInstance, + import_settings: None, + dependencies: [], + ), + ( + id: ("25db6174-afcb-421f-8c1c-0c5ffde21b79"), + path: "assets/materials/textures/pebble_bricks_arm_4k.jpg", + label: "pebble_bricks_arm_4k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 16434309, + content_hash: "f4b80f8443ab093a9e513d92e69890a6e81803728655ef3988113c94a991a106", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee"), + path: "assets/materials/textures/pebble_bricks_diff_4k.jpg", + label: "pebble_bricks_diff_4k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 14259465, + content_hash: "25324328e0beee417d9005906590e6e98f747f2fdcbfaf5bfb57aa80f623cd30", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("7c5235a1-f5d7-4530-b29f-5ea075881df5"), + path: "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + label: "pebble_bricks_nor_gl_4k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 20424876, + content_hash: "337a67d8773a7842991e6de13af5fddf3632d033056b7933dc8ec597e3278cf2", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("113f74df-e39c-41d4-9b5b-e48efe541f7f"), + path: "assets/models/RobotExpressive.glb", + label: "RobotExpressive", + kind: Model, + source_fingerprint: Some(( + byte_len: 463988, + content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: SceneInstance, + hierarchy_mode: SourceHierarchy, + material_slots: [], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron"), + default_animation_clip_id: Some("animation:clip:8:standing"), + )), + dependencies: [], + ), + ( + id: ("3076fad0-89ca-4307-b8d9-cb77fca41907"), + path: "assets/models/metal_office_desk_2k.gltf", + label: "metal_office_desk_2k", + kind: Model, + source_fingerprint: Some(( + byte_len: 13510, + content_hash: "904f3af2a5283a08f60bcfb2651c5280f14f1232ec522154e4878e90909551ce", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.animation.ron"), + default_animation_clip_id: None, + )), + dependencies: [ + "assets/models/metal_office_desk.bin", + "assets/models/textures/metal_office_desk_arm_2k.jpg", + "assets/models/textures/metal_office_desk_diff_2k.jpg", + "assets/models/textures/metal_office_desk_nor_gl_2k.jpg", + ], + ), + ( + id: ("b98ef565-3500-49e7-9935-f685fa9b2594"), + path: "assets/models/painted_wooden_chair_02_2k.fbx", + label: "painted_wooden_chair_02_2k", + kind: Model, + source_fingerprint: Some(( + byte_len: 59964, + content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node1:material0"), + selection: Default, + ), + ], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"), + default_animation_clip_id: None, + )), + dependencies: [ + "assets/models/textures/painted_wooden_chair_02_diff_2k.jpg", + "assets/models/textures/painted_wooden_chair_02_nor_gl_2k.exr", + "assets/models/textures/painted_wooden_chair_02_rough_2k.exr", + ], + ), + ( + id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"), + path: "assets/models/robot_expressive.glb", + label: "robot_expressive", + kind: Model, + source_fingerprint: Some(( + byte_len: 463988, + content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", + )), + import_settings: Model(( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [], + orphaned_material_slots: [], + static_mesh_manifest_path: Some("assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron"), + animation_manifest_path: Some("assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron"), + default_animation_clip_id: Some("animation:clip:8:standing"), + )), + dependencies: [], + ), + ( + id: ("393414a8-3089-435f-b267-6dd3eac0285f"), + path: "assets/models/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 2724325, + content_hash: "0f67cb85769b10467eeea0aa3bf0649c65a2687029d49b6de76a960ab60ce00d", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("e5512ff1-aa00-4592-a740-4574e1059a99"), + path: "assets/models/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 1787420, + content_hash: "5469ef3017f5818e177c08294e9bd9e51cca6f010db09cbead11b103e7fa5e63", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("5212dba1-853f-4dab-98f3-e6395d05a4f7"), + path: "assets/models/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 490814, + content_hash: "b9847b163409459d6d052a236c483ba1c0d72a5510aa9fb067ec3c1f0f5f5bb0", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("09762f59-bcd6-44fc-8157-2ad920177996"), + path: "assets/pebble_bricks_surface_instance.material-instance.ron", + label: "pebble_bricks_surface_instance.material-instance", + kind: MaterialInstance, + import_settings: None, + dependencies: [], + ), + ( + id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"), + path: "assets/post_fx/chromatic_aberration.ron", + label: "chromatic_aberration", + kind: PostProcessEffect, + import_settings: None, + dependencies: [], + ), + ( + id: ("e14a9bc3-9a4e-47b0-901f-b31c004e1836"), + path: "assets/post_fx/vignette.ron", + label: "vignette", + kind: PostProcessEffect, + import_settings: None, + dependencies: [], + ), + ( + id: ("862530b2-8111-4ebb-9078-29723c002e08"), + path: "assets/prefabs/example_base.scn.ron", + label: "example_base.scn", + kind: Level, + import_settings: None, + dependencies: [], + ), + ( + id: ("682191d6-59cd-46a1-9560-6cf7bc1583b2"), + path: "assets/prefabs/example_nested.scn.ron", + label: "example_nested.scn", + kind: Level, + import_settings: None, + dependencies: [], + ), + ( + id: ("c14f6240-5654-4e80-9edb-2b4c44cf8d61"), + path: "assets/prefabs/example_variant.scn.ron", + label: "example_variant.scn", + kind: Level, + import_settings: None, + dependencies: [], + ), + ( + id: ("2c4a9866-17b5-4797-9a8f-726b2ffacdb9"), + path: "assets/rendering_profiles/cave_dark.ron", + label: "cave_dark", + kind: RenderingProfile, + import_settings: None, + dependencies: [], + ), + ( + id: ("802471df-8295-4997-80e2-db4191850fc9"), + path: "assets/rendering_profiles/outdoor_haze.ron", + label: "outdoor_haze", + kind: RenderingProfile, + import_settings: None, + dependencies: [], + ), + ( + id: ("addf6f7f-8114-45f3-8566-70d1eaa1fec3"), + path: "assets/shaders/standard_lit.shader.ron", + label: "standard_lit.shader", + kind: ShaderSchema, + import_settings: None, + dependencies: [], + ), + ( + id: ("a72943d6-3e9d-4629-8887-5c02d99d4f92"), + path: "assets/shaders/surface_tint.shader.ron", + label: "surface_tint.shader", + kind: ShaderSchema, + import_settings: None, + dependencies: [], + ), + ( + id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"), + path: "assets/shaders/unlit.shader.ron", + label: "unlit.shader", + kind: ShaderSchema, + import_settings: None, + dependencies: [], + ), + ( + id: ("c7a227bc-2bae-4a41-857b-28cc6b432a9d"), + path: "assets/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 2724325, + content_hash: "0f67cb85769b10467eeea0aa3bf0649c65a2687029d49b6de76a960ab60ce00d", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Linear, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("c644134e-87c7-43fb-ac30-31c4e0a78b48"), + path: "assets/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 1787420, + content_hash: "5469ef3017f5818e177c08294e9bd9e51cca6f010db09cbead11b103e7fa5e63", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("c661e66a-0745-4dd9-89b0-e611befab602"), + path: "assets/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 490814, + content_hash: "b9847b163409459d6d052a236c483ba1c0d72a5510aa9fb067ec3c1f0f5f5bb0", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Linear, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("0496e62e-1837-4940-a9c9-b2b4ffe9ed90"), + path: "assets/textures/metal_stool_01_arm_2k.jpg", + label: "metal_stool_01_arm_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 3152060, + content_hash: "31b2249ed7b50d3a021312a1e299167897513710ee8ded75c5c5e56aad592e10", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("bddaa4d4-5097-42bf-8d51-dd9a3580442a"), + path: "assets/textures/metal_stool_01_diff_2k.jpg", + label: "metal_stool_01_diff_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 2603921, + content_hash: "e0b9dee5b09f1968e7c6ea82eb9a157d303c25d0600913b8b9c809adf50b73c8", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ( + id: ("c4abe41c-ac88-4aec-8168-27e8a7c90583"), + path: "assets/textures/metal_stool_01_nor_gl_2k.jpg", + label: "metal_stool_01_nor_gl_2k", + kind: Texture, + source_fingerprint: Some(( + byte_len: 2779150, + content_hash: "8c0aa4cf4270c9bf7217fba40b2b48878972ee59cff851cb8dfa57a90f4f373b", + )), + import_settings: Texture(( + semantic: Auto, + color_space: Auto, + mipmaps: Generate, + compression: Auto, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + )), + dependencies: [], + ), + ], +) \ No newline at end of file diff --git a/assets/Furniture/Office/blacksite-m2-robot-expressive.glb b/assets/Furniture/Office/blacksite-m2-robot-expressive.glb new file mode 100644 index 0000000..7cb7550 --- /dev/null +++ b/assets/Furniture/Office/blacksite-m2-robot-expressive.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:047f5e5fb3bb6d378bd1df16ca6137f2a596c99b3a1b5690b4020c05aaf6f319 +size 463988 diff --git a/assets/Furniture/Office/metal_office_desk.bin b/assets/Furniture/Office/metal_office_desk.bin new file mode 100644 index 0000000..9802899 Binary files /dev/null and b/assets/Furniture/Office/metal_office_desk.bin differ diff --git a/assets/Furniture/Office/metal_office_desk.material.ron b/assets/Furniture/Office/metal_office_desk.material.ron new file mode 100644 index 0000000..59852fe --- /dev/null +++ b/assets/Furniture/Office/metal_office_desk.material.ron @@ -0,0 +1,116 @@ +( + schema_version: 2, + label: "metal_office_desk", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: true, + ), + provenance: Some(( + source_path: "assets/Furniture/Office/metal_office_desk_2k.gltf", + source_fingerprint: "904f3af2a5283a08f60bcfb2651c5280f14f1232ec522154e4878e90909551ce", + source_sub_asset_id: "material:0", + source_label: "metal_office_desk", + )), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.23189718), + ), + ( + name: "roughness", + value: Float(1.0), + ), + ( + name: "emissive_color", + value: Color(( + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: Some(( + asset_id: "481b1a35-75e4-4879-879d-ccab183ba2b7", + sub_asset_id: "texture:source", + label: "metal_office_desk_diff_2k", + source_path: Some("assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg"), + )), + channel: Rgba, + ), + ( + name: "normal", + texture: Some(( + asset_id: "cadad4af-c24a-4d52-bdc4-8089dac67f30", + sub_asset_id: "texture:source", + label: "metal_office_desk_nor_gl_2k", + source_path: Some("assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg"), + )), + channel: Rgb, + ), + ( + name: "occlusion", + texture: Some(( + asset_id: "cef332e3-d0d1-41d7-adf2-87462c70f52c", + sub_asset_id: "texture:source", + label: "metal_office_desk_arm_2k", + source_path: Some("assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg"), + )), + channel: R, + ), + ( + name: "roughness", + texture: Some(( + asset_id: "cef332e3-d0d1-41d7-adf2-87462c70f52c", + sub_asset_id: "texture:source", + label: "metal_office_desk_arm_2k", + source_path: Some("assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg"), + )), + channel: G, + ), + ( + name: "metallic", + texture: Some(( + asset_id: "cef332e3-d0d1-41d7-adf2-87462c70f52c", + sub_asset_id: "texture:source", + label: "metal_office_desk_arm_2k", + source_path: Some("assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg"), + )), + channel: B, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], + ), +) \ No newline at end of file diff --git a/assets/Furniture/Office/metal_office_desk_2k.gltf b/assets/Furniture/Office/metal_office_desk_2k.gltf new file mode 100644 index 0000000..8a13add --- /dev/null +++ b/assets/Furniture/Office/metal_office_desk_2k.gltf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6997a8b4e1b82facc95ad698196c42a5c6f1b2734173af138555576a80344189 +size 13500 diff --git a/assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg b/assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg new file mode 100644 index 0000000..7986b9c --- /dev/null +++ b/assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c94b99aec8c9b105ff4d843ac41f229b0effb97abe921f0f8b506ccfd76952 +size 2724325 diff --git a/assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg b/assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg new file mode 100644 index 0000000..fe6aafc --- /dev/null +++ b/assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5854edc8a7979ed4576b914c184bda64d7e6ee9969357738703be49118da30 +size 1787420 diff --git a/assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg b/assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg new file mode 100644 index 0000000..093cd80 --- /dev/null +++ b/assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25392e7682c43ca07abbbbe717388b9e6d40bbca663836916204f99ac0378d34 +size 490814 diff --git a/assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron b/assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron index 0c9f6ca..a745c9b 100644 --- a/assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron +++ b/assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron @@ -8,7 +8,6 @@ format: "glb", fingerprint: ( byte_len: 463988, - modified_unix_secs: 1783799272, content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", ), dependencies: [], @@ -233,4 +232,4 @@ ), ], diagnostics: [], -) \ No newline at end of file +) diff --git a/assets/animations/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.animation.ron b/assets/animations/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.animation.ron new file mode 100644 index 0000000..c9f55fe --- /dev/null +++ b/assets/animations/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.animation.ron @@ -0,0 +1,21 @@ +( + schema_version: 3, + asset_id: "3076fad0-89ca-4307-b8d9-cb77fca41907", + label: "metal_office_desk_2k", + default_animation_clip_id: None, + source: ( + path: "assets/models/metal_office_desk_2k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 13510, + content_hash: "904f3af2a5283a08f60bcfb2651c5280f14f1232ec522154e4878e90909551ce", + ), + dependencies: [ + "assets/models/metal_office_desk.bin", + ], + ), + runtime_supported: true, + skeletons: [], + clips: [], + diagnostics: [], +) diff --git a/assets/animations/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.animation.ron b/assets/animations/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.animation.ron new file mode 100644 index 0000000..a931248 --- /dev/null +++ b/assets/animations/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.animation.ron @@ -0,0 +1,21 @@ +( + schema_version: 3, + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + label: "metal_office_desk_2k", + default_animation_clip_id: None, + source: ( + path: "assets/Furniture/Office/metal_office_desk_2k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 13500, + content_hash: "64fd91fbc603c9675f9c8b5f1291a654deff89b40aeb0a0b877b7b93f6ebf05e", + ), + dependencies: [ + "assets/Furniture/Office/metal_office_desk.bin", + ], + ), + runtime_supported: true, + skeletons: [], + clips: [], + diagnostics: [], +) diff --git a/assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron b/assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron index 8ae208c..81ea0ed 100644 --- a/assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron +++ b/assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron @@ -8,7 +8,6 @@ format: "glb", fingerprint: ( byte_len: 463988, - modified_unix_secs: 1783750957, content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", ), dependencies: [], @@ -233,4 +232,4 @@ ), ], diagnostics: [], -) \ No newline at end of file +) diff --git a/assets/animations/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.animation.ron b/assets/animations/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.animation.ron new file mode 100644 index 0000000..3290c0a --- /dev/null +++ b/assets/animations/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.animation.ron @@ -0,0 +1,235 @@ +( + schema_version: 3, + asset_id: "5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc", + label: "blacksite-m2-robot-expressive", + default_animation_clip_id: None, + source: ( + path: "assets/Furniture/Office/blacksite-m2-robot-expressive.glb", + format: "glb", + fingerprint: ( + byte_len: 463988, + content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", + ), + dependencies: [], + ), + runtime_supported: true, + skeletons: [ + ( + id: "animation:skeleton:0:skeleton_0", + label: "Skeleton 0", + source_index: 0, + signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"), + joint_paths: [ + "RootNode/RobotArmature/Bone", + "RootNode/RobotArmature/Bone/Foot.L", + "RootNode/RobotArmature/Bone/Body", + "RootNode/RobotArmature/Bone/Body/Hips", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R", + "RootNode/RobotArmature/Bone/Body/UpperLeg.L", + "RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L", + "RootNode/RobotArmature/Bone/Body/UpperLeg.R", + "RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R", + "RootNode/RobotArmature/Bone/PoleTarget.L", + "RootNode/RobotArmature/Bone/Foot.R", + "RootNode/RobotArmature/Bone/PoleTarget.R", + ], + ), + ( + id: "animation:skeleton:1:skeleton_1", + label: "Skeleton 1", + source_index: 1, + signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"), + joint_paths: [ + "RootNode/RobotArmature/Bone", + "RootNode/RobotArmature/Bone/Foot.L", + "RootNode/RobotArmature/Bone/Body", + "RootNode/RobotArmature/Bone/Body/Hips", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R", + "RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R", + "RootNode/RobotArmature/Bone/Body/UpperLeg.L", + "RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L", + "RootNode/RobotArmature/Bone/Body/UpperLeg.R", + "RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R", + "RootNode/RobotArmature/Bone/PoleTarget.L", + "RootNode/RobotArmature/Bone/Foot.R", + "RootNode/RobotArmature/Bone/PoleTarget.R", + ], + ), + ], + clips: [ + ( + id: "animation:clip:0:dance", + label: "Dance", + source_index: 0, + duration_seconds: 3.3333333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:1:death", + label: "Death", + source_index: 1, + duration_seconds: 0.9583333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:2:idle", + label: "Idle", + source_index: 2, + duration_seconds: 3.3333333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:3:jump", + label: "Jump", + source_index: 3, + duration_seconds: 0.7083333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:4:no", + label: "No", + source_index: 4, + duration_seconds: 1.6666666, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:5:punch", + label: "Punch", + source_index: 5, + duration_seconds: 0.8333333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:6:running", + label: "Running", + source_index: 6, + duration_seconds: 0.9583333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:7:sitting", + label: "Sitting", + source_index: 7, + duration_seconds: 0.41666666, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:8:standing", + label: "Standing", + source_index: 8, + duration_seconds: 0.41666666, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:9:thumbsup", + label: "ThumbsUp", + source_index: 9, + duration_seconds: 1.5833334, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:10:walking", + label: "Walking", + source_index: 10, + duration_seconds: 0.9583333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:11:walkjump", + label: "WalkJump", + source_index: 11, + duration_seconds: 0.8333333, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:12:wave", + label: "Wave", + source_index: 12, + duration_seconds: 1.8333334, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ( + id: "animation:clip:13:yes", + label: "Yes", + source_index: 13, + duration_seconds: 1.6666666, + target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")), + events: [], + ), + ], + diagnostics: [], +) diff --git a/assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron b/assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron index 6259f6e..b364c25 100644 --- a/assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron +++ b/assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron @@ -8,7 +8,6 @@ format: "fbx", fingerprint: ( byte_len: 59964, - modified_unix_secs: 1780713434, content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46", ), dependencies: [], @@ -17,4 +16,4 @@ skeletons: [], clips: [], diagnostics: [], -) \ No newline at end of file +) diff --git a/assets/animations/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.animation.ron b/assets/animations/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.animation.ron new file mode 100644 index 0000000..af8417a --- /dev/null +++ b/assets/animations/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.animation.ron @@ -0,0 +1,21 @@ +( + schema_version: 3, + asset_id: "bd495161-5ea8-48ef-b0db-5b1d57f33885", + label: "pebble_bricks_4k", + default_animation_clip_id: None, + source: ( + path: "assets/materials/pebble_bricks_4k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 2788, + content_hash: "55a5374732e4174e33859cf415d22ac855cea3efe8d8ff78c6ee42c86d8fd5fb", + ), + dependencies: [ + "assets/materials/pebble_bricks.bin", + ], + ), + runtime_supported: true, + skeletons: [], + clips: [], + diagnostics: [], +) diff --git a/assets/content.catalog.ron b/assets/content.catalog.ron new file mode 100644 index 0000000..b32895f --- /dev/null +++ b/assets/content.catalog.ron @@ -0,0 +1,909 @@ +( + version: 2, + defaults: ( + default_material: None, + ), + records: [ + ( + id: ("5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc"), + path: "assets/Furniture/Office/blacksite-m2-robot-expressive.glb", + label: "blacksite-m2-robot-expressive", + kind: Model, + material_slots: [], + ), + ( + id: ("647a63d4-64c6-4431-ba46-9efdd515cd8f"), + path: "assets/Furniture/Office/metal_office_desk.material.ron", + label: "metal_office_desk.material", + kind: Material, + material_slots: [], + material: Some(( + packed_arm_path: Some("assets/.import-cache/runtime/materials/647a63d4-64c6-4431-ba46-9efdd515cd8f/297372611543b1f7baa1e5e3-arm.basis"), + processing_key: Some("297372611543b1f7baa1e5e3"), + )), + ), + ( + id: ("36c51ff3-29ae-4ab1-aaca-f90f57e27966"), + path: "assets/Furniture/Office/metal_office_desk_2k.gltf", + label: "metal_office_desk_2k", + kind: Model, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node1:mesh1:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node2:mesh2:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node3:mesh3:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node4:mesh4:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node5:mesh5:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node6:mesh6:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node7:mesh7:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ], + ), + ( + id: ("cef332e3-d0d1-41d7-adf2-87462c70f52c"), + path: "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/cef332e3-d0d1-41d7-adf2-87462c70f52c/edf8ef1b2fc54bbe4eafc16d/texture.basis"), + processing_key: Some("edf8ef1b2fc54bbe4eafc16d"), + settings: ( + semantic: MaskData, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("481b1a35-75e4-4879-879d-ccab183ba2b7"), + path: "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/481b1a35-75e4-4879-879d-ccab183ba2b7/534c0d384d22c554b9597064/texture.basis"), + processing_key: Some("534c0d384d22c554b9597064"), + settings: ( + semantic: Color, + color_space: Srgb, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: true, + )), + ), + ( + id: ("cadad4af-c24a-4d52-bdc4-8089dac67f30"), + path: "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/cadad4af-c24a-4d52-bdc4-8089dac67f30/cd1c550f437632f394bf7502/texture.basis"), + processing_key: Some("cd1c550f437632f394bf7502"), + settings: ( + semantic: Normal, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("fe40db93-181f-4c79-86e6-26bd97464c4b"), + path: "assets/audio/Puzzle Lobby.mp3", + label: "Puzzle Lobby", + kind: AudioClip, + material_slots: [], + ), + ( + id: ("c0a7d10e-53c1-4de0-b0dc-34a66f44ba77"), + path: "assets/audio/editor_audition_tone.ogg", + label: "editor_audition_tone", + kind: AudioClip, + material_slots: [], + ), + ( + id: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"), + path: "assets/levels/audio_authoring_showcase.scn.ron", + label: "audio_authoring_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("a7ef65b4-24d6-4c3a-b637-5bb52d8e9a26"), + path: "assets/levels/collider_diagnostics_showcase.scn.ron", + label: "collider_diagnostics_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), + path: "assets/levels/editor_scene 2.scn.ron", + label: "editor_scene 2.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"), + path: "assets/levels/editor_scene 3.scn.ron", + label: "editor_scene 3.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"), + path: "assets/levels/editor_scene.scn.ron", + label: "editor_scene.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"), + path: "assets/levels/navigation_authoring_showcase.scn.ron", + label: "navigation_authoring_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("78c6850b-5a76-4a4f-b48e-7fb00f6dd1ed"), + path: "assets/levels/physics_placement_showcase.scn.ron", + label: "physics_placement_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"), + path: "assets/levels/rendering_showcase.scn.ron", + label: "rendering_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("bca13177-afa9-4691-9b40-288059c7a57e"), + path: "assets/levels/samples/brush_blockout.scn.ron", + label: "brush_blockout.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("607864bf-fa77-4f9e-acc9-2c0906d34163"), + path: "assets/levels/samples/material_lab.scn.ron", + label: "material_lab.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("1b25d451-7b54-41d6-af00-d2e6950f502d"), + path: "assets/levels/terrain_authoring_showcase.scn.ron", + label: "terrain_authoring_showcase.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("782d9f6a-e965-41af-86e1-84ce44de1cd6"), + path: "assets/materials/chrome.ron", + label: "chrome", + kind: Material, + material_slots: [], + ), + ( + id: ("a57f2891-8536-47b8-b476-01c08b36ac43"), + path: "assets/materials/concrete.ron", + label: "concrete", + kind: Material, + material_slots: [], + ), + ( + id: ("4f684592-50b1-47e4-888c-8ba537e4c29d"), + path: "assets/materials/emissive_panel.ron", + label: "emissive_panel", + kind: Material, + material_slots: [], + ), + ( + id: ("0cb3fb85-db18-52ea-8c42-b5ab58e90965"), + path: "assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron", + label: "legacy-05a00cdd0a637f9e.material", + kind: Material, + material_slots: [], + ), + ( + id: ("250a0db7-7108-5adf-b759-39b30c692c35"), + path: "assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron", + label: "legacy-082ba03b8cbb710b.material", + kind: Material, + material_slots: [], + ), + ( + id: ("5b60f0a9-53b8-56b4-8ee9-cffd2e117c55"), + path: "assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron", + label: "legacy-0a21ebb8e495bf57.material", + kind: Material, + material_slots: [], + ), + ( + id: ("8fd7f537-75c1-524d-9f09-6654a3718a8e"), + path: "assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron", + label: "legacy-1a259303a97c4ba9.material", + kind: Material, + material_slots: [], + ), + ( + id: ("7a818722-7a73-5541-838c-cedbef0648f7"), + path: "assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron", + label: "legacy-2b86cae1d3bfacae.material", + kind: Material, + material_slots: [], + ), + ( + id: ("c6c84742-e395-5ccb-96bc-d1bd101623b5"), + path: "assets/materials/migrated/legacy-3546762bbff37951.material.ron", + label: "legacy-3546762bbff37951.material", + kind: Material, + material_slots: [], + ), + ( + id: ("3adc8ea9-1387-5f1c-9404-bbe057dd910d"), + path: "assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron", + label: "legacy-454bcd95aaa4f1ca.material", + kind: Material, + material_slots: [], + ), + ( + id: ("2b67be74-d8b5-509a-9efe-dd58b8010a9a"), + path: "assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron", + label: "legacy-4f65eccbb7dac1a2.material", + kind: Material, + material_slots: [], + ), + ( + id: ("231210a7-5d2a-5d06-b3ec-b3ee0bde636a"), + path: "assets/materials/migrated/legacy-62ef60a686be94f1.material.ron", + label: "legacy-62ef60a686be94f1.material", + kind: Material, + material_slots: [], + ), + ( + id: ("c98f2306-b6f1-5d02-b232-19e74e886e20"), + path: "assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron", + label: "legacy-77cc6503f24bb9e8.material", + kind: Material, + material_slots: [], + ), + ( + id: ("6cc5ffc1-16fb-5652-ab6b-0bb9b4f36ae2"), + path: "assets/materials/migrated/legacy-78bc49856f47eae1.material.ron", + label: "legacy-78bc49856f47eae1.material", + kind: Material, + material_slots: [], + ), + ( + id: ("069977d1-acb8-5bf2-8afa-dfe2abbd4303"), + path: "assets/materials/migrated/legacy-964101ee77293501.material.ron", + label: "legacy-964101ee77293501.material", + kind: Material, + material_slots: [], + ), + ( + id: ("ee32fb60-4ef9-5b7f-a20d-ce216e915530"), + path: "assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron", + label: "legacy-97356bd0ac2f6198.material", + kind: Material, + material_slots: [], + ), + ( + id: ("1cc3cb7a-7ddd-55e3-ba91-71071f30dc83"), + path: "assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron", + label: "legacy-9a630ee3e4848d9d.material", + kind: Material, + material_slots: [], + ), + ( + id: ("81212e15-4fc8-50ca-bd7d-ac596505cb4c"), + path: "assets/materials/migrated/legacy-a74f874eafd5d224.material.ron", + label: "legacy-a74f874eafd5d224.material", + kind: Material, + material_slots: [], + ), + ( + id: ("91e93c0c-4d4b-5e3f-adcd-6c15be6d0a74"), + path: "assets/materials/migrated/legacy-b278a62978900638.material.ron", + label: "legacy-b278a62978900638.material", + kind: Material, + material_slots: [], + ), + ( + id: ("0a4681e1-b5f0-57fa-8ee1-4431e14e322f"), + path: "assets/materials/migrated/legacy-c656429ce3668694.material.ron", + label: "legacy-c656429ce3668694.material", + kind: Material, + material_slots: [], + ), + ( + id: ("ba295f51-426f-5fdb-b444-a5f1cc5199da"), + path: "assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron", + label: "legacy-c6c02ef278fba7c3.material", + kind: Material, + material_slots: [], + ), + ( + id: ("6c8791ac-a746-58a2-b097-17d2fe0494dc"), + path: "assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron", + label: "legacy-c6fbbebd3e1a9943.material", + kind: Material, + material_slots: [], + ), + ( + id: ("41dbf5a0-7a0a-5fac-9ec3-a0ceacb548a9"), + path: "assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron", + label: "legacy-c9d05c61e5cf348d.material", + kind: Material, + material_slots: [], + ), + ( + id: ("2fb3fcd4-f7b7-5f28-bf79-2241e47c7ea8"), + path: "assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron", + label: "legacy-cc0396f5c90fb7bb.material", + kind: Material, + material_slots: [], + ), + ( + id: ("c512d6e2-272b-5e86-8235-4628ed3b6e8c"), + path: "assets/materials/migrated/legacy-e9671004b101f4df.material.ron", + label: "legacy-e9671004b101f4df.material", + kind: Material, + material_slots: [], + ), + ( + id: ("2db7e7f2-c0ab-51df-b71b-37f0e8c038f1"), + path: "assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron", + label: "legacy-fc132d9b30cc63d4.material", + kind: Material, + material_slots: [], + ), + ( + id: ("e9bd02c0-986f-4f90-a6d5-f04388622b94"), + path: "assets/materials/new_material.ron", + label: "new_material", + kind: Material, + material_slots: [], + ), + ( + id: ("d4387c2e-3ca2-4fca-862a-d86def44e424"), + path: "assets/materials/new_material_2.ron", + label: "new_material_2", + kind: Material, + material_slots: [], + ), + ( + id: ("c07a5b05-f27d-46c6-8b8c-9de9cdd14720"), + path: "assets/materials/pebble_bricks.ron", + label: "pebble_bricks", + kind: Material, + material_slots: [], + material: Some(( + packed_arm_path: Some("assets/.import-cache/runtime/materials/c07a5b05-f27d-46c6-8b8c-9de9cdd14720/76a589e4456ec3320b876209-arm.basis"), + processing_key: Some("76a589e4456ec3320b876209"), + )), + ), + ( + id: ("bd495161-5ea8-48ef-b0db-5b1d57f33885"), + path: "assets/materials/pebble_bricks_4k.gltf", + label: "pebble_bricks_4k", + kind: Model, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + sub_asset_id: "", + label: "pebble_bricks", + source_path: Some("assets/materials/pebble_bricks.ron"), + ))), + ), + ], + ), + ( + id: ("d6cb4151-7124-4237-aaf9-f7f8abd5fb76"), + path: "assets/materials/surface_tint.ron", + label: "surface_tint", + kind: Material, + material_slots: [], + ), + ( + id: ("cc2769da-c9a5-4cb3-866a-cc81d0688ee2"), + path: "assets/materials/surface_tint_instance.ron", + label: "surface_tint_instance", + kind: MaterialInstance, + material_slots: [], + ), + ( + id: ("25db6174-afcb-421f-8c1c-0c5ffde21b79"), + path: "assets/materials/textures/pebble_bricks_arm_4k.jpg", + label: "pebble_bricks_arm_4k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/materials/textures/pebble_bricks_arm_4k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/25db6174-afcb-421f-8c1c-0c5ffde21b79/552b39094506da0fbf4cd647/texture.basis"), + processing_key: Some("552b39094506da0fbf4cd647"), + settings: ( + semantic: MaskData, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee"), + path: "assets/materials/textures/pebble_bricks_diff_4k.jpg", + label: "pebble_bricks_diff_4k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/materials/textures/pebble_bricks_diff_4k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee/644db179a14573b2a514e68f/texture.basis"), + processing_key: Some("644db179a14573b2a514e68f"), + settings: ( + semantic: Color, + color_space: Srgb, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: true, + )), + ), + ( + id: ("7c5235a1-f5d7-4530-b29f-5ea075881df5"), + path: "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + label: "pebble_bricks_nor_gl_4k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/7c5235a1-f5d7-4530-b29f-5ea075881df5/944b70940e3dd9a70e851d0e/texture.basis"), + processing_key: Some("944b70940e3dd9a70e851d0e"), + settings: ( + semantic: Normal, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("113f74df-e39c-41d4-9b5b-e48efe541f7f"), + path: "assets/models/RobotExpressive.glb", + label: "RobotExpressive", + kind: Model, + material_slots: [], + ), + ( + id: ("3076fad0-89ca-4307-b8d9-cb77fca41907"), + path: "assets/models/metal_office_desk_2k.gltf", + label: "metal_office_desk_2k", + kind: Model, + material_slots: [], + ), + ( + id: ("b98ef565-3500-49e7-9935-f685fa9b2594"), + path: "assets/models/painted_wooden_chair_02_2k.fbx", + label: "painted_wooden_chair_02_2k", + kind: Model, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node1:material0"), + selection: Default, + ), + ], + ), + ( + id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"), + path: "assets/models/robot_expressive.glb", + label: "robot_expressive", + kind: Model, + material_slots: [], + ), + ( + id: ("393414a8-3089-435f-b267-6dd3eac0285f"), + path: "assets/models/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/models/textures/metal_office_desk_arm_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/393414a8-3089-435f-b267-6dd3eac0285f/edf8ef1b2fc54bbe4eafc16d/texture.basis"), + processing_key: Some("edf8ef1b2fc54bbe4eafc16d"), + settings: ( + semantic: MaskData, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("e5512ff1-aa00-4592-a740-4574e1059a99"), + path: "assets/models/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/models/textures/metal_office_desk_diff_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/e5512ff1-aa00-4592-a740-4574e1059a99/534c0d384d22c554b9597064/texture.basis"), + processing_key: Some("534c0d384d22c554b9597064"), + settings: ( + semantic: Color, + color_space: Srgb, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: true, + )), + ), + ( + id: ("5212dba1-853f-4dab-98f3-e6395d05a4f7"), + path: "assets/models/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/models/textures/metal_office_desk_nor_gl_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/5212dba1-853f-4dab-98f3-e6395d05a4f7/cd1c550f437632f394bf7502/texture.basis"), + processing_key: Some("cd1c550f437632f394bf7502"), + settings: ( + semantic: Normal, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("09762f59-bcd6-44fc-8157-2ad920177996"), + path: "assets/pebble_bricks_surface_instance.material-instance.ron", + label: "pebble_bricks_surface_instance.material-instance", + kind: MaterialInstance, + material_slots: [], + material: Some(( + packed_arm_path: Some("assets/.import-cache/runtime/materials/09762f59-bcd6-44fc-8157-2ad920177996/76a589e4456ec3320b876209-arm.basis"), + processing_key: Some("76a589e4456ec3320b876209"), + )), + ), + ( + id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"), + path: "assets/post_fx/chromatic_aberration.ron", + label: "chromatic_aberration", + kind: PostProcessEffect, + material_slots: [], + ), + ( + id: ("e14a9bc3-9a4e-47b0-901f-b31c004e1836"), + path: "assets/post_fx/vignette.ron", + label: "vignette", + kind: PostProcessEffect, + material_slots: [], + ), + ( + id: ("862530b2-8111-4ebb-9078-29723c002e08"), + path: "assets/prefabs/example_base.scn.ron", + label: "example_base.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("682191d6-59cd-46a1-9560-6cf7bc1583b2"), + path: "assets/prefabs/example_nested.scn.ron", + label: "example_nested.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("c14f6240-5654-4e80-9edb-2b4c44cf8d61"), + path: "assets/prefabs/example_variant.scn.ron", + label: "example_variant.scn", + kind: Level, + material_slots: [], + ), + ( + id: ("2c4a9866-17b5-4797-9a8f-726b2ffacdb9"), + path: "assets/rendering_profiles/cave_dark.ron", + label: "cave_dark", + kind: RenderingProfile, + material_slots: [], + ), + ( + id: ("802471df-8295-4997-80e2-db4191850fc9"), + path: "assets/rendering_profiles/outdoor_haze.ron", + label: "outdoor_haze", + kind: RenderingProfile, + material_slots: [], + ), + ( + id: ("addf6f7f-8114-45f3-8566-70d1eaa1fec3"), + path: "assets/shaders/standard_lit.shader.ron", + label: "standard_lit.shader", + kind: ShaderSchema, + material_slots: [], + ), + ( + id: ("a72943d6-3e9d-4629-8887-5c02d99d4f92"), + path: "assets/shaders/surface_tint.shader.ron", + label: "surface_tint.shader", + kind: ShaderSchema, + material_slots: [], + ), + ( + id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"), + path: "assets/shaders/unlit.shader.ron", + label: "unlit.shader", + kind: ShaderSchema, + material_slots: [], + ), + ( + id: ("c7a227bc-2bae-4a41-857b-28cc6b432a9d"), + path: "assets/textures/metal_office_desk_arm_2k.jpg", + label: "metal_office_desk_arm_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_office_desk_arm_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/c7a227bc-2bae-4a41-857b-28cc6b432a9d/edf8ef1b2fc54bbe4eafc16d/texture.basis"), + processing_key: Some("edf8ef1b2fc54bbe4eafc16d"), + settings: ( + semantic: MaskData, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("c644134e-87c7-43fb-ac30-31c4e0a78b48"), + path: "assets/textures/metal_office_desk_diff_2k.jpg", + label: "metal_office_desk_diff_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_office_desk_diff_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/c644134e-87c7-43fb-ac30-31c4e0a78b48/534c0d384d22c554b9597064/texture.basis"), + processing_key: Some("534c0d384d22c554b9597064"), + settings: ( + semantic: Color, + color_space: Srgb, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: true, + )), + ), + ( + id: ("c661e66a-0745-4dd9-89b0-e611befab602"), + path: "assets/textures/metal_office_desk_nor_gl_2k.jpg", + label: "metal_office_desk_nor_gl_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_office_desk_nor_gl_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/c661e66a-0745-4dd9-89b0-e611befab602/cd1c550f437632f394bf7502/texture.basis"), + processing_key: Some("cd1c550f437632f394bf7502"), + settings: ( + semantic: Normal, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("0496e62e-1837-4940-a9c9-b2b4ffe9ed90"), + path: "assets/textures/metal_stool_01_arm_2k.jpg", + label: "metal_stool_01_arm_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_stool_01_arm_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/0496e62e-1837-4940-a9c9-b2b4ffe9ed90/3ecc62c0eaf1672ca08fb59f/texture.basis"), + processing_key: Some("3ecc62c0eaf1672ca08fb59f"), + settings: ( + semantic: MaskData, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ( + id: ("bddaa4d4-5097-42bf-8d51-dd9a3580442a"), + path: "assets/textures/metal_stool_01_diff_2k.jpg", + label: "metal_stool_01_diff_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_stool_01_diff_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/bddaa4d4-5097-42bf-8d51-dd9a3580442a/2276a96b1a148fd7c16977d5/texture.basis"), + processing_key: Some("2276a96b1a148fd7c16977d5"), + settings: ( + semantic: Color, + color_space: Srgb, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: true, + )), + ), + ( + id: ("c4abe41c-ac88-4aec-8168-27e8a7c90583"), + path: "assets/textures/metal_stool_01_nor_gl_2k.jpg", + label: "metal_stool_01_nor_gl_2k", + kind: Texture, + material_slots: [], + texture: Some(( + source_path: "assets/textures/metal_stool_01_nor_gl_2k.jpg", + processed_path: Some("assets/.import-cache/runtime/textures/c4abe41c-ac88-4aec-8168-27e8a7c90583/d0ec4224c3dcb6fa565c94bb/texture.basis"), + processing_key: Some("d0ec4224c3dcb6fa565c94bb"), + settings: ( + semantic: Normal, + color_space: Linear, + mipmaps: Generate, + compression: Uastc, + max_dimension: None, + filter: Linear, + wrap: Repeat, + anisotropy: 8, + normal_map_convention: OpenGl, + ), + is_srgb: false, + )), + ), + ], +) \ No newline at end of file diff --git a/assets/levels/audio_authoring_showcase.scn.ron b/assets/levels/audio_authoring_showcase.scn.ron index 65ae56a..d097074 100644 --- a/assets/levels/audio_authoring_showcase.scn.ron +++ b/assets/levels/audio_authoring_showcase.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 4294969001: (components: { diff --git a/assets/levels/collider_diagnostics_showcase.scn.ron b/assets/levels/collider_diagnostics_showcase.scn.ron index 6949d43..95eee29 100644 --- a/assets/levels/collider_diagnostics_showcase.scn.ron +++ b/assets/levels/collider_diagnostics_showcase.scn.ron @@ -1,175 +1,208 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Valid Box Collider", - "bevy_transform::components::transform::Transform": ( +(schema_version: 6,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Valid Box Collider", + "bevy_transform::components::transform::Transform": ( translation: (-4.5, 1.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-box"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.18, g: 0.62, b: 0.36, a: 1.0), - metallic: 0.05, roughness: 0.55, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("collider-showcase-box"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Cuboid(x_length: 1.8, y_length: 2.0, z_length: 1.8), ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Valid Sphere Collider", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (1.8, 2.0, 1.8), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "069977d1-acb8-5bf2-8afa-dfe2abbd4303", + sub_asset_id: "material:source", + label: "Legacy Material 964101ee77293501", + source_path: Some("assets/materials/migrated/legacy-964101ee77293501.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 2: (components: { + "bevy_ecs::name::Name": "Valid Sphere Collider", + "bevy_transform::components::transform::Transform": ( translation: (-2.25, 1.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.35, 0.8), ), - "shared::components::ActorId": ("collider-showcase-sphere"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.16, g: 0.52, b: 0.78, a: 1.0), - metallic: 0.08, roughness: 0.42, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Sphere, size: (2.0, 2.0, 2.0)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("collider-showcase-sphere"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Sphere(radius: 1.0), ), - }), - 3: (components: { - "bevy_ecs::name::Name": "Valid Capsule Collider", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Sphere, + size: (2.0, 2.0, 2.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "81212e15-4fc8-50ca-bd7d-ac596505cb4c", + sub_asset_id: "material:source", + label: "Legacy Material a74f874eafd5d224", + source_path: Some("assets/materials/migrated/legacy-a74f874eafd5d224.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 3: (components: { + "bevy_ecs::name::Name": "Valid Capsule Collider", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 1.4, 0.0), rotation: (0.0, 0.1950903, 0.0, 0.9807853), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-capsule"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (2), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.58, g: 0.34, b: 0.78, a: 1.0), - metallic: 0.06, roughness: 0.48, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (1.4, 2.8, 1.4)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("collider-showcase-capsule"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Capsule(radius: 0.7, height: 2.8), ), - }), - 4: (components: { - "bevy_ecs::name::Name": "Trigger Collider", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (1.4, 2.8, 1.4), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "7a818722-7a73-5541-838c-cedbef0648f7", + sub_asset_id: "material:source", + label: "Legacy Material 2b86cae1d3bfacae", + source_path: Some("assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 4: (components: { + "bevy_ecs::name::Name": "Trigger Collider", + "bevy_transform::components::transform::Transform": ( translation: (2.25, 1.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-trigger"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (3), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.12, g: 0.68, b: 0.82, a: 1.0), - metallic: 0.03, roughness: 0.4, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Sphere, size: (2.0, 2.0, 2.0)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("collider-showcase-trigger"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: true, shape: Sphere(radius: 1.0), ), - }), - 5: (components: { - "bevy_ecs::name::Name": "Disabled Collider", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Sphere, + size: (2.0, 2.0, 2.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "6c8791ac-a746-58a2-b097-17d2fe0494dc", + sub_asset_id: "material:source", + label: "Legacy Material c6fbbebd3e1a9943", + source_path: Some("assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 5: (components: { + "bevy_ecs::name::Name": "Disabled Collider", + "bevy_transform::components::transform::Transform": ( translation: (4.5, 1.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-disabled"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (4), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.38, g: 0.40, b: 0.46, a: 1.0), - metallic: 0.02, roughness: 0.68, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("collider-showcase-disabled"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: false, is_trigger: false, shape: Cuboid(x_length: 1.8, y_length: 2.0, z_length: 1.8), ), - }), - 6: (components: { - "bevy_ecs::name::Name": "Missing Collider", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (1.8, 2.0, 1.8), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "ba295f51-426f-5fdb-b444-a5f1cc5199da", + sub_asset_id: "material:source", + label: "Legacy Material c6c02ef278fba7c3", + source_path: Some("assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 6: (components: { + "bevy_ecs::name::Name": "Missing Collider", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 1.0, -3.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-missing"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (5), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.78, g: 0.16, b: 0.20, a: 1.0), - metallic: 0.02, roughness: 0.58, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, - base_color_texture: None, emissive_texture: None, normal_map_texture: None, - metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), - "shared::components::RigidBodyDesc": (body: Static), - }), - 7: (components: { - "bevy_ecs::name::Name": "Collider Showcase Sun", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("collider-showcase-missing"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (1.8, 2.0, 1.8), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "c98f2306-b6f1-5d02-b232-19e74e886e20", + sub_asset_id: "material:source", + label: "Legacy Material 77cc6503f24bb9e8", + source_path: Some("assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 7: (components: { + "bevy_ecs::name::Name": "Collider Showcase Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 8.0, 4.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("collider-showcase-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (6), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("collider-showcase-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (6), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.88, a: 1.0), intensity: 100000.0, range: 0.0, shadows: true, inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/levels/editor_scene 2.scn.ron b/assets/levels/editor_scene 2.scn.ron index 53c1d0e..4087fe3 100644 --- a/assets/levels/editor_scene 2.scn.ron +++ b/assets/levels/editor_scene 2.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 4294967126: (components: { @@ -14,20 +14,6 @@ ), "shared::components::HierarchySiblingIndex": (23), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -37,9 +23,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 4294967158: (components: { "bevy_ecs::name::Name": "Scene Sun", @@ -82,20 +78,6 @@ ), "shared::components::HierarchySiblingIndex": (1), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.42, - g: 0.45, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.92, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -105,9 +87,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (200.0, 1.0, 200.0), - ), + shape: Box, + size: (200.0, 1.0, 200.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "1cc3cb7a-7ddd-55e3-ba91-71071f30dc83", + sub_asset_id: "material:source", + label: "Legacy Material 9a630ee3e4848d9d", + source_path: Some("assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron"), + ))), + ), +), }), 8589934420: (components: { "bevy_ecs::name::Name": "Pillar Copy", @@ -122,20 +114,6 @@ ), "shared::components::HierarchySiblingIndex": (25), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -145,9 +123,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934456: (components: { "bevy_ecs::name::Name": "Player Start", @@ -177,20 +165,6 @@ ), "shared::components::HierarchySiblingIndex": (3), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.95, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -198,9 +172,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2b67be74-d8b5-509a-9efe-dd58b8010a9a", + sub_asset_id: "material:source", + label: "Legacy Material 4f65eccbb7dac1a2", + source_path: Some("assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron"), + ))), + ), +), }), 8589934458: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -215,20 +199,6 @@ ), "shared::components::HierarchySiblingIndex": (4), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.6, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -236,9 +206,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "0a4681e1-b5f0-57fa-8ee1-4431e14e322f", + sub_asset_id: "material:source", + label: "Legacy Material c656429ce3668694", + source_path: Some("assets/materials/migrated/legacy-c656429ce3668694.material.ron"), + ))), + ), +), }), 8589934459: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -253,20 +233,6 @@ ), "shared::components::HierarchySiblingIndex": (5), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -274,9 +240,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "8fd7f537-75c1-524d-9f09-6654a3718a8e", + sub_asset_id: "material:source", + label: "Legacy Material 1a259303a97c4ba9", + source_path: Some("assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron"), + ))), + ), +), }), 8589934460: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -291,20 +267,6 @@ ), "shared::components::HierarchySiblingIndex": (6), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.05, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -312,9 +274,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "91e93c0c-4d4b-5e3f-adcd-6c15be6d0a74", + sub_asset_id: "material:source", + label: "Legacy Material b278a62978900638", + source_path: Some("assets/materials/migrated/legacy-b278a62978900638.material.ron"), + ))), + ), +), }), 8589934461: (components: { "bevy_ecs::name::Name": "Stair", @@ -329,20 +301,6 @@ ), "shared::components::HierarchySiblingIndex": (7), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -352,9 +310,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.8000001, 0.5), - ), + shape: Box, + size: (5.0, 1.8000001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934462: (components: { "bevy_ecs::name::Name": "Stair", @@ -369,20 +337,6 @@ ), "shared::components::HierarchySiblingIndex": (8), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -392,9 +346,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.6200001, 0.5), - ), + shape: Box, + size: (5.0, 1.6200001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934463: (components: { "bevy_ecs::name::Name": "Stair", @@ -409,20 +373,6 @@ ), "shared::components::HierarchySiblingIndex": (9), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -432,9 +382,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.44, 0.5), - ), + shape: Box, + size: (5.0, 1.44, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934464: (components: { "bevy_ecs::name::Name": "Stair", @@ -449,20 +409,6 @@ ), "shared::components::HierarchySiblingIndex": (10), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -472,9 +418,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.26, 0.5), - ), + shape: Box, + size: (5.0, 1.26, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934465: (components: { "bevy_ecs::name::Name": "Stair", @@ -489,20 +445,6 @@ ), "shared::components::HierarchySiblingIndex": (11), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -512,9 +454,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.08, 0.5), - ), + shape: Box, + size: (5.0, 1.08, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934466: (components: { "bevy_ecs::name::Name": "Stair", @@ -529,20 +481,6 @@ ), "shared::components::HierarchySiblingIndex": (12), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -552,9 +490,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.90000004, 0.5), - ), + shape: Box, + size: (5.0, 0.90000004, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934467: (components: { "bevy_ecs::name::Name": "Stair", @@ -569,20 +517,6 @@ ), "shared::components::HierarchySiblingIndex": (13), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -592,9 +526,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.72, 0.5), - ), + shape: Box, + size: (5.0, 0.72, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934468: (components: { "bevy_ecs::name::Name": "Stair", @@ -609,20 +553,6 @@ ), "shared::components::HierarchySiblingIndex": (14), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -632,9 +562,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.54, 0.5), - ), + shape: Box, + size: (5.0, 0.54, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934469: (components: { "bevy_ecs::name::Name": "Stair", @@ -649,20 +589,6 @@ ), "shared::components::HierarchySiblingIndex": (15), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -672,9 +598,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.36, 0.5), - ), + shape: Box, + size: (5.0, 0.36, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934470: (components: { "bevy_ecs::name::Name": "Stair", @@ -689,20 +625,6 @@ ), "shared::components::HierarchySiblingIndex": (16), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -712,9 +634,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.18, 0.5), - ), + shape: Box, + size: (5.0, 0.18, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934471: (components: { "bevy_ecs::name::Name": "Ramp", @@ -729,20 +661,6 @@ ), "shared::components::HierarchySiblingIndex": (17), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.3, - g: 0.45, - b: 0.7, - a: 1.0, - ), - metallic: 0.1, - roughness: 0.55, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -752,9 +670,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.4, 9.0), - ), + shape: Box, + size: (5.0, 0.4, 9.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "ee32fb60-4ef9-5b7f-a20d-ce216e915530", + sub_asset_id: "material:source", + label: "Legacy Material 97356bd0ac2f6198", + source_path: Some("assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron"), + ))), + ), +), }), 8589934472: (components: { "bevy_ecs::name::Name": "Crate", @@ -769,20 +697,6 @@ ), "shared::components::HierarchySiblingIndex": (18), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -792,9 +706,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934473: (components: { "bevy_ecs::name::Name": "Crate", @@ -809,20 +733,6 @@ ), "shared::components::HierarchySiblingIndex": (19), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -832,9 +742,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.5, 1.5, 1.5), - ), + shape: Box, + size: (1.5, 1.5, 1.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934474: (components: { "bevy_ecs::name::Name": "Crate", @@ -849,20 +769,6 @@ ), "shared::components::HierarchySiblingIndex": (20), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -872,9 +778,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934475: (components: { "bevy_ecs::name::Name": "Crate", @@ -889,20 +805,6 @@ ), "shared::components::HierarchySiblingIndex": (21), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -912,9 +814,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934476: (components: { "bevy_ecs::name::Name": "Crate", @@ -929,20 +841,6 @@ ), "shared::components::HierarchySiblingIndex": (22), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -952,9 +850,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934477: (components: { "bevy_ecs::name::Name": "Pillar", @@ -969,20 +877,6 @@ ), "shared::components::HierarchySiblingIndex": (23), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -992,9 +886,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934478: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1009,20 +913,6 @@ ), "shared::components::HierarchySiblingIndex": (24), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1032,9 +922,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934479: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1049,20 +949,6 @@ ), "shared::components::HierarchySiblingIndex": (25), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1072,9 +958,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934480: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1089,20 +985,6 @@ ), "shared::components::HierarchySiblingIndex": (26), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - base_color_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1112,9 +994,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), }, ) \ No newline at end of file diff --git a/assets/levels/editor_scene 3.scn.ron b/assets/levels/editor_scene 3.scn.ron index 78ed583..e53d590 100644 --- a/assets/levels/editor_scene 3.scn.ron +++ b/assets/levels/editor_scene 3.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 4294967124: (components: { @@ -14,28 +14,6 @@ ), "shared::components::HierarchySiblingIndex": (29), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.8, - g: 0.8, - b: 0.8, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.65, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -45,9 +23,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "c6c84742-e395-5ccb-96bc-d1bd101623b5", + sub_asset_id: "material:source", + label: "Legacy Material 3546762bbff37951", + source_path: Some("assets/materials/migrated/legacy-3546762bbff37951.material.ron"), + ))), + ), +), }), 4294967126: (components: { "bevy_ecs::name::Name": "Cube", @@ -62,28 +50,6 @@ ), "shared::components::HierarchySiblingIndex": (28), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.8, - g: 0.8, - b: 0.8, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.65, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -93,9 +59,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "c6c84742-e395-5ccb-96bc-d1bd101623b5", + sub_asset_id: "material:source", + label: "Legacy Material 3546762bbff37951", + source_path: Some("assets/materials/migrated/legacy-3546762bbff37951.material.ron"), + ))), + ), +), }), 4294967128: (components: { "bevy_ecs::name::Name": "Point Light", @@ -166,28 +142,6 @@ ), "shared::components::HierarchySiblingIndex": (1), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.42, - g: 0.45, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.92, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -197,9 +151,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (200.0, 1.0, 200.0), - ), + shape: Box, + size: (200.0, 1.0, 200.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "1cc3cb7a-7ddd-55e3-ba91-71071f30dc83", + sub_asset_id: "material:source", + label: "Legacy Material 9a630ee3e4848d9d", + source_path: Some("assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron"), + ))), + ), +), }), 8589934417: (components: { "bevy_ecs::name::Name": "Spot Light", @@ -257,28 +221,6 @@ ), "shared::components::HierarchySiblingIndex": (3), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.95, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -286,9 +228,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2b67be74-d8b5-509a-9efe-dd58b8010a9a", + sub_asset_id: "material:source", + label: "Legacy Material 4f65eccbb7dac1a2", + source_path: Some("assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron"), + ))), + ), +), }), 8589934458: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -303,28 +255,6 @@ ), "shared::components::HierarchySiblingIndex": (4), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.6, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -332,9 +262,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "0a4681e1-b5f0-57fa-8ee1-4431e14e322f", + sub_asset_id: "material:source", + label: "Legacy Material c656429ce3668694", + source_path: Some("assets/materials/migrated/legacy-c656429ce3668694.material.ron"), + ))), + ), +), }), 8589934459: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -349,28 +289,6 @@ ), "shared::components::HierarchySiblingIndex": (5), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -378,9 +296,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "8fd7f537-75c1-524d-9f09-6654a3718a8e", + sub_asset_id: "material:source", + label: "Legacy Material 1a259303a97c4ba9", + source_path: Some("assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron"), + ))), + ), +), }), 8589934460: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", @@ -395,28 +323,6 @@ ), "shared::components::HierarchySiblingIndex": (6), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.05, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -424,9 +330,19 @@ ), ), "shared::components::Primitive": ( - shape: Sphere, - size: (1.2, 1.2, 1.2), - ), + shape: Sphere, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "91e93c0c-4d4b-5e3f-adcd-6c15be6d0a74", + sub_asset_id: "material:source", + label: "Legacy Material b278a62978900638", + source_path: Some("assets/materials/migrated/legacy-b278a62978900638.material.ron"), + ))), + ), +), }), 8589934461: (components: { "bevy_ecs::name::Name": "Stair", @@ -441,28 +357,6 @@ ), "shared::components::HierarchySiblingIndex": (7), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -472,9 +366,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.8000001, 0.5), - ), + shape: Box, + size: (5.0, 1.8000001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934462: (components: { "bevy_ecs::name::Name": "Stair", @@ -489,28 +393,6 @@ ), "shared::components::HierarchySiblingIndex": (8), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -520,9 +402,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.6200001, 0.5), - ), + shape: Box, + size: (5.0, 1.6200001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934463: (components: { "bevy_ecs::name::Name": "Stair", @@ -537,28 +429,6 @@ ), "shared::components::HierarchySiblingIndex": (9), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -568,9 +438,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.44, 0.5), - ), + shape: Box, + size: (5.0, 1.44, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934464: (components: { "bevy_ecs::name::Name": "Stair", @@ -585,28 +465,6 @@ ), "shared::components::HierarchySiblingIndex": (10), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -616,9 +474,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.26, 0.5), - ), + shape: Box, + size: (5.0, 1.26, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934465: (components: { "bevy_ecs::name::Name": "Stair", @@ -633,28 +501,6 @@ ), "shared::components::HierarchySiblingIndex": (11), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -664,9 +510,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 1.08, 0.5), - ), + shape: Box, + size: (5.0, 1.08, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934466: (components: { "bevy_ecs::name::Name": "Stair", @@ -681,28 +537,6 @@ ), "shared::components::HierarchySiblingIndex": (12), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -712,9 +546,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.90000004, 0.5), - ), + shape: Box, + size: (5.0, 0.90000004, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934467: (components: { "bevy_ecs::name::Name": "Stair", @@ -729,28 +573,6 @@ ), "shared::components::HierarchySiblingIndex": (13), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -760,9 +582,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.72, 0.5), - ), + shape: Box, + size: (5.0, 0.72, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934468: (components: { "bevy_ecs::name::Name": "Stair", @@ -777,28 +609,6 @@ ), "shared::components::HierarchySiblingIndex": (14), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -808,9 +618,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.54, 0.5), - ), + shape: Box, + size: (5.0, 0.54, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934469: (components: { "bevy_ecs::name::Name": "Stair", @@ -825,28 +645,6 @@ ), "shared::components::HierarchySiblingIndex": (15), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -856,9 +654,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.36, 0.5), - ), + shape: Box, + size: (5.0, 0.36, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934470: (components: { "bevy_ecs::name::Name": "Stair", @@ -873,28 +681,6 @@ ), "shared::components::HierarchySiblingIndex": (16), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -904,9 +690,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.18, 0.5), - ), + shape: Box, + size: (5.0, 0.18, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), +), }), 8589934471: (components: { "bevy_ecs::name::Name": "Ramp", @@ -921,28 +717,6 @@ ), "shared::components::HierarchySiblingIndex": (17), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.3, - g: 0.45, - b: 0.7, - a: 1.0, - ), - metallic: 0.1, - roughness: 0.55, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -952,9 +726,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (5.0, 0.4, 9.0), - ), + shape: Box, + size: (5.0, 0.4, 9.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "ee32fb60-4ef9-5b7f-a20d-ce216e915530", + sub_asset_id: "material:source", + label: "Legacy Material 97356bd0ac2f6198", + source_path: Some("assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron"), + ))), + ), +), }), 8589934472: (components: { "bevy_ecs::name::Name": "Crate", @@ -969,28 +753,6 @@ ), "shared::components::HierarchySiblingIndex": (18), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1000,9 +762,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934473: (components: { "bevy_ecs::name::Name": "Crate", @@ -1017,28 +789,6 @@ ), "shared::components::HierarchySiblingIndex": (19), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1048,9 +798,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.5, 1.5, 1.5), - ), + shape: Box, + size: (1.5, 1.5, 1.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934474: (components: { "bevy_ecs::name::Name": "Crate", @@ -1065,28 +825,6 @@ ), "shared::components::HierarchySiblingIndex": (20), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1096,9 +834,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934475: (components: { "bevy_ecs::name::Name": "Crate", @@ -1113,28 +861,6 @@ ), "shared::components::HierarchySiblingIndex": (21), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1144,9 +870,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934476: (components: { "bevy_ecs::name::Name": "Crate", @@ -1161,28 +897,6 @@ ), "shared::components::HierarchySiblingIndex": (22), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1192,9 +906,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), +), }), 8589934477: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1209,28 +933,6 @@ ), "shared::components::HierarchySiblingIndex": (23), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1240,9 +942,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934478: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1257,28 +969,6 @@ ), "shared::components::HierarchySiblingIndex": (24), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1288,9 +978,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934479: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1305,28 +1005,6 @@ ), "shared::components::HierarchySiblingIndex": (25), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1336,9 +1014,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 8589934480: (components: { "bevy_ecs::name::Name": "Pillar", @@ -1353,28 +1041,6 @@ ), "shared::components::HierarchySiblingIndex": (26), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1384,9 +1050,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.2, 7.0, 1.2), - ), + shape: Box, + size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), +), }), 12884901711: (components: { "bevy_ecs::name::Name": "Cube", @@ -1401,28 +1077,6 @@ ), "shared::components::HierarchySiblingIndex": (31), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - base_color: ( - r: 0.7322267, - g: 0.0, - b: 0.0, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/concrete.ron"), - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1432,9 +1086,19 @@ ), ), "shared::components::Primitive": ( - shape: Box, - size: (1.0, 1.0, 1.0), - ), + shape: Box, + size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "a57f2891-8536-47b8-b476-01c08b36ac43", + sub_asset_id: "material:source", + label: "concrete", + source_path: Some("assets/materials/concrete.ron"), + ))), + ), +), }), }, ) \ No newline at end of file diff --git a/assets/levels/editor_scene.scn.ron b/assets/levels/editor_scene.scn.ron index 4a58538..6ed996b 100644 --- a/assets/levels/editor_scene.scn.ron +++ b/assets/levels/editor_scene.scn.ron @@ -1,8 +1,8 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { "shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]), }, entities: { - 4294966587: (components: { + 4294966529: (components: { "bevy_ecs::name::Name": "Brush Part 2", "bevy_transform::components::transform::Transform": ( translation: (25.75, 0.0, 9.5), @@ -162,12 +162,15 @@ emissive_texture: None, normal_map_texture: None, metallic_roughness_texture: None, + occlusion_texture: None, material_asset_path: None, parameters: [], textures: [], + uv_offset: (0.0, 0.0), + uv_tiling: (1.0, 1.0), ), }), - 4294966588: (components: { + 4294966530: (components: { "bevy_ecs::name::Name": "Brush Part 1", "bevy_transform::components::transform::Transform": ( translation: (24.5, 0.0, 7.75), @@ -327,12 +330,15 @@ emissive_texture: None, normal_map_texture: None, metallic_roughness_texture: None, + occlusion_texture: None, material_asset_path: None, parameters: [], textures: [], + uv_offset: (0.0, 0.0), + uv_tiling: (1.0, 1.0), ), }), - 4294966589: (components: { + 4294966531: (components: { "bevy_ecs::name::Name": "Crate Copy", "bevy_transform::components::transform::Transform": ( translation: (15.624287, 0.75, 1.8058548), @@ -492,12 +498,15 @@ emissive_texture: None, normal_map_texture: None, metallic_roughness_texture: None, + occlusion_texture: None, material_asset_path: None, parameters: [], textures: [], + uv_offset: (0.0, 0.0), + uv_tiling: (1.0, 1.0), ), }), - 4294966590: (components: { + 4294966532: (components: { "bevy_ecs::name::Name": "Post Process Volume", "bevy_transform::components::transform::Transform": ( translation: (28.0, 2.0, -1.0), @@ -652,7 +661,7 @@ label: None, ), }), - 4294966591: (components: { + 4294966533: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 3.5, -6.0), @@ -665,35 +674,6 @@ ), "shared::components::HierarchySiblingIndex": (8), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -705,9 +685,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), ), }), - 4294966592: (components: { + 4294966534: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (8.0, 3.5, -6.0), @@ -720,35 +710,6 @@ ), "shared::components::HierarchySiblingIndex": (9), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -760,9 +721,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), ), }), - 4294966593: (components: { + 4294966535: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 3.5, 6.0), @@ -775,35 +746,6 @@ ), "shared::components::HierarchySiblingIndex": (10), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -815,9 +757,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), ), }), - 4294966594: (components: { + 4294966536: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (8.0, 3.5, 6.0), @@ -830,35 +782,6 @@ ), "shared::components::HierarchySiblingIndex": (11), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.85, - g: 0.85, - b: 0.88, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -870,9 +793,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.2, 7.0, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "231210a7-5d2a-5d06-b3ec-b3ee0bde636a", + sub_asset_id: "material:source", + label: "Legacy Material 62ef60a686be94f1", + source_path: Some("assets/materials/migrated/legacy-62ef60a686be94f1.material.ron"), + ))), + ), ), }), - 4294966595: (components: { + 4294966537: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (-0.8005953, 0.3732614, 0.21393776), @@ -885,35 +818,6 @@ ), "shared::components::HierarchySiblingIndex": (1), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -925,9 +829,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), ), }), - 4294966596: (components: { + 4294966538: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (0.59940434, 0.3732614, 0.013936996), @@ -940,35 +854,6 @@ ), "shared::components::HierarchySiblingIndex": (2), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -980,9 +865,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), ), }), - 4294966597: (components: { + 4294966539: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (-0.10059604, 1.3955705, 0.11393738), @@ -995,35 +890,6 @@ ), "shared::components::HierarchySiblingIndex": (0), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1035,9 +901,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), ), }), - 4294966598: (components: { + 4294966540: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (14.575503, 4.5, -15.5), @@ -1196,12 +1072,15 @@ emissive_texture: None, normal_map_texture: None, metallic_roughness_texture: None, + occlusion_texture: None, material_asset_path: Some("assets/materials/concrete.ron"), parameters: [], textures: [], + uv_offset: (0.0, 0.0), + uv_tiling: (1.0, 1.0), ), }), - 4294966599: (components: { + 4294966541: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (5.4, 0.5, 2.4), @@ -1214,35 +1093,6 @@ ), "shared::components::HierarchySiblingIndex": (28), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.72, - g: 0.45, - b: 0.2, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1254,9 +1104,19 @@ "shared::components::Primitive": ( shape: Box, size: (1.0, 1.0, 1.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2db7e7f2-c0ab-51df-b71b-37f0e8c038f1", + sub_asset_id: "material:source", + label: "Legacy Material fc132d9b30cc63d4", + source_path: Some("assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron"), + ))), + ), ), }), - 4294966600: (components: { + 4294966542: (components: { "bevy_ecs::name::Name": "Ramp", "bevy_transform::components::transform::Transform": ( translation: (-14.0, 1.6, 0.0), @@ -1269,35 +1129,6 @@ ), "shared::components::HierarchySiblingIndex": (16), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.3, - g: 0.45, - b: 0.7, - a: 1.0, - ), - metallic: 0.1, - roughness: 0.55, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1309,9 +1140,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.4, 9.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "ee32fb60-4ef9-5b7f-a20d-ce216e915530", + sub_asset_id: "material:source", + label: "Legacy Material 97356bd0ac2f6198", + source_path: Some("assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron"), + ))), + ), ), }), - 4294966601: (components: { + 4294966543: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.09, 14.0), @@ -1324,35 +1165,6 @@ ), "shared::components::HierarchySiblingIndex": (17), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1364,9 +1176,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.18, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966602: (components: { + 4294966544: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.18, 13.5), @@ -1379,35 +1201,6 @@ ), "shared::components::HierarchySiblingIndex": (18), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1419,9 +1212,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.36, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966603: (components: { + 4294966545: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.27, 13.0), @@ -1434,35 +1237,6 @@ ), "shared::components::HierarchySiblingIndex": (19), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1474,9 +1248,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.54, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966604: (components: { + 4294966546: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.36, 12.5), @@ -1489,35 +1273,6 @@ ), "shared::components::HierarchySiblingIndex": (20), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1529,9 +1284,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.72, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966605: (components: { + 4294966547: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.45000002, 12.0), @@ -1544,35 +1309,6 @@ ), "shared::components::HierarchySiblingIndex": (21), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1584,9 +1320,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 0.90000004, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966606: (components: { + 4294966548: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.54, 11.5), @@ -1599,35 +1345,6 @@ ), "shared::components::HierarchySiblingIndex": (22), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1639,9 +1356,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 1.08, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966607: (components: { + 4294966549: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.63, 11.0), @@ -1654,35 +1381,6 @@ ), "shared::components::HierarchySiblingIndex": (23), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1694,9 +1392,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 1.26, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966608: (components: { + 4294966550: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.72, 10.5), @@ -1709,35 +1417,6 @@ ), "shared::components::HierarchySiblingIndex": (24), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1749,9 +1428,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 1.44, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966609: (components: { + 4294966551: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.81000006, 10.0), @@ -1764,35 +1453,6 @@ ), "shared::components::HierarchySiblingIndex": (25), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1804,9 +1464,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 1.6200001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966610: (components: { + 4294966552: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.90000004, 9.5), @@ -1819,35 +1489,6 @@ ), "shared::components::HierarchySiblingIndex": (26), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.52, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.8, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -1859,9 +1500,19 @@ "shared::components::Primitive": ( shape: Box, size: (5.0, 1.8000001, 0.5), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "3adc8ea9-1387-5f1c-9404-bbe057dd910d", + sub_asset_id: "material:source", + label: "Legacy Material 454bcd95aaa4f1ca", + source_path: Some("assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron"), + ))), + ), ), }), - 4294966611: (components: { + 4294966553: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 0.6, 4.0), @@ -1874,35 +1525,6 @@ ), "shared::components::HierarchySiblingIndex": (15), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.05, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -1912,9 +1534,19 @@ "shared::components::Primitive": ( shape: Sphere, size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "91e93c0c-4d4b-5e3f-adcd-6c15be6d0a74", + sub_asset_id: "material:source", + label: "Legacy Material b278a62978900638", + source_path: Some("assets/materials/migrated/legacy-b278a62978900638.material.ron"), + ))), + ), ), }), - 4294966612: (components: { + 4294966554: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-5.8, 0.6, 4.0), @@ -1927,35 +1559,6 @@ ), "shared::components::HierarchySiblingIndex": (13), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.3, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -1965,9 +1568,19 @@ "shared::components::Primitive": ( shape: Sphere, size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "8fd7f537-75c1-524d-9f09-6654a3718a8e", + sub_asset_id: "material:source", + label: "Legacy Material 1a259303a97c4ba9", + source_path: Some("assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron"), + ))), + ), ), }), - 4294966613: (components: { + 4294966555: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-3.6, 0.6, 4.0), @@ -1980,35 +1593,6 @@ ), "shared::components::HierarchySiblingIndex": (14), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.6, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -2018,9 +1602,19 @@ "shared::components::Primitive": ( shape: Sphere, size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "0a4681e1-b5f0-57fa-8ee1-4431e14e322f", + sub_asset_id: "material:source", + label: "Legacy Material c656429ce3668694", + source_path: Some("assets/materials/migrated/legacy-c656429ce3668694.material.ron"), + ))), + ), ), }), - 4294966614: (components: { + 4294966556: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-1.3999996, 0.6, 4.0), @@ -2033,35 +1627,6 @@ ), "shared::components::HierarchySiblingIndex": (7), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.9, - g: 0.9, - b: 0.92, - a: 1.0, - ), - metallic: 1.0, - roughness: 0.95, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Sphere( @@ -2071,9 +1636,19 @@ "shared::components::Primitive": ( shape: Sphere, size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2b67be74-d8b5-509a-9efe-dd58b8010a9a", + sub_asset_id: "material:source", + label: "Legacy Material 4f65eccbb7dac1a2", + source_path: Some("assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron"), + ))), + ), ), }), - 4294966615: (components: { + 4294966557: (components: { "bevy_ecs::name::Name": "Player Start", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.9900649, 12.46175), @@ -2088,7 +1663,7 @@ "shared::components::LevelObject": (), "shared::components::PlayerSpawn": (), }), - 4294966616: (components: { + 4294966558: (components: { "bevy_ecs::name::Name": "Ground", "bevy_transform::components::transform::Transform": ( translation: (0.0, -0.5, 0.0), @@ -2101,35 +1676,6 @@ ), "shared::components::HierarchySiblingIndex": (12), "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.42, - g: 0.45, - b: 0.48, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.92, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), "shared::components::PhysicsBody": ( body: Static, collider: Cuboid( @@ -2141,13 +1687,23 @@ "shared::components::Primitive": ( shape: Box, size: (200.0, 1.0, 200.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + sub_asset_id: "material:source", + label: "pebble_bricks", + source_path: Some("assets/materials/pebble_bricks.ron"), + ))), + ), ), }), - 4294966617: (components: { + 4294966559: (components: { "bevy_ecs::name::Name": "Scene Sun", "bevy_transform::components::transform::Transform": ( translation: (0.0, 5.316672, 0.0), - rotation: (-0.21401538, 0.31161797, 0.67592126, 0.6326311), + rotation: (-0.3045251, 0.40742344, 0.62288165, 0.5943812), scale: (1.0, 1.0, 1.0), ), "shared::components::ActorKind": Light, @@ -2160,8 +1716,8 @@ kind: Directional, color: ( r: 1.0, - g: 0.95, - b: 0.85, + g: 1.0, + b: 1.0, a: 1.0, ), intensity: 100000.0, @@ -2171,5 +1727,565 @@ outer_angle_deg: 35.0, ), }), + 68719475876: (components: { + "bevy_ecs::hierarchy::ChildOf": (107374181688), + "bevy_ecs::name::Name": "metal_office_desk / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("bb88fac5-50d9-40a8-ab3b-4ef20d2fcca0"), + "shared::components::ActorKind": StaticMesh, + "shared::components::AuthoringComponentStates": ( + states: [ + ( + component_id: "physics.collider", + type_name: "", + active: true, + ), + ], + ), + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node2:mesh2:primitive0", + label: "metal_office_desk / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node2:mesh2:primitive0"), + name: "metal_office_desk / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node2:mesh2:primitive0", + label: "metal_office_desk / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node2:mesh2:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node2:mesh2:primitive0"), + name: "metal_office_desk / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 77309410416: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("0d83d985-275c-4bf1-998f-c1de6990dc1f"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node3:mesh3:primitive0", + label: "metal_office_desk_drawer_04 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node3:mesh3:primitive0"), + name: "metal_office_desk_drawer_04 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node3:mesh3:primitive0", + label: "metal_office_desk_drawer_04 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node3:mesh3:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node3:mesh3:primitive0"), + name: "metal_office_desk_drawer_04 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 81604377613: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("5589764d-9f00-444d-9904-6d9e28b70e1e"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node1:mesh1:primitive0", + label: "metal_office_desk_tray_01 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (6), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node1:mesh1:primitive0"), + name: "metal_office_desk_tray_01 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node1:mesh1:primitive0", + label: "metal_office_desk_tray_01 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node1:mesh1:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node1:mesh1:primitive0"), + name: "metal_office_desk_tray_01 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 85899344880: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("63866135-caca-4061-aada-15ca763718a1"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node4:mesh4:primitive0", + label: "metal_office_desk_drawer_03 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node4:mesh4:primitive0"), + name: "metal_office_desk_drawer_03 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node4:mesh4:primitive0", + label: "metal_office_desk_drawer_03 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node4:mesh4:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node4:mesh4:primitive0"), + name: "metal_office_desk_drawer_03 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 85899345209: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("56208828-a8de-466c-8fe8-bcbc236a98de"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node5:mesh5:primitive0", + label: "metal_office_desk_drawer_05 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node5:mesh5:primitive0"), + name: "metal_office_desk_drawer_05 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node5:mesh5:primitive0", + label: "metal_office_desk_drawer_05 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node5:mesh5:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node5:mesh5:primitive0"), + name: "metal_office_desk_drawer_05 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 90194312192: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("1b1e06b2-1bb3-4bd5-b3e2-9f2d83297268"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node0:mesh0:primitive0", + label: "metal_office_desk_drawer_06 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node0:mesh0:primitive0"), + name: "metal_office_desk_drawer_06 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node0:mesh0:primitive0", + label: "metal_office_desk_drawer_06 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node0:mesh0:primitive0"), + name: "metal_office_desk_drawer_06 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 98784246793: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("1797b075-3f86-46be-ab56-9835f60d7487"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node7:mesh7:primitive0", + label: "metal_office_desk_drawer_01 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node7:mesh7:primitive0"), + name: "metal_office_desk_drawer_01 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node7:mesh7:primitive0", + label: "metal_office_desk_drawer_01 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node7:mesh7:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node7:mesh7:primitive0"), + name: "metal_office_desk_drawer_01 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), + 107374181688: (components: { + "bevy_ecs::name::Name": "metal_office_desk_2k", + "bevy_transform::components::transform::Transform": ( + translation: (2.0, 0.1, 9.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("e0c30906-7519-45c6-8f54-19f7780e8bd5"), + "shared::components::ActorKind": Empty, + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (30), + "shared::components::LevelObject": (), + }), + 120259083352: (components: { + "bevy_ecs::hierarchy::ChildOf": (68719475876), + "bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("85c6a7c9-d48f-4858-813b-b3fc106feff0"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( + enabled: true, + is_trigger: false, + shape: StaticMesh( + meshes: [ + ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node6:mesh6:primitive0", + label: "metal_office_desk_drawer_02 / Primitive 0", + source_path: None, + ), + ], + cooking: Default, + ), + ), + "shared::components::EditorVisibility": ( + visible: true, + ), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::RigidBodyDesc": ( + body: Static, + ), + "shared::components::StaticMeshRenderer": ( + slots: [ + ( + id: ("draw:scene0:node6:mesh6:primitive0"), + name: "metal_office_desk_drawer_02 / Primitive 0", + mesh: ( + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + sub_asset_id: "draw:scene0:node6:mesh6:primitive0", + label: "metal_office_desk_drawer_02 / Primitive 0", + source_path: None, + ), + material_slot_id: ("slot:draw:scene0:node6:mesh6:primitive0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + visible: true, + cast_shadows: true, + receive_shadows: true, + ), + ], + materials: ( + slots: [ + ( + id: ("slot:draw:scene0:node6:mesh6:primitive0"), + name: "metal_office_desk_drawer_02 / Primitive 0", + material: None, + ), + ], + orphaned_assignments: [], + ), + ), + }), }, ) \ No newline at end of file diff --git a/assets/levels/navigation_authoring_showcase.scn.ron b/assets/levels/navigation_authoring_showcase.scn.ron index 6f95235..beb0e00 100644 --- a/assets/levels/navigation_authoring_showcase.scn.ron +++ b/assets/levels/navigation_authoring_showcase.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 1: (components: { @@ -67,7 +67,15 @@ "shared::components::EditorVisibility": (visible: true), "shared::components::HierarchySiblingIndex": (4), "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)), + "shared::components::Primitive": ( + shape: Box, + size: (16.0, 0.5, 16.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: None, + ), +), }), }, -) +) \ No newline at end of file diff --git a/assets/levels/physics_placement_showcase.scn.ron b/assets/levels/physics_placement_showcase.scn.ron index 6803934..9104027 100644 --- a/assets/levels/physics_placement_showcase.scn.ron +++ b/assets/levels/physics_placement_showcase.scn.ron @@ -1,157 +1,151 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Placement Floor", - "bevy_transform::components::transform::Transform": ( +(schema_version: 6,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Placement Floor", + "bevy_transform::components::transform::Transform": ( translation: (0.0, -0.25, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("physics-placement-floor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.12, g: 0.14, b: 0.16, a: 1.0), - metallic: 0.05, - roughness: 0.72, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (14.0, 0.5, 10.0)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("physics-placement-floor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Cuboid(x_length: 14.0, y_length: 0.5, z_length: 10.0), ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Placement Cuboid", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (14.0, 0.5, 10.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "6cc5ffc1-16fb-5652-ab6b-0bb9b4f36ae2", + sub_asset_id: "material:source", + label: "Legacy Material 78bc49856f47eae1", + source_path: Some("assets/materials/migrated/legacy-78bc49856f47eae1.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 2: (components: { + "bevy_ecs::name::Name": "Placement Cuboid", + "bevy_transform::components::transform::Transform": ( translation: (-2.0, 3.0, 0.0), rotation: (0.1305262, 0.0, 0.0, 0.9914449), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("physics-placement-prop-a"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.88, g: 0.48, b: 0.12, a: 1.0), - metallic: 0.1, - roughness: 0.42, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (1.2, 1.2, 1.2)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("physics-placement-prop-a"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Cuboid(x_length: 1.2, y_length: 1.2, z_length: 1.2), ), - }), - 3: (components: { - "bevy_ecs::name::Name": "Placement Sphere", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (1.2, 1.2, 1.2), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "0cb3fb85-db18-52ea-8c42-b5ab58e90965", + sub_asset_id: "material:source", + label: "Legacy Material 05a00cdd0a637f9e", + source_path: Some("assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 3: (components: { + "bevy_ecs::name::Name": "Placement Sphere", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 5.0, 0.0), rotation: (0.0, 0.2164396, 0.0, 0.976296), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("physics-placement-prop-b"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (2), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.10, g: 0.58, b: 0.72, a: 1.0), - metallic: 0.15, - roughness: 0.35, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - "shared::components::Primitive": (shape: Sphere, size: (1.6, 1.6, 1.6)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("physics-placement-prop-b"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Sphere(radius: 0.8), ), - }), - 4: (components: { - "bevy_ecs::name::Name": "Placement Capsule", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Sphere, + size: (1.6, 1.6, 1.6), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "5b60f0a9-53b8-56b4-8ee9-cffd2e117c55", + sub_asset_id: "material:source", + label: "Legacy Material 0a21ebb8e495bf57", + source_path: Some("assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 4: (components: { + "bevy_ecs::name::Name": "Placement Capsule", + "bevy_transform::components::transform::Transform": ( translation: (2.0, 7.0, 0.0), rotation: (0.092296, 0.092296, -0.008077, 0.991405), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("physics-placement-prop-c"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (3), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.62, g: 0.24, b: 0.56, a: 1.0), - metallic: 0.08, - roughness: 0.5, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - "shared::components::Primitive": (shape: Box, size: (0.9, 1.8, 0.9)), - "shared::components::RigidBodyDesc": (body: Static), - "shared::components::ColliderDesc": ( + "shared::components::ActorId": ("physics-placement-prop-c"), + "shared::components::ActorKind": StaticMesh, + "shared::components::ColliderDesc": ( enabled: true, is_trigger: false, shape: Capsule(radius: 0.45, height: 1.8), ), - }), - 5: (components: { - "bevy_ecs::name::Name": "Placement Sun", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (0.9, 1.8, 0.9), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "2fb3fcd4-f7b7-5f28-bf79-2241e47c7ea8", + sub_asset_id: "material:source", + label: "Legacy Material cc0396f5c90fb7bb", + source_path: Some("assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron"), + ))), + ), +), + "shared::components::RigidBodyDesc": (body: Static), + }), + 5: (components: { + "bevy_ecs::name::Name": "Placement Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 8.0, 2.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("physics-placement-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (4), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("physics-placement-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), intensity: 100000.0, @@ -160,5 +154,6 @@ inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/levels/rendering_showcase.scn.ron b/assets/levels/rendering_showcase.scn.ron index 1e0194a..34c9739 100644 --- a/assets/levels/rendering_showcase.scn.ron +++ b/assets/levels/rendering_showcase.scn.ron @@ -1,162 +1,154 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Rendering Lab Floor", - "bevy_transform::components::transform::Transform": ( +(schema_version: 6,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Rendering Lab Floor", + "bevy_transform::components::transform::Transform": ( translation: (1.0, -0.25, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-floor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (38.0, 0.5, 12.0)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.12, g: 0.14, b: 0.16, a: 1.0), - metallic: 0.05, - roughness: 0.72, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Vignette Anchor", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("rendering-showcase-floor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (38.0, 0.5, 12.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "6cc5ffc1-16fb-5652-ab6b-0bb9b4f36ae2", + sub_asset_id: "material:source", + label: "Legacy Material 78bc49856f47eae1", + source_path: Some("assets/materials/migrated/legacy-78bc49856f47eae1.material.ron"), + ))), + ), +), + }), + 2: (components: { + "bevy_ecs::name::Name": "Vignette Anchor", + "bevy_transform::components::transform::Transform": ( translation: (-12.0, 1.2, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-vignette-anchor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Sphere, size: (2.4, 2.4, 2.4)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.82, g: 0.18, b: 0.2, a: 1.0), - metallic: 0.08, - roughness: 0.38, - emissive_color: (r: 1.0, g: 0.2, b: 0.2, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - }), - 3: (components: { - "bevy_ecs::name::Name": "Fog Anchor", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("rendering-showcase-vignette-anchor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Sphere, + size: (2.4, 2.4, 2.4), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "c512d6e2-272b-5e86-8235-4628ed3b6e8c", + sub_asset_id: "material:source", + label: "Legacy Material e9671004b101f4df", + source_path: Some("assets/materials/migrated/legacy-e9671004b101f4df.material.ron"), + ))), + ), +), + }), + 3: (components: { + "bevy_ecs::name::Name": "Fog Anchor", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 1.4, 0.0), rotation: (0.0, 0.258819, 0.0, 0.9659258), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-fog-anchor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (2), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (2.8, 2.8, 2.8)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.1, g: 0.58, b: 0.72, a: 1.0), - metallic: 0.15, - roughness: 0.35, - emissive_color: (r: 0.1, g: 0.58, b: 0.72, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - }), - 4: (components: { - "bevy_ecs::name::Name": "Exposure Anchor", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("rendering-showcase-fog-anchor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (2.8, 2.8, 2.8), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "41dbf5a0-7a0a-5fac-9ec3-a0ceacb548a9", + sub_asset_id: "material:source", + label: "Legacy Material c9d05c61e5cf348d", + source_path: Some("assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron"), + ))), + ), +), + }), + 4: (components: { + "bevy_ecs::name::Name": "Exposure Anchor", + "bevy_transform::components::transform::Transform": ( translation: (14.0, 1.3, 0.0), rotation: (0.0, -0.1736482, 0.0, 0.9848078), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-exposure-anchor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (3), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (3.0, 2.6, 3.0)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.92, g: 0.62, b: 0.12, a: 1.0), - metallic: 0.12, - roughness: 0.32, - emissive_color: (r: 1.0, g: 0.65, b: 0.12, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], - ), - }), - 5: (components: { - "bevy_ecs::name::Name": "Emissive Comparison Panel", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("rendering-showcase-exposure-anchor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (3.0, 2.6, 3.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "250a0db7-7108-5adf-b759-39b30c692c35", + sub_asset_id: "material:source", + label: "Legacy Material 082ba03b8cbb710b", + source_path: Some("assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron"), + ))), + ), +), + }), + 5: (components: { + "bevy_ecs::name::Name": "Emissive Comparison Panel", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 2.6, -4.8), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-emissive-panel"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (4), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (26.0, 1.2, 0.25)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.15, g: 0.22, b: 0.28, a: 1.0), - metallic: 0.0, - roughness: 0.35, - emissive_color: (r: 0.55, g: 0.85, b: 1.0, a: 1.0), - emissive_intensity: 2500.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/emissive_panel.ron"), - parameters: [], - textures: [], - ), - }), - 6: (components: { - "bevy_ecs::name::Name": "Fog Volume", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("rendering-showcase-emissive-panel"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (26.0, 1.2, 0.25), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + material: Some((( + asset_id: "4f684592-50b1-47e4-888c-8ba537e4c29d", + sub_asset_id: "material:source", + label: "emissive_panel", + source_path: Some("assets/materials/emissive_panel.ron"), + ))), + ), +), + }), + 6: (components: { + "bevy_ecs::name::Name": "Fog Volume", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 2.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-fog-volume"), - "shared::components::ActorKind": PostProcessVolume, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (5), - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( + "shared::components::ActorId": ("rendering-showcase-fog-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( half_extents: (6.0, 3.0, 6.0), priority: 0, blend_distance: 2.0, @@ -168,20 +160,20 @@ profile: None, label: Some("Foggy courtyard"), ), - }), - 7: (components: { - "bevy_ecs::name::Name": "Dark Exposure Volume", - "bevy_transform::components::transform::Transform": ( + }), + 7: (components: { + "bevy_ecs::name::Name": "Dark Exposure Volume", + "bevy_transform::components::transform::Transform": ( translation: (14.0, 2.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-exposure-volume"), - "shared::components::ActorKind": PostProcessVolume, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (6), - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( + "shared::components::ActorId": ("rendering-showcase-exposure-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (6), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( half_extents: (4.0, 2.5, 4.0), priority: 5, blend_distance: 1.5, @@ -193,20 +185,20 @@ profile: Some("assets/rendering_profiles/cave_dark.ron"), label: Some("Cave mouth"), ), - }), - 8: (components: { - "bevy_ecs::name::Name": "Vignette FX Volume", - "bevy_transform::components::transform::Transform": ( + }), + 8: (components: { + "bevy_ecs::name::Name": "Vignette FX Volume", + "bevy_transform::components::transform::Transform": ( translation: (-12.0, 2.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-vignette-volume"), - "shared::components::ActorKind": PostProcessVolume, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (7), - "shared::components::LevelObject": (), - "shared::components::PostProcessVolumeDesc": ( + "shared::components::ActorId": ("rendering-showcase-vignette-volume"), + "shared::components::ActorKind": PostProcessVolume, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (7), + "shared::components::LevelObject": (), + "shared::components::PostProcessVolumeDesc": ( half_extents: (5.0, 2.0, 5.0), priority: 2, blend_distance: 2.0, @@ -215,20 +207,20 @@ profile: None, label: Some("Vignette demo"), ), - }), - 9: (components: { - "bevy_ecs::name::Name": "Rendering Lab Sun", - "bevy_transform::components::transform::Transform": ( + }), + 9: (components: { + "bevy_ecs::name::Name": "Rendering Lab Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 10.0, 5.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("rendering-showcase-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (8), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("rendering-showcase-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (8), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), intensity: 100000.0, @@ -237,5 +229,6 @@ inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/levels/samples/brush_blockout.scn.ron b/assets/levels/samples/brush_blockout.scn.ron index da24239..62f02b3 100644 --- a/assets/levels/samples/brush_blockout.scn.ron +++ b/assets/levels/samples/brush_blockout.scn.ron @@ -1,32 +1,16 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Foundation Brush", - "bevy_transform::components::transform::Transform": ( +(schema_version: 6,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Foundation Brush", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.5, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-brush-foundation"), - "shared::components::ActorKind": Brush, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0), - metallic: 0.0, - roughness: 0.85, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/concrete.ron"), - parameters: [], - textures: [], - ), - "shared::components::BrushDesc": ( + "shared::components::ActorId": ("sample-brush-foundation"), + "shared::components::ActorKind": Brush, + "shared::components::BrushDesc": ( kind: Additive, faces: [ ( @@ -99,20 +83,10 @@ cast_shadows: true, receive_shadows: true, ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Material Face Tower", - "bevy_transform::components::transform::Transform": ( - translation: (-2.5, 3.0, 0.0), - rotation: (0.0, 0.0, 0.0, 1.0), - scale: (1.0, 1.0, 1.0), - ), - "shared::components::ActorId": ("sample-brush-material-tower"), - "shared::components::ActorKind": Brush, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( shader: (kind: StandardLit, schema_path: None, shader_path: None), base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0), metallic: 0.0, @@ -127,7 +101,17 @@ parameters: [], textures: [], ), - "shared::components::BrushDesc": ( + }), + 2: (components: { + "bevy_ecs::name::Name": "Material Face Tower", + "bevy_transform::components::transform::Transform": ( + translation: (-2.5, 3.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("sample-brush-material-tower"), + "shared::components::ActorKind": Brush, + "shared::components::BrushDesc": ( kind: Additive, faces: [ ( @@ -205,35 +189,35 @@ cast_shadows: true, receive_shadows: true, ), - }), - 3: (components: { - "bevy_ecs::name::Name": "Subtractive Marker", - "bevy_transform::components::transform::Transform": ( - translation: (2.5, 2.0, 0.0), - rotation: (0.0, 0.258819, 0.0, 0.9659258), - scale: (1.0, 1.0, 1.0), - ), - "shared::components::ActorId": ("sample-brush-subtractive-marker"), - "shared::components::ActorKind": Brush, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (2), - "shared::components::LevelObject": (), - "shared::components::MaterialDesc": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.72, g: 0.14, b: 0.16, a: 1.0), - metallic: 0.05, - roughness: 0.4, - emissive_color: (r: 1.0, g: 0.25, b: 0.2, a: 1.0), + base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0), + metallic: 0.0, + roughness: 0.85, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, base_color_texture: None, emissive_texture: None, normal_map_texture: None, metallic_roughness_texture: None, - material_asset_path: None, + material_asset_path: Some("assets/materials/concrete.ron"), parameters: [], textures: [], ), - "shared::components::BrushDesc": ( + }), + 3: (components: { + "bevy_ecs::name::Name": "Subtractive Marker", + "bevy_transform::components::transform::Transform": ( + translation: (2.5, 2.0, 0.0), + rotation: (0.0, 0.258819, 0.0, 0.9659258), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("sample-brush-subtractive-marker"), + "shared::components::ActorKind": Brush, + "shared::components::BrushDesc": ( kind: SubtractiveMarker, faces: [ ( @@ -306,20 +290,38 @@ cast_shadows: true, receive_shadows: true, ), - }), - 4: (components: { - "bevy_ecs::name::Name": "Brush Lab Sun", - "bevy_transform::components::transform::Transform": ( + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.72, g: 0.14, b: 0.16, a: 1.0), + metallic: 0.05, + roughness: 0.4, + emissive_color: (r: 1.0, g: 0.25, b: 0.2, a: 1.0), + emissive_intensity: 0.0, + base_color_texture: None, + emissive_texture: None, + normal_map_texture: None, + metallic_roughness_texture: None, + material_asset_path: None, + parameters: [], + textures: [], + ), + }), + 4: (components: { + "bevy_ecs::name::Name": "Brush Lab Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 8.0, 4.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-brush-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (3), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("sample-brush-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), intensity: 100000.0, @@ -328,5 +330,6 @@ inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/levels/samples/material_lab.scn.ron b/assets/levels/samples/material_lab.scn.ron index 7911031..8eadeb1 100644 --- a/assets/levels/samples/material_lab.scn.ron +++ b/assets/levels/samples/material_lab.scn.ron @@ -1,182 +1,159 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Material Lab Floor", - "bevy_transform::components::transform::Transform": ( +(schema_version: 5,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Material Lab Floor", + "bevy_transform::components::transform::Transform": ( translation: (0.0, -0.2, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-floor"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (14.0, 0.4, 8.0)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0), - metallic: 0.0, - roughness: 0.85, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/concrete.ron"), - parameters: [], - textures: [], - ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Concrete Reference", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("sample-material-floor"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (14.0, 0.4, 8.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + source_material: None, + material: Some((( + asset_id: "a57f2891-8536-47b8-b476-01c08b36ac43", + sub_asset_id: "material:source", + label: "concrete", + source_path: Some("assets/materials/concrete.ron"), + ))), + ), +), + }), + 2: (components: { + "bevy_ecs::name::Name": "Concrete Reference", + "bevy_transform::components::transform::Transform": ( translation: (-4.0, 1.0, 0.0), rotation: (0.0, 0.2164396, 0.0, 0.976296), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-concrete"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (2.0, 2.0, 2.0)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0), - metallic: 0.0, - roughness: 0.85, - emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/concrete.ron"), - parameters: [], - textures: [], - ), - }), - 3: (components: { - "bevy_ecs::name::Name": "Custom Surface Reference", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("sample-material-concrete"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (2.0, 2.0, 2.0), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + source_material: None, + material: Some((( + asset_id: "a57f2891-8536-47b8-b476-01c08b36ac43", + sub_asset_id: "material:source", + label: "concrete", + source_path: Some("assets/materials/concrete.ron"), + ))), + ), +), + }), + 3: (components: { + "bevy_ecs::name::Name": "Custom Surface Reference", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 1.2, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-custom-surface"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (2), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Sphere, size: (2.4, 2.4, 2.4)), - "shared::components::MaterialDesc": ( - shader: ( - kind: Custom, - schema_path: Some("assets/shaders/surface_tint.shader.ron"), - shader_path: Some("assets/shaders/surface_tint.wgsl"), - ), - base_color: (r: 0.12, g: 0.42, b: 0.95, a: 1.0), - metallic: 0.15, - roughness: 0.28, - emissive_color: (r: 0.02, g: 0.08, b: 0.25, a: 1.0), - emissive_intensity: 3.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/surface_tint.ron"), - parameters: [ - (name: "tint", value: Color((r: 0.12, g: 0.42, b: 0.95, a: 1.0))), - (name: "roughness", value: Float(0.28)), - (name: "metallic", value: Float(0.15)), - (name: "emissive", value: Color((r: 0.02, g: 0.08, b: 0.25, a: 1.0))), - (name: "emissive_intensity", value: Float(3.0)), - ], - textures: [], - ), - }), - 4: (components: { - "bevy_ecs::name::Name": "Material Instance Reference", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("sample-material-custom-surface"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Sphere, + size: (2.4, 2.4, 2.4), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + source_material: None, + material: Some((( + asset_id: "d6cb4151-7124-4237-aaf9-f7f8abd5fb76", + sub_asset_id: "material:source", + label: "surface_tint", + source_path: Some("assets/materials/surface_tint.ron"), + ))), + ), +), + }), + 4: (components: { + "bevy_ecs::name::Name": "Material Instance Reference", + "bevy_transform::components::transform::Transform": ( translation: (4.0, 1.0, 0.0), rotation: (0.0, -0.1305262, 0.0, 0.9914449), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-instance"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (3), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (2.6, 2.0, 2.6)), - "shared::components::MaterialDesc": ( - shader: ( - kind: Custom, - schema_path: Some("assets/shaders/surface_tint.shader.ron"), - shader_path: Some("assets/shaders/surface_tint.wgsl"), - ), - base_color: (r: 0.12, g: 0.42, b: 0.95, a: 1.0), - metallic: 0.15, - roughness: 0.28, - emissive_color: (r: 0.02, g: 0.08, b: 0.25, a: 1.0), - emissive_intensity: 3.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/surface_tint_instance.ron"), - parameters: [ - (name: "tint", value: Color((r: 0.12, g: 0.42, b: 0.95, a: 1.0))), - (name: "roughness", value: Float(0.28)), - (name: "metallic", value: Float(0.15)), - (name: "emissive", value: Color((r: 0.02, g: 0.08, b: 0.25, a: 1.0))), - (name: "emissive_intensity", value: Float(3.0)), - ], - textures: [], - ), - }), - 5: (components: { - "bevy_ecs::name::Name": "Emissive Reference Panel", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("sample-material-instance"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (2.6, 2.0, 2.6), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + source_material: None, + material: Some((( + asset_id: "cc2769da-c9a5-4cb3-866a-cc81d0688ee2", + sub_asset_id: "material:instance", + label: "surface_tint_instance", + source_path: Some("assets/materials/surface_tint_instance.ron"), + ))), + ), +), + }), + 5: (components: { + "bevy_ecs::name::Name": "Emissive Reference Panel", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 2.3, -3.6), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-emissive-panel"), - "shared::components::ActorKind": StaticMesh, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (4), - "shared::components::LevelObject": (), - "shared::components::Primitive": (shape: Box, size: (9.0, 3.0, 0.25)), - "shared::components::MaterialDesc": ( - shader: (kind: StandardLit, schema_path: None, shader_path: None), - base_color: (r: 0.15, g: 0.22, b: 0.28, a: 1.0), - metallic: 0.0, - roughness: 0.35, - emissive_color: (r: 0.55, g: 0.85, b: 1.0, a: 1.0), - emissive_intensity: 2500.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: Some("assets/materials/emissive_panel.ron"), - parameters: [], - textures: [], - ), - }), - 6: (components: { - "bevy_ecs::name::Name": "Material Lab Sun", - "bevy_transform::components::transform::Transform": ( + "shared::components::ActorId": ("sample-material-emissive-panel"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::Primitive": ( + shape: Box, + size: (9.0, 3.0, 0.25), + surface: ( + id: ("slot:primitive:surface"), + name: "Surface", + source_material: None, + material: Some((( + asset_id: "4f684592-50b1-47e4-888c-8ba537e4c29d", + sub_asset_id: "material:source", + label: "emissive_panel", + source_path: Some("assets/materials/emissive_panel.ron"), + ))), + ), +), + }), + 6: (components: { + "bevy_ecs::name::Name": "Material Lab Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 8.0, 4.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("sample-material-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (5), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("sample-material-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), intensity: 100000.0, @@ -185,5 +162,6 @@ inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/levels/terrain_authoring_showcase.scn.ron b/assets/levels/terrain_authoring_showcase.scn.ron index fc5cb3c..a782819 100644 --- a/assets/levels/terrain_authoring_showcase.scn.ron +++ b/assets/levels/terrain_authoring_showcase.scn.ron @@ -1,17 +1,19 @@ -(schema_version: 4, resources: {}, entities: { - 1: (components: { - "bevy_ecs::name::Name": "Terrain Showcase", - "bevy_transform::components::transform::Transform": ( +(schema_version: 6,resources: { + }, + entities: { + 1: (components: { + "bevy_ecs::name::Name": "Terrain Showcase", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("terrain-showcase-main"), - "shared::components::ActorKind": Terrain, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (0), - "shared::components::LevelObject": (), - "shared::components::TerrainDesc": ( + "shared::components::ActorId": ("terrain-showcase-main"), + "shared::components::ActorKind": Terrain, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::TerrainDesc": ( schema_version: 1, resolution: 5, heights: [ @@ -61,20 +63,20 @@ cast_shadows: true, receive_shadows: true, ), - }), - 2: (components: { - "bevy_ecs::name::Name": "Terrain Sun", - "bevy_transform::components::transform::Transform": ( + }), + 2: (components: { + "bevy_ecs::name::Name": "Terrain Sun", + "bevy_transform::components::transform::Transform": ( translation: (0.0, 8.0, 0.0), rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), - "shared::components::ActorId": ("terrain-showcase-sun"), - "shared::components::ActorKind": Light, - "shared::components::EditorVisibility": (visible: true), - "shared::components::HierarchySiblingIndex": (1), - "shared::components::LevelObject": (), - "shared::components::LightDesc": ( + "shared::components::ActorId": ("terrain-showcase-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( kind: Directional, color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0), intensity: 100000.0, @@ -83,5 +85,6 @@ inner_angle_deg: 25.0, outer_angle_deg: 35.0, ), - }), -}) + }), + }, +) \ No newline at end of file diff --git a/assets/materials/chrome.ron b/assets/materials/chrome.ron new file mode 100644 index 0000000..8b10d5f --- /dev/null +++ b/assets/materials/chrome.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "New Material 3", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(1.0), + ), + ( + name: "roughness", + value: Float(0.0), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/concrete.ron b/assets/materials/concrete.ron index dc8ffd8..61655c3 100644 --- a/assets/materials/concrete.ron +++ b/assets/materials/concrete.ron @@ -1,40 +1,85 @@ ( - schema_version: 1, + schema_version: 2, label: "Concrete", - shader: None, + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), shader_ref: None, render_state: ( alpha_mode: Opaque, alpha_cutoff: 0.5, double_sided: false, ), - material: ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.55, - g: 0.54, - b: 0.52, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.85, - emissive_color: ( - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], - textures: [], + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.55, + g: 0.54, + b: 0.52, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.85), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: None, + channel: Rgba, + ), + ( + name: "metallic", + texture: None, + channel: B, + ), + ( + name: "roughness", + texture: None, + channel: G, + ), + ( + name: "occlusion", + texture: None, + channel: R, + ), + ( + name: "normal", + texture: None, + channel: Rgb, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], ), ) \ No newline at end of file diff --git a/assets/materials/emissive_panel.ron b/assets/materials/emissive_panel.ron index e9d296c..b858fa4 100644 --- a/assets/materials/emissive_panel.ron +++ b/assets/materials/emissive_panel.ron @@ -1,40 +1,50 @@ ( - schema_version: 1, + schema_version: 2, label: "Emissive Panel", - shader: None, + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), shader_ref: None, render_state: ( alpha_mode: Opaque, alpha_cutoff: 0.5, double_sided: false, ), - material: ( - shader: ( - kind: StandardLit, - schema_path: None, - shader_path: None, - ), - base_color: ( - r: 0.15, - g: 0.22, - b: 0.28, - a: 1.0, - ), - metallic: 0.0, - roughness: 0.35, - emissive_color: ( - r: 0.55, - g: 0.85, - b: 1.0, - a: 1.0, - ), - emissive_intensity: 2500.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [], + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.15, + g: 0.22, + b: 0.28, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.35), + ), + ( + name: "emissive_color", + value: Color(( + r: 0.55, + g: 0.85, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(2500.0), + ), + ], textures: [], ), ) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron b/assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron new file mode 100644 index 0000000..c342733 --- /dev/null +++ b/assets/materials/migrated/legacy-05a00cdd0a637f9e.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 05a00cdd0a637f9e", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.88, + g: 0.48, + b: 0.12, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.1), + ), + ( + name: "roughness", + value: Float(0.42), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron b/assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron new file mode 100644 index 0000000..9e9d4c6 --- /dev/null +++ b/assets/materials/migrated/legacy-082ba03b8cbb710b.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 082ba03b8cbb710b", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.92, + g: 0.62, + b: 0.12, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.12), + ), + ( + name: "roughness", + value: Float(0.32), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 0.65, + b: 0.12, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron b/assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron new file mode 100644 index 0000000..7edf64b --- /dev/null +++ b/assets/materials/migrated/legacy-0a21ebb8e495bf57.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 0a21ebb8e495bf57", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.1, + g: 0.58, + b: 0.72, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.15), + ), + ( + name: "roughness", + value: Float(0.35), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron b/assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron new file mode 100644 index 0000000..65f345d --- /dev/null +++ b/assets/materials/migrated/legacy-1a259303a97c4ba9.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 1a259303a97c4ba9", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.9, + g: 0.9, + b: 0.92, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(1.0), + ), + ( + name: "roughness", + value: Float(0.3), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron b/assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron new file mode 100644 index 0000000..6f6a0c4 --- /dev/null +++ b/assets/materials/migrated/legacy-2b86cae1d3bfacae.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 2b86cae1d3bfacae", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.58, + g: 0.34, + b: 0.78, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.06), + ), + ( + name: "roughness", + value: Float(0.48), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-3546762bbff37951.material.ron b/assets/materials/migrated/legacy-3546762bbff37951.material.ron new file mode 100644 index 0000000..07fea64 --- /dev/null +++ b/assets/materials/migrated/legacy-3546762bbff37951.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 3546762bbff37951", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.65), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron b/assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron new file mode 100644 index 0000000..5f34913 --- /dev/null +++ b/assets/materials/migrated/legacy-454bcd95aaa4f1ca.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 454bcd95aaa4f1ca", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.55, + g: 0.52, + b: 0.48, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.8), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron b/assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron new file mode 100644 index 0000000..7ea2e68 --- /dev/null +++ b/assets/materials/migrated/legacy-4f65eccbb7dac1a2.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 4f65eccbb7dac1a2", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.9, + g: 0.9, + b: 0.92, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(1.0), + ), + ( + name: "roughness", + value: Float(0.95), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-62ef60a686be94f1.material.ron b/assets/materials/migrated/legacy-62ef60a686be94f1.material.ron new file mode 100644 index 0000000..727d790 --- /dev/null +++ b/assets/materials/migrated/legacy-62ef60a686be94f1.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 62ef60a686be94f1", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.85, + g: 0.85, + b: 0.88, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.3), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron b/assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron new file mode 100644 index 0000000..f372eb5 --- /dev/null +++ b/assets/materials/migrated/legacy-77cc6503f24bb9e8.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 77cc6503f24bb9e8", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.78, + g: 0.16, + b: 0.2, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.02), + ), + ( + name: "roughness", + value: Float(0.58), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-78bc49856f47eae1.material.ron b/assets/materials/migrated/legacy-78bc49856f47eae1.material.ron new file mode 100644 index 0000000..5c56700 --- /dev/null +++ b/assets/materials/migrated/legacy-78bc49856f47eae1.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 78bc49856f47eae1", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.12, + g: 0.14, + b: 0.16, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.05), + ), + ( + name: "roughness", + value: Float(0.72), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-964101ee77293501.material.ron b/assets/materials/migrated/legacy-964101ee77293501.material.ron new file mode 100644 index 0000000..bc33464 --- /dev/null +++ b/assets/materials/migrated/legacy-964101ee77293501.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 964101ee77293501", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.18, + g: 0.62, + b: 0.36, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.05), + ), + ( + name: "roughness", + value: Float(0.55), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron b/assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron new file mode 100644 index 0000000..282915b --- /dev/null +++ b/assets/materials/migrated/legacy-97356bd0ac2f6198.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 97356bd0ac2f6198", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.3, + g: 0.45, + b: 0.7, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.1), + ), + ( + name: "roughness", + value: Float(0.55), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron b/assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron new file mode 100644 index 0000000..dbab876 --- /dev/null +++ b/assets/materials/migrated/legacy-9a630ee3e4848d9d.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material 9a630ee3e4848d9d", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.42, + g: 0.45, + b: 0.48, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.92), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-a74f874eafd5d224.material.ron b/assets/materials/migrated/legacy-a74f874eafd5d224.material.ron new file mode 100644 index 0000000..b4ca607 --- /dev/null +++ b/assets/materials/migrated/legacy-a74f874eafd5d224.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material a74f874eafd5d224", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.16, + g: 0.52, + b: 0.78, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.08), + ), + ( + name: "roughness", + value: Float(0.42), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-b278a62978900638.material.ron b/assets/materials/migrated/legacy-b278a62978900638.material.ron new file mode 100644 index 0000000..8934c62 --- /dev/null +++ b/assets/materials/migrated/legacy-b278a62978900638.material.ron @@ -0,0 +1,85 @@ +( + schema_version: 2, + label: "Legacy Material b278a62978900638", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.9, + g: 0.9, + b: 0.92, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(1.0), + ), + ( + name: "roughness", + value: Float(0.0), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: None, + channel: Rgba, + ), + ( + name: "metallic", + texture: None, + channel: B, + ), + ( + name: "roughness", + texture: None, + channel: G, + ), + ( + name: "occlusion", + texture: None, + channel: R, + ), + ( + name: "normal", + texture: None, + channel: Rgb, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-c656429ce3668694.material.ron b/assets/materials/migrated/legacy-c656429ce3668694.material.ron new file mode 100644 index 0000000..e70066a --- /dev/null +++ b/assets/materials/migrated/legacy-c656429ce3668694.material.ron @@ -0,0 +1,85 @@ +( + schema_version: 2, + label: "Legacy Material c656429ce3668694", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.9, + g: 0.9, + b: 0.92, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.0), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: None, + channel: Rgba, + ), + ( + name: "metallic", + texture: None, + channel: B, + ), + ( + name: "roughness", + texture: None, + channel: G, + ), + ( + name: "occlusion", + texture: None, + channel: R, + ), + ( + name: "normal", + texture: None, + channel: Rgb, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron b/assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron new file mode 100644 index 0000000..d3f5bbc --- /dev/null +++ b/assets/materials/migrated/legacy-c6c02ef278fba7c3.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material c6c02ef278fba7c3", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.38, + g: 0.4, + b: 0.46, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.02), + ), + ( + name: "roughness", + value: Float(0.68), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron b/assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron new file mode 100644 index 0000000..2ce27b1 --- /dev/null +++ b/assets/materials/migrated/legacy-c6fbbebd3e1a9943.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material c6fbbebd3e1a9943", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.12, + g: 0.68, + b: 0.82, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.03), + ), + ( + name: "roughness", + value: Float(0.4), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron b/assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron new file mode 100644 index 0000000..ceca9f3 --- /dev/null +++ b/assets/materials/migrated/legacy-c9d05c61e5cf348d.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material c9d05c61e5cf348d", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.1, + g: 0.58, + b: 0.72, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.15), + ), + ( + name: "roughness", + value: Float(0.35), + ), + ( + name: "emissive_color", + value: Color(( + r: 0.1, + g: 0.58, + b: 0.72, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron b/assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron new file mode 100644 index 0000000..c77766e --- /dev/null +++ b/assets/materials/migrated/legacy-cc0396f5c90fb7bb.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material cc0396f5c90fb7bb", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.62, + g: 0.24, + b: 0.56, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.08), + ), + ( + name: "roughness", + value: Float(0.5), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-e9671004b101f4df.material.ron b/assets/materials/migrated/legacy-e9671004b101f4df.material.ron new file mode 100644 index 0000000..9fc2fbf --- /dev/null +++ b/assets/materials/migrated/legacy-e9671004b101f4df.material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "Legacy Material e9671004b101f4df", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.82, + g: 0.18, + b: 0.2, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.08), + ), + ( + name: "roughness", + value: Float(0.38), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 0.2, + b: 0.2, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron b/assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron new file mode 100644 index 0000000..eb9a4c3 --- /dev/null +++ b/assets/materials/migrated/legacy-fc132d9b30cc63d4.material.ron @@ -0,0 +1,89 @@ +( + schema_version: 2, + label: "Legacy Material fc132d9b30cc63d4", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.72, + g: 0.45, + b: 0.2, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.85), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ( + name: "normal", + value: Float(0.85), + ), + ], + textures: [ + ( + name: "base_color", + texture: None, + channel: Rgba, + ), + ( + name: "metallic", + texture: None, + channel: B, + ), + ( + name: "roughness", + texture: None, + channel: G, + ), + ( + name: "occlusion", + texture: None, + channel: R, + ), + ( + name: "normal", + texture: None, + channel: Rgb, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], + ), +) \ No newline at end of file diff --git a/assets/materials/new_material.ron b/assets/materials/new_material.ron new file mode 100644 index 0000000..8dffac9 --- /dev/null +++ b/assets/materials/new_material.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "New Material", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.65), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/new_material_2.ron b/assets/materials/new_material_2.ron new file mode 100644 index 0000000..926cdba --- /dev/null +++ b/assets/materials/new_material_2.ron @@ -0,0 +1,50 @@ +( + schema_version: 2, + label: "New Material 2", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: false, + ), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(0.0), + ), + ( + name: "roughness", + value: Float(0.65), + ), + ( + name: "emissive_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/materials/pebble_bricks.bin b/assets/materials/pebble_bricks.bin new file mode 100644 index 0000000..f533a26 Binary files /dev/null and b/assets/materials/pebble_bricks.bin differ diff --git a/assets/materials/pebble_bricks.ron b/assets/materials/pebble_bricks.ron new file mode 100644 index 0000000..2df044a --- /dev/null +++ b/assets/materials/pebble_bricks.ron @@ -0,0 +1,128 @@ +( + schema_version: 2, + label: "pebble_bricks", + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), + shader_ref: None, + render_state: ( + alpha_mode: Opaque, + alpha_cutoff: 0.5, + double_sided: true, + ), + provenance: Some(( + source_path: "assets/materials/pebble_bricks_4k.gltf", + source_fingerprint: "55a5374732e4174e33859cf415d22ac855cea3efe8d8ff78c6ee42c86d8fd5fb", + source_sub_asset_id: "material:0", + source_label: "pebble_bricks", + )), + inputs: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "metallic", + value: Float(1.0), + ), + ( + name: "roughness", + value: Float(1.0), + ), + ( + name: "emissive_color", + value: Color(( + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(0.0), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ( + name: "uv_offset", + value: Vec2((0.0, 0.0)), + ), + ( + name: "uv_tiling", + value: Vec2((25.0, 25.0)), + ), + ( + name: "normal", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: Some(( + asset_id: "d3b9bec5-c0ea-4f83-ad80-f9e2a8cb69ee", + sub_asset_id: "texture:source", + label: "pebble_bricks_diff_4k", + source_path: Some("assets/materials/textures/pebble_bricks_diff_4k.jpg"), + )), + channel: Rgba, + ), + ( + name: "normal", + texture: Some(( + asset_id: "7c5235a1-f5d7-4530-b29f-5ea075881df5", + sub_asset_id: "texture:source", + label: "pebble_bricks_nor_gl_4k", + source_path: Some("assets/materials/textures/pebble_bricks_nor_gl_4k.jpg"), + )), + channel: Rgb, + ), + ( + name: "occlusion", + texture: Some(( + asset_id: "25db6174-afcb-421f-8c1c-0c5ffde21b79", + sub_asset_id: "texture:source", + label: "pebble_bricks_arm_4k", + source_path: Some("assets/materials/textures/pebble_bricks_arm_4k.jpg"), + )), + channel: R, + ), + ( + name: "roughness", + texture: Some(( + asset_id: "25db6174-afcb-421f-8c1c-0c5ffde21b79", + sub_asset_id: "texture:source", + label: "pebble_bricks_arm_4k", + source_path: Some("assets/materials/textures/pebble_bricks_arm_4k.jpg"), + )), + channel: G, + ), + ( + name: "metallic", + texture: Some(( + asset_id: "25db6174-afcb-421f-8c1c-0c5ffde21b79", + sub_asset_id: "texture:source", + label: "pebble_bricks_arm_4k", + source_path: Some("assets/materials/textures/pebble_bricks_arm_4k.jpg"), + )), + channel: B, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, + ), + ], + ), +) \ No newline at end of file diff --git a/assets/materials/pebble_bricks_4k.gltf b/assets/materials/pebble_bricks_4k.gltf new file mode 100644 index 0000000..6043300 --- /dev/null +++ b/assets/materials/pebble_bricks_4k.gltf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abe2d69811650bf94f75867c72ea7c16734e0d5a6a07a4f36bef1cf347ec9120 +size 2788 diff --git a/assets/materials/surface_tint.ron b/assets/materials/surface_tint.ron index 3844b3a..e006ce7 100644 --- a/assets/materials/surface_tint.ron +++ b/assets/materials/surface_tint.ron @@ -1,71 +1,103 @@ ( - schema_version: 1, + schema_version: 2, label: "Surface Tint", - shader: Some("assets/shaders/surface_tint.shader.ron"), + shader: ( + kind: StandardLit, + schema_path: None, + shader_path: None, + ), shader_ref: None, render_state: ( alpha_mode: Opaque, alpha_cutoff: 0.5, - double_sided: false, + double_sided: true, ), - material: ( - shader: ( - kind: Custom, - schema_path: Some("assets/shaders/surface_tint.shader.ron"), - shader_path: Some("assets/shaders/surface_tint.wgsl"), - ), - base_color: ( - r: 0.12, - g: 0.42, - b: 0.95, - a: 1.0, - ), - metallic: 0.15, - roughness: 0.28, - emissive_color: ( - r: 0.02, - g: 0.08, - b: 0.25, - a: 1.0, - ), - emissive_intensity: 3.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: [ + inputs: ( + values: [ ( - name: "tint", + name: "base_color", value: Color(( - r: 0.12, - g: 0.42, - b: 0.95, + r: 0.0, + g: 0.0, + b: 0.0, a: 1.0, )), ), + ( + name: "metallic", + value: Float(0.32), + ), ( name: "roughness", value: Float(0.28), ), ( - name: "metallic", - value: Float(0.15), - ), - ( - name: "emissive", + name: "emissive_color", value: Color(( - r: 0.02, - g: 0.08, - b: 0.25, + r: 0.0, + g: 0.0, + b: 0.0, a: 1.0, )), ), ( name: "emissive_intensity", - value: Float(3.0), + value: Float(0.0), + ), + ( + name: "tint", + value: Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + )), + ), + ( + name: "emissive", + value: Color(( + r: 0.8786662, + g: 0.0, + b: 0.0, + a: 1.0, + )), + ), + ( + name: "occlusion", + value: Float(1.0), + ), + ], + textures: [ + ( + name: "base_color", + texture: None, + channel: Rgba, + ), + ( + name: "metallic", + texture: None, + channel: B, + ), + ( + name: "roughness", + texture: None, + channel: G, + ), + ( + name: "occlusion", + texture: None, + channel: R, + ), + ( + name: "normal", + texture: None, + channel: Rgb, + ), + ( + name: "emissive_color", + texture: None, + channel: Rgb, ), ], - textures: [], ), ) \ No newline at end of file diff --git a/assets/materials/surface_tint_instance.ron b/assets/materials/surface_tint_instance.ron index c0b16a2..a74a70a 100644 --- a/assets/materials/surface_tint_instance.ron +++ b/assets/materials/surface_tint_instance.ron @@ -1,5 +1,5 @@ ( - schema_version: 1, + schema_version: 2, label: "surface_tint Instance", base: (( asset_id: "d6cb4151-7124-4237-aaf9-f7f8abd5fb76", @@ -7,6 +7,31 @@ label: "surface_tint", source_path: Some("assets/materials/surface_tint.ron"), )), - parameters: [], - textures: [], + overrides: ( + values: [ + ( + name: "base_color", + value: Color(( + r: 0.82078725, + g: 0.04000172, + b: 0.04000172, + a: 1.0, + )), + ), + ( + name: "emissive_intensity", + value: Float(20000.0), + ), + ( + name: "tint", + value: Color(( + r: 0.32096356, + g: 0.32096356, + b: 0.32096356, + a: 1.0, + )), + ), + ], + textures: [], + ), ) \ No newline at end of file diff --git a/assets/materials/textures/pebble_bricks_arm_4k.jpg b/assets/materials/textures/pebble_bricks_arm_4k.jpg new file mode 100644 index 0000000..2c8ebc4 --- /dev/null +++ b/assets/materials/textures/pebble_bricks_arm_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c918907704ae266e2ccef6784be62e64de67833b3967b8acf41c833fcdc5b15 +size 16434309 diff --git a/assets/materials/textures/pebble_bricks_diff_4k.jpg b/assets/materials/textures/pebble_bricks_diff_4k.jpg new file mode 100644 index 0000000..9801ee9 --- /dev/null +++ b/assets/materials/textures/pebble_bricks_diff_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38993a7b7efb9c037d12b99bbb464c0b46ab9f1c1d0ab93e5370452b7d61498d +size 14259465 diff --git a/assets/materials/textures/pebble_bricks_nor_gl_4k.jpg b/assets/materials/textures/pebble_bricks_nor_gl_4k.jpg new file mode 100644 index 0000000..6c32003 --- /dev/null +++ b/assets/materials/textures/pebble_bricks_nor_gl_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:565457e7271e8546d938ec17505f9565a734769023c1cd0438524e727002bbbc +size 20424876 diff --git a/assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron b/assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron index a836a4e..806ebf6 100644 --- a/assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron +++ b/assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron @@ -17,7 +17,8 @@ lod0_only: true, placement_mode: SceneInstance, hierarchy_mode: SourceHierarchy, - material_policy: SourceMaterials, + material_slots: [], + orphaned_material_slots: [], ), metadata: ( mesh_count: 14, @@ -357,4 +358,4 @@ "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.", "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.", ], -) \ No newline at end of file +) diff --git a/assets/meshes/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.static_mesh.ron b/assets/meshes/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.static_mesh.ron new file mode 100644 index 0000000..e38d0d0 --- /dev/null +++ b/assets/meshes/generated/3076fad0-89ca-4307-b8d9-cb77fca41907.static_mesh.ron @@ -0,0 +1,193 @@ +( + schema_version: 4, + asset_id: "3076fad0-89ca-4307-b8d9-cb77fca41907", + label: "metal_office_desk_2k", + source: ( + path: "assets/models/metal_office_desk_2k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 13510, + content_hash: "904f3af2a5283a08f60bcfb2651c5280f14f1232ec522154e4878e90909551ce", + ), + dependencies: [ + "assets/models/metal_office_desk.bin", + "assets/models/textures/metal_office_desk_arm_2k.jpg", + "assets/models/textures/metal_office_desk_diff_2k.jpg", + "assets/models/textures/metal_office_desk_nor_gl_2k.jpg", + ], + ), + import: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [], + orphaned_material_slots: [], + ), + metadata: ( + mesh_count: 9, + material_count: 1, + node_count: 9, + animation_count: 0, + skin_count: 0, + light_count: 0, + camera_count: 0, + ), + parts: [ + ( + id: "draw:scene0:node0:mesh0:primitive0", + name: "metal_office_desk_drawer_06 / Primitive 0", + mesh_label: "Mesh0/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_06"), + source_mesh: Some("Mesh0"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node1:mesh1:primitive0", + name: "metal_office_desk_tray_01 / Primitive 0", + mesh_label: "Mesh1/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_tray_01"), + source_mesh: Some("Mesh1"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node2:mesh2:primitive0", + name: "metal_office_desk / Primitive 0", + mesh_label: "Mesh2/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk"), + source_mesh: Some("Mesh2"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node3:mesh3:primitive0", + name: "metal_office_desk_drawer_04 / Primitive 0", + mesh_label: "Mesh3/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_04"), + source_mesh: Some("Mesh3"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node4:mesh4:primitive0", + name: "metal_office_desk_drawer_03 / Primitive 0", + mesh_label: "Mesh4/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_03"), + source_mesh: Some("Mesh4"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node5:mesh5:primitive0", + name: "metal_office_desk_drawer_05 / Primitive 0", + mesh_label: "Mesh5/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_05"), + source_mesh: Some("Mesh5"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node6:mesh6:primitive0", + name: "metal_office_desk_drawer_02 / Primitive 0", + mesh_label: "Mesh6/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_02"), + source_mesh: Some("Mesh6"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node7:mesh7:primitive0", + name: "metal_office_desk_drawer_01 / Primitive 0", + mesh_label: "Mesh7/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_01"), + source_mesh: Some("Mesh7"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node8:mesh8:primitive0", + name: "metal_office_desk_tray_02 / Primitive 0", + mesh_label: "Mesh8/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_tray_02"), + source_mesh: Some("Mesh8"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ], + warnings: [], +) diff --git a/assets/meshes/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.static_mesh.ron b/assets/meshes/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.static_mesh.ron new file mode 100644 index 0000000..a4d620c --- /dev/null +++ b/assets/meshes/generated/36c51ff3-29ae-4ab1-aaca-f90f57e27966.static_mesh.ron @@ -0,0 +1,249 @@ +( + schema_version: 4, + asset_id: "36c51ff3-29ae-4ab1-aaca-f90f57e27966", + label: "metal_office_desk_2k", + source: ( + path: "assets/Furniture/Office/metal_office_desk_2k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 13500, + content_hash: "64fd91fbc603c9675f9c8b5f1291a654deff89b40aeb0a0b877b7b93f6ebf05e", + ), + dependencies: [ + "assets/Furniture/Office/metal_office_desk.bin", + "assets/Furniture/Office/textures/metal_office_desk_arm_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_diff_2k.jpg", + "assets/Furniture/Office/textures/metal_office_desk_nor_gl_2k.jpg", + ], + ), + import: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SourceHierarchy, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node1:mesh1:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node2:mesh2:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node3:mesh3:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node4:mesh4:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node5:mesh5:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node6:mesh6:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ( + slot_id: ("slot:draw:scene0:node7:mesh7:primitive0"), + selection: Project((( + asset_id: "647a63d4-64c6-4431-ba46-9efdd515cd8f", + sub_asset_id: "", + label: "metal_office_desk", + source_path: Some("assets/Furniture/Office/metal_office_desk.material.ron"), + ))), + ), + ], + orphaned_material_slots: [], + ), + metadata: ( + mesh_count: 9, + material_count: 1, + node_count: 9, + animation_count: 0, + skin_count: 0, + light_count: 0, + camera_count: 0, + ), + parts: [ + ( + id: "draw:scene0:node0:mesh0:primitive0", + name: "metal_office_desk_drawer_06 / Primitive 0", + mesh_label: "Mesh0/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_06"), + source_mesh: Some("Mesh0"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node1:mesh1:primitive0", + name: "metal_office_desk_tray_01 / Primitive 0", + mesh_label: "Mesh1/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_tray_01"), + source_mesh: Some("Mesh1"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node2:mesh2:primitive0", + name: "metal_office_desk / Primitive 0", + mesh_label: "Mesh2/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk"), + source_mesh: Some("Mesh2"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node3:mesh3:primitive0", + name: "metal_office_desk_drawer_04 / Primitive 0", + mesh_label: "Mesh3/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_04"), + source_mesh: Some("Mesh3"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node4:mesh4:primitive0", + name: "metal_office_desk_drawer_03 / Primitive 0", + mesh_label: "Mesh4/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_03"), + source_mesh: Some("Mesh4"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node5:mesh5:primitive0", + name: "metal_office_desk_drawer_05 / Primitive 0", + mesh_label: "Mesh5/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_05"), + source_mesh: Some("Mesh5"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node6:mesh6:primitive0", + name: "metal_office_desk_drawer_02 / Primitive 0", + mesh_label: "Mesh6/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_02"), + source_mesh: Some("Mesh6"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ( + id: "draw:scene0:node7:mesh7:primitive0", + name: "metal_office_desk_drawer_01 / Primitive 0", + mesh_label: "Mesh7/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "metal_office_desk", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("metal_office_desk_drawer_01"), + source_mesh: Some("Mesh7"), + source_material: Some("metal_office_desk"), + skinned: false, + ), + ], + warnings: [], +) diff --git a/assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron b/assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron index 4363ddd..813a13b 100644 --- a/assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron +++ b/assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron @@ -17,7 +17,8 @@ lod0_only: true, placement_mode: StaticAsset, hierarchy_mode: SingleActor, - material_policy: SourceMaterials, + material_slots: [], + orphaned_material_slots: [], ), metadata: ( mesh_count: 14, @@ -357,4 +358,4 @@ "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.", "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.", ], -) \ No newline at end of file +) diff --git a/assets/meshes/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.static_mesh.ron b/assets/meshes/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.static_mesh.ron new file mode 100644 index 0000000..c644955 --- /dev/null +++ b/assets/meshes/generated/5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc.static_mesh.ron @@ -0,0 +1,361 @@ +( + schema_version: 4, + asset_id: "5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc", + label: "blacksite-m2-robot-expressive", + source: ( + path: "assets/Furniture/Office/blacksite-m2-robot-expressive.glb", + format: "glb", + fingerprint: ( + byte_len: 463988, + content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521", + ), + dependencies: [], + ), + import: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [], + orphaned_material_slots: [], + ), + metadata: ( + mesh_count: 14, + material_count: 3, + node_count: 74, + animation_count: 14, + skin_count: 2, + light_count: 0, + camera_count: 0, + ), + parts: [ + ( + id: "draw:scene0:node4:mesh0:primitive0", + name: "Foot.L / Primitive 0", + mesh_label: "Mesh0/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (0.62405795, 0.39766362, 0.19111444), + rotation: (0.7071068, 0.00000005228401, 0.000000015504241, -0.70710665), + scale: (100.0, 99.999985, 100.0), + ), + source_node: Some("RootNode/RobotArmature/Bone/Foot.L/Foot.L"), + source_mesh: Some("Mesh0"), + source_material: Some("Grey"), + skinned: false, + ), + ( + id: "draw:scene0:node7:mesh1:primitive0", + name: "Torso / Primitive 0", + mesh_label: "Mesh1/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (-0.0027156367, 2.1737509, -0.019581214), + rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067), + scale: (100.0, 99.99998, 99.99998), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"), + source_mesh: Some("Mesh1"), + source_material: Some("Grey"), + skinned: false, + ), + ( + id: "draw:scene0:node7:mesh1:primitive1", + name: "Torso / Primitive 1", + mesh_label: "Mesh1/Primitive1", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.0027156367, 2.1737509, -0.019581214), + rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067), + scale: (100.0, 99.99998, 99.99998), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"), + source_mesh: Some("Mesh1"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node61:mesh10:primitive0", + name: "Leg.R / Primitive 0", + mesh_label: "Mesh10/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.6746981, 1.1586236, 0.046261936), + rotation: (0.7693698, 0.080835514, 0.110868365, -0.6238942), + scale: (99.99999, 99.99886, 100.00024), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/Leg.R"), + source_mesh: Some("Mesh10"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node63:mesh11:primitive0", + name: "LowerLeg.R / Primitive 0", + mesh_label: "Mesh11/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.73626167, 0.34982973, 0.22594015), + rotation: (-0.65091395, -0.099058665, -0.09436109, 0.7467224), + scale: (99.99999, 99.998604, 100.00058), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R/LowerLeg.R"), + source_mesh: Some("Mesh11"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node72:mesh12:primitive0", + name: "Hand.R / Primitive 0", + mesh_label: "Mesh12/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.002715637, 2.3702974, -0.020994147), + rotation: (0.70710677, 0.0, 0.0, -0.7071067), + scale: (100.0, 99.99999, 99.99999), + ), + source_node: Some("RootNode/Hand.R"), + source_mesh: Some("Mesh12"), + source_material: Some("Main"), + skinned: true, + ), + ( + id: "draw:scene0:node72:mesh12:primitive1", + name: "Hand.R / Primitive 1", + mesh_label: "Mesh12/Primitive1", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (-0.002715637, 2.3702974, -0.020994147), + rotation: (0.70710677, 0.0, 0.0, -0.7071067), + scale: (100.0, 99.99999, 99.99999), + ), + source_node: Some("RootNode/Hand.R"), + source_mesh: Some("Mesh12"), + source_material: Some("Grey"), + skinned: true, + ), + ( + id: "draw:scene0:node73:mesh13:primitive0", + name: "Hand.L / Primitive 0", + mesh_label: "Mesh13/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.002715637, 2.3702974, -0.020994147), + rotation: (0.70710677, 0.0, 0.0, -0.7071067), + scale: (100.0, 99.99999, 99.99999), + ), + source_node: Some("RootNode/Hand.L"), + source_mesh: Some("Mesh13"), + source_material: Some("Main"), + skinned: true, + ), + ( + id: "draw:scene0:node73:mesh13:primitive1", + name: "Hand.L / Primitive 1", + mesh_label: "Mesh13/Primitive1", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (-0.002715637, 2.3702974, -0.020994147), + rotation: (0.70710677, 0.0, 0.0, -0.7071067), + scale: (100.0, 99.99999, 99.99999), + ), + source_node: Some("RootNode/Hand.L"), + source_mesh: Some("Mesh13"), + source_material: Some("Grey"), + skinned: true, + ), + ( + id: "draw:scene0:node13:mesh2:primitive0", + name: "Head / Primitive 0", + mesh_label: "Mesh2/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (-0.05334633, 3.6175368, -0.0068152454), + rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757), + scale: (99.99997, 99.99996, 99.99997), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"), + source_mesh: Some("Mesh2"), + source_material: Some("Grey"), + skinned: false, + ), + ( + id: "draw:scene0:node13:mesh2:primitive1", + name: "Head / Primitive 1", + mesh_label: "Mesh2/Primitive1", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.05334633, 3.6175368, -0.0068152454), + rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757), + scale: (99.99997, 99.99996, 99.99997), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"), + source_mesh: Some("Mesh2"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node13:mesh2:primitive2", + name: "Head / Primitive 2", + mesh_label: "Mesh2/Primitive2", + material_id: Some("material:material2"), + material_slot_name: "Black", + material_label: Some("Material2"), + local_transform: ( + translation: (-0.05334633, 3.6175368, -0.0068152454), + rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757), + scale: (99.99997, 99.99996, 99.99997), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"), + source_mesh: Some("Mesh2"), + source_material: Some("Black"), + skinned: false, + ), + ( + id: "draw:scene0:node68:mesh3:primitive0", + name: "Foot.R / Primitive 0", + mesh_label: "Mesh3/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "Grey", + material_label: Some("Material0"), + local_transform: ( + translation: (-0.637997, 0.3976636, 0.19111452), + rotation: (0.70710677, 0.00000005201811, 0.000000015770217, -0.7071067), + scale: (100.0, 99.999985, 100.0), + ), + source_node: Some("RootNode/RobotArmature/Bone/Foot.R/Foot.R"), + source_mesh: Some("Mesh3"), + source_material: Some("Grey"), + skinned: false, + ), + ( + id: "draw:scene0:node16:mesh4:primitive0", + name: "Shoulder.L / Primitive 0", + mesh_label: "Mesh4/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (0.58268076, 2.3724597, -0.020994142), + rotation: (-0.59886926, 0.37597278, -0.37597266, 0.59886944), + scale: (99.99996, 99.999954, 99.99997), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/Shoulder.L"), + source_mesh: Some("Mesh4"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node18:mesh5:primitive0", + name: "Arm.L / Primitive 0", + mesh_label: "Mesh5/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (0.26500192, 2.8489676, 0.13069445), + rotation: (-0.5164575, 0.4218856, -0.32294926, 0.67155623), + scale: (99.99994, 99.99994, 99.99999), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/Arm.L"), + source_mesh: Some("Mesh5"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node36:mesh6:primitive0", + name: "Shoulder.R / Primitive 0", + mesh_label: "Mesh6/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.6369861, 2.3718858, -0.020994166), + rotation: (0.6017577, 0.37133226, -0.37133226, -0.6017577), + scale: (99.999954, 99.99997, 99.99994), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/Shoulder.R"), + source_mesh: Some("Mesh6"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node38:mesh7:primitive0", + name: "Arm.R / Primitive 0", + mesh_label: "Mesh7/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (-0.82568955, 1.9825263, 0.040243305), + rotation: (0.6276481, 0.35463694, -0.3873087, -0.5747021), + scale: (22.70405, 22.704054, 22.704046), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/Arm.R"), + source_mesh: Some("Mesh7"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node56:mesh8:primitive0", + name: "Leg.L / Primitive 0", + mesh_label: "Mesh8/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (0.6488191, 1.1620699, 0.057466835), + rotation: (0.76387787, -0.05410902, -0.070370995, -0.6392268), + scale: (99.99999, 100.00079, 100.00024), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/Leg.L"), + source_mesh: Some("Mesh8"), + source_material: Some("Main"), + skinned: false, + ), + ( + id: "draw:scene0:node58:mesh9:primitive0", + name: "LowerLeg.L / Primitive 0", + mesh_label: "Mesh9/Primitive0", + material_id: Some("material:material1"), + material_slot_name: "Main", + material_label: Some("Material1"), + local_transform: ( + translation: (0.68805873, 0.3497491, 0.23465614), + rotation: (-0.6547612, 0.06408298, 0.061960325, 0.7505613), + scale: (100.000015, 100.0006, 100.000496), + ), + source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L/LowerLeg.L"), + source_mesh: Some("Mesh9"), + source_material: Some("Main"), + skinned: false, + ), + ], + warnings: [ + "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.", + "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.", + ], +) diff --git a/assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron b/assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron index 7110e7d..15a4e35 100644 --- a/assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron +++ b/assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron @@ -21,7 +21,13 @@ lod0_only: true, placement_mode: StaticAsset, hierarchy_mode: SingleActor, - material_policy: AuthoringOverride, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node1:material0"), + selection: Default, + ), + ], + orphaned_material_slots: [], ), metadata: ( mesh_count: 1, diff --git a/assets/meshes/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.static_mesh.ron b/assets/meshes/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.static_mesh.ron new file mode 100644 index 0000000..5943ec1 --- /dev/null +++ b/assets/meshes/generated/bd495161-5ea8-48ef-b0db-5b1d57f33885.static_mesh.ron @@ -0,0 +1,67 @@ +( + schema_version: 4, + asset_id: "bd495161-5ea8-48ef-b0db-5b1d57f33885", + label: "pebble_bricks_4k", + source: ( + path: "assets/materials/pebble_bricks_4k.gltf", + format: "gltf", + fingerprint: ( + byte_len: 2788, + content_hash: "55a5374732e4174e33859cf415d22ac855cea3efe8d8ff78c6ee42c86d8fd5fb", + ), + dependencies: [ + "assets/materials/pebble_bricks.bin", + "assets/materials/textures/pebble_bricks_arm_4k.jpg", + "assets/materials/textures/pebble_bricks_diff_4k.jpg", + "assets/materials/textures/pebble_bricks_nor_gl_4k.jpg", + ], + ), + import: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_slots: [ + ( + slot_id: ("slot:draw:scene0:node0:mesh0:primitive0"), + selection: Project((( + asset_id: "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + sub_asset_id: "", + label: "pebble_bricks", + source_path: Some("assets/materials/pebble_bricks.ron"), + ))), + ), + ], + orphaned_material_slots: [], + ), + metadata: ( + mesh_count: 1, + material_count: 1, + node_count: 1, + animation_count: 0, + skin_count: 0, + light_count: 0, + camera_count: 0, + ), + parts: [ + ( + id: "draw:scene0:node0:mesh0:primitive0", + name: "sphere_gltf / Primitive 0", + mesh_label: "Mesh0/Primitive0", + material_id: Some("material:material0"), + material_slot_name: "pebble_bricks", + material_label: Some("Material0"), + local_transform: ( + translation: (0.0, 0.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + source_node: Some("sphere_gltf"), + source_mesh: Some("Mesh0"), + source_material: Some("pebble_bricks"), + skinned: false, + ), + ], + warnings: [], +) diff --git a/assets/models/metal_office_desk.bin b/assets/models/metal_office_desk.bin new file mode 100644 index 0000000..9802899 Binary files /dev/null and b/assets/models/metal_office_desk.bin differ diff --git a/assets/models/metal_office_desk_2k.gltf b/assets/models/metal_office_desk_2k.gltf new file mode 100644 index 0000000..3fc15c9 --- /dev/null +++ b/assets/models/metal_office_desk_2k.gltf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4323f2e6cae9abb82146449b45445ee9446068c5a9b2ca23e018503b3b0af035 +size 13510 diff --git a/assets/models/textures/metal_office_desk_arm_2k.jpg b/assets/models/textures/metal_office_desk_arm_2k.jpg new file mode 100644 index 0000000..7986b9c --- /dev/null +++ b/assets/models/textures/metal_office_desk_arm_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c94b99aec8c9b105ff4d843ac41f229b0effb97abe921f0f8b506ccfd76952 +size 2724325 diff --git a/assets/models/textures/metal_office_desk_diff_2k.jpg b/assets/models/textures/metal_office_desk_diff_2k.jpg new file mode 100644 index 0000000..fe6aafc --- /dev/null +++ b/assets/models/textures/metal_office_desk_diff_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5854edc8a7979ed4576b914c184bda64d7e6ee9969357738703be49118da30 +size 1787420 diff --git a/assets/models/textures/metal_office_desk_nor_gl_2k.jpg b/assets/models/textures/metal_office_desk_nor_gl_2k.jpg new file mode 100644 index 0000000..093cd80 --- /dev/null +++ b/assets/models/textures/metal_office_desk_nor_gl_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25392e7682c43ca07abbbbe717388b9e6d40bbca663836916204f99ac0378d34 +size 490814 diff --git a/assets/pebble_bricks_surface_instance.material-instance.ron b/assets/pebble_bricks_surface_instance.material-instance.ron new file mode 100644 index 0000000..d5513b2 --- /dev/null +++ b/assets/pebble_bricks_surface_instance.material-instance.ron @@ -0,0 +1,14 @@ +( + schema_version: 2, + label: "pebble_bricks Surface Instance", + base: (( + asset_id: "c07a5b05-f27d-46c6-8b8c-9de9cdd14720", + sub_asset_id: "material:source", + label: "pebble_bricks", + source_path: Some("assets/materials/pebble_bricks.ron"), + )), + overrides: ( + values: [], + textures: [], + ), +) \ No newline at end of file diff --git a/assets/prefabs/example_base.scn.ron b/assets/prefabs/example_base.scn.ron index e3f91e6..7fb81b2 100644 --- a/assets/prefabs/example_base.scn.ron +++ b/assets/prefabs/example_base.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 1: (components: { diff --git a/assets/prefabs/example_nested.scn.ron b/assets/prefabs/example_nested.scn.ron index a98c3c4..ccfb586 100644 --- a/assets/prefabs/example_nested.scn.ron +++ b/assets/prefabs/example_nested.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 1: (components: { diff --git a/assets/prefabs/example_variant.scn.ron b/assets/prefabs/example_variant.scn.ron index 58623bd..0502bfa 100644 --- a/assets/prefabs/example_variant.scn.ron +++ b/assets/prefabs/example_variant.scn.ron @@ -1,4 +1,4 @@ -(schema_version: 4,resources: { +(schema_version: 6,resources: { }, entities: { 1: (components: { diff --git a/assets/project.ron b/assets/project.ron index ca7e10d..b4dafc0 100644 --- a/assets/project.ron +++ b/assets/project.ron @@ -1,20 +1,22 @@ ( - version: 1, + version: 2, project_id: "blacksite-foundation", name: "Bevy FPS Foundation", project_kind: Game, - template_version: 1, - capabilities: ["editor", "gameplay", "rendering", "audio", "solari"], + template_version: 2, + capabilities: [ + "editor", + "gameplay", + "rendering", + "audio", + "solari", + ], default_level: "assets/levels/editor_scene.scn.ron", asset_roots: [ - "assets/audio", - "assets/models", - "assets/textures", - "assets/materials", - "assets/levels", + "assets", ], rendering: ( - gi_mode: Auto, + gi_mode: Forward, hdr: true, exposure_mode: Manual, exposure_ev100: 12.5, @@ -85,4 +87,4 @@ editor_look_sensitivity: 0.003, editor_pan_speed: 0.015, ), -) +) \ No newline at end of file diff --git a/assets/shaders/standard_lit.shader.ron b/assets/shaders/standard_lit.shader.ron index 02d4ca7..2a7f50a 100644 --- a/assets/shaders/standard_lit.shader.ron +++ b/assets/shaders/standard_lit.shader.ron @@ -1,73 +1,147 @@ ( - schema_version: 1, + schema_version: 2, label: "Standard Lit", kind: StandardLit, wgsl_path: None, - parameters: [ - ( - name: "base_color", - display_name: "Base Color", - group: "Surface", - property_type: Color, - ), - ( - name: "metallic", - display_name: "Metallic", - group: "Surface", - property_type: Float( - min: Some(0.0), - max: Some(1.0), + schema: ( + groups: [ + ( + id: "surface_inputs", + display_name: "Surface Inputs", + order: 0, + advanced: false, ), - ), - ( - name: "roughness", - display_name: "Roughness", - group: "Surface", - property_type: Float( - min: Some(0.0), - max: Some(1.0), + ( + id: "advanced_inputs", + display_name: "Advanced Inputs", + order: 100, + advanced: true, ), - ), - ( - name: "emissive_color", - display_name: "Emissive Color", - group: "Emission", - property_type: Color, - ), - ( - name: "emissive_intensity", - display_name: "Emissive Nits", - group: "Emission", - property_type: Float( - min: Some(0.0), - max: Some(20000.0), + ], + inputs: [ + ( + name: "base_color", + display_name: "Base Color", + group: "surface_inputs", + order: 0, + property_type: Color, + default_value: Some(Color(( + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, + ))), + texture: Some(( + semantic: Color, + default_channel: Rgba, + allow_channel_override: false, + )), + tooltip: "Color multiplier applied to the base-color texture.", + advanced: false, ), - ), - ( - name: "base_color_texture", - display_name: "Base Color Texture", - group: "Textures", - property_type: Texture, - ), - ( - name: "normal_map_texture", - display_name: "Normal Map", - group: "Textures", - property_type: Texture, - ), - ( - name: "metallic_roughness_texture", - display_name: "Metallic/Roughness Texture", - group: "Textures", - property_type: Texture, - ), - ( - name: "emissive_texture", - display_name: "Emissive Texture", - group: "Textures", - property_type: Texture, - ), - ], - default_values: [], - default_textures: [], + ( + name: "metallic", + display_name: "Metallic", + group: "surface_inputs", + order: 10, + property_type: Float( + min: Some(0.0), + max: Some(1.0), + ), + default_value: Some(Float(0.0)), + texture: Some(( + semantic: Scalar, + default_channel: B, + allow_channel_override: true, + )), + tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.", + advanced: false, + ), + ( + name: "roughness", + display_name: "Roughness", + group: "surface_inputs", + order: 20, + property_type: Float( + min: Some(0.0), + max: Some(1.0), + ), + default_value: Some(Float(0.65)), + texture: Some(( + semantic: Scalar, + default_channel: G, + allow_channel_override: true, + )), + tooltip: "Scalar multiplier; ARM/ORM uses the green channel.", + advanced: false, + ), + ( + name: "occlusion", + display_name: "Occlusion", + group: "surface_inputs", + order: 30, + property_type: Float( + min: Some(0.0), + max: Some(1.0), + ), + default_value: Some(Float(1.0)), + texture: Some(( + semantic: Scalar, + default_channel: R, + allow_channel_override: true, + )), + tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.", + advanced: false, + ), + ( + name: "normal", + display_name: "Normal Map", + group: "surface_inputs", + order: 40, + property_type: Texture, + default_value: None, + texture: Some(( + semantic: Normal, + default_channel: Rgb, + allow_channel_override: false, + )), + tooltip: "", + advanced: false, + ), + ( + name: "emissive_color", + display_name: "Emissive", + group: "surface_inputs", + order: 50, + property_type: Color, + default_value: Some(Color(( + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + ))), + texture: Some(( + semantic: Color, + default_channel: Rgb, + allow_channel_override: false, + )), + tooltip: "Emissive color multiplied by the texture and intensity.", + advanced: false, + ), + ( + name: "emissive_intensity", + display_name: "Emissive Intensity", + group: "surface_inputs", + order: 60, + property_type: Float( + min: Some(0.0), + max: Some(50000.0), + ), + default_value: Some(Float(0.0)), + texture: None, + tooltip: "Emissive luminance multiplier in nits.", + advanced: false, + ), + ], + ), ) \ No newline at end of file diff --git a/assets/shaders/surface_tint.shader.ron b/assets/shaders/surface_tint.shader.ron index 6c346a3..2e64c77 100644 --- a/assets/shaders/surface_tint.shader.ron +++ b/assets/shaders/surface_tint.shader.ron @@ -1,80 +1,98 @@ ( - schema_version: 1, + schema_version: 2, label: "Surface Tint", kind: Custom, wgsl_path: Some("assets/shaders/surface_tint.wgsl"), - parameters: [ - ( - name: "tint", - display_name: "Tint", - group: "Surface", - property_type: Color, - ), - ( - name: "roughness", - display_name: "Roughness", - group: "Surface", - property_type: Float( - min: Some(0.001), - max: Some(1.0), + schema: ( + groups: [ + ( + id: "surface", + display_name: "Surface", + order: 0, + advanced: false, ), - ), - ( - name: "metallic", - display_name: "Metallic", - group: "Surface", - property_type: Float( - min: Some(0.0), - max: Some(1.0), + ( + id: "emission", + display_name: "Emission", + order: 1, + advanced: false, ), - ), - ( - name: "emissive", - display_name: "Emissive", - group: "Emission", - property_type: Color, - ), - ( - name: "emissive_intensity", - display_name: "Emissive Nits", - group: "Emission", - property_type: Float( - min: Some(0.0), - max: Some(20000.0), + ], + inputs: [ + ( + name: "tint", + display_name: "Tint", + group: "surface", + order: 0, + property_type: Color, + default_value: Some(Color(( + r: 0.12, + g: 0.42, + b: 0.95, + a: 1.0, + ))), + texture: None, + tooltip: "", + advanced: false, ), - ), - ], - default_values: [ - ( - name: "tint", - value: Color(( - r: 0.12, - g: 0.42, - b: 0.95, - a: 1.0, - )), - ), - ( - name: "roughness", - value: Float(0.28), - ), - ( - name: "metallic", - value: Float(0.15), - ), - ( - name: "emissive", - value: Color(( - r: 0.02, - g: 0.08, - b: 0.25, - a: 1.0, - )), - ), - ( - name: "emissive_intensity", - value: Float(3.0), - ), - ], - default_textures: [], + ( + name: "roughness", + display_name: "Roughness", + group: "surface", + order: 1, + property_type: Float( + min: Some(0.001), + max: Some(1.0), + ), + default_value: Some(Float(0.28)), + texture: None, + tooltip: "", + advanced: false, + ), + ( + name: "metallic", + display_name: "Metallic", + group: "surface", + order: 2, + property_type: Float( + min: Some(0.0), + max: Some(1.0), + ), + default_value: Some(Float(0.15)), + texture: None, + tooltip: "", + advanced: false, + ), + ( + name: "emissive", + display_name: "Emissive", + group: "emission", + order: 3, + property_type: Color, + default_value: Some(Color(( + r: 0.02, + g: 0.08, + b: 0.25, + a: 1.0, + ))), + texture: None, + tooltip: "", + advanced: false, + ), + ( + name: "emissive_intensity", + display_name: "Emissive Nits", + group: "emission", + order: 4, + property_type: Float( + min: Some(0.0), + max: Some(20000.0), + ), + default_value: Some(Float(3.0)), + texture: None, + tooltip: "", + advanced: false, + ), + ], + ), ) \ No newline at end of file diff --git a/assets/shaders/unlit.shader.ron b/assets/shaders/unlit.shader.ron index bf04d97..578b048 100644 --- a/assets/shaders/unlit.shader.ron +++ b/assets/shaders/unlit.shader.ron @@ -1,37 +1,81 @@ ( - schema_version: 1, + schema_version: 2, label: "Unlit", kind: Unlit, wgsl_path: None, - parameters: [ - ( - name: "base_color", - display_name: "Base Color", - group: "Surface", - property_type: Color, - ), - ( - name: "base_color_texture", - display_name: "Base Color Texture", - group: "Textures", - property_type: Texture, - ), - ( - name: "emissive_color", - display_name: "Emissive Color", - group: "Emission", - property_type: Color, - ), - ( - name: "emissive_intensity", - display_name: "Emissive Nits", - group: "Emission", - property_type: Float( - min: Some(0.0), - max: Some(20000.0), + schema: ( + groups: [ + ( + id: "surface", + display_name: "Surface", + order: 0, + advanced: false, ), - ), - ], - default_values: [], - default_textures: [], + ( + id: "textures", + display_name: "Textures", + order: 1, + advanced: false, + ), + ( + id: "emission", + display_name: "Emission", + order: 2, + advanced: false, + ), + ], + inputs: [ + ( + name: "base_color", + display_name: "Base Color", + group: "surface", + order: 0, + property_type: Color, + default_value: None, + texture: None, + tooltip: "", + advanced: false, + ), + ( + name: "base_color_texture", + display_name: "Base Color Texture", + group: "textures", + order: 1, + property_type: Texture, + default_value: None, + texture: Some(( + semantic: Mask, + default_channel: Rgba, + allow_channel_override: true, + )), + tooltip: "", + advanced: false, + ), + ( + name: "emissive_color", + display_name: "Emissive Color", + group: "emission", + order: 2, + property_type: Color, + default_value: None, + texture: None, + tooltip: "", + advanced: false, + ), + ( + name: "emissive_intensity", + display_name: "Emissive Nits", + group: "emission", + order: 3, + property_type: Float( + min: Some(0.0), + max: Some(20000.0), + ), + default_value: None, + texture: None, + tooltip: "", + advanced: false, + ), + ], + ), ) \ No newline at end of file diff --git a/assets/textures/metal_office_desk_arm_2k.jpg b/assets/textures/metal_office_desk_arm_2k.jpg new file mode 100644 index 0000000..7986b9c --- /dev/null +++ b/assets/textures/metal_office_desk_arm_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c94b99aec8c9b105ff4d843ac41f229b0effb97abe921f0f8b506ccfd76952 +size 2724325 diff --git a/assets/textures/metal_office_desk_diff_2k.jpg b/assets/textures/metal_office_desk_diff_2k.jpg new file mode 100644 index 0000000..fe6aafc --- /dev/null +++ b/assets/textures/metal_office_desk_diff_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5854edc8a7979ed4576b914c184bda64d7e6ee9969357738703be49118da30 +size 1787420 diff --git a/assets/textures/metal_office_desk_nor_gl_2k.jpg b/assets/textures/metal_office_desk_nor_gl_2k.jpg new file mode 100644 index 0000000..093cd80 --- /dev/null +++ b/assets/textures/metal_office_desk_nor_gl_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25392e7682c43ca07abbbbe717388b9e6d40bbca663836916204f99ac0378d34 +size 490814 diff --git a/crates/blacksite_surface/src/lib.rs b/crates/blacksite_surface/src/lib.rs index 43b9276..80952cd 100644 --- a/crates/blacksite_surface/src/lib.rs +++ b/crates/blacksite_surface/src/lib.rs @@ -3,8 +3,10 @@ //! The authoring layer persists named values. This crate validates and packs them into a fixed GPU //! contract and supplies the same surface evaluator source to raster and Solari integrations. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::Path; use bevy::material::OpaqueRendererMethod; use bevy::mesh::MeshVertexBufferLayoutRef; @@ -16,1080 +18,91 @@ use bevy::render::extract_resource::ExtractResource; use bevy::render::render_resource::{ AsBindGroup, RenderPipelineDescriptor, ShaderType, SpecializedMeshPipelineError, }; -use bevy::shader::{Shader, ShaderRef}; +use bevy::shader::{Shader, ShaderDefVal, ShaderRef}; use shared::{ - asset_server_path, material_from_desc, HydratedRendererMaterialBinding, - HydratedTerrainMaterialBinding, MaterialAsset, MaterialDesc, MaterialInstanceAsset, - MaterialParameterValue, MaterialRef, ShaderPropertyType, ShaderSchemaAsset, + asset_server_path, HydratedMaterialSelection, HydratedMaterialSlotBinding, + HydratedTerrainMaterialBinding, MaterialAsset, MaterialDesc, MaterialInputSet, + MaterialInstanceAsset, MaterialParameterValue, MaterialPropertyBlock, MaterialPropertyBlocks, + MaterialRef, RuntimeContentCatalog, ShaderPropertyType, ShaderSchemaAsset, TerrainMaterialLayer, MATERIAL_INSTANCE_SCHEMA_VERSION, TERRAIN_MATERIAL_LAYER_LIMIT, }; +mod live_documents; +pub use live_documents::*; + pub const SURFACE_ABI_VERSION: u32 = 1; pub const SURFACE_PARAMETER_LANES: usize = 16; pub const SURFACE_TEXTURE_SLOTS: usize = 8; pub const DEFAULT_SURFACE_SHADER_PATH: &str = "shaders/blacksite_surface.wgsl"; pub const TERRAIN_LAYER_SHADER_PATH: &str = "shaders/terrain_layers.wgsl"; - -#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] -pub struct TerrainLayerUniform { - pub base_colors: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], - /// Metallic, perceptual roughness, normal-map enabled, layer enabled. - pub properties: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], - pub uv_scales: Vec4, - pub base_texture_enabled: Vec4, +pub const DEFAULT_GRID_SHADER_ID: u32 = 0xffff_ff01; +pub const DEFAULT_GRID_LABEL: &str = "Default Grid — Engine Built-in"; +const DEFAULT_GRID_SHADER_UUID: uuid::Uuid = + uuid::Uuid::from_u128(0x3420_f5ad_551c_4bd6_a902_5f8a_5ce5_9f01); +const DEFAULT_GRID_EVALUATOR: &str = r#" +fn grid_axis(position: vec2) -> f32 { + let cell = floor(position / 0.5); + let checker = (cell.x + cell.y) - 2.0 * floor((cell.x + cell.y) * 0.5); + let major_cell = floor(position / 4.0); + let major = (major_cell.x + major_cell.y) - 2.0 * floor((major_cell.x + major_cell.y) * 0.5); + return checker * 0.72 + major * 0.28; } -impl Default for TerrainLayerUniform { - fn default() -> Self { - Self { - base_colors: [Vec4::ONE; TERRAIN_MATERIAL_LAYER_LIMIT], - properties: [Vec4::new(0.0, 0.9, 0.0, 0.0); TERRAIN_MATERIAL_LAYER_LIMIT], - uv_scales: Vec4::splat(8.0), - base_texture_enabled: Vec4::ZERO, - } - } -} - -#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] -pub struct TerrainLayerExtension { - #[uniform(100)] - pub uniform: TerrainLayerUniform, - #[texture(101)] - #[sampler(102)] - pub base_color0: Option>, - #[texture(103)] - #[sampler(104)] - pub normal0: Option>, - #[texture(105)] - #[sampler(106)] - pub base_color1: Option>, - #[texture(107)] - #[sampler(108)] - pub normal1: Option>, - #[texture(109)] - #[sampler(110)] - pub base_color2: Option>, - #[texture(111)] - #[sampler(112)] - pub normal2: Option>, - #[texture(113)] - #[sampler(114)] - pub base_color3: Option>, - #[texture(115)] - #[sampler(116)] - pub normal3: Option>, -} - -impl MaterialExtension for TerrainLayerExtension { - fn fragment_shader() -> ShaderRef { - TERRAIN_LAYER_SHADER_PATH.into() - } - - fn deferred_fragment_shader() -> ShaderRef { - TERRAIN_LAYER_SHADER_PATH.into() - } -} - -pub type TerrainLayerMaterial = ExtendedMaterial; - -#[derive(Debug, Clone)] -struct TerrainLayerCacheEntry { - layers: Vec, - handle: Handle, - revision: u64, -} - -#[derive(Resource, Default)] -struct TerrainLayerMaterialCache(HashMap); - -type TerrainMaterialBindings<'w, 's> = - Query<'w, 's, (Entity, &'static HydratedTerrainMaterialBinding)>; - -#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] -pub struct SurfaceUniform { - pub shader_id: u32, - pub flags: u32, - pub alpha_cutoff: f32, - pub abi_version: u32, - pub params: [Vec4; SURFACE_PARAMETER_LANES], - pub uv_transforms: [Vec4; SURFACE_TEXTURE_SLOTS], -} - -impl Default for SurfaceUniform { - fn default() -> Self { - Self { - shader_id: 0, - flags: 0, - alpha_cutoff: 0.5, - abi_version: SURFACE_ABI_VERSION, - params: [Vec4::ZERO; SURFACE_PARAMETER_LANES], - uv_transforms: [Vec4::new(1.0, 1.0, 0.0, 0.0); SURFACE_TEXTURE_SLOTS], - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct SurfaceExtensionKey { - shader: AssetId, -} - -impl From<&SurfaceExtension> for SurfaceExtensionKey { - fn from(extension: &SurfaceExtension) -> Self { - Self { - shader: extension.shader.id(), - } - } -} - -#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] -#[bind_group_data(SurfaceExtensionKey)] -pub struct SurfaceExtension { - #[uniform(100)] - pub uniform: SurfaceUniform, - #[texture(101)] - #[sampler(102)] - pub texture0: Option>, - #[texture(103)] - #[sampler(104)] - pub texture1: Option>, - #[texture(105)] - #[sampler(106)] - pub texture2: Option>, - #[texture(107)] - #[sampler(108)] - pub texture3: Option>, - #[texture(109)] - #[sampler(110)] - pub texture4: Option>, - #[texture(111)] - #[sampler(112)] - pub texture5: Option>, - #[texture(113)] - #[sampler(114)] - pub texture6: Option>, - #[texture(115)] - #[sampler(116)] - pub texture7: Option>, - #[reflect(ignore)] - pub shader: Handle, -} - -impl SurfaceExtension { - pub fn set_texture(&mut self, index: usize, handle: Option>) { - match index { - 0 => self.texture0 = handle, - 1 => self.texture1 = handle, - 2 => self.texture2 = handle, - 3 => self.texture3 = handle, - 4 => self.texture4 = handle, - 5 => self.texture5 = handle, - 6 => self.texture6 = handle, - 7 => self.texture7 = handle, - _ => {} - } - } -} - -impl MaterialExtension for SurfaceExtension { - fn fragment_shader() -> ShaderRef { - DEFAULT_SURFACE_SHADER_PATH.into() - } - - fn deferred_fragment_shader() -> ShaderRef { - DEFAULT_SURFACE_SHADER_PATH.into() - } - - fn specialize( - _pipeline: &MaterialExtensionPipeline, - descriptor: &mut RenderPipelineDescriptor, - _layout: &MeshVertexBufferLayoutRef, - key: MaterialExtensionKey, - ) -> Result<(), SpecializedMeshPipelineError> { - if let (AssetId::Uuid { uuid }, Some(fragment)) = - (key.bind_group_data.shader, descriptor.fragment.as_mut()) - { - fragment.shader = Handle::Uuid(uuid, Default::default()); - } - Ok(()) - } -} - -pub type SurfaceMaterial = ExtendedMaterial; - -#[derive(Resource, Default)] -pub struct SurfaceMaterialCache { - handles: HashMap>, - standard_handles: HashMap>, - revisions: HashMap, - standard_only: HashMap, - dependencies: HashMap>, - failed_revisions: HashMap, -} - -enum BuiltRendererMaterial { - Standard(Box), - Surface(Box), -} - -#[derive(Resource, Default, Debug, Clone)] -pub struct SurfaceDiagnostics(pub Vec); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurfaceEvaluatorRecord { - pub shader_id: u32, - pub evaluator_source: String, - pub revision: u64, - pub alpha_mode: shared::MaterialAlphaMode, -} - -/// Active, validated evaluator generation consumed by both raster and Solari render worlds. -#[derive(Resource, ExtractResource, Default, Debug, Clone)] -pub struct SurfaceEvaluatorRegistry { - pub evaluators: HashMap, -} - -pub struct SurfaceMaterialPlugin; - -impl Plugin for SurfaceMaterialPlugin { - fn build(&self, app: &mut App) { - app.add_plugins(MaterialPlugin::::default()) - .add_plugins(MaterialPlugin::::default()) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems( - Update, - ( - sync_surface_material_bindings, - sync_terrain_layer_material_bindings, - ), - ); - } -} - -fn sync_terrain_layer_material_bindings( - mut commands: Commands, - asset_server: Res, - mut materials: ResMut>, - mut cache: ResMut, - mut diagnostics: ResMut, - bindings: TerrainMaterialBindings, -) { - for (entity, binding) in &bindings { - if binding.layers.is_empty() { - continue; - } - let revision = terrain_layer_revision(&binding.layers); - let cached = cache - .0 - .get(&binding.owner) - .filter(|entry| entry.layers == binding.layers && entry.revision == revision) - .map(|entry| entry.handle.clone()); - let handle = cached.unwrap_or_else(|| { - let material = - build_terrain_layer_material(&asset_server, &binding.layers, &mut diagnostics.0); - let handle = cache - .0 - .get(&binding.owner) - .map(|entry| entry.handle.clone()) - .filter(|handle| { - materials - .get_mut(handle) - .map(|mut slot| { - *slot = material.clone(); - }) - .is_some() - }) - .unwrap_or_else(|| materials.add(material)); - cache.0.insert( - binding.owner, - TerrainLayerCacheEntry { - layers: binding.layers.clone(), - handle: handle.clone(), - revision, - }, - ); - handle - }); - queue_terrain_material_binding(&mut commands, entity, handle); - } -} - -fn queue_terrain_material_binding( - commands: &mut Commands, - entity: Entity, - handle: Handle, -) { - // Generated render entities can be removed by a scene switch after this system's query - // but before deferred commands apply. That lifecycle race is expected and must not abort - // the editor while opening another scene. - commands - .entity(entity) - .try_remove::>() - .try_insert(MeshMaterial3d(handle)); -} - -fn terrain_layer_revision(layers: &[TerrainMaterialLayer]) -> u64 { - let mut dependencies = layers - .iter() - .filter_map(|layer| layer.material.as_ref()) - .filter_map(|reference| reference.source_path.as_deref()) - .flat_map(|path| material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()])) - .collect::>(); - dependencies.sort(); - dependencies.dedup(); - dependency_revision(&dependencies) -} - -fn build_terrain_layer_material( - asset_server: &AssetServer, - layers: &[TerrainMaterialLayer], - diagnostics: &mut Vec, -) -> TerrainLayerMaterial { - let mut extension = TerrainLayerExtension::default(); - for (index, layer) in layers.iter().take(TERRAIN_MATERIAL_LAYER_LIMIT).enumerate() { - extension.uniform.uv_scales[index] = layer.uv_scale; - let desc = resolve_terrain_layer(layer, index, diagnostics); - let color = desc.base_color.to_color().to_linear(); - extension.uniform.base_colors[index] = - Vec4::new(color.red, color.green, color.blue, color.alpha); - extension.uniform.properties[index] = Vec4::new( - desc.metallic, - desc.roughness, - f32::from(desc.normal_map_texture.is_some()), - 1.0, - ); - extension.uniform.base_texture_enabled[index] = - f32::from(desc.base_color_texture.is_some()); - let base_color = desc - .base_color_texture - .as_deref() - .map(|path| asset_server.load(asset_server_path(path))); - let normal = desc - .normal_map_texture - .as_deref() - .map(|path| asset_server.load(asset_server_path(path))); - match index { - 0 => { - extension.base_color0 = base_color; - extension.normal0 = normal; - } - 1 => { - extension.base_color1 = base_color; - extension.normal1 = normal; - } - 2 => { - extension.base_color2 = base_color; - extension.normal2 = normal; - } - 3 => { - extension.base_color3 = base_color; - extension.normal3 = normal; - } - _ => {} - } - } - TerrainLayerMaterial { - base: StandardMaterial { - base_color: Color::WHITE, - perceptual_roughness: 1.0, - // Terrain is intentionally absent from the Solari acceleration structure until its - // ray-tracing evaluator exists, so keep the layer blend in the raster forward pass. - opaque_render_method: OpaqueRendererMethod::Forward, - ..default() - }, - extension, - } -} - -fn resolve_terrain_layer( - layer: &TerrainMaterialLayer, - index: usize, - diagnostics: &mut Vec, -) -> MaterialDesc { - let Some(reference) = layer.material.as_ref() else { - diagnostics.push(format!( - "terrain material layer {} is unassigned; using visible fallback", - index + 1 - )); - return terrain_layer_fallback(); - }; - let Some(path) = reference.source_path.as_deref() else { - diagnostics.push(format!( - "terrain material layer {} ({}) has no loadable source path; using visible fallback", - index + 1, - reference.label - )); - return terrain_layer_fallback(); - }; - match shared::load_resolved_material_from_path(path) { - Ok((desc, _)) => desc, - Err(error) => { - diagnostics.push(format!( - "terrain material layer {} ({}) could not resolve: {error}; using visible fallback", - index + 1, - reference.label - )); - terrain_layer_fallback() - } - } -} - -fn terrain_layer_fallback() -> MaterialDesc { - MaterialDesc { - base_color: shared::ColorDesc::srgb(0.24, 0.29, 0.25), - roughness: 0.92, - ..Default::default() - } -} - -#[allow(clippy::too_many_arguments, clippy::type_complexity)] -fn sync_surface_material_bindings( - mut commands: Commands, - asset_server: Res, - mut shaders: ResMut>, - mut standard_materials: ResMut>, - mut surface_materials: ResMut>, - mut cache: ResMut, - mut diagnostics: ResMut, - mut evaluator_registry: ResMut, - bindings: Query<( - Entity, - &HydratedRendererMaterialBinding, - Option<&MeshMaterial3d>, - Option<&MeshMaterial3d>, - )>, -) { - for (entity, binding, standard_handle, surface_handle) in &bindings { - let Some(reference) = binding.effective_material.as_ref() else { - continue; - }; - let Some(path) = reference.0.source_path.as_deref() else { - continue; - }; - let dependencies = cache - .dependencies - .get(reference) - .cloned() - .unwrap_or_else(|| vec![path.to_string()]); - let revision = dependency_revision(&dependencies); - if cache.failed_revisions.get(reference) == Some(&revision) { - continue; - } - if cache.standard_only.get(reference) == Some(&revision) { - if let Some(handle) = cache.standard_handles.get(reference).cloned() { - if standard_handle.is_none_or(|current| current.0 != handle) - || surface_handle.is_some() - { - commands - .entity(entity) - .try_remove::>() - .try_insert(MeshMaterial3d(handle)); - } - } - continue; - } - let stale = cache.revisions.get(reference) != Some(&revision); - let handle = if !stale { - cache.handles.get(reference).cloned() - } else { - None - }; - let handle = match handle { - Some(handle) => handle, - None => match build_surface_material( - reference, - path, - &asset_server, - &mut shaders, - &mut standard_materials, - standard_handle, - &mut evaluator_registry, - ) { - Ok(BuiltRendererMaterial::Surface(material)) => { - let material = *material; - cache.standard_only.remove(reference); - cache.standard_handles.remove(reference); - cache.failed_revisions.remove(reference); - if let Some(existing) = cache.handles.get(reference).cloned() { - if let Some(mut slot) = surface_materials.get_mut(&existing) { - *slot = material; - } - existing - } else { - let handle = surface_materials.add(material); - cache.handles.insert(reference.clone(), handle.clone()); - handle - } - } - Ok(BuiltRendererMaterial::Standard(material)) => { - let material = *material; - // Plain Material assets stay on Bevy's StandardMaterial path, while still - // sharing one live-updated handle across every renderer slot. - let handle = standard_handle - .map(|handle| handle.0.clone()) - .or_else(|| cache.standard_handles.get(reference).cloned()) - .unwrap_or_else(|| standard_materials.add(material.clone())); - if let Some(mut slot) = standard_materials.get_mut(&handle) { - *slot = material; - } - cache.handles.remove(reference); - cache.revisions.remove(reference); - cache - .standard_handles - .insert(reference.clone(), handle.clone()); - let next_dependencies = - material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]); - let next_revision = dependency_revision(&next_dependencies); - cache - .dependencies - .insert(reference.clone(), next_dependencies); - cache.standard_only.insert(reference.clone(), next_revision); - cache.failed_revisions.remove(reference); - evaluator_registry - .evaluators - .remove(&stable_shader_id(reference)); - if standard_handle.is_none_or(|current| current.0 != handle) - || surface_handle.is_some() - { - commands - .entity(entity) - .try_remove::>() - .try_insert(MeshMaterial3d(handle)); - } - continue; - } - Err(error) => { - cache.failed_revisions.insert(reference.clone(), revision); - diagnostics.0.push(format!( - "material {} could not build Surface ABI: {error}", - reference.0.label - )); - continue; - } - }, - }; - let next_dependencies = - material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]); - let next_revision = dependency_revision(&next_dependencies); - cache - .dependencies - .insert(reference.clone(), next_dependencies); - cache.revisions.insert(reference.clone(), next_revision); - if surface_handle.is_none_or(|current| current.0 != handle) { - commands - .entity(entity) - .try_remove::>() - .try_insert(MeshMaterial3d(handle)); - } - } -} - -fn build_surface_material( - reference: &MaterialRef, - path: &str, - asset_server: &AssetServer, - shaders: &mut Assets, - standard_materials: &mut Assets, - existing_standard: Option<&MeshMaterial3d>, - evaluator_registry: &mut SurfaceEvaluatorRegistry, -) -> Result { - let (mut asset, instance) = match MaterialAsset::load_from_path(path) { - Ok(asset) => (asset, None), - Err(_) => { - let instance = MaterialInstanceAsset::load_from_path(path)?; - if instance.schema_version != MATERIAL_INSTANCE_SCHEMA_VERSION { - return Err(format!( - "unsupported material-instance schema {}", - instance.schema_version - )); - } - let base_path = instance - .base - .0 - .source_path - .as_deref() - .ok_or_else(|| "material instance base has no loadable path".to_string())?; - (MaterialAsset::load_from_path(base_path)?, Some(instance)) - } - }; - if let Some(instance) = instance.as_ref() { - instance.apply_to(&mut asset.material); - } - let schema_path = asset - .shader_ref - .as_ref() - .and_then(|reference| reference.source_path.as_deref()) - .or(asset.material.shader.schema_path.as_deref()) - .or(asset - .shader - .as_deref() - .filter(|path| path.ends_with(".ron"))); - let schema = schema_path - .map(ShaderSchemaAsset::load_from_path) - .transpose()?; - let evaluator_source = schema - .as_ref() - .and_then(|schema| schema.wgsl_path.as_deref()) - .map(fs::read_to_string) - .transpose() - .map_err(|error| format!("could not read surface WGSL: {error}"))?; - - let mut base = existing_standard - .and_then(|handle| standard_materials.get(&handle.0).cloned()) - .unwrap_or_else(|| material_from_desc(asset_server, &asset.material)); - base.alpha_mode = match asset.render_state.alpha_mode { - shared::MaterialAlphaMode::Opaque => AlphaMode::Opaque, - shared::MaterialAlphaMode::Cutout => AlphaMode::Mask(asset.render_state.alpha_cutoff), - }; - base.cull_mode = - (!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back); - - let Some(evaluator) = evaluator_source.as_deref() else { - return Ok(BuiltRendererMaterial::Standard(Box::new(base))); - }; - validate_surface_evaluator(evaluator)?; - let runtime_shader_id = stable_shader_id(reference); - evaluator_registry.evaluators.insert( - runtime_shader_id, - SurfaceEvaluatorRecord { - shader_id: runtime_shader_id, - evaluator_source: evaluator.to_string(), - revision: evaluator_revision(evaluator), - alpha_mode: asset.render_state.alpha_mode, - }, - ); - let virtual_path = format!("generated://surface/{}.wgsl", reference.0.asset_id); - let shader_uuid = shader_uuid_for_material(reference); - let shader_id = AssetId::::Uuid { uuid: shader_uuid }; - shaders - .insert( - shader_id, - Shader::from_wgsl(compose_surface_shader(evaluator), virtual_path), - ) - .map_err(|error| format!("could not replace generated surface shader: {error}"))?; - let shader = Handle::Uuid(shader_uuid, Default::default()); - - let mut extension = SurfaceExtension { - shader, - ..default() - }; - extension.uniform.alpha_cutoff = asset.render_state.alpha_cutoff; - extension.uniform.shader_id = runtime_shader_id; - if let Some(schema) = schema.as_ref() { - pack_schema_values( - &mut extension, - schema, - &asset.material, - instance.as_ref(), - asset_server, - )?; - } - Ok(BuiltRendererMaterial::Surface(Box::new(ExtendedMaterial { - base, - extension, - }))) -} - -fn material_dependency_paths(path: &str) -> Result, String> { - let mut dependencies = vec![path.to_string()]; - let asset = match MaterialAsset::load_from_path(path) { - Ok(asset) => asset, - Err(_) => { - let instance = MaterialInstanceAsset::load_from_path(path)?; - let base_path = instance - .base - .0 - .source_path - .as_deref() - .ok_or_else(|| "material instance base has no loadable path".to_string())?; - dependencies.push(base_path.to_string()); - MaterialAsset::load_from_path(base_path)? - } - }; - let schema_path = asset - .shader_ref - .as_ref() - .and_then(|reference| reference.source_path.as_deref()) - .or(asset.material.shader.schema_path.as_deref()) - .or(asset - .shader - .as_deref() - .filter(|path| path.ends_with(".ron"))); - if let Some(schema_path) = schema_path { - dependencies.push(schema_path.to_string()); - if let Some(wgsl_path) = ShaderSchemaAsset::load_from_path(schema_path)?.wgsl_path { - dependencies.push(wgsl_path); - } - } - dependencies.sort(); - dependencies.dedup(); - Ok(dependencies) -} - -fn dependency_revision(paths: &[String]) -> u64 { - let mut hash = 0xcbf29ce484222325u64; - for path in paths { - for byte in path.bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); - } - match fs::metadata(path) { - Ok(metadata) => { - for byte in metadata.len().to_le_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); - } - if let Ok(modified) = metadata.modified() { - if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { - for byte in duration.as_nanos().to_le_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); - } - } - } - } - Err(_) => { - hash ^= u64::MAX; - hash = hash.wrapping_mul(0x100000001b3); - } - } - } - hash -} - -fn stable_shader_id(reference: &MaterialRef) -> u32 { - let mut hash = 0x811c9dc5u32; - for byte in reference.0.asset_id.bytes() { - hash ^= u32::from(byte); - hash = hash.wrapping_mul(0x01000193); - } - hash -} - -fn evaluator_revision(source: &str) -> u64 { - let mut hash = 0xcbf29ce484222325u64; - for byte in source.bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); - } - hash -} - -fn shader_uuid_for_material(reference: &MaterialRef) -> uuid::Uuid { - if let Ok(source) = uuid::Uuid::parse_str(&reference.0.asset_id) { - let mixed = source.as_u128() ^ 0x7f8c_4e90_f9ab_4f28_9b44_47c1_b2d5_5ac1u128; - return uuid::Uuid::from_u128(mixed); - } - let mut bytes = [0u8; 16]; - for (index, byte) in reference.0.asset_id.bytes().enumerate() { - bytes[index % 16] = bytes[index % 16].wrapping_mul(31).wrapping_add(byte); - } - uuid::Uuid::from_bytes(bytes) -} - -fn pack_schema_values( - extension: &mut SurfaceExtension, - schema: &ShaderSchemaAsset, - material: &shared::MaterialDesc, - instance: Option<&MaterialInstanceAsset>, - asset_server: &AssetServer, -) -> Result<(), String> { - let mut numeric_index = 0usize; - let mut texture_index = 0usize; - for property in &schema.parameters { - if matches!(property.property_type, ShaderPropertyType::Texture) { - if texture_index >= SURFACE_TEXTURE_SLOTS { - return Err(format!( - "surface schema exceeds {SURFACE_TEXTURE_SLOTS} textures" - )); - } - let binding = instance - .and_then(|instance| { - instance - .textures - .iter() - .find(|value| value.name == property.name) - }) - .or_else(|| { - material - .textures - .iter() - .find(|value| value.name == property.name) - }); - let handle = binding - .and_then(|binding| binding.texture.as_ref()) - .and_then(|reference| reference.source_path.as_deref()) - .map(asset_server_path) - .map(|path| asset_server.load(path)); - extension.set_texture(texture_index, handle); - texture_index += 1; - continue; - } - if numeric_index >= SURFACE_PARAMETER_LANES { - return Err(format!( - "surface schema exceeds {SURFACE_PARAMETER_LANES} parameter lanes" - )); - } - let value = instance - .and_then(|instance| { - instance - .parameters - .iter() - .find(|value| value.name == property.name) - }) - .or_else(|| { - material - .parameters - .iter() - .find(|value| value.name == property.name) - }) - .or_else(|| { - schema - .default_values - .iter() - .find(|value| value.name == property.name) - }); - extension.uniform.params[numeric_index] = value - .map(|value| parameter_lane(&value.value, &property.property_type)) - .transpose()? - .unwrap_or(Vec4::ZERO); - numeric_index += 1; - } - Ok(()) -} - -fn parameter_lane( - value: &MaterialParameterValue, - property_type: &ShaderPropertyType, -) -> Result { - match (value, property_type) { - (MaterialParameterValue::Bool(value), ShaderPropertyType::Bool) => { - Ok(Vec4::new(u32::from(*value) as f32, 0.0, 0.0, 0.0)) - } - (MaterialParameterValue::Float(value), ShaderPropertyType::Float { .. }) => { - Ok(Vec4::new(*value, 0.0, 0.0, 0.0)) - } - (MaterialParameterValue::Vec2(value), ShaderPropertyType::Vec2) => { - Ok(Vec4::new(value.x, value.y, 0.0, 0.0)) - } - (MaterialParameterValue::Vec3(value), ShaderPropertyType::Vec3) => Ok(value.extend(0.0)), - (MaterialParameterValue::Color(value), ShaderPropertyType::Color) => { - Ok(Vec4::new(value.r, value.g, value.b, value.a)) - } - (MaterialParameterValue::Enum(value), ShaderPropertyType::Enum { options }) => { - let index = options - .iter() - .position(|option| option == value) - .ok_or_else(|| format!("enum value `{value}` is not declared by the schema"))?; - Ok(Vec4::new(index as f32, 0.0, 0.0, 0.0)) - } - _ => Err("material value does not match shader schema type".into()), - } -} - -pub fn validate_surface_evaluator(source: &str) -> Result<(), String> { - if !source.contains("fn evaluate(") { - return Err("surface WGSL must define `fn evaluate(`".into()); - } - for forbidden in [ - "@group", - "@binding", - "@vertex", - "@fragment", - "@compute", - "var<", - "rayQuery", - "dpdx", - "dpdy", - "fwidth", - "discard", - "Barrier", - "subgroup", - "textureStore", - "atomic", - ] { - if source.contains(forbidden) { - return Err(format!( - "surface WGSL uses forbidden construct `{forbidden}`" - )); - } - } - let validation_source = format!("{EVALUATOR_VALIDATION_PRELUDE}\n{source}"); - let module = naga::front::wgsl::parse_str(&validation_source) - .map_err(|error| format!("surface WGSL syntax error: {error}"))?; - naga::valid::Validator::new( - naga::valid::ValidationFlags::all(), - naga::valid::Capabilities::empty(), - ) - .validate(&module) - .map_err(|error| format!("surface WGSL validation error: {error}"))?; - Ok(()) -} - -const EVALUATOR_VALIDATION_PRELUDE: &str = r#" -struct SurfaceInput { - uv0: vec2, - world_position: vec3, - world_normal: vec3, -} -struct SurfaceParams { lanes: array, 16>, } -struct SurfaceSamples { values: array, 8>, } -struct Surface { - base_color: vec4, - normal_ts: vec3, - emissive: vec3, - metallic: f32, - perceptual_roughness: f32, - reflectance: f32, - occlusion: f32, - model: u32, -} -fn surface_default() -> Surface { - var surface: Surface; - surface.base_color = vec4(1.0); - surface.normal_ts = vec3(0.0, 0.0, 1.0); - surface.emissive = vec3(0.0); +fn evaluate( + input: SurfaceInput, + params: SurfaceParams, + samples: SurfaceSamples, +) -> Surface { + var surface = surface_default(); + let weights = abs(normalize(input.world_normal)); + let normalized_weights = weights / max(weights.x + weights.y + weights.z, 0.0001); + let grid = grid_axis(input.world_position.yz) * normalized_weights.x + + grid_axis(input.world_position.xz) * normalized_weights.y + + grid_axis(input.world_position.xy) * normalized_weights.z; + let shade = mix(0.32, 0.56, grid); + surface.base_color = vec4(vec3(shade), 1.0); + surface.perceptual_roughness = 0.86; surface.metallic = 0.0; - surface.perceptual_roughness = 0.5; - surface.reflectance = 0.5; + surface.emissive = vec3(shade * 0.035); + surface.reflectance = 0.35; surface.occlusion = 1.0; - surface.model = 0u; return surface; } "#; -pub fn compose_surface_shader(evaluator: &str) -> String { - format!( - "{}\n{}\n{}", - include_str!("surface_header.wgsl"), - evaluator, - include_str!("surface_footer.wgsl") - ) -} +#[path = "surface/cache_plugin.rs"] +mod cache_plugin; +#[path = "surface/packing.rs"] +mod packing; +#[path = "surface/property_blocks.rs"] +mod property_blocks; +#[path = "surface/resolver.rs"] +mod resolver; +#[path = "surface/surface_abi.rs"] +mod surface_abi; +#[path = "surface/terrain.rs"] +mod terrain; +#[path = "surface/terrain_types.rs"] +mod terrain_types; +#[path = "surface/validation.rs"] +mod validation; + +pub use cache_plugin::{ + DefaultGridMaterialHandles, SurfaceDiagnostics, SurfaceEvaluatorRecord, + SurfaceEvaluatorRegistry, SurfaceMaterialCache, SurfaceMaterialPlugin, +}; +pub use property_blocks::validate_property_block_for_promotion; +pub use surface_abi::{SurfaceExtension, SurfaceExtensionKey, SurfaceMaterial, SurfaceUniform}; +pub use terrain_types::{TerrainLayerExtension, TerrainLayerMaterial, TerrainLayerUniform}; +pub use validation::{compose_surface_shader, validate_surface_evaluator}; + +use cache_plugin::*; +use packing::*; +use property_blocks::*; +use resolver::*; +use terrain::*; +use terrain_types::*; #[cfg(test)] -mod tests { - use super::*; - use bevy::ecs::world::CommandQueue; - - #[test] - fn stale_generated_terrain_binding_is_nonfatal_during_scene_switch() { - let mut world = World::new(); - let entity = world.spawn_empty().id(); - let mut queue = CommandQueue::default(); - let mut commands = Commands::new(&mut queue, &world); - queue_terrain_material_binding(&mut commands, entity, Handle::default()); - - world.entity_mut(entity).despawn(); - queue.apply(&mut world); - - assert!(world.get_entity(entity).is_err()); - } - - #[test] - fn rejects_resource_bindings_and_missing_entry() { - assert!(validate_surface_evaluator("fn nope() {}").is_err()); - assert!(validate_surface_evaluator( - "@group(0) @binding(0) var t: texture_2d; fn evaluate() {}" - ) - .is_err()); - for compute_incompatible in [ - "dpdx(input.uv0.x)", - "dpdy(input.uv0.y)", - "fwidth(input.uv0.x)", - "discard", - "workgroupBarrier()", - "subgroupAdd(1u)", - ] { - let source = format!( - "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface {{ let invalid = {compute_incompatible}; return surface_default(); }}" - ); - assert!( - validate_surface_evaluator(&source).is_err(), - "accepted compute-incompatible evaluator: {compute_incompatible}" - ); - } - assert!(validate_surface_evaluator( - "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return missing_symbol; }" - ) - .is_err()); - } - - #[test] - fn accepts_surface_only_module_and_composes_wrapper() { - let source = "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return surface_default(); }"; - validate_surface_evaluator(source).unwrap(); - let composed = compose_surface_shader(source); - assert!(composed.contains(source)); - assert!(composed.contains("@fragment")); - } - - #[test] - fn abi_limits_are_stable() { - assert_eq!(SURFACE_PARAMETER_LANES, 16); - assert_eq!(SURFACE_TEXTURE_SLOTS, 8); - assert_eq!(std::mem::size_of::(), 400); - } - - #[test] - fn material_extensions_leave_non_deferred_prepasses_to_standard_material() { - assert!(matches!( - ::prepass_fragment_shader(), - ShaderRef::Default - )); - assert!(matches!( - ::prepass_fragment_shader(), - ShaderRef::Default - )); - } - - #[test] - fn terrain_layer_builder_packs_project_material_values() { - let mut app = App::new(); - app.add_plugins((MinimalPlugins, AssetPlugin::default())); - let asset_server = app.world().resource::(); - let materials_root = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/materials"); - let layers = [ - TerrainMaterialLayer { - material: Some( - shared::EditorAssetRef::new("concrete", "material:source", "Concrete") - .with_source_path( - materials_root.join("concrete.ron").display().to_string(), - ), - ), - uv_scale: 6.0, - }, - TerrainMaterialLayer { - material: Some( - shared::EditorAssetRef::new("tint", "material:source", "Surface Tint") - .with_source_path( - materials_root - .join("surface_tint.ron") - .display() - .to_string(), - ), - ), - uv_scale: 10.0, - }, - ]; - let mut diagnostics = Vec::new(); - let material = build_terrain_layer_material(asset_server, &layers, &mut diagnostics); - - assert!(diagnostics.is_empty(), "{diagnostics:?}"); - assert_eq!(material.extension.uniform.uv_scales.x, 6.0); - assert_eq!(material.extension.uniform.uv_scales.y, 10.0); - assert_eq!(material.extension.uniform.properties[0].w, 1.0); - assert_eq!(material.extension.uniform.properties[1].w, 1.0); - assert_eq!( - material.base.opaque_render_method, - OpaqueRendererMethod::Forward - ); - assert_ne!( - material.extension.uniform.base_colors[0], - material.extension.uniform.base_colors[1] - ); - } -} +#[path = "surface/tests.rs"] +mod tests; diff --git a/crates/blacksite_surface/src/live_documents.rs b/crates/blacksite_surface/src/live_documents.rs new file mode 100644 index 0000000..113326d --- /dev/null +++ b/crates/blacksite_surface/src/live_documents.rs @@ -0,0 +1,67 @@ +//! Editor-owned live Material and Material Instance values. + +use std::collections::HashMap; + +use bevy::prelude::Resource; +use shared::{MaterialAsset, MaterialInstanceAsset, MaterialRef}; + +#[derive(Debug, Clone)] +pub enum LiveMaterialDocument { + Material(MaterialAsset), + Instance(MaterialInstanceAsset), +} + +#[derive(Debug, Clone)] +pub struct LiveMaterialDocumentEntry { + pub revision: u64, + pub document: LiveMaterialDocument, +} + +#[derive(Resource, Default, Debug)] +pub struct LiveMaterialDocumentOverlay { + documents: HashMap, +} + +impl LiveMaterialDocumentOverlay { + pub fn update_material( + &mut self, + asset_id: impl Into, + revision: u64, + asset: MaterialAsset, + ) { + self.documents.insert( + asset_id.into(), + LiveMaterialDocumentEntry { + revision, + document: LiveMaterialDocument::Material(asset), + }, + ); + } + + pub fn update_instance( + &mut self, + asset_id: impl Into, + revision: u64, + instance: MaterialInstanceAsset, + ) { + self.documents.insert( + asset_id.into(), + LiveMaterialDocumentEntry { + revision, + document: LiveMaterialDocument::Instance(instance), + }, + ); + } + + pub fn remove(&mut self, asset_id: &str) { + self.documents.remove(asset_id); + } + + pub fn revision_for_asset(&self, asset_id: &str) -> Option { + self.documents.get(asset_id).map(|entry| entry.revision) + } + + pub(crate) fn get(&self, reference: &MaterialRef) -> Option<&LiveMaterialDocumentEntry> { + self.documents.get(&reference.0.asset_id) + } +} diff --git a/crates/blacksite_surface/src/surface/cache_plugin.rs b/crates/blacksite_surface/src/surface/cache_plugin.rs new file mode 100644 index 0000000..5ef3455 --- /dev/null +++ b/crates/blacksite_surface/src/surface/cache_plugin.rs @@ -0,0 +1,138 @@ +use super::*; + +#[derive(Resource, Default)] +pub struct SurfaceMaterialCache { + pub(super) handles: HashMap>, + pub(super) standard_handles: HashMap>, + pub(super) revisions: HashMap, + pub(super) standard_only: HashMap, + pub(super) instance_bases: HashMap, + pub(super) failed_revisions: HashMap, + pub(super) disk_generation: u64, +} + +impl SurfaceMaterialCache { + /// Invalidates disk-backed project materials after an explicit save or watcher event. + /// Interactive edits use `LiveMaterialDocumentOverlay`, so synchronization never needs to + /// stat or reparse authored files for every bound entity on every frame. + pub fn invalidate_disk_documents(&mut self) { + self.disk_generation = self.disk_generation.wrapping_add(1); + self.failed_revisions.clear(); + } + + pub(super) fn disk_generation(&self) -> u64 { + self.disk_generation + } + + pub(super) fn revision_for_handles( + &self, + standard: Option<&MeshMaterial3d>, + surface: Option<&MeshMaterial3d>, + ) -> u64 { + if let Some(handle) = standard { + if let Some((reference, _)) = self + .standard_handles + .iter() + .find(|(_, cached)| cached.id() == handle.0.id()) + { + return self + .standard_only + .get(reference) + .copied() + .unwrap_or_default(); + } + } + if let Some(handle) = surface { + if let Some((reference, _)) = self + .handles + .iter() + .find(|(_, cached)| cached.id() == handle.0.id()) + { + return self.revisions.get(reference).copied().unwrap_or_default(); + } + } + 0 + } +} + +#[derive(Resource, Default)] +pub(super) struct MaterialPropertyBlockCache { + pub(super) entries: HashMap<(Entity, String), PropertyBlockCacheEntry>, +} + +pub(super) enum PropertyBlockHandle { + Standard(Handle), + Surface(Handle), +} + +pub(super) struct PropertyBlockCacheEntry { + pub(super) revision: u64, + pub(super) handle: PropertyBlockHandle, +} + +#[derive(Resource, Default, Debug, Clone)] +pub struct DefaultGridMaterialHandles { + pub surface: Option>, + pub emergency_standard: Option>, +} + +pub(super) enum BuiltRendererMaterial { + Standard(Box), + Surface(Box), +} + +#[derive(Resource, Default, Debug, Clone)] +pub struct SurfaceDiagnostics(pub Vec); + +pub(super) fn record_diagnostic(diagnostics: &mut Vec, message: String) { + if diagnostics.iter().any(|existing| existing == &message) { + return; + } + const MAX_DIAGNOSTICS: usize = 256; + if diagnostics.len() == MAX_DIAGNOSTICS { + diagnostics.remove(0); + } + diagnostics.push(message); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceEvaluatorRecord { + pub shader_id: u32, + pub evaluator_source: String, + pub revision: u64, + pub alpha_mode: shared::MaterialAlphaMode, +} + +/// Active, validated evaluator generation consumed by both raster and Solari render worlds. +#[derive(Resource, ExtractResource, Default, Debug, Clone)] +pub struct SurfaceEvaluatorRegistry { + pub evaluators: HashMap, +} + +pub struct SurfaceMaterialPlugin; + +impl Plugin for SurfaceMaterialPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(MaterialPlugin::::default()) + .add_plugins(MaterialPlugin::::default()) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems( + Update, + ( + ( + sync_surface_material_bindings, + apply_material_property_blocks, + ) + .chain(), + sync_terrain_layer_material_bindings, + ), + ); + } +} diff --git a/crates/blacksite_surface/src/surface/packing.rs b/crates/blacksite_surface/src/surface/packing.rs new file mode 100644 index 0000000..fc92776 --- /dev/null +++ b/crates/blacksite_surface/src/surface/packing.rs @@ -0,0 +1,229 @@ +use super::*; + +pub(super) fn pack_schema_values( + extension: &mut SurfaceExtension, + schema: &ShaderSchemaAsset, + inputs: &shared::MaterialInputSet, + instance: Option<&MaterialInstanceAsset>, + asset_server: &AssetServer, + runtime_catalog: Option<&RuntimeContentCatalog>, +) -> Result<(), String> { + let mut numeric_index = 0usize; + let mut texture_index = 0usize; + for property in &schema.schema.inputs { + if property.texture.is_some() + || matches!(property.property_type, ShaderPropertyType::Texture) + { + if texture_index >= SURFACE_TEXTURE_SLOTS { + return Err(format!( + "surface schema exceeds {SURFACE_TEXTURE_SLOTS} textures" + )); + } + let binding = instance + .and_then(|instance| { + instance + .overrides + .textures + .iter() + .find(|value| value.name == property.name) + }) + .or_else(|| { + inputs + .textures + .iter() + .find(|value| value.name == property.name) + }); + let handle = binding + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| { + runtime_texture_spec( + runtime_catalog, + reference, + property.texture.as_ref().is_some_and(|texture| { + texture.semantic == shared::TextureSemantic::Color + }), + ) + }) + .map(|spec| shared::load_runtime_texture(asset_server, &spec)); + extension.set_texture(texture_index, handle); + texture_index += 1; + } + if matches!(property.property_type, ShaderPropertyType::Texture) { + continue; + } + if numeric_index >= SURFACE_PARAMETER_LANES { + return Err(format!( + "surface schema exceeds {SURFACE_PARAMETER_LANES} parameter lanes" + )); + } + let value = instance + .and_then(|instance| { + instance + .overrides + .values + .iter() + .find(|value| value.name == property.name) + }) + .or_else(|| { + inputs + .values + .iter() + .find(|value| value.name == property.name) + }) + .map(|value| &value.value) + .or(property.default_value.as_ref()); + extension.uniform.params[numeric_index] = value + .map(|value| parameter_lane(value, &property.property_type)) + .transpose()? + .unwrap_or(Vec4::ZERO); + numeric_index += 1; + } + Ok(()) +} + +pub(super) fn load_runtime_content_catalog() -> Option { + let source = fs::read_to_string("assets/content.catalog.ron").ok()?; + ron::from_str(&source).ok() +} + +pub(super) fn runtime_texture_path( + catalog: Option<&RuntimeContentCatalog>, + reference: &shared::EditorAssetRef, +) -> Option { + catalog + .and_then(|catalog| { + catalog + .records + .iter() + .find(|record| record.id.as_string() == reference.asset_id) + }) + .and_then(|record| record.texture.as_ref()) + .and_then(|runtime| runtime.processed_path.clone()) + .or_else(|| reference.source_path.clone()) +} + +pub(super) fn runtime_texture_spec( + catalog: Option<&RuntimeContentCatalog>, + reference: &shared::EditorAssetRef, + fallback_srgb: bool, +) -> Option { + catalog + .and_then(|catalog| catalog.texture_load_spec(reference)) + .or_else(|| { + reference + .source_path + .as_ref() + .map(|path| shared::RuntimeTextureLoadSpec { + path: path.clone(), + is_srgb: fallback_srgb, + filter: shared::TextureFilter::Linear, + wrap: shared::TextureWrap::Repeat, + anisotropy: 8, + }) + }) +} + +pub(super) fn packed_runtime_texture_spec( + catalog: Option<&RuntimeContentCatalog>, + inputs: &MaterialInputSet, + instance: Option<&MaterialInstanceAsset>, + material: &MaterialRef, +) -> Result, String> { + let Some(catalog) = catalog else { + return Ok(None); + }; + let Some(path) = catalog + .records + .iter() + .find(|record| record.id.as_string() == material.0.asset_id) + .and_then(|record| record.material.as_ref()) + .and_then(|runtime| runtime.packed_arm_path.clone()) + else { + return Ok(None); + }; + let mut source_specs = ["occlusion", "roughness", "metallic"] + .into_iter() + .filter_map(|name| { + instance + .and_then(|instance| instance.overrides.texture(name)) + .or_else(|| inputs.texture(name)) + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| catalog.texture_load_spec(reference)) + }); + let Some(first) = source_specs.next() else { + return Ok(None); + }; + if source_specs.any(|spec| { + spec.filter != first.filter + || spec.wrap != first.wrap + || spec.anisotropy != first.anisotropy + }) { + return Err("packed AO/roughness/metallic textures must use matching filter, wrap, and anisotropy settings".into()); + } + Ok(Some(shared::RuntimeTextureLoadSpec { + path, + is_srgb: false, + filter: first.filter, + wrap: first.wrap, + anisotropy: first.anisotropy, + })) +} + +pub(super) fn apply_runtime_texture_paths( + resolved: &mut MaterialDesc, + inputs: &MaterialInputSet, + instance: Option<&MaterialInstanceAsset>, + material: &MaterialRef, + catalog: Option<&RuntimeContentCatalog>, +) { + let binding = |name: &str| { + instance + .and_then(|instance| instance.overrides.texture(name)) + .or_else(|| inputs.texture(name)) + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| runtime_texture_path(catalog, reference)) + }; + resolved.base_color_texture = binding("base_color"); + resolved.normal_map_texture = binding("normal"); + resolved.emissive_texture = binding("emissive"); + let packed_arm = catalog + .and_then(|catalog| { + catalog + .records + .iter() + .find(|record| record.id.as_string() == material.0.asset_id) + }) + .and_then(|record| record.material.as_ref()) + .and_then(|runtime| runtime.packed_arm_path.clone()); + resolved.metallic_roughness_texture = packed_arm.clone(); + resolved.occlusion_texture = packed_arm; +} + +pub(super) fn parameter_lane( + value: &MaterialParameterValue, + property_type: &ShaderPropertyType, +) -> Result { + match (value, property_type) { + (MaterialParameterValue::Bool(value), ShaderPropertyType::Bool) => { + Ok(Vec4::new(u32::from(*value) as f32, 0.0, 0.0, 0.0)) + } + (MaterialParameterValue::Float(value), ShaderPropertyType::Float { .. }) => { + Ok(Vec4::new(*value, 0.0, 0.0, 0.0)) + } + (MaterialParameterValue::Vec2(value), ShaderPropertyType::Vec2) => { + Ok(Vec4::new(value.x, value.y, 0.0, 0.0)) + } + (MaterialParameterValue::Vec3(value), ShaderPropertyType::Vec3) => Ok(value.extend(0.0)), + (MaterialParameterValue::Color(value), ShaderPropertyType::Color) => { + Ok(Vec4::new(value.r, value.g, value.b, value.a)) + } + (MaterialParameterValue::Enum(value), ShaderPropertyType::Enum { options }) => { + let index = options + .iter() + .position(|option| option == value) + .ok_or_else(|| format!("enum value `{value}` is not declared by the schema"))?; + Ok(Vec4::new(index as f32, 0.0, 0.0, 0.0)) + } + _ => Err("material value does not match shader schema type".into()), + } +} diff --git a/crates/blacksite_surface/src/surface/property_blocks.rs b/crates/blacksite_surface/src/surface/property_blocks.rs new file mode 100644 index 0000000..1399875 --- /dev/null +++ b/crates/blacksite_surface/src/surface/property_blocks.rs @@ -0,0 +1,523 @@ +use super::*; + +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub(super) fn apply_material_property_blocks( + mut commands: Commands, + asset_server: Res, + owners: Query<&MaterialPropertyBlocks>, + bindings: Query<( + Entity, + &HydratedMaterialSlotBinding, + Option<&MeshMaterial3d>, + Option<&MeshMaterial3d>, + )>, + mut standard_materials: ResMut>, + mut surface_materials: ResMut>, + surface_cache: Res, + mut cache: ResMut, + mut diagnostics: ResMut, +) { + let mut active = HashSet::new(); + for (entity, binding, standard_handle, surface_handle) in &bindings { + let Ok(blocks) = owners.get(binding.owner) else { + continue; + }; + let Some(block) = blocks + .slots + .iter() + .find(|block| block.slot_id == binding.slot_id) + else { + continue; + }; + let revision = property_block_revision( + block, + standard_handle, + surface_handle, + surface_cache.revision_for_handles(standard_handle, surface_handle), + ); + let key = (binding.owner, binding.slot_id.0.clone()); + active.insert(key.clone()); + let cached = cache + .entries + .get(&key) + .filter(|entry| entry.revision == revision); + let handle = if let Some(entry) = cached { + match &entry.handle { + PropertyBlockHandle::Standard(handle) => { + PropertyBlockHandle::Standard(handle.clone()) + } + PropertyBlockHandle::Surface(handle) => { + PropertyBlockHandle::Surface(handle.clone()) + } + } + } else if let Some(source) = surface_handle + .and_then(|handle| surface_materials.get(&handle.0)) + .cloned() + { + let mut material = source; + let application = + apply_standard_property_block(&mut material.base, block, &asset_server, true) + .and_then(|()| { + binding + .selection + .project_reference() + .map_or(Ok(()), |reference| { + apply_surface_property_block( + &mut material, + reference, + block, + &asset_server, + ) + }) + }); + if let Err(error) = application { + record_diagnostic( + &mut diagnostics.0, + format!( + "material property block for {} was ignored: {error}", + binding.slot_id.0 + ), + ); + continue; + } + let handle = cache + .entries + .get(&key) + .and_then(|entry| match &entry.handle { + PropertyBlockHandle::Surface(handle) => Some(handle.clone()), + PropertyBlockHandle::Standard(_) => None, + }); + if let Some(handle) = handle { + if let Some(mut existing) = surface_materials.get_mut(&handle) { + *existing = material; + } + PropertyBlockHandle::Surface(handle) + } else { + PropertyBlockHandle::Surface(surface_materials.add(material)) + } + } else if let Some(source) = standard_handle + .and_then(|handle| standard_materials.get(&handle.0)) + .cloned() + { + let mut material = source; + if let Err(error) = + apply_standard_property_block(&mut material, block, &asset_server, false) + { + record_diagnostic( + &mut diagnostics.0, + format!( + "material property block for {} was ignored: {error}", + binding.slot_id.0 + ), + ); + continue; + } + let handle = cache + .entries + .get(&key) + .and_then(|entry| match &entry.handle { + PropertyBlockHandle::Standard(handle) => Some(handle.clone()), + PropertyBlockHandle::Surface(_) => None, + }); + if let Some(handle) = handle { + if let Some(mut existing) = standard_materials.get_mut(&handle) { + *existing = material; + } + PropertyBlockHandle::Standard(handle) + } else { + PropertyBlockHandle::Standard(standard_materials.add(material)) + } + } else { + continue; + }; + match &handle { + PropertyBlockHandle::Standard(handle) => { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle.clone())); + } + PropertyBlockHandle::Surface(handle) => { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle.clone())); + } + } + cache + .entries + .insert(key, PropertyBlockCacheEntry { revision, handle }); + } + cache.entries.retain(|key, entry| { + if active.contains(key) { + return true; + } + match &entry.handle { + PropertyBlockHandle::Standard(handle) => { + standard_materials.remove(handle.id()); + } + PropertyBlockHandle::Surface(handle) => { + surface_materials.remove(handle.id()); + } + } + false + }); +} + +pub(super) fn property_block_revision( + block: &MaterialPropertyBlock, + standard: Option<&MeshMaterial3d>, + surface: Option<&MeshMaterial3d>, + base_revision: u64, +) -> u64 { + let mut hasher = DefaultHasher::new(); + format!("{block:?}").hash(&mut hasher); + standard.map(|handle| handle.0.id()).hash(&mut hasher); + surface.map(|handle| handle.0.id()).hash(&mut hasher); + base_revision.hash(&mut hasher); + hasher.finish() +} + +pub(super) fn apply_standard_property_block( + material: &mut StandardMaterial, + block: &MaterialPropertyBlock, + asset_server: &AssetServer, + allow_custom: bool, +) -> Result<(), String> { + validate_standard_property_block(block, allow_custom)?; + for parameter in &block.parameters { + match ( + parameter.name.to_ascii_lowercase().as_str(), + ¶meter.value, + ) { + ("base_color" | "albedo", MaterialParameterValue::Color(value)) => { + material.base_color = value.to_color() + } + ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, + ("roughness", MaterialParameterValue::Float(value)) => { + material.perceptual_roughness = *value + } + ("emissive", MaterialParameterValue::Color(value)) => { + material.emissive = value.to_color().into() + } + _ => continue, + } + } + for texture in &block.textures { + let handle = texture + .texture + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .map(asset_server_path) + .map(|path| asset_server.load(path)); + match texture.name.to_ascii_lowercase().as_str() { + "base_color" | "albedo" => material.base_color_texture = handle, + "normal" | "normal_map" => material.normal_map_texture = handle, + "metallic_roughness" => material.metallic_roughness_texture = handle, + "emissive" => material.emissive_texture = handle, + _ => continue, + } + } + Ok(()) +} + +pub(super) fn validate_standard_property_block( + block: &MaterialPropertyBlock, + allow_custom: bool, +) -> Result<(), String> { + validate_standard_property_block_at(Path::new("."), block, allow_custom) +} + +/// Validates a runtime block against the material path selected for editor promotion. +/// +/// The editor passes the resolved project root so relative texture references are checked without +/// relying on process working-directory state. Custom properties are accepted only when the +/// selected material has an active Surface evaluator and its schema declares them. +pub fn validate_property_block_for_promotion( + project_root: &Path, + block: &MaterialPropertyBlock, + schema: Option<&ShaderSchemaAsset>, + custom_surface: bool, +) -> Result<(), String> { + validate_standard_property_block_at(project_root, block, custom_surface)?; + if custom_surface { + validate_surface_property_block(schema, block)?; + } + Ok(()) +} + +pub(super) fn validate_standard_property_block_at( + project_root: &Path, + block: &MaterialPropertyBlock, + allow_custom: bool, +) -> Result<(), String> { + let mut parameter_names = HashSet::new(); + for parameter in &block.parameters { + if !parameter_names.insert(parameter.name.as_str()) { + return Err(format!( + "parameter `{}` is assigned more than once", + parameter.name + )); + } + let name = parameter.name.to_ascii_lowercase(); + let valid = match (name.as_str(), ¶meter.value) { + ("base_color" | "albedo" | "emissive", MaterialParameterValue::Color(value)) => { + [value.r, value.g, value.b, value.a] + .into_iter() + .all(f32::is_finite) + } + ("metallic" | "roughness", MaterialParameterValue::Float(value)) => { + value.is_finite() && (0.0..=1.0).contains(value) + } + ("base_color" | "albedo" | "emissive" | "metallic" | "roughness", _) => false, + _ if allow_custom => true, + _ => { + return Err(format!( + "parameter `{}` is not supported by Standard materials", + parameter.name + )); + } + }; + if !valid { + return Err(format!( + "parameter `{}` has an invalid type or value", + parameter.name + )); + } + } + + let mut texture_names = HashSet::new(); + for texture in &block.textures { + if !texture_names.insert(texture.name.as_str()) { + return Err(format!( + "texture `{}` is assigned more than once", + texture.name + )); + } + let name = texture.name.to_ascii_lowercase(); + if !matches!( + name.as_str(), + "base_color" | "albedo" | "normal" | "normal_map" | "metallic_roughness" | "emissive" + ) && !allow_custom + { + return Err(format!( + "texture `{}` is not supported by Standard materials", + texture.name + )); + } + if let Some(reference) = texture.texture.as_ref() { + let path = reference + .source_path + .as_deref() + .ok_or_else(|| format!("texture `{}` has no loadable source path", texture.name))?; + let path = Path::new(path); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + project_root.join(path) + }; + if !resolved.is_file() { + return Err(format!( + "texture `{}` is missing at {}", + texture.name, + path.display() + )); + } + } + } + Ok(()) +} + +pub(super) fn apply_surface_property_block( + material: &mut SurfaceMaterial, + reference: &MaterialRef, + block: &MaterialPropertyBlock, + asset_server: &AssetServer, +) -> Result<(), String> { + let path = reference + .0 + .source_path + .as_deref() + .ok_or_else(|| "base material has no loadable path".to_string())?; + let (mut asset, mut instance) = match MaterialAsset::load_from_path(path) { + Ok(asset) => (asset, None), + Err(_) => { + let instance = MaterialInstanceAsset::load_from_path(path)?; + let base_path = instance + .base + .0 + .source_path + .as_deref() + .ok_or_else(|| "material instance base has no loadable path".to_string())?; + (MaterialAsset::load_from_path(base_path)?, Some(instance)) + } + }; + let schema_path = asset + .shader_ref + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .or(asset.shader.schema_path.as_deref()); + let schema = schema_path + .map(ShaderSchemaAsset::load_from_path) + .transpose()?; + validate_surface_property_block(schema.as_ref(), block)?; + merge_surface_property_block(&mut asset, &mut instance, block); + if let Some(schema) = schema.as_ref() { + let runtime_catalog = load_runtime_content_catalog(); + pack_schema_values( + &mut material.extension, + schema, + &asset.inputs, + instance.as_ref(), + asset_server, + runtime_catalog.as_ref(), + )?; + } + let mut resolved = MaterialDesc::default(); + asset.inputs.apply_to_material_desc(&mut resolved); + if let Some(instance) = instance.as_ref() { + instance.apply_to(&mut resolved); + } + material.extension.uniform.uv_transforms.fill(Vec4::new( + resolved.uv_tiling.x, + resolved.uv_tiling.y, + resolved.uv_offset.x, + resolved.uv_offset.y, + )); + Ok(()) +} + +pub(super) fn merge_surface_property_block( + asset: &mut MaterialAsset, + instance: &mut Option, + block: &MaterialPropertyBlock, +) { + if let Some(instance) = instance.as_mut() { + instance.merge_overrides(block.parameters.clone(), block.textures.clone()); + } else { + merge_material_input_overrides(&mut asset.inputs, block); + } +} + +pub(super) fn merge_material_input_overrides( + inputs: &mut shared::MaterialInputSet, + block: &MaterialPropertyBlock, +) { + for parameter in &block.parameters { + if let Some(existing) = inputs + .values + .iter_mut() + .find(|value| value.name == parameter.name) + { + *existing = parameter.clone(); + } else { + inputs.values.push(parameter.clone()); + } + } + for texture in &block.textures { + if let Some(existing) = inputs + .textures + .iter_mut() + .find(|value| value.name == texture.name) + { + *existing = texture.clone(); + } else { + inputs.textures.push(texture.clone()); + } + } +} + +pub(super) fn validate_surface_property_block( + schema: Option<&ShaderSchemaAsset>, + block: &MaterialPropertyBlock, +) -> Result<(), String> { + for parameter in &block.parameters { + if matches!( + parameter.name.to_ascii_lowercase().as_str(), + "base_color" | "albedo" | "emissive" | "metallic" | "roughness" + ) { + continue; + } + let property = schema + .and_then(|schema| { + schema + .schema + .inputs + .iter() + .find(|property| property.name == parameter.name) + }) + .ok_or_else(|| { + format!( + "parameter `{}` is not declared by the Surface schema", + parameter.name + ) + })?; + if matches!(property.property_type, ShaderPropertyType::Texture) { + return Err(format!( + "parameter `{}` targets a texture property", + parameter.name + )); + } + parameter_lane(¶meter.value, &property.property_type)?; + if !parameter_value_is_finite(¶meter.value) { + return Err(format!( + "parameter `{}` contains a non-finite value", + parameter.name + )); + } + if let (MaterialParameterValue::Float(value), ShaderPropertyType::Float { min, max }) = + (¶meter.value, &property.property_type) + { + if !value.is_finite() + || min.is_some_and(|min| *value < min) + || max.is_some_and(|max| *value > max) + { + return Err(format!( + "parameter `{}` is outside its declared finite range", + parameter.name + )); + } + } + } + for texture in &block.textures { + if matches!( + texture.name.to_ascii_lowercase().as_str(), + "base_color" | "albedo" | "normal" | "normal_map" | "metallic_roughness" | "emissive" + ) { + continue; + } + let property = schema + .and_then(|schema| { + schema + .schema + .inputs + .iter() + .find(|property| property.name == texture.name) + }) + .ok_or_else(|| { + format!( + "texture `{}` is not declared by the Surface schema", + texture.name + ) + })?; + if !matches!(property.property_type, ShaderPropertyType::Texture) { + return Err(format!( + "texture `{}` targets a non-texture property", + texture.name + )); + } + } + Ok(()) +} + +pub(super) fn parameter_value_is_finite(value: &MaterialParameterValue) -> bool { + match value { + MaterialParameterValue::Bool(_) | MaterialParameterValue::Enum(_) => true, + MaterialParameterValue::Float(value) => value.is_finite(), + MaterialParameterValue::Vec2(value) => value.is_finite(), + MaterialParameterValue::Vec3(value) => value.is_finite(), + MaterialParameterValue::Color(value) => [value.r, value.g, value.b, value.a] + .into_iter() + .all(f32::is_finite), + } +} diff --git a/crates/blacksite_surface/src/surface/resolver.rs b/crates/blacksite_surface/src/surface/resolver.rs new file mode 100644 index 0000000..fc08c4e --- /dev/null +++ b/crates/blacksite_surface/src/surface/resolver.rs @@ -0,0 +1,541 @@ +use super::*; + +pub(super) fn resolve_material_desc( + reference: &MaterialRef, + path: &str, + live_documents: &LiveMaterialDocumentOverlay, +) -> Result { + let (asset, instance) = match live_documents.get(reference) { + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Material(asset), + .. + }) => (asset.clone(), None), + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Instance(instance), + .. + }) => ( + live_base_material(&instance.base, live_documents)?, + Some(instance.clone()), + ), + None => return shared::load_resolved_material_from_path(path).map(|(desc, _)| desc), + }; + let mut resolved = MaterialDesc { + shader: asset.shader.clone(), + ..Default::default() + }; + asset.inputs.apply_to_material_desc(&mut resolved); + if let Some(instance) = instance.as_ref() { + instance.apply_to(&mut resolved); + } + Ok(resolved) +} + +pub(super) fn material_live_revision( + reference: &MaterialRef, + live_documents: &LiveMaterialDocumentOverlay, +) -> Option { + let entry = live_documents.get(reference)?; + let base_revision = match &entry.document { + LiveMaterialDocument::Material(_) => None, + LiveMaterialDocument::Instance(instance) => live_documents + .get(&instance.base) + .map(|base| base.revision.rotate_left(1)), + }; + Some(base_revision.map_or(entry.revision, |base| entry.revision ^ base)) +} + +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub(super) fn sync_surface_material_bindings( + mut commands: Commands, + asset_server: Res, + mut shaders: ResMut>, + mut standard_materials: ResMut>, + mut surface_materials: ResMut>, + mut cache: ResMut, + live_documents: Res, + mut default_grid: ResMut, + content_defaults: Res, + mut diagnostics: ResMut, + mut evaluator_registry: ResMut, + bindings: Query<( + Entity, + &HydratedMaterialSlotBinding, + Option<&MeshMaterial3d>, + Option<&MeshMaterial3d>, + )>, +) { + let default_grid_handle = ensure_default_grid_material( + &mut shaders, + &mut standard_materials, + &mut surface_materials, + &mut default_grid, + &mut evaluator_registry, + ); + for (entity, binding, standard_handle, surface_handle) in &bindings { + if matches!( + binding.selection, + HydratedMaterialSelection::ImportedSource { .. } + ) { + continue; + } + let reference = binding + .selection + .project_reference() + .or(content_defaults.default_material.as_ref()); + let Some(reference) = reference else { + queue_default_grid_binding(&mut commands, entity, default_grid_handle.clone()); + continue; + }; + let Some(path) = reference.0.source_path.as_deref() else { + record_diagnostic( + &mut diagnostics.0, + format!( + "material {} has no loadable source path; using {DEFAULT_GRID_LABEL}", + reference.0.label + ), + ); + queue_default_grid_binding(&mut commands, entity, default_grid_handle.clone()); + continue; + }; + let live_revision = material_live_revision(reference, &live_documents).or_else(|| { + cache + .instance_bases + .get(reference) + .and_then(|base| live_documents.get(base)) + .map(|entry| entry.revision.rotate_left(1)) + }); + let revision = live_revision + .map(|revision| revision | (1_u64 << 63)) + .unwrap_or_else(|| cache.disk_generation()); + if cache.failed_revisions.get(reference) == Some(&revision) { + queue_default_grid_binding(&mut commands, entity, default_grid_handle.clone()); + continue; + } + if cache.standard_only.get(reference) == Some(&revision) { + if let Some(handle) = cache.standard_handles.get(reference).cloned() { + if standard_handle.is_none_or(|current| current.0 != handle) + || surface_handle.is_some() + { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle)); + } + } + continue; + } + let stale = cache.revisions.get(reference) != Some(&revision); + let handle = if !stale { + cache.handles.get(reference).cloned() + } else { + None + }; + let handle = match handle { + Some(handle) => handle, + None => match build_surface_material( + reference, + path, + &live_documents, + &asset_server, + &mut shaders, + &mut evaluator_registry, + ) { + Ok((BuiltRendererMaterial::Surface(material), base_reference)) => { + let material = *material; + if let Some(base_reference) = base_reference { + cache + .instance_bases + .insert(reference.clone(), base_reference); + } else { + cache.instance_bases.remove(reference); + } + cache.standard_only.remove(reference); + cache.standard_handles.remove(reference); + cache.failed_revisions.remove(reference); + if let Some(existing) = cache.handles.get(reference).cloned() { + if let Some(mut slot) = surface_materials.get_mut(&existing) { + *slot = material; + } + existing + } else { + let handle = surface_materials.add(material); + cache.handles.insert(reference.clone(), handle.clone()); + handle + } + } + Ok((BuiltRendererMaterial::Standard(material), base_reference)) => { + let material = *material; + if let Some(base_reference) = base_reference { + cache + .instance_bases + .insert(reference.clone(), base_reference); + } else { + cache.instance_bases.remove(reference); + } + // Plain Material assets stay on Bevy's StandardMaterial path, while still + // sharing one live-updated handle across every renderer slot. + // Never adopt the renderer's current handle here: hydration starts every + // surface on one singleton emergency handle, so adopting it would make + // unrelated Materials overwrite each other. Cache one handle per reference. + let handle = cache + .standard_handles + .get(reference) + .cloned() + .unwrap_or_else(|| standard_materials.add(material.clone())); + if let Some(mut slot) = standard_materials.get_mut(&handle) { + *slot = material; + } + cache.handles.remove(reference); + cache.revisions.remove(reference); + cache + .standard_handles + .insert(reference.clone(), handle.clone()); + cache.standard_only.insert(reference.clone(), revision); + cache.failed_revisions.remove(reference); + evaluator_registry + .evaluators + .remove(&stable_shader_id(reference)); + if standard_handle.is_none_or(|current| current.0 != handle) + || surface_handle.is_some() + { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle)); + } + continue; + } + Err(error) => { + cache.failed_revisions.insert(reference.clone(), revision); + record_diagnostic( + &mut diagnostics.0, + format!( + "material {} could not build Surface ABI: {error}; using {DEFAULT_GRID_LABEL}", + reference.0.label + ), + ); + queue_default_grid_binding(&mut commands, entity, default_grid_handle.clone()); + continue; + } + }, + }; + cache.revisions.insert(reference.clone(), revision); + if surface_handle.is_none_or(|current| current.0 != handle) { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle)); + } + } +} + +pub(super) fn queue_default_grid_binding( + commands: &mut Commands, + entity: Entity, + handle: Handle, +) { + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle)); +} + +pub(super) fn ensure_default_grid_material( + shaders: &mut Assets, + standard_materials: &mut Assets, + surface_materials: &mut Assets, + handles: &mut DefaultGridMaterialHandles, + evaluator_registry: &mut SurfaceEvaluatorRegistry, +) -> Handle { + if let Some(handle) = handles.surface.as_ref() { + return handle.clone(); + } + validate_surface_evaluator(DEFAULT_GRID_EVALUATOR) + .expect("embedded DefaultGrid evaluator must remain valid"); + let shader_id = AssetId::::Uuid { + uuid: DEFAULT_GRID_SHADER_UUID, + }; + shaders + .insert( + shader_id, + Shader::from_wgsl( + compose_surface_shader(DEFAULT_GRID_EVALUATOR), + "builtin://default-grid.wgsl", + ), + ) + .expect("reserved DefaultGrid shader ID must be replaceable"); + evaluator_registry.evaluators.insert( + DEFAULT_GRID_SHADER_ID, + SurfaceEvaluatorRecord { + shader_id: DEFAULT_GRID_SHADER_ID, + evaluator_source: DEFAULT_GRID_EVALUATOR.to_string(), + revision: evaluator_revision(DEFAULT_GRID_EVALUATOR), + alpha_mode: shared::MaterialAlphaMode::Opaque, + }, + ); + let emergency = standard_materials.add(StandardMaterial { + base_color: Color::srgb(0.46, 0.46, 0.46), + perceptual_roughness: 0.86, + cull_mode: None, + ..default() + }); + let material = SurfaceMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 0.86, + cull_mode: None, + ..default() + }, + extension: SurfaceExtension { + uniform: SurfaceUniform { + shader_id: DEFAULT_GRID_SHADER_ID, + ..default() + }, + shader: Handle::Uuid(DEFAULT_GRID_SHADER_UUID, Default::default()), + ..default() + }, + }; + let handle = surface_materials.add(material); + handles.emergency_standard = Some(emergency); + handles.surface = Some(handle.clone()); + handle +} + +pub(super) fn build_surface_material( + reference: &MaterialRef, + path: &str, + live_documents: &LiveMaterialDocumentOverlay, + asset_server: &AssetServer, + shaders: &mut Assets, + evaluator_registry: &mut SurfaceEvaluatorRegistry, +) -> Result<(BuiltRendererMaterial, Option), String> { + let (asset, instance, base_reference) = match live_documents.get(reference) { + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Material(asset), + .. + }) => (asset.clone(), None, None), + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Instance(instance), + .. + }) => { + if instance.schema_version != MATERIAL_INSTANCE_SCHEMA_VERSION { + return Err(format!( + "unsupported material-instance schema {}", + instance.schema_version + )); + } + ( + live_base_material(&instance.base, live_documents)?, + Some(instance.clone()), + Some(instance.base.clone()), + ) + } + None => match MaterialAsset::load_from_path(path) { + Ok(asset) => (asset, None, None), + Err(_) => { + let instance = MaterialInstanceAsset::load_from_path(path)?; + if instance.schema_version != MATERIAL_INSTANCE_SCHEMA_VERSION { + return Err(format!( + "unsupported material-instance schema {}", + instance.schema_version + )); + } + let base_reference = instance.base.clone(); + ( + live_base_material(&base_reference, live_documents)?, + Some(instance), + Some(base_reference), + ) + } + }, + }; + let mut resolved = shared::MaterialDesc { + shader: asset.shader.clone(), + ..Default::default() + }; + asset.inputs.apply_to_material_desc(&mut resolved); + if let Some(instance) = instance.as_ref() { + instance.apply_to(&mut resolved); + } + let runtime_catalog = load_runtime_content_catalog(); + apply_runtime_texture_paths( + &mut resolved, + &asset.inputs, + instance.as_ref(), + reference, + runtime_catalog.as_ref(), + ); + let schema_path = asset + .shader_ref + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .or(asset.shader.schema_path.as_deref()); + let schema = schema_path + .map(ShaderSchemaAsset::load_from_path) + .transpose()?; + let evaluator_source = schema + .as_ref() + .and_then(|schema| schema.wgsl_path.as_deref()) + .map(fs::read_to_string) + .transpose() + .map_err(|error| format!("could not read surface WGSL: {error}"))?; + + // Hydration deliberately attaches the singleton emergency StandardMaterial first so geometry + // stays visible before this resolver runs. Its neutral values must never become the base of an + // assigned project Material. + let runtime_binding = |name: &str| { + instance + .as_ref() + .and_then(|instance| instance.overrides.texture(name)) + .or_else(|| asset.inputs.texture(name)) + }; + let base_color_spec = runtime_binding("base_color") + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| runtime_texture_spec(runtime_catalog.as_ref(), reference, true)); + let normal_spec = runtime_binding("normal") + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| runtime_texture_spec(runtime_catalog.as_ref(), reference, false)); + let emissive_spec = runtime_binding("emissive") + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| runtime_texture_spec(runtime_catalog.as_ref(), reference, true)); + let packed_spec = packed_runtime_texture_spec( + runtime_catalog.as_ref(), + &asset.inputs, + instance.as_ref(), + reference, + )?; + let specs = [base_color_spec, normal_spec, emissive_spec, packed_spec] + .into_iter() + .flatten() + .map(|spec| (spec.path.clone(), spec)) + .collect::>(); + let mut base = + shared::material_from_desc_with_texture_loader(asset_server, &resolved, |path| { + specs.get(path).map_or_else( + || asset_server.load(asset_server_path(path)), + |spec| shared::load_runtime_texture(asset_server, spec), + ) + }); + base.alpha_mode = match asset.render_state.alpha_mode { + shared::MaterialAlphaMode::Opaque => AlphaMode::Opaque, + shared::MaterialAlphaMode::Cutout => AlphaMode::Mask(asset.render_state.alpha_cutoff), + }; + base.cull_mode = + (!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back); + + let Some(evaluator) = evaluator_source.as_deref() else { + return Ok(( + BuiltRendererMaterial::Standard(Box::new(base)), + base_reference, + )); + }; + validate_surface_evaluator(evaluator)?; + let runtime_shader_id = stable_shader_id(reference); + let evaluator_source_revision = evaluator_revision(evaluator); + let shader_changed = evaluator_registry + .evaluators + .get(&runtime_shader_id) + .is_none_or(|record| record.revision != evaluator_source_revision); + evaluator_registry.evaluators.insert( + runtime_shader_id, + SurfaceEvaluatorRecord { + shader_id: runtime_shader_id, + evaluator_source: evaluator.to_string(), + revision: evaluator_source_revision, + alpha_mode: asset.render_state.alpha_mode, + }, + ); + let virtual_path = format!("generated://surface/{}.wgsl", reference.0.asset_id); + let shader_uuid = shader_uuid_for_material(reference); + let shader_id = AssetId::::Uuid { uuid: shader_uuid }; + if shader_changed || shaders.get(shader_id).is_none() { + shaders + .insert( + shader_id, + Shader::from_wgsl(compose_surface_shader(evaluator), virtual_path), + ) + .map_err(|error| format!("could not replace generated surface shader: {error}"))?; + } + let shader = Handle::Uuid(shader_uuid, Default::default()); + + let mut extension = SurfaceExtension { + shader, + ..default() + }; + extension.uniform.alpha_cutoff = asset.render_state.alpha_cutoff; + extension.uniform.shader_id = runtime_shader_id; + if let Some(schema) = schema.as_ref() { + pack_schema_values( + &mut extension, + schema, + &asset.inputs, + instance.as_ref(), + asset_server, + runtime_catalog.as_ref(), + )?; + } + extension.uniform.uv_transforms.fill(Vec4::new( + resolved.uv_tiling.x, + resolved.uv_tiling.y, + resolved.uv_offset.x, + resolved.uv_offset.y, + )); + Ok(( + BuiltRendererMaterial::Surface(Box::new(ExtendedMaterial { base, extension })), + base_reference, + )) +} + +pub(super) fn live_base_material( + reference: &MaterialRef, + live_documents: &LiveMaterialDocumentOverlay, +) -> Result { + match live_documents.get(reference) { + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Material(asset), + .. + }) => Ok(asset.clone()), + Some(LiveMaterialDocumentEntry { + document: LiveMaterialDocument::Instance(_), + .. + }) => Err("nested Material Instances are not supported".to_string()), + None => { + let base_path = reference + .0 + .source_path + .as_deref() + .ok_or_else(|| "material instance base has no loadable path".to_string())?; + MaterialAsset::load_from_path(base_path) + } + } +} + +pub(super) fn stable_shader_id(reference: &MaterialRef) -> u32 { + let mut hash = 0x811c9dc5u32; + for byte in reference.0.asset_id.bytes() { + hash ^= u32::from(byte); + hash = hash.wrapping_mul(0x01000193); + } + hash +} + +pub(super) fn evaluator_revision(source: &str) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in source.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +pub(super) fn shader_uuid_for_material(reference: &MaterialRef) -> uuid::Uuid { + if let Ok(source) = uuid::Uuid::parse_str(&reference.0.asset_id) { + let mixed = source.as_u128() ^ 0x7f8c_4e90_f9ab_4f28_9b44_47c1_b2d5_5ac1u128; + return uuid::Uuid::from_u128(mixed); + } + let mut bytes = [0u8; 16]; + for (index, byte) in reference.0.asset_id.bytes().enumerate() { + bytes[index % 16] = bytes[index % 16].wrapping_mul(31).wrapping_add(byte); + } + uuid::Uuid::from_bytes(bytes) +} diff --git a/crates/blacksite_surface/src/surface/surface_abi.rs b/crates/blacksite_surface/src/surface/surface_abi.rs new file mode 100644 index 0000000..8e35af9 --- /dev/null +++ b/crates/blacksite_surface/src/surface/surface_abi.rs @@ -0,0 +1,131 @@ +use super::*; + +#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] +pub struct SurfaceUniform { + pub shader_id: u32, + pub flags: u32, + pub alpha_cutoff: f32, + pub abi_version: u32, + pub params: [Vec4; SURFACE_PARAMETER_LANES], + pub uv_transforms: [Vec4; SURFACE_TEXTURE_SLOTS], +} + +impl Default for SurfaceUniform { + fn default() -> Self { + Self { + shader_id: 0, + flags: 0, + alpha_cutoff: 0.5, + abi_version: SURFACE_ABI_VERSION, + params: [Vec4::ZERO; SURFACE_PARAMETER_LANES], + uv_transforms: [Vec4::new(1.0, 1.0, 0.0, 0.0); SURFACE_TEXTURE_SLOTS], + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SurfaceExtensionKey { + shader: AssetId, +} + +impl From<&SurfaceExtension> for SurfaceExtensionKey { + fn from(extension: &SurfaceExtension) -> Self { + Self { + shader: extension.shader.id(), + } + } +} + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] +#[bind_group_data(SurfaceExtensionKey)] +pub struct SurfaceExtension { + #[uniform(100)] + pub uniform: SurfaceUniform, + #[texture(101)] + #[sampler(102)] + pub texture0: Option>, + #[texture(103)] + #[sampler(104)] + pub texture1: Option>, + #[texture(105)] + #[sampler(106)] + pub texture2: Option>, + #[texture(107)] + #[sampler(108)] + pub texture3: Option>, + #[texture(109)] + #[sampler(110)] + pub texture4: Option>, + #[texture(111)] + #[sampler(112)] + pub texture5: Option>, + #[texture(113)] + #[sampler(114)] + pub texture6: Option>, + #[texture(115)] + #[sampler(116)] + pub texture7: Option>, + #[reflect(ignore)] + pub shader: Handle, +} + +impl SurfaceExtension { + pub fn set_texture(&mut self, index: usize, handle: Option>) { + match index { + 0 => self.texture0 = handle, + 1 => self.texture1 = handle, + 2 => self.texture2 = handle, + 3 => self.texture3 = handle, + 4 => self.texture4 = handle, + 5 => self.texture5 = handle, + 6 => self.texture6 = handle, + 7 => self.texture7 = handle, + _ => {} + } + } +} + +impl MaterialExtension for SurfaceExtension { + fn fragment_shader() -> ShaderRef { + DEFAULT_SURFACE_SHADER_PATH.into() + } + + fn deferred_fragment_shader() -> ShaderRef { + DEFAULT_SURFACE_SHADER_PATH.into() + } + + fn specialize( + _pipeline: &MaterialExtensionPipeline, + descriptor: &mut RenderPipelineDescriptor, + _layout: &MeshVertexBufferLayoutRef, + key: MaterialExtensionKey, + ) -> Result<(), SpecializedMeshPipelineError> { + if let (AssetId::Uuid { uuid }, Some(fragment)) = + (key.bind_group_data.shader, descriptor.fragment.as_mut()) + { + // The evaluator fragment owns forward and deferred shading, but ordinary depth, + // normal, and motion-vector prepasses must retain Bevy's standard prepass shader. + // Substituting the evaluator there calls `deferred_output()` without a + // `DEFERRED_PREPASS` output attachment and fails shader validation at runtime. + if surface_evaluator_owns_fragment(&fragment.shader_defs) { + fragment.shader = Handle::Uuid(uuid, Default::default()); + } + } + Ok(()) + } +} + +pub(super) fn surface_evaluator_owns_fragment(shader_defs: &[ShaderDefVal]) -> bool { + !shader_def_enabled(shader_defs, "PREPASS_PIPELINE") + || shader_def_enabled(shader_defs, "DEFERRED_PREPASS") +} + +pub(super) fn shader_def_enabled(shader_defs: &[ShaderDefVal], expected: &str) -> bool { + shader_defs.iter().any(|shader_def| match shader_def { + ShaderDefVal::Bool(name, enabled) => name == expected && *enabled, + ShaderDefVal::Int(name, value) => name == expected && *value != 0, + ShaderDefVal::UInt(name, value) => name == expected && *value != 0, + }) +} + +pub type SurfaceMaterial = ExtendedMaterial; diff --git a/crates/blacksite_surface/src/surface/terrain.rs b/crates/blacksite_surface/src/surface/terrain.rs new file mode 100644 index 0000000..7fbd3c0 --- /dev/null +++ b/crates/blacksite_surface/src/surface/terrain.rs @@ -0,0 +1,229 @@ +use super::*; +use bevy::ecs::system::SystemParam; + +#[derive(SystemParam)] +pub(super) struct TerrainMaterialResources<'w> { + asset_server: Res<'w, AssetServer>, + materials: ResMut<'w, Assets>, + cache: ResMut<'w, TerrainLayerMaterialCache>, + surface_cache: Res<'w, SurfaceMaterialCache>, + live_documents: Res<'w, LiveMaterialDocumentOverlay>, + diagnostics: ResMut<'w, SurfaceDiagnostics>, +} + +pub(super) fn sync_terrain_layer_material_bindings( + mut commands: Commands, + resources: TerrainMaterialResources, + bindings: TerrainMaterialBindings, +) { + let TerrainMaterialResources { + asset_server, + mut materials, + mut cache, + surface_cache, + live_documents, + mut diagnostics, + } = resources; + for (entity, binding) in &bindings { + if binding.layers.is_empty() { + continue; + } + let revision = terrain_layer_revision( + &binding.layers, + &live_documents, + surface_cache.disk_generation(), + ); + let cached = cache + .0 + .get(&binding.owner) + .filter(|entry| entry.layers == binding.layers && entry.revision == revision) + .map(|entry| entry.handle.clone()); + let handle = cached.unwrap_or_else(|| { + let material = build_terrain_layer_material( + &asset_server, + &binding.layers, + &live_documents, + &mut diagnostics.0, + ); + let handle = cache + .0 + .get(&binding.owner) + .map(|entry| entry.handle.clone()) + .filter(|handle| { + materials + .get_mut(handle) + .map(|mut slot| { + *slot = material.clone(); + }) + .is_some() + }) + .unwrap_or_else(|| materials.add(material)); + cache.0.insert( + binding.owner, + TerrainLayerCacheEntry { + layers: binding.layers.clone(), + handle: handle.clone(), + revision, + }, + ); + handle + }); + queue_terrain_material_binding(&mut commands, entity, handle); + } +} + +pub(super) fn queue_terrain_material_binding( + commands: &mut Commands, + entity: Entity, + handle: Handle, +) { + // Generated render entities can be removed by a scene switch after this system's query + // but before deferred commands apply. That lifecycle race is expected and must not abort + // the editor while opening another scene. + commands + .entity(entity) + .try_remove::>() + .try_insert(MeshMaterial3d(handle)); +} + +pub(super) fn terrain_layer_revision( + layers: &[TerrainMaterialLayer], + live_documents: &LiveMaterialDocumentOverlay, + disk_generation: u64, +) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for layer in layers { + for byte in layer.uv_scale.to_bits().to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + let Some(reference) = layer.material.as_ref() else { + hash ^= u64::MAX; + continue; + }; + for byte in reference.asset_id.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + let revision = material_live_revision(&MaterialRef(reference.clone()), live_documents) + .unwrap_or(disk_generation); + for byte in revision.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash +} + +pub(super) fn build_terrain_layer_material( + asset_server: &AssetServer, + layers: &[TerrainMaterialLayer], + live_documents: &LiveMaterialDocumentOverlay, + diagnostics: &mut Vec, +) -> TerrainLayerMaterial { + let mut extension = TerrainLayerExtension::default(); + for (index, layer) in layers.iter().take(TERRAIN_MATERIAL_LAYER_LIMIT).enumerate() { + extension.uniform.uv_scales[index] = layer.uv_scale; + let desc = resolve_terrain_layer(layer, index, live_documents, diagnostics); + let color = desc.base_color.to_color().to_linear(); + extension.uniform.base_colors[index] = + Vec4::new(color.red, color.green, color.blue, color.alpha); + extension.uniform.properties[index] = Vec4::new( + desc.metallic, + desc.roughness, + f32::from(desc.normal_map_texture.is_some()), + 1.0, + ); + extension.uniform.base_texture_enabled[index] = + f32::from(desc.base_color_texture.is_some()); + let base_color = desc + .base_color_texture + .as_deref() + .map(|path| asset_server.load(asset_server_path(path))); + let normal = desc + .normal_map_texture + .as_deref() + .map(|path| asset_server.load(asset_server_path(path))); + match index { + 0 => { + extension.base_color0 = base_color; + extension.normal0 = normal; + } + 1 => { + extension.base_color1 = base_color; + extension.normal1 = normal; + } + 2 => { + extension.base_color2 = base_color; + extension.normal2 = normal; + } + 3 => { + extension.base_color3 = base_color; + extension.normal3 = normal; + } + _ => {} + } + } + TerrainLayerMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 1.0, + // Terrain is intentionally absent from the Solari acceleration structure until its + // ray-tracing evaluator exists, so keep the layer blend in the raster forward pass. + opaque_render_method: OpaqueRendererMethod::Forward, + ..default() + }, + extension, + } +} + +pub(super) fn resolve_terrain_layer( + layer: &TerrainMaterialLayer, + index: usize, + live_documents: &LiveMaterialDocumentOverlay, + diagnostics: &mut Vec, +) -> MaterialDesc { + let Some(reference) = layer.material.as_ref() else { + record_diagnostic( + diagnostics, + format!( + "terrain material layer {} is unassigned; using visible fallback", + index + 1 + ), + ); + return terrain_layer_fallback(); + }; + let Some(path) = reference.source_path.as_deref() else { + record_diagnostic( + diagnostics, + format!( + "terrain material layer {} ({}) has no loadable source path; using visible fallback", + index + 1, + reference.label + ), + ); + return terrain_layer_fallback(); + }; + match resolve_material_desc(&MaterialRef(reference.clone()), path, live_documents) { + Ok(desc) => desc, + Err(error) => { + record_diagnostic( + diagnostics, + format!( + "terrain material layer {} ({}) could not resolve: {error}; using visible fallback", + index + 1, + reference.label + ), + ); + terrain_layer_fallback() + } + } +} + +pub(super) fn terrain_layer_fallback() -> MaterialDesc { + MaterialDesc { + base_color: shared::ColorDesc::srgb(0.24, 0.29, 0.25), + roughness: 0.92, + ..Default::default() + } +} diff --git a/crates/blacksite_surface/src/surface/terrain_types.rs b/crates/blacksite_surface/src/surface/terrain_types.rs new file mode 100644 index 0000000..6cab21b --- /dev/null +++ b/crates/blacksite_surface/src/surface/terrain_types.rs @@ -0,0 +1,76 @@ +use super::*; + +#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] +pub struct TerrainLayerUniform { + pub base_colors: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], + /// Metallic, perceptual roughness, normal-map enabled, layer enabled. + pub properties: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], + pub uv_scales: Vec4, + pub base_texture_enabled: Vec4, +} + +impl Default for TerrainLayerUniform { + fn default() -> Self { + Self { + base_colors: [Vec4::ONE; TERRAIN_MATERIAL_LAYER_LIMIT], + properties: [Vec4::new(0.0, 0.9, 0.0, 0.0); TERRAIN_MATERIAL_LAYER_LIMIT], + uv_scales: Vec4::splat(8.0), + base_texture_enabled: Vec4::ZERO, + } + } +} + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] +pub struct TerrainLayerExtension { + #[uniform(100)] + pub uniform: TerrainLayerUniform, + #[texture(101)] + #[sampler(102)] + pub base_color0: Option>, + #[texture(103)] + #[sampler(104)] + pub normal0: Option>, + #[texture(105)] + #[sampler(106)] + pub base_color1: Option>, + #[texture(107)] + #[sampler(108)] + pub normal1: Option>, + #[texture(109)] + #[sampler(110)] + pub base_color2: Option>, + #[texture(111)] + #[sampler(112)] + pub normal2: Option>, + #[texture(113)] + #[sampler(114)] + pub base_color3: Option>, + #[texture(115)] + #[sampler(116)] + pub normal3: Option>, +} + +impl MaterialExtension for TerrainLayerExtension { + fn fragment_shader() -> ShaderRef { + TERRAIN_LAYER_SHADER_PATH.into() + } + + fn deferred_fragment_shader() -> ShaderRef { + TERRAIN_LAYER_SHADER_PATH.into() + } +} + +pub type TerrainLayerMaterial = ExtendedMaterial; + +#[derive(Debug, Clone)] +pub(super) struct TerrainLayerCacheEntry { + pub(super) layers: Vec, + pub(super) handle: Handle, + pub(super) revision: u64, +} + +#[derive(Resource, Default)] +pub(super) struct TerrainLayerMaterialCache(pub(super) HashMap); + +pub(super) type TerrainMaterialBindings<'w, 's> = + Query<'w, 's, (Entity, &'static HydratedTerrainMaterialBinding)>; diff --git a/crates/blacksite_surface/src/surface/tests.rs b/crates/blacksite_surface/src/surface/tests.rs new file mode 100644 index 0000000..64ee292 --- /dev/null +++ b/crates/blacksite_surface/src/surface/tests.rs @@ -0,0 +1,727 @@ +use super::*; +use crate::surface_abi::surface_evaluator_owns_fragment; +use bevy::ecs::world::CommandQueue; + +fn live_material_app() -> App { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_resource::>() + .init_resource::>() + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, sync_surface_material_bindings); + app +} + +fn authored_standard_material(label: &str, color: shared::ColorDesc) -> MaterialAsset { + MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: label.into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + inputs: shared::MaterialInputSet::from_material_desc(&MaterialDesc { + base_color: color, + roughness: 0.4, + ..Default::default() + }), + provenance: None, + } +} + +#[test] +fn stale_generated_terrain_binding_is_nonfatal_during_scene_switch() { + let mut world = World::new(); + let entity = world.spawn_empty().id(); + let mut queue = CommandQueue::default(); + let mut commands = Commands::new(&mut queue, &world); + queue_terrain_material_binding(&mut commands, entity, Handle::default()); + + world.entity_mut(entity).despawn(); + queue.apply(&mut world); + + assert!(world.get_entity(entity).is_err()); +} + +#[test] +fn rejects_resource_bindings_and_missing_entry() { + assert!(validate_surface_evaluator("fn nope() {}").is_err()); + assert!(validate_surface_evaluator( + "@group(0) @binding(0) var t: texture_2d; fn evaluate() {}" + ) + .is_err()); + for compute_incompatible in [ + "dpdx(input.uv0.x)", + "dpdy(input.uv0.y)", + "fwidth(input.uv0.x)", + "discard", + "workgroupBarrier()", + "subgroupAdd(1u)", + ] { + let source = format!( + "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface {{ let invalid = {compute_incompatible}; return surface_default(); }}" + ); + assert!( + validate_surface_evaluator(&source).is_err(), + "accepted compute-incompatible evaluator: {compute_incompatible}" + ); + } + assert!(validate_surface_evaluator( + "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return missing_symbol; }" + ) + .is_err()); +} + +#[test] +fn accepts_surface_only_module_and_composes_wrapper() { + let source = "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return surface_default(); }"; + validate_surface_evaluator(source).unwrap(); + let composed = compose_surface_shader(source); + assert!(composed.contains(source)); + assert!(composed.contains("@fragment")); +} + +#[test] +fn embedded_default_grid_is_uv_independent_and_surface_valid() { + validate_surface_evaluator(DEFAULT_GRID_EVALUATOR).unwrap(); + assert!(DEFAULT_GRID_EVALUATOR.contains("world_position")); + assert!(DEFAULT_GRID_EVALUATOR.contains("0.5")); + assert!(DEFAULT_GRID_EVALUATOR.contains("4.0")); + assert!(!DEFAULT_GRID_EVALUATOR.contains("uv0")); +} + +#[test] +fn assigned_standard_material_replaces_emergency_values() { + let path = std::env::temp_dir().join(format!( + "blacksite-assigned-material-{}-{}.material.ron", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let authored_color = shared::ColorDesc::srgb(0.25, 0.0, 1.0); + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Purple Chrome".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + inputs: shared::MaterialInputSet::from_material_desc(&MaterialDesc { + base_color: authored_color, + metallic: 0.93, + roughness: 0.17, + ..Default::default() + }), + provenance: None, + }; + std::fs::write( + &path, + ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_resource::>() + .init_resource::>() + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, sync_surface_material_bindings); + let emergency = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial { + base_color: Color::srgb(0.46, 0.46, 0.46), + metallic: 0.0, + perceptual_roughness: 0.86, + ..Default::default() + }); + let reference = MaterialRef::new( + shared::EditorAssetRef::new("purple-chrome", "material:source", "Purple Chrome") + .with_source_path(path.to_string_lossy()), + ); + let draw = app + .world_mut() + .spawn(( + HydratedMaterialSlotBinding { + owner: Entity::PLACEHOLDER, + slot_id: shared::ComponentInstanceId::new("slot:primitive:surface"), + selection: HydratedMaterialSelection::Project { + reference, + layer: shared::HydratedMaterialLayer::Actor, + }, + }, + MeshMaterial3d(emergency.clone()), + )) + .id(); + + app.update(); + + let resolved = app + .world() + .get::>(draw) + .unwrap(); + assert_ne!( + resolved.0, emergency, + "assigned Materials must detach from the singleton emergency handle" + ); + let materials = app.world().resource::>(); + let material = materials.get(&resolved.0).unwrap(); + assert_eq!(material.base_color, authored_color.to_color()); + assert_eq!(material.metallic, 0.93); + assert_eq!(material.perceptual_roughness, 0.17); + let emergency_material = materials.get(&emergency).unwrap(); + assert_eq!(emergency_material.base_color, Color::srgb(0.46, 0.46, 0.46)); + assert_eq!(emergency_material.metallic, 0.0); + assert_eq!(emergency_material.perceptual_roughness, 0.86); + assert!(app.world().resource::().0.is_empty()); + + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn live_material_edits_persist_across_frames_and_reuse_the_handle() { + let mut app = live_material_app(); + let reference = MaterialRef::new( + shared::EditorAssetRef::new("live-material", "material:source", "Live Material") + .with_source_path("assets/materials/live_material.ron"), + ); + let first_color = shared::ColorDesc::srgb(0.8, 0.1, 0.05); + app.world_mut() + .resource_mut::() + .update_material( + reference.0.asset_id.clone(), + 1, + authored_standard_material("Live Material", first_color), + ); + let emergency = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial::default()); + let draw = app + .world_mut() + .spawn(( + HydratedMaterialSlotBinding { + owner: Entity::PLACEHOLDER, + slot_id: shared::ComponentInstanceId::new("slot:primitive:surface"), + selection: HydratedMaterialSelection::Project { + reference: reference.clone(), + layer: shared::HydratedMaterialLayer::Actor, + }, + }, + MeshMaterial3d(emergency), + )) + .id(); + + app.update(); + let handle = app + .world() + .get::>(draw) + .unwrap() + .0 + .clone(); + assert_eq!( + app.world() + .resource::>() + .get(&handle) + .unwrap() + .base_color, + first_color.to_color() + ); + + let second_color = shared::ColorDesc::srgb(0.05, 0.15, 0.9); + app.world_mut() + .resource_mut::() + .update_material( + reference.0.asset_id.clone(), + 2, + authored_standard_material("Live Material", second_color), + ); + app.update(); + app.update(); + + assert_eq!( + app.world() + .get::>(draw) + .unwrap() + .0, + handle, + "ordinary live edits must mutate the cached handle instead of rehydrating geometry" + ); + assert_eq!( + app.world() + .resource::>() + .get(&handle) + .unwrap() + .base_color, + second_color.to_color(), + "disk-backed state must not overwrite the live document on a later frame" + ); +} + +#[test] +fn live_base_material_changes_flow_through_direct_instances() { + let mut app = live_material_app(); + let base_reference = MaterialRef::new( + shared::EditorAssetRef::new("live-base", "material:source", "Live Base") + .with_source_path("assets/materials/live_base.ron"), + ); + let instance_reference = MaterialRef::new( + shared::EditorAssetRef::new("live-instance", "material:instance", "Live Instance") + .with_source_path("assets/materials/live_instance.ron"), + ); + let first_color = shared::ColorDesc::srgb(0.7, 0.2, 0.1); + { + let mut overlay = app + .world_mut() + .resource_mut::(); + overlay.update_material( + base_reference.0.asset_id.clone(), + 1, + authored_standard_material("Live Base", first_color), + ); + overlay.update_instance( + instance_reference.0.asset_id.clone(), + 1, + MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Live Instance".into(), + base: base_reference.clone(), + overrides: shared::MaterialInputSet { + values: vec![shared::MaterialParameter { + name: "metallic".into(), + value: MaterialParameterValue::Float(0.75), + }], + textures: Vec::new(), + }, + }, + ); + } + let emergency = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial::default()); + let draw = app + .world_mut() + .spawn(( + HydratedMaterialSlotBinding { + owner: Entity::PLACEHOLDER, + slot_id: shared::ComponentInstanceId::new("slot:body"), + selection: HydratedMaterialSelection::Project { + reference: instance_reference, + layer: shared::HydratedMaterialLayer::Actor, + }, + }, + MeshMaterial3d(emergency), + )) + .id(); + app.update(); + let handle = app + .world() + .get::>(draw) + .unwrap() + .0 + .clone(); + + let second_color = shared::ColorDesc::srgb(0.1, 0.25, 0.85); + app.world_mut() + .resource_mut::() + .update_material( + base_reference.0.asset_id.clone(), + 2, + authored_standard_material("Live Base", second_color), + ); + app.update(); + app.update(); + + let materials = app.world().resource::>(); + let resolved = materials.get(&handle).unwrap(); + assert_eq!(resolved.base_color, second_color.to_color()); + assert_eq!(resolved.metallic, 0.75); + assert_eq!( + app.world() + .get::>(draw) + .unwrap() + .0, + handle + ); +} + +#[test] +fn property_block_revision_is_stable_and_changes_with_values() { + let mut block = MaterialPropertyBlock { + slot_id: shared::ComponentInstanceId::new("slot:body"), + parameters: vec![shared::MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(0.25), + }], + textures: Vec::new(), + }; + let first = property_block_revision(&block, None, None, 7); + assert_eq!(first, property_block_revision(&block, None, None, 7)); + block.parameters[0].value = MaterialParameterValue::Float(0.8); + assert_ne!(first, property_block_revision(&block, None, None, 7)); + assert_ne!(first, property_block_revision(&block, None, None, 8)); +} + +#[test] +fn invalid_standard_property_blocks_are_rejected_before_mutation() { + let mut block = MaterialPropertyBlock { + slot_id: shared::ComponentInstanceId::new("slot:body"), + parameters: vec![shared::MaterialParameter { + name: "unsupported".into(), + value: MaterialParameterValue::Float(0.25), + }], + textures: Vec::new(), + }; + + assert!(validate_standard_property_block(&block, false).is_err()); + assert!(validate_standard_property_block(&block, true).is_ok()); + block.parameters[0] = shared::MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(1.25), + }; + assert!(validate_standard_property_block(&block, false).is_err()); + block.parameters[0].value = MaterialParameterValue::Color(shared::ColorDesc::default()); + assert!(validate_standard_property_block(&block, false).is_err()); +} + +#[test] +fn surface_property_block_overrides_existing_instance_value() { + let property = shared::MaterialParameter { + name: "tint_strength".into(), + value: MaterialParameterValue::Float(0.9), + }; + let block = MaterialPropertyBlock { + slot_id: shared::ComponentInstanceId::new("slot:body"), + parameters: vec![property.clone()], + textures: Vec::new(), + }; + let schema = ShaderSchemaAsset { + schema_version: shared::SURFACE_SHADER_SCHEMA_VERSION, + label: "Tint".into(), + kind: shared::MaterialShaderKind::Custom, + wgsl_path: None, + schema: shared::MaterialInputSchema { + groups: vec![shared::MaterialInputGroupDesc { + id: "surface".into(), + display_name: "Surface".into(), + ..Default::default() + }], + inputs: vec![shared::ShaderPropertyDesc { + name: "tint_strength".into(), + display_name: "Tint strength".into(), + group: "surface".into(), + order: 0, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.0)), + texture: None, + tooltip: String::new(), + advanced: false, + presentation: Default::default(), + }], + }, + }; + validate_standard_property_block(&block, true).unwrap(); + validate_surface_property_block(Some(&schema), &block).unwrap(); + + let mut asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Base".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + inputs: shared::MaterialInputSet::from_material_desc(&MaterialDesc { + parameters: vec![shared::MaterialParameter { + name: "tint_strength".into(), + value: MaterialParameterValue::Float(0.1), + }], + ..Default::default() + }), + provenance: None, + }; + let mut instance = Some(MaterialInstanceAsset { + schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Variant".into(), + base: MaterialRef::new(shared::EditorAssetRef::new( + "base-id", + "material:base", + "Base", + )), + overrides: shared::MaterialInputSet { + values: vec![shared::MaterialParameter { + name: "tint_strength".into(), + value: MaterialParameterValue::Float(0.4), + }], + textures: Vec::new(), + }, + }); + + merge_surface_property_block(&mut asset, &mut instance, &block); + + assert_eq!( + instance.unwrap().overrides.values, + vec![property], + "runtime block must be stronger than the selected Material Instance" + ); + assert_eq!( + asset + .inputs + .values + .iter() + .find(|value| value.name == "tint_strength") + .unwrap() + .value, + MaterialParameterValue::Float(0.1), + "promotion/runtime overrides must not mutate the shared base asset" + ); +} + +#[test] +fn standard_property_blocks_are_owner_local_and_reuse_cached_handles() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin::default()) + .init_resource::>() + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, apply_material_property_blocks); + let base = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial { + perceptual_roughness: 0.5, + ..Default::default() + }); + let slot = shared::ComponentInstanceId::new("slot:body"); + let spawn_owner = |world: &mut World, roughness: f32| { + world + .spawn(MaterialPropertyBlocks { + slots: vec![MaterialPropertyBlock { + slot_id: slot.clone(), + parameters: vec![shared::MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(roughness), + }], + textures: Vec::new(), + }], + }) + .id() + }; + let owner_a = spawn_owner(app.world_mut(), 0.2); + let owner_b = spawn_owner(app.world_mut(), 0.8); + let spawn_draw = |world: &mut World, owner| { + world + .spawn(( + HydratedMaterialSlotBinding { + owner, + slot_id: slot.clone(), + selection: HydratedMaterialSelection::Inherit, + }, + MeshMaterial3d(base.clone()), + )) + .id() + }; + let draw_a = spawn_draw(app.world_mut(), owner_a); + let draw_b = spawn_draw(app.world_mut(), owner_b); + + app.update(); + + let handle_a = app + .world() + .get::>(draw_a) + .unwrap() + .0 + .clone(); + let handle_b = app + .world() + .get::>(draw_b) + .unwrap() + .0 + .clone(); + assert_ne!(handle_a, handle_b); + assert_ne!(handle_a, base); + assert_ne!(handle_b, base); + let materials = app.world().resource::>(); + assert_eq!(materials.get(&base).unwrap().perceptual_roughness, 0.5); + assert_eq!(materials.get(&handle_a).unwrap().perceptual_roughness, 0.2); + assert_eq!(materials.get(&handle_b).unwrap().perceptual_roughness, 0.8); + assert_eq!( + app.world() + .resource::() + .entries + .len(), + 2 + ); + let material_count = materials.len(); + + app.update(); + + assert_eq!( + app.world() + .get::>(draw_a) + .unwrap() + .0, + handle_a + ); + assert_eq!( + app.world() + .get::>(draw_b) + .unwrap() + .0, + handle_b + ); + assert_eq!( + app.world().resource::>().len(), + material_count, + "steady property-block application must not grow material handles" + ); +} + +#[test] +fn invalid_property_block_keeps_base_visible_with_one_diagnostic() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin::default()) + .init_resource::>() + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, apply_material_property_blocks); + let base = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial::default()); + let slot_id = shared::ComponentInstanceId::new("slot:body"); + let owner = app + .world_mut() + .spawn(MaterialPropertyBlocks { + slots: vec![MaterialPropertyBlock { + slot_id: slot_id.clone(), + parameters: vec![shared::MaterialParameter { + name: "unsupported".into(), + value: MaterialParameterValue::Float(0.5), + }], + textures: Vec::new(), + }], + }) + .id(); + let draw = app + .world_mut() + .spawn(( + HydratedMaterialSlotBinding { + owner, + slot_id, + selection: HydratedMaterialSelection::Inherit, + }, + MeshMaterial3d(base.clone()), + )) + .id(); + + app.update(); + app.update(); + + assert_eq!( + app.world() + .get::>(draw) + .unwrap() + .0, + base + ); + let diagnostics = &app.world().resource::().0; + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].contains("unsupported")); +} + +#[test] +fn abi_limits_are_stable() { + assert_eq!(SURFACE_PARAMETER_LANES, 16); + assert_eq!(SURFACE_TEXTURE_SLOTS, 8); + assert_eq!(std::mem::size_of::(), 400); +} + +#[test] +fn material_extensions_leave_non_deferred_prepasses_to_standard_material() { + assert!(matches!( + ::prepass_fragment_shader(), + ShaderRef::Default + )); + assert!(matches!( + ::prepass_fragment_shader(), + ShaderRef::Default + )); + assert!(!surface_evaluator_owns_fragment(&[ + "PREPASS_PIPELINE".into(), + "NORMAL_PREPASS".into(), + ])); + assert!(surface_evaluator_owns_fragment(&[ + "PREPASS_PIPELINE".into(), + "DEFERRED_PREPASS".into(), + ])); + assert!(surface_evaluator_owns_fragment(&[])); +} + +#[test] +fn terrain_layer_builder_packs_project_material_values() { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + let asset_server = app.world().resource::(); + let materials_root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/materials"); + let layers = [ + TerrainMaterialLayer { + material: Some( + shared::EditorAssetRef::new("concrete", "material:source", "Concrete") + .with_source_path(materials_root.join("concrete.ron").display().to_string()), + ), + uv_scale: 6.0, + }, + TerrainMaterialLayer { + material: Some( + shared::EditorAssetRef::new("tint", "material:source", "Surface Tint") + .with_source_path( + materials_root + .join("surface_tint.ron") + .display() + .to_string(), + ), + ), + uv_scale: 10.0, + }, + ]; + let mut diagnostics = Vec::new(); + let live_documents = LiveMaterialDocumentOverlay::default(); + let material = + build_terrain_layer_material(asset_server, &layers, &live_documents, &mut diagnostics); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert_eq!(material.extension.uniform.uv_scales.x, 6.0); + assert_eq!(material.extension.uniform.uv_scales.y, 10.0); + assert_eq!(material.extension.uniform.properties[0].w, 1.0); + assert_eq!(material.extension.uniform.properties[1].w, 1.0); + assert_eq!( + material.base.opaque_render_method, + OpaqueRendererMethod::Forward + ); + assert_ne!( + material.extension.uniform.base_colors[0], + material.extension.uniform.base_colors[1] + ); +} diff --git a/crates/blacksite_surface/src/surface/validation.rs b/crates/blacksite_surface/src/surface/validation.rs new file mode 100644 index 0000000..027ba63 --- /dev/null +++ b/crates/blacksite_surface/src/surface/validation.rs @@ -0,0 +1,81 @@ +use super::*; + +pub fn validate_surface_evaluator(source: &str) -> Result<(), String> { + if !source.contains("fn evaluate(") { + return Err("surface WGSL must define `fn evaluate(`".into()); + } + for forbidden in [ + "@group", + "@binding", + "@vertex", + "@fragment", + "@compute", + "var<", + "rayQuery", + "dpdx", + "dpdy", + "fwidth", + "discard", + "Barrier", + "subgroup", + "textureStore", + "atomic", + ] { + if source.contains(forbidden) { + return Err(format!( + "surface WGSL uses forbidden construct `{forbidden}`" + )); + } + } + let validation_source = format!("{EVALUATOR_VALIDATION_PRELUDE}\n{source}"); + let module = naga::front::wgsl::parse_str(&validation_source) + .map_err(|error| format!("surface WGSL syntax error: {error}"))?; + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::empty(), + ) + .validate(&module) + .map_err(|error| format!("surface WGSL validation error: {error}"))?; + Ok(()) +} + +pub(super) const EVALUATOR_VALIDATION_PRELUDE: &str = r#" +struct SurfaceInput { + uv0: vec2, + world_position: vec3, + world_normal: vec3, +} +struct SurfaceParams { lanes: array, 16>, } +struct SurfaceSamples { values: array, 8>, } +struct Surface { + base_color: vec4, + normal_ts: vec3, + emissive: vec3, + metallic: f32, + perceptual_roughness: f32, + reflectance: f32, + occlusion: f32, + model: u32, +} +fn surface_default() -> Surface { + var surface: Surface; + surface.base_color = vec4(1.0); + surface.normal_ts = vec3(0.0, 0.0, 1.0); + surface.emissive = vec3(0.0); + surface.metallic = 0.0; + surface.perceptual_roughness = 0.5; + surface.reflectance = 0.5; + surface.occlusion = 1.0; + surface.model = 0u; + return surface; +} +"#; + +pub fn compose_surface_shader(evaluator: &str) -> String { + format!( + "{}\n{}\n{}", + include_str!("../surface_header.wgsl"), + evaluator, + include_str!("../surface_footer.wgsl") + ) +} diff --git a/crates/content_pipeline/AGENTS.md b/crates/content_pipeline/AGENTS.md new file mode 100644 index 0000000..44922d5 --- /dev/null +++ b/crates/content_pipeline/AGENTS.md @@ -0,0 +1,12 @@ +# Content pipeline subtree rules + +- This crate owns UI-independent asset, import, catalog, transaction, watcher, and processing + behavior. +- Preserve transaction atomicity, byte-identical rollback, stable identity, reference repair, + derived-artifact ownership, managed-path protection, watcher suppression, and deterministic + restart behavior. +- Prefer focused tests here over heavy editor tests. +- Never depend on the editor crate. +- Keep GPU and rendering requirements out of headless paths. +- A defect in one content operation may justify one bounded sibling-operation invariant audit, not + an unlimited repository audit. diff --git a/crates/content_pipeline/Cargo.toml b/crates/content_pipeline/Cargo.toml new file mode 100644 index 0000000..a754c2f --- /dev/null +++ b/crates/content_pipeline/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "content_pipeline" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "UI-independent Blacksite content discovery, registry, and transaction pipeline" + +[dependencies] +blake3 = "1" +bevy.workspace = true +bevy_ufbx.workspace = true +basis-universal = "0.3.1" +gltf = { version = "1.4", features = ["extras"] } +image = "0.25.10" +notify = "6.1" +ron = "0.8" +serde.workspace = true +serde_json = "1" +shared.workspace = true +walkdir = "2.5" +uuid = { version = "1", features = ["v4"] } +ufbx = "0.9" + +[dev-dependencies] diff --git a/crates/content_pipeline/src/animation.rs b/crates/content_pipeline/src/animation.rs new file mode 100644 index 0000000..21525c8 --- /dev/null +++ b/crates/content_pipeline/src/animation.rs @@ -0,0 +1,912 @@ +//! Generated animation artifacts for imported model sources. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use serde::Deserialize; +use shared::{ + animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationClipRecord, + AnimationDiagnosticSeverity, AnimationEventDesc, AnimationImportDiagnostic, AnimationManifest, + AnimationManifestSource, AnimationSkeletonRecord, AnimationSkeletonSignature, + AnimationSourceFingerprint, ANIMATION_ARTIFACT_DIR, ANIMATION_MANIFEST_SCHEMA_VERSION, +}; + +use crate::fingerprint::write_pretty_ron_if_changed; +use shared::AssetRecord; + +pub fn animation_manifest_path(asset_id: &str) -> String { + format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron") +} + +pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result { + let (manifest, path) = plan_animation_artifact(record)?; + + if write_pretty_ron_if_changed(&path, &manifest) + .map_err(|error| format!("could not publish animation manifest {path}: {error}"))? + { + bevy::log::info!( + "Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}", + record.path, + path, + manifest.skeletons.len(), + manifest.clips.len(), + manifest.runtime_supported + ); + } + + Ok(manifest) +} + +pub fn plan_animation_artifact( + record: &mut AssetRecord, +) -> Result<(AnimationManifest, String), String> { + let mut manifest = build_animation_manifest(record)?; + manifest.source.dependencies.sort(); + manifest.source.dependencies.dedup(); + + let path = animation_manifest_path(&record.id.as_string()); + record.model_import_mut().animation_manifest_path = Some(path.clone()); + record + .dependencies + .extend(manifest.source.dependencies.iter().cloned()); + record.dependencies.sort(); + record.dependencies.dedup(); + Ok((manifest, path)) +} + +pub fn load_animation_manifest(path: &str) -> Result { + let text = + fs::read_to_string(path).map_err(|error| format!("could not read {path}: {error}"))?; + ron::from_str(&text).map_err(|error| format!("could not parse {path}: {error}")) +} + +pub fn build_animation_manifest(record: &AssetRecord) -> Result { + let bytes = fs::read(&record.path) + .map_err(|error| format!("could not read {}: {error}", record.path))?; + let fingerprint = source_fingerprint(&bytes); + let format = source_format(&record.path)?; + match format.as_str() { + "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes), + "fbx" => build_fbx_manifest(record, format, fingerprint, &bytes), + _ => Err(format!("unsupported animation source format `{format}`")), + } +} + +#[derive(Debug, Clone)] +struct SkeletonCandidate { + compatible_nodes: BTreeSet, + signature: AnimationSkeletonSignature, +} + +fn build_gltf_manifest( + record: &AssetRecord, + format: String, + fingerprint: AnimationSourceFingerprint, + bytes: &[u8], +) -> Result { + let gltf = gltf::Gltf::from_slice(bytes) + .map_err(|error| format!("could not parse glTF {}: {error}", record.path))?; + let base = Path::new(&record.path).parent(); + let buffers = gltf::import_buffers(&gltf.document, base, gltf.blob.clone()) + .map_err(|error| format!("could not load glTF buffers for {}: {error}", record.path))?; + + let mut dependencies = gltf + .document + .buffers() + .filter_map(|buffer| match buffer.source() { + gltf::buffer::Source::Uri(uri) if !uri.starts_with("data:") => { + Some(resolve_dependency(&record.path, uri)) + } + _ => None, + }) + .collect::>(); + dependencies.sort(); + dependencies.dedup(); + + let node_identity_names = gltf + .document + .nodes() + .map(|node| bevy_node_segment(node.name(), node.index())) + .collect::>(); + let readable_node_names = gltf + .document + .nodes() + .map(|node| normalized_node_segment(node.name(), node.index())) + .collect::>(); + let mut parents = vec![None; node_identity_names.len()]; + for node in gltf.document.nodes() { + for child in node.children() { + parents[child.index()] = Some(node.index()); + } + } + + let mut skeletons = Vec::new(); + let mut candidates = Vec::new(); + for skin in gltf.document.skins() { + let source_index = skin.index(); + let label = skin + .name() + .filter(|name| !name.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Skeleton {source_index}")); + let joint_indices = skin.joints().map(|joint| joint.index()).collect::>(); + let joint_identity_paths = joint_indices + .iter() + .map(|index| node_path_segments(*index, &node_identity_names, &parents)) + .collect::>(); + let joint_paths = joint_indices + .iter() + .map(|index| node_path(*index, &readable_node_names, &parents)) + .collect::>(); + let bind_poses = skin + .reader(|buffer| Some(buffers[buffer.index()].0.as_slice())) + .read_inverse_bind_matrices() + .map(|matrices| matrices.map(gltf_matrix_bytes).collect::>()) + .unwrap_or_else(|| vec![identity_matrix_bytes(); joint_paths.len()]); + let signature = skeleton_signature(&joint_identity_paths, &bind_poses); + let mut compatible_nodes = joint_indices.into_iter().collect::>(); + if let Some(root) = skin.skeleton() { + compatible_nodes.insert(root.index()); + } + candidates.push(SkeletonCandidate { + compatible_nodes, + signature: signature.clone(), + }); + skeletons.push(AnimationSkeletonRecord { + id: animation_skeleton_sub_asset_id(source_index, &label), + label, + source_index, + signature, + joint_paths, + }); + } + + let mut diagnostics = Vec::new(); + let mut clips = Vec::new(); + let mut animation_roots = BTreeSet::new(); + for animation in gltf.document.animations() { + let source_index = animation.index(); + let label = animation + .name() + .filter(|name| !name.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Animation {source_index}")); + let mut duration_seconds = 0.0_f32; + let mut target_nodes = BTreeSet::new(); + for channel in animation.channels() { + let target_index = channel.target().node().index(); + target_nodes.insert(target_index); + animation_roots.insert(top_level_node(target_index, &parents)); + if let Some(inputs) = channel + .reader(|buffer| Some(buffers[buffer.index()].0.as_slice())) + .read_inputs() + { + for input in inputs { + if input.is_finite() { + duration_seconds = duration_seconds.max(input); + } + } + } + } + let target_skeleton_signature = matching_skeleton_signature(&target_nodes, &candidates); + if !candidates.is_empty() && target_skeleton_signature.is_none() { + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Warning, + code: "animation.clip_skeleton_unresolved".into(), + message: format!( + "clip `{label}` does not target a uniquely identifiable imported skeleton" + ), + repair: "Export the clip with channels targeting one skeleton, or split unrelated rigs into separate glTF assets.".into(), + }); + } + let events = gltf_animation_events(&animation, duration_seconds, &label, &mut diagnostics); + clips.push(AnimationClipRecord { + id: animation_clip_sub_asset_id(source_index, &label), + label, + source_index, + duration_seconds, + target_skeleton_signature, + events, + }); + } + + let default_animation_clip_id = record.model_import().default_animation_clip_id.clone(); + validate_default_animation_clip( + default_animation_clip_id.as_deref(), + &clips, + &mut diagnostics, + ); + + let runtime_supported = animation_roots.len() <= 1; + if !runtime_supported { + let roots = animation_roots + .iter() + .map(|index| format!("`{}` (node {index})", node_identity_names[*index])) + .collect::>() + .join(", "); + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Error, + code: "animation.multiple_roots_unsupported".into(), + message: format!( + "animation channels target {} distinct top-level roots ({roots}); Bevy requires a separate AnimationPlayer for each root", + animation_roots.len() + ), + repair: "Split the source into one animated rig per glTF asset, or export every clip under one common top-level root, then reimport.".into(), + }); + } + + Ok(AnimationManifest { + schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, + asset_id: record.id.as_string(), + label: record.label.clone(), + default_animation_clip_id, + source: AnimationManifestSource { + path: record.path.clone(), + format, + fingerprint, + dependencies, + }, + runtime_supported, + skeletons, + clips, + diagnostics, + }) +} + +fn build_fbx_manifest( + record: &AssetRecord, + format: String, + fingerprint: AnimationSourceFingerprint, + bytes: &[u8], +) -> Result { + let scene = ufbx::load_memory( + bytes, + ufbx::LoadOpts { + target_unit_meters: 1.0, + target_axes: ufbx::CoordinateAxes::right_handed_y_up(), + ..Default::default() + }, + ) + .map_err(|error| format!("could not parse FBX {}: {error:?}", record.path))?; + + let mut skeletons = Vec::new(); + for (source_index, skin) in scene.skin_deformers.as_ref().iter().enumerate() { + let label = if skin.element.name.is_empty() { + format!("Skeleton {source_index}") + } else { + skin.element.name.to_string() + }; + let mut joint_identity_paths = Vec::new(); + let mut joint_paths = Vec::new(); + let mut bind_poses = Vec::new(); + for cluster in skin.clusters.as_ref() { + let Some(bone) = cluster.bone_node.as_ref() else { + continue; + }; + let segments = fbx_node_path_segments(bone.as_ref()); + joint_paths.push(segments.join("/")); + joint_identity_paths.push(segments); + bind_poses.push(fbx_matrix_bytes(&cluster.bind_to_world)); + } + let signature = skeleton_signature(&joint_identity_paths, &bind_poses); + skeletons.push(AnimationSkeletonRecord { + id: animation_skeleton_sub_asset_id(source_index, &label), + label, + source_index, + signature, + joint_paths, + }); + } + + let fallback_signature = (skeletons.len() == 1).then(|| skeletons[0].signature.clone()); + let clips = scene + .anim_stacks + .as_ref() + .iter() + .enumerate() + .map(|(source_index, stack)| { + let label = if stack.element.name.is_empty() { + format!("Animation {source_index}") + } else { + stack.element.name.to_string() + }; + AnimationClipRecord { + id: animation_clip_sub_asset_id(source_index, &label), + label, + source_index, + duration_seconds: (stack.time_end - stack.time_begin).max(0.0) as f32, + target_skeleton_signature: fallback_signature.clone(), + events: Vec::new(), + } + }) + .collect::>(); + + let (runtime_supported, mut diagnostics) = fbx_runtime_support( + scene.anim_stacks.as_ref().len(), + scene.skin_deformers.as_ref().len(), + ); + let default_animation_clip_id = record.model_import().default_animation_clip_id.clone(); + validate_default_animation_clip( + default_animation_clip_id.as_deref(), + &clips, + &mut diagnostics, + ); + + Ok(AnimationManifest { + schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, + asset_id: record.id.as_string(), + label: record.label.clone(), + default_animation_clip_id, + source: AnimationManifestSource { + path: record.path.clone(), + format, + fingerprint, + dependencies: Vec::new(), + }, + runtime_supported, + skeletons, + clips, + diagnostics, + }) +} + +fn validate_default_animation_clip( + default_clip_id: Option<&str>, + clips: &[AnimationClipRecord], + diagnostics: &mut Vec, +) { + let Some(default_clip_id) = default_clip_id.filter(|id| !id.trim().is_empty()) else { + return; + }; + if clips.iter().any(|clip| clip.id == default_clip_id) { + return; + } + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Error, + code: "animation.default_clip_missing".into(), + message: format!( + "configured default animation clip `{default_clip_id}` no longer exists in the imported source" + ), + repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(), + }); +} + +fn fbx_runtime_support( + animation_count: usize, + skin_count: usize, +) -> (bool, Vec) { + let mut diagnostics = Vec::new(); + if animation_count > 0 || skin_count > 0 { + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Error, + code: "animation.fbx_runtime_unsupported".into(), + message: format!( + "FBX contains {animation_count} animation stack(s) and {skin_count} skin deformer(s), but Blacksite's current FBX loader cannot build a runtime skeleton or clips" + ), + repair: "Export the animated source as glTF 2.0 (.glb or .gltf). Keep FBX only for static mesh import until hierarchy, SkinnedMesh, and AnimationClip conversion are implemented.".into(), + }); + } + (false, diagnostics) +} + +#[derive(Debug, Default, Deserialize)] +struct GltfAnimationExtras { + #[serde(default)] + blacksite_events: Vec, +} + +#[derive(Debug, Deserialize)] +struct GltfAnimationEvent { + id: String, + time_seconds: f32, + #[serde(default)] + payload: Option, +} + +fn gltf_animation_events( + animation: &gltf::Animation<'_>, + duration_seconds: f32, + clip_label: &str, + diagnostics: &mut Vec, +) -> Vec { + let Some(raw) = animation.extras().as_ref() else { + return Vec::new(); + }; + let extras = match serde_json::from_str::(raw.get()) { + Ok(extras) => extras, + Err(error) => { + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Warning, + code: "animation.events_invalid".into(), + message: format!("clip `{clip_label}` has invalid animation event metadata: {error}"), + repair: "Use an animation extras object with `blacksite_events` entries containing `id`, `time_seconds`, and optional string `payload`.".into(), + }); + return Vec::new(); + } + }; + + let mut events = Vec::new(); + for event in extras.blacksite_events { + let valid_time = event.time_seconds.is_finite() + && event.time_seconds >= 0.0 + && event.time_seconds <= duration_seconds; + if event.id.trim().is_empty() || !valid_time { + diagnostics.push(AnimationImportDiagnostic { + severity: AnimationDiagnosticSeverity::Warning, + code: "animation.event_out_of_range".into(), + message: format!( + "clip `{clip_label}` contains an event with an empty ID or time outside 0..={duration_seconds:.3}s" + ), + repair: "Give every event a stable non-empty ID and place it within the imported clip duration.".into(), + }); + continue; + } + events.push(AnimationEventDesc { + id: event.id, + time_seconds: event.time_seconds, + payload: event.payload, + }); + } + events.sort_by(|left, right| { + left.time_seconds + .total_cmp(&right.time_seconds) + .then(left.id.cmp(&right.id)) + .then(left.payload.cmp(&right.payload)) + }); + events +} + +fn matching_skeleton_signature( + target_nodes: &BTreeSet, + candidates: &[SkeletonCandidate], +) -> Option { + let scored = candidates + .iter() + .map(|candidate| { + ( + candidate + .compatible_nodes + .intersection(target_nodes) + .count(), + candidate, + ) + }) + .filter(|(score, _)| *score > 0) + .collect::>(); + let best_score = scored.iter().map(|(score, _)| *score).max()?; + let mut best = scored + .into_iter() + .filter(|(score, _)| *score == best_score) + .map(|(_, candidate)| candidate); + let candidate = best.next()?; + best.all(|other| other.signature == candidate.signature) + .then(|| candidate.signature.clone()) +} + +fn node_path(index: usize, names: &[String], parents: &[Option]) -> String { + node_path_segments(index, names, parents).join("/") +} + +fn node_path_segments(index: usize, names: &[String], parents: &[Option]) -> Vec { + let mut segments = Vec::new(); + let mut current = Some(index); + while let Some(node_index) = current { + segments.push(names[node_index].clone()); + current = parents[node_index]; + } + segments.reverse(); + segments +} + +fn top_level_node(index: usize, parents: &[Option]) -> usize { + let mut current = index; + while let Some(parent) = parents[current] { + current = parent; + } + current +} + +fn bevy_node_segment(name: Option<&str>, index: usize) -> String { + name.map(str::to_string) + .unwrap_or_else(|| format!("GltfNode{index}")) +} + +fn normalized_node_segment(name: Option<&str>, index: usize) -> String { + name.map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.replace(['/', '\\'], "_")) + .unwrap_or_else(|| format!("Node{index}")) +} + +fn fbx_node_path_segments(node: &ufbx::Node) -> Vec { + let mut segments = Vec::new(); + let mut current = Some(node); + while let Some(node) = current { + segments.push(normalized_node_segment( + (!node.element.name.is_empty()).then(|| node.element.name.as_ref()), + node.element.element_id as usize, + )); + current = node.parent.as_ref().map(|parent| parent.as_ref()); + } + segments.reverse(); + segments +} + +fn skeleton_signature( + joint_path_segments: &[Vec], + bind_pose_bytes: &[Vec], +) -> AnimationSkeletonSignature { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"blacksite-animation-skeleton-v2\0"); + hasher.update(&(joint_path_segments.len() as u64).to_le_bytes()); + for path in joint_path_segments { + hasher.update(&(path.len() as u64).to_le_bytes()); + for segment in path { + let bytes = segment.as_bytes(); + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + } + hasher.update(&(bind_pose_bytes.len() as u64).to_le_bytes()); + for bytes in bind_pose_bytes { + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + AnimationSkeletonSignature::new(hasher.finalize().to_hex().to_string()) +} + +fn gltf_matrix_bytes(matrix: [[f32; 4]; 4]) -> Vec { + matrix + .into_iter() + .flatten() + .flat_map(f32::to_le_bytes) + .collect() +} + +fn identity_matrix_bytes() -> Vec { + gltf_matrix_bytes([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]) +} + +fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec { + [ + matrix.m00, matrix.m10, matrix.m20, matrix.m01, matrix.m11, matrix.m21, matrix.m02, + matrix.m12, matrix.m22, matrix.m03, matrix.m13, matrix.m23, + ] + .into_iter() + .flat_map(f64::to_le_bytes) + .collect() +} + +fn source_fingerprint(bytes: &[u8]) -> AnimationSourceFingerprint { + AnimationSourceFingerprint::from_bytes(bytes) +} + +fn source_format(path: &str) -> Result { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .ok_or_else(|| format!("asset path `{path}` has no extension")) +} + +fn resolve_dependency(source_path: &str, uri: &str) -> String { + Path::new(source_path) + .parent() + .unwrap_or_else(|| Path::new("")) + .join(uri) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::{AssetId, AssetImportSettings, ImportSettings}; + use uuid::Uuid; + + fn fixture_record(path: &Path) -> AssetRecord { + AssetRecord { + id: AssetId(Uuid::nil()), + path: path.to_string_lossy().replace('\\', "/"), + label: "Animated Hero".into(), + kind: shared::AssetKind::Model, + source_fingerprint: None, + import_settings: AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + } + } + + fn write_animated_gltf(root: &Path) -> std::path::PathBuf { + fs::create_dir_all(root).unwrap(); + let mut buffer = Vec::new(); + for value in [0.0_f32, 1.25] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + fs::write(root.join("animated.bin"), buffer).unwrap(); + let gltf = r#"{ + "asset":{"version":"2.0"}, + "scene":0, + "scenes":[{"nodes":[0]}], + "nodes":[{"name":"Armature","children":[1]},{"name":"Hip"}], + "skins":[{"name":"Hero Rig","skeleton":0,"joints":[1]}], + "buffers":[{"uri":"animated.bin","byteLength":40}], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":8}, + {"buffer":0,"byteOffset":8,"byteLength":32} + ], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.25]}, + {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"} + ], + "animations":[{ + "name":"Idle Loop", + "samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}], + "channels":[{"sampler":0,"target":{"node":1,"path":"rotation"}}], + "extras":{"blacksite_events":[{"id":"footstep.left","time_seconds":0.5,"payload":"stone"}]} + }] + }"#; + let path = root.join("animated.gltf"); + fs::write(&path, gltf).unwrap(); + path + } + + fn write_multi_root_animated_gltf(root: &Path) -> std::path::PathBuf { + fs::create_dir_all(root).unwrap(); + let mut buffer = Vec::new(); + for value in [0.0_f32, 1.0] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + fs::write(root.join("multi-root.bin"), buffer).unwrap(); + let gltf = r#"{ + "asset":{"version":"2.0"}, + "scene":0, + "scenes":[{"nodes":[0,2]}], + "nodes":[ + {"name":"Character A","children":[1]}, + {"name":"Joint A"}, + {"name":"Character B","children":[3]}, + {"name":"Joint B"} + ], + "buffers":[{"uri":"multi-root.bin","byteLength":40}], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":8}, + {"buffer":0,"byteOffset":8,"byteLength":32} + ], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.0]}, + {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"} + ], + "animations":[{ + "name":"Two Players Required", + "samplers":[ + {"input":0,"output":1,"interpolation":"LINEAR"}, + {"input":0,"output":1,"interpolation":"LINEAR"} + ], + "channels":[ + {"sampler":0,"target":{"node":1,"path":"rotation"}}, + {"sampler":1,"target":{"node":3,"path":"rotation"}} + ] + }] + }"#; + let path = root.join("multi-root.gltf"); + fs::write(&path, gltf).unwrap(); + path + } + + #[test] + fn animation_manifest_path_uses_registry_uuid() { + assert_eq!( + animation_manifest_path("abc"), + "assets/animations/generated/abc.animation.ron" + ); + } + + #[test] + fn gltf_extraction_is_deterministic_and_preserves_duration_events_and_signature() { + let root = std::env::temp_dir().join(format!("blacksite-animation-{}", Uuid::new_v4())); + let path = write_animated_gltf(&root); + let record = fixture_record(&path); + + let first = build_animation_manifest(&record).unwrap(); + let second = build_animation_manifest(&record).unwrap(); + + assert_eq!(first, second); + assert_eq!(first.schema_version, ANIMATION_MANIFEST_SCHEMA_VERSION); + assert!(first.default_animation_clip_id.is_none()); + assert!(first.runtime_supported); + assert_eq!(first.source.dependencies.len(), 1); + assert_eq!(first.skeletons.len(), 1); + assert_eq!(first.clips.len(), 1); + assert_eq!(first.skeletons[0].id, "animation:skeleton:0:hero_rig"); + assert!(!first.skeletons[0].signature.is_empty()); + assert_eq!(first.clips[0].id, "animation:clip:0:idle_loop"); + assert_eq!(first.clips[0].duration_seconds, 1.25); + assert_eq!( + first.clips[0].target_skeleton_signature, + Some(first.skeletons[0].signature.clone()) + ); + assert_eq!(first.clips[0].events.len(), 1); + assert_eq!(first.clips[0].events[0].id, "footstep.left"); + assert_eq!(first.clips[0].events[0].time_seconds, 0.5); + assert_eq!(first.diagnostics, Vec::new()); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn legacy_mtime_metadata_does_not_rewrite_equivalent_animation_manifest() { + let root = + std::env::temp_dir().join(format!("blacksite-animation-legacy-{}", Uuid::new_v4())); + let path = write_animated_gltf(&root); + let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); + let canonical = + ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(); + let marker = format!("byte_len: {},", manifest.source.fingerprint.byte_len); + let legacy = canonical.replacen( + &marker, + &format!("{marker}\n modified_unix_secs: 123456,"), + 1, + ); + assert_ne!(legacy, canonical); + let artifact = root.join("legacy.animation.ron"); + fs::write(&artifact, &legacy).unwrap(); + + assert!(!write_pretty_ron_if_changed(&artifact, &manifest).unwrap()); + assert_eq!(fs::read_to_string(&artifact).unwrap(), legacy); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() { + let root = + std::env::temp_dir().join(format!("blacksite-animation-default-{}", Uuid::new_v4())); + let path = write_animated_gltf(&root); + let mut record = fixture_record(&path); + record.model_import_mut().default_animation_clip_id = + Some("animation:clip:0:idle_loop".into()); + + let manifest = build_animation_manifest(&record).unwrap(); + assert_eq!( + manifest.default_animation_clip_id.as_deref(), + Some("animation:clip:0:idle_loop") + ); + assert!(manifest.diagnostics.is_empty()); + + record.model_import_mut().default_animation_clip_id = + Some("animation:clip:99:removed".into()); + let stale = build_animation_manifest(&record).unwrap(); + assert!(stale.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "animation.default_clip_missing" + && diagnostic.severity == AnimationDiagnosticSeverity::Error + })); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exact_signature_changes_with_joint_path_or_bind_pose() { + let identity = identity_matrix_bytes(); + let first = skeleton_signature( + &[vec!["Armature".into(), "Hip".into()]], + std::slice::from_ref(&identity), + ); + let renamed = skeleton_signature( + &[vec!["Armature".into(), "Pelvis".into()]], + std::slice::from_ref(&identity), + ); + let mut changed_pose = identity; + changed_pose[0] ^= 1; + let rebound = skeleton_signature(&[vec!["Armature".into(), "Hip".into()]], &[changed_pose]); + assert_ne!(first, renamed); + assert_ne!(first, rebound); + } + + #[test] + fn exact_signature_preserves_bevy_node_bytes_and_segment_boundaries() { + let identity = identity_matrix_bytes(); + let slash_in_name = vec![vec!["Armature".into(), "Hip/Joint".into()]]; + let slash_as_boundary = vec![vec!["Armature/Hip".into(), "Joint".into()]]; + + assert_eq!(slash_in_name[0].join("/"), slash_as_boundary[0].join("/")); + assert_ne!( + skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)), + skeleton_signature(&slash_as_boundary, std::slice::from_ref(&identity)) + ); + + let backslash = vec![vec!["Armature".into(), "Hip\\Joint".into()]]; + assert_eq!( + normalized_node_segment(Some("Hip/Joint"), 1), + normalized_node_segment(Some("Hip\\Joint"), 1) + ); + assert_ne!( + skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)), + skeleton_signature(&backslash, std::slice::from_ref(&identity)) + ); + assert_eq!(bevy_node_segment(Some(" /Joint "), 7), " /Joint "); + assert_eq!(bevy_node_segment(None, 7), "GltfNode7"); + } + + #[test] + fn gltf_with_multiple_animation_roots_is_runtime_unsupported() { + let root = + std::env::temp_dir().join(format!("blacksite-animation-multi-root-{}", Uuid::new_v4())); + let path = write_multi_root_animated_gltf(&root); + let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); + + assert!(!manifest.runtime_supported); + let diagnostic = manifest + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "animation.multiple_roots_unsupported") + .expect("multi-root glTF should include an actionable blocking diagnostic"); + assert_eq!(diagnostic.severity, AnimationDiagnosticSeverity::Error); + assert!(diagnostic.message.contains("2 distinct top-level roots")); + assert!(diagnostic.repair.contains("one common top-level root")); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn animated_or_skinned_fbx_is_runtime_unsupported_with_repair_guidance() { + let (supported, diagnostics) = fbx_runtime_support(2, 1); + assert!(!supported); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].severity, AnimationDiagnosticSeverity::Error); + assert_eq!(diagnostics[0].code, "animation.fbx_runtime_unsupported"); + assert!(diagnostics[0].repair.contains("glTF 2.0")); + + let (supported, diagnostics) = fbx_runtime_support(0, 0); + assert!(!supported); + assert!(diagnostics.is_empty()); + } + + #[test] + fn committed_robot_fixture_exposes_a_skin_and_multiple_named_states() { + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models/robot_expressive.glb"); + let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); + + assert!(manifest.runtime_supported); + assert_eq!(manifest.skeletons.len(), 2); + assert_eq!(manifest.skeletons[0].id, "animation:skeleton:0:skeleton_0"); + assert_eq!(manifest.skeletons[1].id, "animation:skeleton:1:skeleton_1"); + assert!(manifest + .skeletons + .iter() + .all(|skeleton| skeleton.joint_paths.len() == 43)); + let signature = &manifest.skeletons[0].signature; + assert!(manifest + .skeletons + .iter() + .all(|skeleton| &skeleton.signature == signature)); + assert_eq!(manifest.clips.len(), 14); + for expected in ["Idle", "Walking", "Running", "Jump", "Wave"] { + assert!( + manifest.clips.iter().any(|clip| clip.label == expected), + "missing expected fixture clip {expected}" + ); + } + assert!(manifest + .clips + .iter() + .all(|clip| clip.duration_seconds.is_finite() && clip.duration_seconds > 0.0)); + assert!(manifest + .clips + .iter() + .all(|clip| { clip.target_skeleton_signature.as_ref() == Some(signature) })); + assert!(manifest.diagnostics.is_empty()); + } +} diff --git a/crates/content_pipeline/src/fingerprint.rs b/crates/content_pipeline/src/fingerprint.rs new file mode 100644 index 0000000..8667964 --- /dev/null +++ b/crates/content_pipeline/src/fingerprint.rs @@ -0,0 +1,111 @@ +use std::fs; +use std::path::Path; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use shared::AssetSourceFingerprint; + +pub fn fingerprint_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .map_err(|error| format!("could not read imported source {}: {error}", path.display()))?; + Ok(AssetSourceFingerprint::from_bytes(&bytes)) +} + +/// Writes canonical pretty RON only when the parsed document changes semantically. +/// +/// Equivalent existing bytes, including custom formatting and final-newline policy, stay intact. +pub fn write_pretty_ron_if_changed(path: impl AsRef, value: &T) -> Result +where + T: DeserializeOwned + PartialEq + Serialize, +{ + let path = path.as_ref(); + if fs::read_to_string(path) + .ok() + .and_then(|text| ron::from_str::(&text).ok()) + .is_some_and(|existing| existing == *value) + { + return Ok(false); + } + + let text = ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize RON: {error}"))?; + if fs::read(path).ok().as_deref() == Some(text.as_bytes()) { + return Ok(false); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::write(path, text) + .map_err(|error| format!("could not write {}: {error}", path.display()))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use std::fs::{File, FileTimes}; + use std::time::{Duration, SystemTime}; + use uuid::Uuid; + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + struct Fixture { + count: u32, + label: String, + } + + fn fixture_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "blacksite-fingerprint-{name}-{}.ron", + Uuid::new_v4() + )) + } + + #[test] + fn metadata_only_drift_does_not_change_content_identity() { + let path = fixture_path("mtime"); + fs::write(&path, b"stable source bytes").unwrap(); + let before = fingerprint_file(&path).unwrap(); + File::options() + .write(true) + .open(&path) + .unwrap() + .set_times( + FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)), + ) + .unwrap(); + + assert_eq!(fingerprint_file(&path).unwrap(), before); + fs::remove_file(path).unwrap(); + } + + #[test] + fn same_size_byte_change_updates_content_identity() { + let path = fixture_path("same-size"); + fs::write(&path, b"source-a").unwrap(); + let before = fingerprint_file(&path).unwrap(); + fs::write(&path, b"source-b").unwrap(); + let after = fingerprint_file(&path).unwrap(); + + assert_eq!(before.byte_len, after.byte_len); + assert_ne!(before.content_hash, after.content_hash); + fs::remove_file(path).unwrap(); + } + + #[test] + fn equivalent_ron_preserves_exact_existing_bytes() { + let path = fixture_path("semantic"); + let existing = b"( label: \"stable\", count: 7, )\n\n"; + fs::write(&path, existing).unwrap(); + let value = Fixture { + count: 7, + label: "stable".into(), + }; + + assert!(!write_pretty_ron_if_changed(&path, &value).unwrap()); + assert_eq!(fs::read(&path).unwrap(), existing); + fs::remove_file(path).unwrap(); + } +} diff --git a/crates/content_pipeline/src/import.rs b/crates/content_pipeline/src/import.rs new file mode 100644 index 0000000..0973a9c --- /dev/null +++ b/crates/content_pipeline/src/import.rs @@ -0,0 +1,1497 @@ +//! External asset bundle inspection, conversion, and transactional import. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; + +use bevy_ufbx::texture::{external_texture_paths, external_texture_reference}; +use shared::{ + AssetSourceFingerprint, ColorDesc, MaterialAlphaMode, MaterialAsset, MaterialDesc, + MaterialRenderState, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FbxDependencyInspection { + pub relative_paths: Vec, + pub resolved_paths: Vec, + pub missing_paths: Vec, +} + +/// Parses every external FBX texture reference without loading it through Bevy. +pub fn inspect_fbx_dependencies(source: &Path) -> Result { + let bytes = fs::read(source) + .map_err(|error| format!("could not read FBX {}: {error}", source.display()))?; + let filename_hint = source.to_string_lossy(); + let scene = ufbx::load_memory( + &bytes, + ufbx::LoadOpts { + target_unit_meters: 1.0, + target_axes: ufbx::CoordinateAxes::right_handed_y_up(), + filename: ufbx::StringOpt::Ref(&filename_hint), + ..Default::default() + }, + ) + .map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?; + let relative_paths = external_texture_paths(&scene).map_err(|errors| { + format!( + "unsafe FBX texture reference(s) in {}: {}", + source.display(), + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("; ") + ) + })?; + let source_root = source.parent().unwrap_or_else(|| Path::new("")); + let canonical_root = fs::canonicalize(source_root).map_err(|error| { + format!( + "could not resolve FBX source directory {}: {error}", + source_root.display() + ) + })?; + let mut resolved_paths = Vec::new(); + let mut missing_paths = Vec::new(); + for relative_path in &relative_paths { + let resolved = source_root.join(relative_path); + if !resolved.is_file() { + missing_paths.push(resolved.clone()); + resolved_paths.push(resolved); + continue; + } + let canonical = fs::canonicalize(&resolved).map_err(|error| { + format!( + "could not resolve FBX dependency {}: {error}", + resolved.display() + ) + })?; + if !canonical.starts_with(&canonical_root) { + return Err(format!( + "FBX dependency {} resolves outside source directory {}", + resolved.display(), + source_root.display() + )); + } + resolved_paths.push(resolved); + } + missing_paths.sort(); + Ok(FbxDependencyInspection { + relative_paths, + resolved_paths, + missing_paths, + }) +} + +/// Returns one stable browser-facing error for all missing FBX source textures. +pub fn validate_fbx_dependencies(source: &Path) -> Result<(), String> { + let inspection = inspect_fbx_dependencies(source)?; + if inspection.missing_paths.is_empty() { + return Ok(()); + } + Err(format!( + "missing {} FBX source texture(s): {}", + inspection.missing_paths.len(), + inspection + .missing_paths + .iter() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .collect::>() + .join(", ") + )) +} + +/// Copies an FBX and every referenced external texture as one staged filesystem transaction. +pub fn copy_fbx_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> { + let entries = plan_fbx_bundle(source)?; + copy_bundle_transactionally(&entries, dest_dir) +} + +fn plan_fbx_bundle(source: &Path) -> Result, String> { + let inspection = inspect_fbx_dependencies(source)?; + if !inspection.missing_paths.is_empty() { + return Err(format!( + "FBX import is missing {} required texture(s): {}", + inspection.missing_paths.len(), + inspection + .missing_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )); + } + let file_name = source + .file_name() + .ok_or_else(|| "FBX import path has no file name".to_string())?; + let mut entries = BTreeMap::new(); + entries.insert(PathBuf::from(file_name), source.to_path_buf()); + for (relative, resolved) in inspection + .relative_paths + .iter() + .zip(inspection.resolved_paths.iter()) + { + let relative = PathBuf::from(relative); + if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) { + if existing != *resolved { + return Err(format!( + "FBX import maps multiple source files to {}", + relative.display() + )); + } + } + } + Ok(entries) +} + +/// Copies a textual glTF and all of its external buffers and images as one transaction. +/// Binary GLB files are self-contained and should use the ordinary single-file import path. +pub fn copy_gltf_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> { + let entries = plan_gltf_bundle(source)?; + copy_bundle_transactionally(&entries, dest_dir) +} + +fn plan_gltf_bundle(source: &Path) -> Result, String> { + let document = gltf::Gltf::open(source) + .map_err(|error| format!("could not parse glTF {}: {error}", source.display()))?; + let source_root = source.parent().unwrap_or_else(|| Path::new("")); + let canonical_root = fs::canonicalize(source_root).map_err(|error| { + format!( + "could not resolve glTF source directory {}: {error}", + source_root.display() + ) + })?; + let file_name = source + .file_name() + .ok_or_else(|| "glTF import path has no file name".to_string())?; + let mut entries = BTreeMap::from([(PathBuf::from(file_name), source.to_path_buf())]); + let uris = document + .buffers() + .filter_map(|buffer| match buffer.source() { + gltf::buffer::Source::Uri(uri) => Some(uri), + gltf::buffer::Source::Bin => None, + }) + .chain(document.images().filter_map(|image| match image.source() { + gltf::image::Source::Uri { uri, .. } if !uri.starts_with("data:") => Some(uri), + _ => None, + })); + for uri in uris { + let relative = decode_gltf_uri_path(uri)?; + validate_bundle_relative_path(&relative)?; + let resolved = source_root.join(&relative); + let metadata = fs::symlink_metadata(&resolved).map_err(|error| { + format!( + "missing glTF dependency {} referenced by {}: {error}", + resolved.display(), + source.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "glTF dependency {} must be a regular non-symlink file", + resolved.display() + )); + } + let canonical = fs::canonicalize(&resolved).map_err(|error| { + format!( + "could not resolve glTF dependency {}: {error}", + resolved.display() + ) + })?; + if !canonical.starts_with(&canonical_root) { + return Err(format!( + "glTF dependency {} resolves outside source directory {}", + resolved.display(), + source_root.display() + )); + } + if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) { + if existing != resolved { + return Err(format!( + "glTF import maps multiple source files to {}", + relative.display() + )); + } + } + } + Ok(entries) +} + +pub fn planned_import_targets(source: &Path, destination: &Path) -> Result, String> { + let extension = source + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + let relative_paths = if extension.eq_ignore_ascii_case("fbx") { + plan_fbx_bundle(source)?.into_keys().collect::>() + } else if extension.eq_ignore_ascii_case("gltf") { + plan_gltf_bundle(source)?.into_keys().collect::>() + } else { + vec![PathBuf::from(source.file_name().ok_or_else(|| { + format!("import path has no file name: {}", source.display()) + })?)] + }; + Ok(relative_paths + .into_iter() + .map(|relative| destination.join(relative)) + .collect()) +} + +fn fingerprint_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .map_err(|error| format!("could not read imported source {}: {error}", path.display()))?; + Ok(AssetSourceFingerprint::from_bytes(&bytes)) +} + +pub fn import_external_assets(paths: &[PathBuf]) -> Result { + import_external_assets_to(paths, Path::new(crate::ASSETS_DIRECTORY)) +} + +pub fn import_external_assets_to(paths: &[PathBuf], destination: &Path) -> Result { + let plan = plan_external_assets_import(paths, destination)?; + commit_external_assets_import(&plan) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalAssetImportEntry { + pub source: PathBuf, + pub source_files: Vec<(PathBuf, AssetSourceFingerprint)>, + pub targets: Vec, + pub adopt_in_place: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalAssetImportPlan { + pub project_root: PathBuf, + pub destination: PathBuf, + pub entries: Vec, + pub conflicts: Vec, + /// Reviewed editor registry bytes. Import refuses publication if another process changes them. + pub registry_snapshot: Option>, + /// Reviewed stripped runtime catalog bytes, guarded independently from the source registry. + pub runtime_catalog_snapshot: Option>, +} + +impl ExternalAssetImportPlan { + pub fn imported_count(&self) -> usize { + self.entries.len() + } + + pub fn can_commit(&self) -> bool { + self.conflicts.is_empty() + } +} + +pub fn plan_external_assets_import( + paths: &[PathBuf], + destination: &Path, +) -> Result { + plan_external_assets_import_at(Path::new("."), paths, destination) +} + +pub fn plan_external_assets_import_at( + project_root: &Path, + paths: &[PathBuf], + destination: &Path, +) -> Result { + crate::validate_asset_path(destination)?; + if destination + .components() + .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) + { + return Err("imports cannot target hidden or engine-managed directories".into()); + } + let assets_root = fs::canonicalize(project_root.join(crate::ASSETS_DIRECTORY)) + .map_err(|error| format!("could not resolve project assets root: {error}"))?; + let destination_absolute = project_root.join(destination); + let mut entries = Vec::new(); + let mut planned_targets = BTreeSet::new(); + let mut conflicts = Vec::new(); + for source in paths { + let canonical_source = fs::canonicalize(source) + .map_err(|error| format!("could not resolve import {}: {error}", source.display()))?; + let source_fingerprint = fingerprint_file(&canonical_source)?; + if canonical_source.starts_with(&assets_root) { + let target = canonical_source + .strip_prefix(project_root) + .unwrap_or(&canonical_source) + .to_path_buf(); + entries.push(ExternalAssetImportEntry { + source: source.clone(), + source_files: vec![(canonical_source.clone(), source_fingerprint)], + targets: vec![target], + adopt_in_place: true, + }); + continue; + } + let absolute_targets = planned_import_targets(source, &destination_absolute)?; + let mut targets = Vec::with_capacity(absolute_targets.len()); + for absolute_target in &absolute_targets { + let target = absolute_target + .strip_prefix(project_root) + .map_err(|_| { + format!( + "planned target {} escaped project root {}", + absolute_target.display(), + project_root.display() + ) + })? + .to_path_buf(); + if absolute_target.exists() { + conflicts.push(format!("{} already exists", target.display())); + } + if !planned_targets.insert(target.clone()) { + conflicts.push(format!("multiple sources target {}", target.display())); + } + targets.push(target); + } + let source_parent = source.parent().unwrap_or_else(|| Path::new(".")); + let mut source_files = Vec::with_capacity(targets.len()); + for target in &targets { + let relative = target.strip_prefix(destination).map_err(|_| { + format!( + "planned target {} escaped destination {}", + target.display(), + destination.display() + ) + })?; + let dependency = source_parent.join(relative); + source_files.push((dependency.clone(), fingerprint_file(&dependency)?)); + } + entries.push(ExternalAssetImportEntry { + source: source.clone(), + source_files, + targets, + adopt_in_place: false, + }); + } + conflicts.sort(); + conflicts.dedup(); + Ok(ExternalAssetImportPlan { + project_root: project_root.to_path_buf(), + destination: destination.to_path_buf(), + entries, + conflicts, + registry_snapshot: fs::read(project_root.join(crate::REGISTRY_PATH)).ok(), + runtime_catalog_snapshot: fs::read(project_root.join(crate::RUNTIME_CATALOG_PATH)).ok(), + }) +} + +pub fn commit_external_assets_import(plan: &ExternalAssetImportPlan) -> Result { + if !plan.can_commit() { + return Err(format!( + "import has unresolved conflicts: {}", + plan.conflicts.join("; ") + )); + } + if fs::read(plan.project_root.join(crate::REGISTRY_PATH)) + .ok() + .as_deref() + != plan.registry_snapshot.as_deref() + { + return Err("asset registry changed after import review".into()); + } + if fs::read(plan.project_root.join(crate::RUNTIME_CATALOG_PATH)) + .ok() + .as_deref() + != plan.runtime_catalog_snapshot.as_deref() + { + return Err("runtime content catalog changed after import review".into()); + } + let destination = plan.project_root.join(&plan.destination); + for entry in &plan.entries { + for (source, reviewed) in &entry.source_files { + let current = fingerprint_file(source)?; + if ¤t != reviewed { + return Err(format!( + "import source {} changed after review", + source.display() + )); + } + } + if !entry.adopt_in_place { + for target in &entry.targets { + if plan.project_root.join(target).exists() { + return Err(format!( + "import target {} changed after review", + target.display() + )); + } + } + } + } + let planned_targets = plan + .entries + .iter() + .filter(|entry| !entry.adopt_in_place) + .flat_map(|entry| { + entry + .targets + .iter() + .map(|target| plan.project_root.join(target)) + }) + .collect::>(); + + let mut published = Vec::new(); + let mut created_directories = BTreeSet::new(); + for target in &planned_targets { + let mut parent = target.parent(); + while let Some(directory) = parent { + if directory.exists() || !directory.starts_with(&destination) { + break; + } + created_directories.insert(directory.to_path_buf()); + parent = directory.parent(); + } + } + let result: Result<(), String> = (|| { + for entry in plan.entries.iter().filter(|entry| !entry.adopt_in_place) { + let source = &entry.source; + let targets = &entry.targets; + let extension = source + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + if extension.eq_ignore_ascii_case("fbx") { + copy_fbx_bundle(source, &destination)?; + published.extend(targets.iter().map(|target| plan.project_root.join(target))); + continue; + } + if extension.eq_ignore_ascii_case("gltf") { + copy_gltf_bundle(source, &destination)?; + published.extend(targets.iter().map(|target| plan.project_root.join(target))); + continue; + } + fs::create_dir_all(&destination) + .map_err(|err| format!("could not create {}: {err}", destination.display()))?; + let dest = plan.project_root.join( + targets + .first() + .ok_or_else(|| format!("import {} produced no target", source.display()))?, + ); + copy_file_create_new(source, &dest).map_err(|err| { + format!( + "could not copy {} to {}: {err}", + source.display(), + dest.display() + ) + })?; + published.push(dest); + } + Ok(()) + })(); + if let Err(error) = result { + for target in published.into_iter().rev() { + let _ = fs::remove_file(target); + } + let mut created_directories = created_directories.into_iter().collect::>(); + created_directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + for directory in created_directories { + let _ = fs::remove_dir(directory); + } + return Err(format!("import batch rolled back: {error}")); + } + Ok(plan.imported_count()) +} + +/// Removes only files published by a reviewed external import. In-project adoption is never +/// removed. Empty directories created beneath the selected destination are cleaned deepest-first. +pub fn rollback_external_assets_import(plan: &ExternalAssetImportPlan) -> Result<(), String> { + let destination = plan.project_root.join(&plan.destination); + let mut failures = Vec::new(); + let mut directories = BTreeSet::new(); + let mut targets = plan + .entries + .iter() + .filter(|entry| !entry.adopt_in_place) + .flat_map(|entry| entry.targets.iter()) + .map(|target| plan.project_root.join(target)) + .collect::>(); + targets.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + targets.dedup(); + for target in targets { + if let Some(parent) = target.parent() { + let mut candidate = Some(parent); + while let Some(directory) = candidate { + if !directory.starts_with(&destination) { + break; + } + directories.insert(directory.to_path_buf()); + candidate = directory.parent(); + } + } + if target.exists() { + let result = if target.is_dir() { + fs::remove_dir_all(&target) + } else { + fs::remove_file(&target) + }; + if let Err(error) = result { + failures.push(format!("could not remove {}: {error}", target.display())); + } + } + } + let mut directories = directories.into_iter().collect::>(); + directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + for directory in directories { + if directory != destination { + let _ = fs::remove_dir(&directory); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +fn copy_file_create_new(source: &Path, destination: &Path) -> io::Result { + let mut source = fs::File::open(source)?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + match io::copy(&mut source, &mut output) { + Ok(bytes) => Ok(bytes), + Err(error) => { + drop(output); + let _ = fs::remove_file(destination); + Err(error) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractedGltfMaterial { + pub source_index: usize, + pub source_name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct PlannedGltfMaterial { + pub source_index: usize, + pub source_name: String, + pub path: PathBuf, + pub asset: MaterialAsset, +} + +/// Plans editable project Materials for any model source format whose PBR material contract is +/// understood by the importer. Embedded texture payloads remain source-owned; scalar values and +/// safe external texture references are still converted. +pub fn plan_model_material_extraction( + source: &Path, + destination: &Path, +) -> Result, String> { + match source + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("gltf" | "glb") => plan_gltf_material_extraction(source, destination), + Some("fbx") => plan_fbx_material_extraction(source, destination), + Some(extension) => Err(format!( + "material extraction does not support model format `{extension}`" + )), + None => Err("model source has no file extension".into()), + } +} + +pub fn plan_gltf_material_extraction( + source: &Path, + destination: &Path, +) -> Result, String> { + let document = gltf::Gltf::open(source) + .map_err(|error| format!("could not parse glTF {}: {error}", source.display()))?; + let source_fingerprint = blake3::hash( + &fs::read(source) + .map_err(|error| format!("could not fingerprint {}: {error}", source.display()))?, + ) + .to_hex() + .to_string(); + crate::validate_asset_path(destination)?; + let mut planned = Vec::new(); + let mut reserved = BTreeSet::new(); + for material in document.materials() { + let index = material.index().unwrap_or(planned.len()); + let source_name = material + .name() + .map(str::to_string) + .unwrap_or_else(|| format!("Material {index}")); + let pbr = material.pbr_metallic_roughness(); + let color = pbr.base_color_factor(); + let emissive = material.emissive_factor(); + let parent = source.parent().unwrap_or_else(|| Path::new("")); + let material_desc = MaterialDesc { + base_color: ColorDesc::srgb(color[0], color[1], color[2]), + metallic: pbr.metallic_factor(), + roughness: pbr.roughness_factor(), + emissive_color: ColorDesc::srgb(emissive[0], emissive[1], emissive[2]), + emissive_intensity: if emissive == [0.0, 0.0, 0.0] { + 0.0 + } else { + 1.0 + }, + base_color_texture: pbr + .base_color_texture() + .and_then(|texture| gltf_texture_path(parent, texture.texture())), + normal_map_texture: material + .normal_texture() + .and_then(|texture| gltf_texture_path(parent, texture.texture())), + metallic_roughness_texture: pbr + .metallic_roughness_texture() + .and_then(|texture| gltf_texture_path(parent, texture.texture())), + emissive_texture: material + .emissive_texture() + .and_then(|texture| gltf_texture_path(parent, texture.texture())), + ..Default::default() + }; + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: source_name.clone(), + shader: material_desc.shader.clone(), + shader_ref: None, + render_state: MaterialRenderState { + alpha_mode: match material.alpha_mode() { + gltf::material::AlphaMode::Mask => MaterialAlphaMode::Cutout, + _ => MaterialAlphaMode::Opaque, + }, + alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5), + double_sided: material.double_sided(), + }, + provenance: Some(shared::MaterialProvenance { + source_path: source.to_string_lossy().replace('\\', "/"), + source_fingerprint: source_fingerprint.clone(), + source_sub_asset_id: format!("material:{index}"), + source_label: source_name.clone(), + }), + inputs: shared::MaterialInputSet::from_material_desc(&material_desc), + }; + let stem = safe_material_stem(&source_name); + let path = unique_material_path(destination, &stem, &mut reserved); + planned.push(PlannedGltfMaterial { + source_index: index, + source_name, + path, + asset, + }); + } + Ok(planned) +} + +pub fn plan_fbx_material_extraction( + source: &Path, + destination: &Path, +) -> Result, String> { + let bytes = fs::read(source) + .map_err(|error| format!("could not read FBX {}: {error}", source.display()))?; + let filename_hint = source.to_string_lossy(); + let scene = ufbx::load_memory( + &bytes, + ufbx::LoadOpts { + target_unit_meters: 1.0, + target_axes: ufbx::CoordinateAxes::right_handed_y_up(), + filename: ufbx::StringOpt::Ref(&filename_hint), + ..Default::default() + }, + ) + .map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?; + crate::validate_asset_path(destination)?; + let source_fingerprint = blake3::hash(&bytes).to_hex().to_string(); + let parent = source.parent().unwrap_or_else(|| Path::new("")); + let mut texture_paths = BTreeMap::new(); + for (index, texture) in scene.textures.as_ref().iter().enumerate() { + let reference = external_texture_reference(index, texture) + .map_err(|error| format!("unsafe FBX texture reference: {error}"))?; + if let Some(reference) = reference { + texture_paths.insert( + reference.element_id, + parent + .join(reference.relative_path) + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + + let mut planned = Vec::new(); + let mut reserved = BTreeSet::new(); + for (index, material) in scene.materials.as_ref().iter().enumerate() { + if material.element.element_id == 0 { + continue; + } + let source_name = if material.element.name.is_empty() { + format!("Material {index}") + } else { + material.element.name.to_string() + }; + let base_color = if material.pbr.base_color.has_value { + material.pbr.base_color.value_vec4 + } else { + material.fbx.diffuse_color.value_vec4 + }; + let emission = if material.pbr.emission_color.has_value { + material.pbr.emission_color.value_vec4 + } else { + material.fbx.emission_color.value_vec4 + }; + let metallic = if material.pbr.metalness.has_value { + material.pbr.metalness.value_vec4.x as f32 + } else { + 0.0 + }; + let roughness = if material.pbr.roughness.has_value { + material.pbr.roughness.value_vec4.x as f32 + } else { + MaterialDesc::default().roughness + }; + let mut material_desc = MaterialDesc { + base_color: ColorDesc::srgb( + base_color.x as f32, + base_color.y as f32, + base_color.z as f32, + ), + metallic, + roughness, + emissive_color: ColorDesc::srgb( + emission.x as f32, + emission.y as f32, + emission.z as f32, + ), + emissive_intensity: if emission.x == 0.0 && emission.y == 0.0 && emission.z == 0.0 { + 0.0 + } else { + 1.0 + }, + ..Default::default() + }; + for texture in &material.textures { + let Some(path) = texture_paths.get(&texture.texture.element.element_id) else { + continue; + }; + match texture.material_prop.as_ref() { + "DiffuseColor" | "BaseColor" => { + material_desc.base_color_texture = Some(path.clone()) + } + "NormalMap" => material_desc.normal_map_texture = Some(path.clone()), + "Metallic" => material_desc.metallic_roughness_texture = Some(path.clone()), + "Roughness" if material_desc.metallic_roughness_texture.is_none() => { + material_desc.metallic_roughness_texture = Some(path.clone()) + } + "EmissiveColor" => material_desc.emissive_texture = Some(path.clone()), + _ => {} + } + } + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: source_name.clone(), + shader: material_desc.shader.clone(), + shader_ref: None, + render_state: MaterialRenderState { + // MaterialAsset currently has no blended mode. Preserve visibility and report the + // converted state as opaque instead of pretending a lossy blend is exact. + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: 0.5, + double_sided: material.features.double_sided.enabled, + }, + provenance: Some(shared::MaterialProvenance { + source_path: source.to_string_lossy().replace('\\', "/"), + source_fingerprint: source_fingerprint.clone(), + source_sub_asset_id: format!("material:{index}"), + source_label: source_name.clone(), + }), + inputs: shared::MaterialInputSet::from_material_desc(&material_desc), + }; + let stem = safe_material_stem(&source_name); + let path = unique_material_path(destination, &stem, &mut reserved); + planned.push(PlannedGltfMaterial { + source_index: index, + source_name, + path, + asset, + }); + } + Ok(planned) +} + +/// Converts supported glTF metallic/roughness materials into ordinary editable project assets. +/// Existing files are never overwritten; deterministic numeric suffixes create a new revision. +pub fn extract_gltf_materials( + source: &Path, + destination: &Path, +) -> Result, String> { + let planned = plan_gltf_material_extraction(source, destination)?; + fs::create_dir_all(destination) + .map_err(|error| format!("could not create {}: {error}", destination.display()))?; + let mut extracted = Vec::new(); + let mut published = Vec::new(); + let result: Result<(), String> = (|| { + for material in planned { + let bytes = + ron::ser::to_string_pretty(&material.asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize extracted material: {error}"))?; + fs::write(&material.path, format!("{bytes}\n")).map_err(|error| { + format!("could not publish {}: {error}", material.path.display()) + })?; + published.push(material.path.clone()); + extracted.push(ExtractedGltfMaterial { + source_index: material.source_index, + source_name: material.source_name, + path: material.path, + }); + } + Ok(()) + })(); + if let Err(error) = result { + for path in published { + let _ = fs::remove_file(path); + } + return Err(error); + } + Ok(extracted) +} + +fn gltf_texture_path(parent: &Path, texture: gltf::Texture<'_>) -> Option { + let gltf::image::Source::Uri { uri, .. } = texture.source().source() else { + return None; + }; + let relative = decode_gltf_uri_path(uri).ok()?; + Some(parent.join(relative).to_string_lossy().replace('\\', "/")) +} + +fn safe_material_stem(label: &str) -> String { + let stem = label + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + let stem = stem.trim_matches('_'); + if stem.is_empty() { + "material".into() + } else { + stem.into() + } +} + +fn unique_material_path( + destination: &Path, + stem: &str, + reserved: &mut BTreeSet, +) -> PathBuf { + (1..) + .map(|suffix| { + if suffix == 1 { + destination.join(format!("{stem}.material.ron")) + } else { + destination.join(format!("{stem}_{suffix}.material.ron")) + } + }) + .find(|candidate| !candidate.exists() && reserved.insert(candidate.clone())) + .expect("numeric material suffix space is effectively unbounded") +} + +/// Finds an editable Material previously extracted from one stable source-material slot. +pub fn find_matching_extracted_material( + destination: &Path, + source: &Path, + source_sub_asset_id: &str, +) -> Option { + let normalized_source = source.to_string_lossy().replace('\\', "/"); + let mut existing = fs::read_dir(destination) + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter_map(|path| { + let asset = MaterialAsset::load_from_path(path.to_string_lossy().as_ref()).ok()?; + let provenance = asset.provenance.as_ref()?; + (provenance.source_path == normalized_source + && provenance.source_sub_asset_id == source_sub_asset_id) + .then_some(path) + }) + .collect::>(); + existing.sort(); + existing.into_iter().next() +} + +fn decode_gltf_uri_path(uri: &str) -> Result { + if uri.starts_with("data:") { + return Err("embedded glTF data URI is not an external dependency".into()); + } + if uri.contains(['?', '#']) || uri.contains('\\') { + return Err(format!( + "glTF dependency URI {uri:?} is not a safe file path" + )); + } + let bytes = uri.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let Some(pair) = bytes.get(index + 1..index + 3) else { + return Err(format!( + "glTF dependency URI {uri:?} has invalid percent encoding" + )); + }; + let text = std::str::from_utf8(pair) + .map_err(|_| format!("glTF dependency URI {uri:?} has invalid percent encoding"))?; + decoded.push(u8::from_str_radix(text, 16).map_err(|_| { + format!("glTF dependency URI {uri:?} has invalid percent encoding") + })?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let decoded = String::from_utf8(decoded) + .map_err(|_| format!("glTF dependency URI {uri:?} is not UTF-8"))?; + Ok(PathBuf::from(decoded)) +} + +fn copy_bundle_transactionally( + entries: &BTreeMap, + dest_dir: &Path, +) -> Result<(), String> { + fs::create_dir_all(dest_dir) + .map_err(|error| format!("could not create {}: {error}", dest_dir.display()))?; + let canonical_dest_dir = fs::canonicalize(dest_dir).map_err(|error| { + format!( + "could not resolve import destination {}: {error}", + dest_dir.display() + ) + })?; + for relative in entries.keys() { + validate_bundle_relative_path(relative)?; + validate_existing_destination_parents(dest_dir, &canonical_dest_dir, relative)?; + let target = dest_dir.join(relative); + if target.is_dir() { + return Err(format!( + "import target {} is an existing directory", + target.display() + )); + } + } + let collisions = entries + .keys() + .map(|relative| dest_dir.join(relative)) + .filter(|target| target.exists()) + .collect::>(); + if !collisions.is_empty() { + return Err(format!( + "import collision(s): {}", + collisions + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )); + } + + let stage_root = dest_dir.join(format!(".blacksite-import-{}", uuid::Uuid::new_v4())); + let staged_root = stage_root.join("new"); + let stage_result = (|| { + for (relative, source) in entries { + let staged = staged_root.join(relative); + if let Some(parent) = staged.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "could not create import staging {}: {error}", + parent.display() + ) + })?; + } + fs::copy(source, &staged).map_err(|error| { + format!( + "could not stage bundle file {} as {}: {error}", + source.display(), + relative.display() + ) + })?; + } + commit_staged_bundle(entries, dest_dir, &canonical_dest_dir, &staged_root) + })(); + let cleanup_result = fs::remove_dir_all(&stage_root); + match (stage_result, cleanup_result) { + (Err(error), _) => Err(error), + (Ok(()), Ok(())) => Ok(()), + (Ok(()), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + // The transaction is already committed at this point. A stale hidden staging directory is + // safe to leave for later cleanup and must not turn a successful import into a false + // rollback report. + (Ok(()), Err(_)) => Ok(()), + } +} + +fn validate_bundle_relative_path(relative: &Path) -> Result<(), String> { + if relative.as_os_str().is_empty() + || !relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(format!( + "import target path {} is not a safe relative path", + relative.display() + )); + } + Ok(()) +} + +fn validate_existing_destination_parents( + dest_dir: &Path, + canonical_dest_dir: &Path, + relative: &Path, +) -> Result<(), String> { + let mut current = dest_dir.to_path_buf(); + let Some(parent) = relative.parent() else { + return Ok(()); + }; + for component in parent.components() { + let Component::Normal(segment) = component else { + return Err(format!( + "import target path {} is not a safe relative path", + relative.display() + )); + }; + current.push(segment); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(format!( + "could not inspect import target {}: {error}", + current.display() + )); + } + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "import target parent {} is a symbolic link", + current.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "import target parent {} is not a directory", + current.display() + )); + } + let canonical = fs::canonicalize(¤t).map_err(|error| { + format!( + "could not resolve import target parent {}: {error}", + current.display() + ) + })?; + if !canonical.starts_with(canonical_dest_dir) { + return Err(format!( + "import target parent {} resolves outside destination {}", + current.display(), + dest_dir.display() + )); + } + } + Ok(()) +} + +fn commit_staged_bundle( + entries: &BTreeMap, + dest_dir: &Path, + canonical_dest_dir: &Path, + staged_root: &Path, +) -> Result<(), String> { + let mut installed = Vec::new(); + let backups = Vec::new(); + for relative in entries.keys() { + let target = dest_dir.join(relative); + let staged = staged_root.join(relative); + if let Some(parent) = target.parent() { + if let Err(error) = fs::create_dir_all(parent) { + return rollback_bundle( + &installed, + &backups, + format!( + "could not create import target {}: {error}", + parent.display() + ), + ); + } + let canonical_parent = match fs::canonicalize(parent) { + Ok(canonical_parent) => canonical_parent, + Err(error) => { + return rollback_bundle( + &installed, + &backups, + format!( + "could not resolve import target {}: {error}", + parent.display() + ), + ); + } + }; + if !canonical_parent.starts_with(canonical_dest_dir) { + return rollback_bundle( + &installed, + &backups, + format!( + "import target {} resolves outside destination {}", + parent.display(), + dest_dir.display() + ), + ); + } + } + if let Err(error) = fs::rename(&staged, &target) { + return rollback_bundle( + &installed, + &backups, + format!( + "could not publish import target {}: {error}", + target.display() + ), + ); + } + installed.push(target); + } + Ok(()) +} + +fn rollback_bundle( + installed: &[PathBuf], + backups: &[(PathBuf, PathBuf)], + cause: String, +) -> Result<(), String> { + let mut rollback_errors = Vec::new(); + for target in installed.iter().rev() { + if let Err(error) = fs::remove_file(target) { + if error.kind() != std::io::ErrorKind::NotFound { + rollback_errors.push(format!("remove {}: {error}", target.display())); + } + } + } + for (backup, target) in backups.iter().rev() { + if let Err(error) = fs::rename(backup, target) { + rollback_errors.push(format!( + "restore {} to {}: {error}", + backup.display(), + target.display() + )); + } + } + if rollback_errors.is_empty() { + Err(cause) + } else { + Err(format!( + "{cause}; rollback also failed: {}", + rollback_errors.join("; ") + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!("blacksite-fbx-{label}-{}", uuid::Uuid::new_v4())) + } + + fn committed_chair_bytes() -> Vec { + fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/models/painted_wooden_chair_02_2k.fbx"), + ) + .unwrap() + } + + fn replace_equal_length(bytes: &mut [u8], from: &[u8], to: &[u8]) -> usize { + assert_eq!(from.len(), to.len()); + let mut count = 0; + let mut offset = 0; + while let Some(index) = bytes[offset..] + .windows(from.len()) + .position(|window| window == from) + { + let start = offset + index; + bytes[start..start + from.len()].copy_from_slice(to); + offset = start + from.len(); + count += 1; + } + count + } + + fn write_textures(root: &Path, folder: &str) { + let folder = root.join(folder); + fs::create_dir_all(&folder).unwrap(); + for name in [ + "painted_wooden_chair_02_diff_2k.jpg", + "painted_wooden_chair_02_nor_gl_2k.exr", + "painted_wooden_chair_02_rough_2k.exr", + ] { + fs::write(folder.join(name), name.as_bytes()).unwrap(); + } + } + + #[test] + fn committed_chair_reports_one_stable_missing_dependency_state() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/models/painted_wooden_chair_02_2k.fbx"); + + let error = validate_fbx_dependencies(&path).unwrap_err(); + + assert!(error.starts_with("missing 3 FBX source texture(s):")); + assert_eq!(error.matches("painted_wooden_chair_02_").count(), 3); + } + + #[test] + fn sibling_texture_bundle_is_copied_with_relative_layout() { + let root = temp_root("sibling"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + let source = source_root.join("chair.fbx"); + fs::write(&source, committed_chair_bytes()).unwrap(); + write_textures(&source_root, "textures"); + + copy_fbx_bundle(&source, &destination).unwrap(); + + assert!(destination.join("chair.fbx").is_file()); + assert!(destination + .join("textures/painted_wooden_chair_02_diff_2k.jpg") + .is_file()); + assert!(!destination.read_dir().unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".blacksite-import-"))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn valid_bundle_collision_never_overwrites_existing_content() { + let root = temp_root("collision"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + fs::create_dir_all(&destination).unwrap(); + let source = source_root.join("chair.fbx"); + fs::write(&source, committed_chair_bytes()).unwrap(); + write_textures(&source_root, "textures"); + fs::write(destination.join("chair.fbx"), b"authored existing bytes").unwrap(); + + let error = copy_fbx_bundle(&source, &destination).unwrap_err(); + + assert!(error.contains("import collision")); + assert_eq!( + fs::read(destination.join("chair.fbx")).unwrap(), + b"authored existing bytes" + ); + assert!(!destination.join("textures").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn fbm_texture_bundle_is_copied_with_relative_layout() { + let root = temp_root("fbm"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + let source = source_root.join("chair.fbx"); + let mut bytes = committed_chair_bytes(); + assert!(replace_equal_length(&mut bytes, b"textures/", b"test.fbm/") > 0); + fs::write(&source, bytes).unwrap(); + write_textures(&source_root, "test.fbm"); + + copy_fbx_bundle(&source, &destination).unwrap(); + + assert!(destination + .join("test.fbm/painted_wooden_chair_02_diff_2k.jpg") + .is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn traversal_reference_is_rejected_before_destination_changes() { + let root = temp_root("traversal"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + fs::create_dir_all(&destination).unwrap(); + let source = source_root.join("chair.fbx"); + let mut bytes = committed_chair_bytes(); + assert!(replace_equal_length(&mut bytes, b"textures/", b"../evil//") > 0); + fs::write(&source, bytes).unwrap(); + fs::write(destination.join("chair.fbx"), b"original").unwrap(); + + let error = copy_fbx_bundle(&source, &destination).unwrap_err(); + + assert!(error.contains("parent traversal")); + assert_eq!( + fs::read(destination.join("chair.fbx")).unwrap(), + b"original" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_bundle_preserves_existing_destination() { + let root = temp_root("missing"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + fs::create_dir_all(&destination).unwrap(); + let source = source_root.join("chair.fbx"); + fs::write(&source, committed_chair_bytes()).unwrap(); + fs::write(destination.join("chair.fbx"), b"original").unwrap(); + + let error = copy_fbx_bundle(&source, &destination).unwrap_err(); + + assert!(error.contains("missing 3 required texture(s)")); + assert_eq!( + fs::read(destination.join("chair.fbx")).unwrap(), + b"original" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn destination_symlink_escape_is_rejected_before_external_changes() { + use std::os::unix::fs::symlink; + + let root = temp_root("destination-symlink"); + let source_root = root.join("source"); + let destination = root.join("destination"); + let outside = root.join("outside"); + fs::create_dir_all(&source_root).unwrap(); + fs::create_dir_all(&destination).unwrap(); + fs::create_dir_all(&outside).unwrap(); + let source = source_root.join("chair.fbx"); + fs::write(&source, committed_chair_bytes()).unwrap(); + write_textures(&source_root, "textures"); + symlink(&outside, destination.join("textures")).unwrap(); + + let error = copy_fbx_bundle(&source, &destination).unwrap_err(); + + assert!(error.contains("symbolic link")); + assert!(!destination.join("chair.fbx").exists()); + assert!(!outside.join("painted_wooden_chair_02_diff_2k.jpg").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn gltf_external_dependencies_are_copied_with_relative_layout() { + let root = temp_root("gltf-bundle"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(source_root.join("textures")).unwrap(); + fs::write(source_root.join("mesh.bin"), [0_u8; 12]).unwrap(); + fs::write(source_root.join("textures/base color.png"), b"png").unwrap(); + fs::write( + source_root.join("mesh.gltf"), + r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"mesh.bin","byteLength":12}],"images":[{"uri":"textures/base%20color.png"}]}"#, + ) + .unwrap(); + + copy_gltf_bundle(&source_root.join("mesh.gltf"), &destination).unwrap(); + + assert!(destination.join("mesh.gltf").is_file()); + assert!(destination.join("mesh.bin").is_file()); + assert!(destination.join("textures/base color.png").is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn gltf_traversal_is_rejected_without_destination_changes() { + let root = temp_root("gltf-traversal"); + let source_root = root.join("source"); + let destination = root.join("destination"); + fs::create_dir_all(&source_root).unwrap(); + fs::create_dir_all(&destination).unwrap(); + fs::write( + source_root.join("mesh.gltf"), + r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"../outside.bin","byteLength":4}]}"#, + ) + .unwrap(); + fs::write(root.join("outside.bin"), [0_u8; 4]).unwrap(); + + let error = copy_gltf_bundle(&source_root.join("mesh.gltf"), &destination).unwrap_err(); + + assert!(error.contains("safe relative path")); + assert!(!destination.join("mesh.gltf").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn gltf_pbr_material_extracts_without_overwriting_edits() { + let root = temp_root("gltf-material"); + let source_root = root.join("source"); + let destination = + PathBuf::from("assets").join(format!(".extract-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&source_root).unwrap(); + fs::write( + source_root.join("mesh.gltf"), + r#"{"asset":{"version":"2.0"},"materials":[{"name":"Paint Red","pbrMetallicRoughness":{"baseColorFactor":[0.8,0.1,0.05,1.0],"metallicFactor":0.7,"roughnessFactor":0.3}}]}"#, + ) + .unwrap(); + + let first = extract_gltf_materials(&source_root.join("mesh.gltf"), &destination).unwrap(); + assert_eq!( + find_matching_extracted_material( + &destination, + &source_root.join("mesh.gltf"), + "material:0", + ) + .as_deref(), + Some(first[0].path.as_path()) + ); + let second = extract_gltf_materials(&source_root.join("mesh.gltf"), &destination).unwrap(); + + assert_ne!(first[0].path, second[0].path); + let asset = MaterialAsset::load_from_path(&first[0].path.to_string_lossy()).unwrap(); + assert_eq!(asset.label, "Paint Red"); + let mut material = MaterialDesc::default(); + asset.inputs.apply_to_material_desc(&mut material); + assert_eq!(material.metallic, 0.7); + assert_eq!(material.roughness, 0.3); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(destination).unwrap(); + } + + #[test] + fn fbx_pbr_material_planning_preserves_provenance_and_external_textures() { + let root = temp_root("fbx-material"); + let source_root = root.join("source"); + let destination = + PathBuf::from("assets").join(format!(".extract-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&source_root).unwrap(); + let source = source_root.join("chair.fbx"); + fs::write(&source, committed_chair_bytes()).unwrap(); + + let planned = plan_model_material_extraction(&source, &destination).unwrap(); + + assert!(!planned.is_empty()); + assert!(planned + .iter() + .all(|entry| entry.path.starts_with(&destination))); + assert!(planned.iter().all(|entry| entry + .asset + .provenance + .as_ref() + .is_some_and(|provenance| provenance.source_path.ends_with("chair.fbx") + && provenance.source_sub_asset_id.starts_with("material:")))); + assert!(planned + .iter() + .any(|entry| !entry.asset.inputs.textures.is_empty())); + assert!(!destination.exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/content_pipeline/src/lib.rs b/crates/content_pipeline/src/lib.rs new file mode 100644 index 0000000..c55655d --- /dev/null +++ b/crates/content_pipeline/src/lib.rs @@ -0,0 +1,868 @@ +//! UI-independent project content discovery and publication. + +use serde::{Deserialize, Serialize}; +use shared::{ + AssetKind, AssetRecord, AssetRegistryDocument, AssetSourceFingerprint, ComponentInstanceId, + MaterialImportPolicy, ModelMaterialSelection, ModelMaterialSlotSelection, + RuntimeContentCatalog, +}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use walkdir::{DirEntry, WalkDir}; + +pub mod animation; +mod fingerprint; +mod import; +mod pbr_grouping; +pub mod static_mesh; +mod texture; +mod transaction; +mod trash; +mod watcher; +pub use fingerprint::*; +pub use import::*; +pub use pbr_grouping::*; +pub use texture::*; +pub use transaction::*; +pub use trash::*; +pub use watcher::*; + +pub const ASSETS_DIRECTORY: &str = "assets"; +pub const REGISTRY_PATH: &str = "assets/.index/registry.ron"; +pub const RUNTIME_CATALOG_PATH: &str = "assets/content.catalog.ron"; + +const MANAGED_DIRECTORIES: &[&str] = &[".index", ".thumbnails", ".trash", ".import-cache"]; +const MANAGED_PATH_PREFIXES: &[&str] = &[ + "meshes/generated", + "animations/generated", + "navigation/generated", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContentDiagnostic { + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProcessingReport { + pub discovered: usize, + pub added: usize, + pub moved: usize, + pub removed: usize, + pub changed: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProcessedContent { + pub registry: AssetRegistryDocument, + pub runtime_catalog: RuntimeContentCatalog, + pub report: ProcessingReport, +} + +/// One imported file whose current bytes match more than one missing registry record. Automatic +/// reconciliation must stop so the editor can ask which stable identity actually moved. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExternalMoveConflict { + pub new_path: String, + pub candidate_paths: Vec, + pub kind: AssetKind, + pub fingerprint: AssetSourceFingerprint, +} + +pub fn find_ambiguous_external_moves( + project_root: &Path, + previous: &AssetRegistryDocument, +) -> Result, String> { + let assets_root = project_root.join(ASSETS_DIRECTORY); + if !assets_root.is_dir() { + return Err(format!( + "project has no assets directory: {}", + assets_root.display() + )); + } + let mut discovered = Vec::new(); + for entry in WalkDir::new(&assets_root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| should_descend(entry, &assets_root)) + { + let entry = entry.map_err(|error| format!("content scan failed: {error}"))?; + if !entry.file_type().is_file() { + continue; + } + let kind = classify_asset_file(entry.path()); + if !kind.is_imported_source() { + continue; + } + let relative = entry + .path() + .strip_prefix(project_root) + .map_err(|error| format!("content path escaped project: {error}"))?; + let path = relative.to_string_lossy().replace('\\', "/"); + let bytes = fs::read(entry.path()) + .map_err(|error| format!("could not fingerprint {path}: {error}"))?; + discovered.push((path, kind, AssetSourceFingerprint::from_bytes(&bytes))); + } + let current_paths = discovered + .iter() + .map(|(path, _, _)| path.clone()) + .collect::>(); + let previous_paths = previous + .records + .iter() + .map(|record| record.path.as_str()) + .collect::>(); + let mut conflicts = Vec::new(); + for (new_path, kind, fingerprint) in discovered { + if previous_paths.contains(new_path.as_str()) { + continue; + } + let mut candidate_paths = previous + .records + .iter() + .filter(|record| { + !current_paths.contains(record.path.as_str()) + && record.kind == kind + && record.source_fingerprint.as_ref() == Some(&fingerprint) + }) + .map(|record| record.path.clone()) + .collect::>(); + candidate_paths.sort(); + candidate_paths.dedup(); + if candidate_paths.len() > 1 { + conflicts.push(ExternalMoveConflict { + new_path, + candidate_paths, + kind, + fingerprint, + }); + } + } + conflicts.sort_by(|left, right| left.new_path.cmp(&right.new_path)); + Ok(conflicts) +} + +pub fn is_managed_path(relative_to_assets: &Path) -> bool { + let managed_directory = relative_to_assets + .components() + .next() + .is_some_and(|component| { + let Component::Normal(name) = component else { + return false; + }; + MANAGED_DIRECTORIES + .iter() + .any(|managed| name == OsStr::new(managed)) + }); + let normalized = relative_to_assets.to_string_lossy().replace('\\', "/"); + managed_directory + || MANAGED_PATH_PREFIXES.iter().any(|prefix| { + normalized == *prefix + || normalized + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +/// Validates a user-selected project content path without consulting the filesystem. +pub fn validate_asset_path(path: &Path) -> Result<(), String> { + if path.is_absolute() { + return Err("content paths must be project-relative".into()); + } + let mut components = path.components(); + if components.next() != Some(Component::Normal(OsStr::new(ASSETS_DIRECTORY))) { + return Err("content paths must be located under `assets/`".into()); + } + let remainder = components.collect::(); + if remainder.as_os_str().is_empty() { + return Ok(()); + } + if remainder.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err("content paths cannot escape `assets/`".into()); + } + if is_managed_path(&remainder) { + return Err("engine-managed content directories cannot be edited".into()); + } + Ok(()) +} + +/// Classifies an asset by its filename/schema suffix rather than its containing folder. +pub fn classify_asset(path: &Path) -> AssetKind { + let name = path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if name.ends_with(".material-instance.ron") { + return AssetKind::MaterialInstance; + } + if name.ends_with(".material.ron") { + return AssetKind::Material; + } + if name.ends_with(".scn.ron") { + return AssetKind::Level; + } + if name.ends_with(".prefab.ron") { + return AssetKind::Prefab; + } + if name.ends_with(".post-process.ron") { + return AssetKind::PostProcessEffect; + } + if name.ends_with(".rendering-profile.ron") { + return AssetKind::RenderingProfile; + } + if name.ends_with(".shader-schema.ron") { + return AssetKind::ShaderSchema; + } + match path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "gltf" | "glb" | "fbx" => AssetKind::Model, + "png" | "jpg" | "jpeg" | "webp" | "ktx2" | "dds" | "tga" | "exr" | "hdr" => { + AssetKind::Texture + } + "ogg" | "oga" | "spx" | "wav" | "mp3" | "flac" => AssetKind::AudioClip, + "mat" | "material" => AssetKind::Material, + _ => AssetKind::Unknown, + } +} + +/// Adds schema-based classification for authored RON assets whose filename is user-controlled. +pub fn classify_asset_file(path: &Path) -> AssetKind { + let path_kind = classify_asset(path); + if path_kind != AssetKind::Unknown && !path.to_string_lossy().ends_with(".scn.ron") { + return path_kind; + } + if path.extension().and_then(OsStr::to_str) != Some("ron") { + return path_kind; + } + let Ok(source) = fs::read_to_string(path) else { + return path_kind; + }; + if ron::from_str::(&source).is_ok() { + return AssetKind::MaterialInstance; + } + if ron::from_str::(&source).is_ok() { + return AssetKind::Material; + } + if ron::from_str::(&source).is_ok() { + return AssetKind::ShaderSchema; + } + if ron::from_str::(&source).is_ok() { + return AssetKind::RenderingProfile; + } + if ron::from_str::(&source).is_ok() { + return AssetKind::PostProcessEffect; + } + path_kind +} + +fn should_descend(entry: &DirEntry, assets_root: &Path) -> bool { + if entry.file_type().is_symlink() { + return false; + } + entry + .path() + .strip_prefix(assets_root) + .ok() + .is_none_or(|relative| !is_managed_path(relative)) +} + +pub fn scan_project( + project_root: &Path, + previous: &AssetRegistryDocument, +) -> Result { + let assets_root = project_root.join(ASSETS_DIRECTORY); + if !assets_root.is_dir() { + return Err(format!( + "project has no assets directory: {}", + assets_root.display() + )); + } + + let mut discovered = Vec::new(); + let mut diagnostics = Vec::new(); + let previous_kinds = previous + .records + .iter() + .map(|record| (record.path.as_str(), record.kind)) + .collect::>(); + for entry in WalkDir::new(&assets_root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| should_descend(entry, &assets_root)) + { + let entry = entry.map_err(|error| format!("content scan failed: {error}"))?; + if !entry.file_type().is_file() { + continue; + } + let relative = entry + .path() + .strip_prefix(project_root) + .map_err(|error| format!("content path escaped project: {error}"))?; + let path = relative.to_string_lossy().replace('\\', "/"); + let mut kind = classify_asset_file(entry.path()); + if matches!(kind, AssetKind::Level | AssetKind::Unknown) { + kind = previous_kinds.get(path.as_str()).copied().unwrap_or(kind); + } + if kind == AssetKind::Unknown { + continue; + } + let fingerprint = if kind.is_imported_source() { + match fs::read(entry.path()) { + Ok(bytes) => Some(AssetSourceFingerprint::from_bytes(&bytes)), + Err(error) => { + diagnostics.push(ContentDiagnostic { + path: path.clone(), + message: format!("could not fingerprint imported source: {error}"), + }); + None + } + } + } else { + None + }; + discovered.push((path, kind, fingerprint)); + } + + let mut by_path = previous + .records + .iter() + .cloned() + .map(|record| (record.path.clone(), record)) + .collect::>(); + let current_paths = discovered + .iter() + .map(|(path, _, _)| path.clone()) + .collect::>(); + let mut records = Vec::with_capacity(discovered.len()); + let mut report = ProcessingReport { + discovered: discovered.len(), + diagnostics, + ..Default::default() + }; + + for (path, kind, fingerprint) in discovered { + let label = Path::new(&path) + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("Asset") + .to_string(); + let mut record = by_path.remove(&path); + if record.is_none() { + if let Some(fingerprint) = fingerprint.as_ref() { + let mut candidates = by_path + .iter() + .filter(|(old_path, record)| { + !current_paths.contains(*old_path) + && record.kind == kind + && record.source_fingerprint.as_ref() == Some(fingerprint) + }) + .map(|(old_path, _)| old_path.clone()) + .collect::>(); + candidates.sort(); + match candidates.as_slice() { + [candidate] => record = by_path.remove(candidate), + [] => {} + _ => { + return Err(format!( + "ambiguous external move for {path}: source fingerprint matches {}; resolve the stable identity in the editor Content Browser before refreshing or running headless processing", + candidates.join(", ") + )); + } + } + } + } + if let Some(existing) = record.as_mut() { + if existing.path != path { + report.moved += 1; + } + existing.path = path; + existing.label = label; + existing.kind = kind; + existing.source_fingerprint = fingerprint; + } else { + report.added += 1; + record = Some(AssetRecord { + id: shared::AssetId::new(), + path, + label, + kind, + source_fingerprint: fingerprint, + import_settings: shared::AssetImportSettings::for_kind(kind), + dependencies: Vec::new(), + }); + } + let mut record = record.expect("record is initialized"); + migrate_legacy_model_material_policy(project_root, &mut record, &mut report.diagnostics); + records.push(record); + } + report.removed = by_path.len(); + records.sort_by(|left, right| left.path.cmp(&right.path)); + let registry = AssetRegistryDocument { + schema_version: shared::ASSET_REGISTRY_SCHEMA_VERSION, + defaults: previous.defaults.clone(), + records, + }; + report.changed = ®istry != previous; + let runtime_catalog = RuntimeContentCatalog::from(®istry); + Ok(ProcessedContent { + registry, + runtime_catalog, + report, + }) +} + +#[derive(Deserialize)] +struct ModelManifestSlotView { + #[serde(default)] + parts: Vec, +} + +#[derive(Deserialize)] +struct ModelManifestPartView { + #[serde(default)] + id: String, + #[serde(default)] + material_id: Option, + #[serde(default)] + material_label: Option, +} + +fn migrate_legacy_model_material_policy( + project_root: &Path, + record: &mut AssetRecord, + diagnostics: &mut Vec, +) { + if record.kind != AssetKind::Model { + return; + } + if record.model_import().material_policy != MaterialImportPolicy::AuthoringOverride { + return; + } + let Some(manifest_path) = record.model_import().static_mesh_manifest_path.as_deref() else { + diagnostics.push(ContentDiagnostic { + path: record.path.clone(), + message: "legacy Authoring Override needs a regenerated model manifest before per-slot migration" + .into(), + }); + return; + }; + let path = project_root.join(manifest_path); + let manifest = fs::read_to_string(&path) + .map_err(|error| format!("could not read {}: {error}", path.display())) + .and_then(|source| { + ron::from_str::(&source) + .map_err(|error| format!("could not parse {}: {error}", path.display())) + }); + let manifest = match manifest { + Ok(manifest) => manifest, + Err(message) => { + diagnostics.push(ContentDiagnostic { + path: record.path.clone(), + message, + }); + return; + } + }; + let mut migrated = record.model_import().material_slots.clone(); + for part in manifest.parts { + if part.material_id.is_none() && part.material_label.is_none() { + continue; + } + if part.id.trim().is_empty() { + diagnostics.push(ContentDiagnostic { + path: record.path.clone(), + message: "legacy material policy cannot migrate a model part with no stable ID; regenerate its manifest in the editor" + .into(), + }); + return; + } + let slot_id = ComponentInstanceId::new(format!("slot:{}", part.id)); + if !migrated + .iter() + .any(|selection| selection.slot_id == slot_id) + { + migrated.push(ModelMaterialSlotSelection { + slot_id, + selection: ModelMaterialSelection::Default, + }); + } + } + migrated.sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0)); + record.model_import_mut().material_slots = migrated; + record.model_import_mut().material_policy = MaterialImportPolicy::SourceMaterials; +} + +pub fn serialize_registry(document: &AssetRegistryDocument) -> Result, String> { + ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default()) + .map(|source| source.into_bytes()) + .map_err(|error| format!("could not serialize asset registry: {error}")) +} + +pub fn serialize_runtime_catalog(catalog: &RuntimeContentCatalog) -> Result, String> { + ron::ser::to_string_pretty(catalog, ron::ser::PrettyConfig::default()) + .map(|source| source.into_bytes()) + .map_err(|error| format!("could not serialize runtime catalog: {error}")) +} + +/// Publishes the editor registry and stripped runtime catalog as one rollback-capable pair. +pub fn publish_content_documents( + project_root: &Path, + document: &AssetRegistryDocument, +) -> Result<(), String> { + let runtime = RuntimeContentCatalog::from(document); + publish_content_documents_with_catalog(project_root, document, &runtime) +} + +pub fn publish_content_documents_with_catalog( + project_root: &Path, + document: &AssetRegistryDocument, + runtime: &RuntimeContentCatalog, +) -> Result<(), String> { + let registry_path = project_root.join(REGISTRY_PATH); + let runtime_path = project_root.join(RUNTIME_CATALOG_PATH); + let original_registry = fs::read(®istry_path).ok(); + let original_runtime = fs::read(&runtime_path).ok(); + let registry_bytes = serialize_registry(document)?; + let runtime_bytes = serialize_runtime_catalog(runtime)?; + if let Err(error) = write_if_changed(®istry_path, ®istry_bytes) + .and_then(|_| write_if_changed(&runtime_path, &runtime_bytes)) + { + restore_published_file(®istry_path, original_registry.as_deref()); + restore_published_file(&runtime_path, original_runtime.as_deref()); + return Err(format!("content catalog publication rolled back: {error}")); + } + Ok(()) +} + +fn restore_published_file(path: &Path, bytes: Option<&[u8]>) { + if let Some(bytes) = bytes { + let _ = fs::write(path, bytes); + } else { + let _ = fs::remove_file(path); + } +} + +pub fn write_if_changed(path: &Path, bytes: &[u8]) -> Result { + if fs::read(path).ok().as_deref() == Some(bytes) { + return Ok(false); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + let temporary = path.with_extension("tmp"); + fs::write(&temporary, bytes) + .map_err(|error| format!("could not stage {}: {error}", path.display()))?; + fs::rename(&temporary, path) + .map_err(|error| format!("could not publish {}: {error}", path.display()))?; + Ok(true) +} + +#[derive(Debug, Clone)] +pub struct PlannedModelArtifacts { + pub record: shared::AssetRecord, + pub static_manifest: static_mesh::StaticMeshManifest, + pub static_path: std::path::PathBuf, + pub static_bytes: Vec, + pub animation_path: std::path::PathBuf, + pub animation_bytes: Vec, +} + +pub fn plan_model_artifacts(record: &shared::AssetRecord) -> Result { + plan_model_artifacts_at(std::path::Path::new("."), record) +} + +pub fn plan_model_artifacts_at( + project_root: &std::path::Path, + record: &shared::AssetRecord, +) -> Result { + let source_path = record.path.clone(); + let mut planned_record = record.clone(); + planned_record.path = project_root + .join(&source_path) + .to_string_lossy() + .into_owned(); + let (static_manifest, static_path) = + static_mesh::plan_static_mesh_artifact(&mut planned_record) + .map_err(|error| format!("static mesh processor failed: {error}"))?; + let (mut animation_manifest, animation_path) = + animation::plan_animation_artifact(&mut planned_record) + .map_err(|error| format!("animation processor failed: {error}"))?; + let mut static_manifest = static_manifest; + static_manifest.source.path.clone_from(&source_path); + animation_manifest.source.path.clone_from(&source_path); + for dependency in &mut static_manifest.source.dependencies { + *dependency = project_catalog_path(project_root, dependency); + } + for dependency in &mut animation_manifest.source.dependencies { + *dependency = project_catalog_path(project_root, dependency); + } + planned_record.path = source_path; + planned_record.dependencies = static_manifest.source.dependencies.clone(); + planned_record + .dependencies + .extend(animation_manifest.source.dependencies.iter().cloned()); + planned_record.dependencies.sort(); + planned_record.dependencies.dedup(); + let pretty = ron::ser::PrettyConfig::default(); + let static_bytes = ron::ser::to_string_pretty(&static_manifest, pretty.clone()) + .map(|source| format!("{source}\n").into_bytes()) + .map_err(|error| format!("could not serialize static mesh artifact: {error}"))?; + let animation_bytes = ron::ser::to_string_pretty(&animation_manifest, pretty) + .map(|source| format!("{source}\n").into_bytes()) + .map_err(|error| format!("could not serialize animation artifact: {error}"))?; + Ok(PlannedModelArtifacts { + record: planned_record, + static_manifest, + static_path: project_root.join(static_path), + static_bytes, + animation_path: project_root.join(animation_path), + animation_bytes, + }) +} + +fn project_catalog_path(project_root: &std::path::Path, path: &str) -> String { + let path = std::path::Path::new(path); + let relative = path.strip_prefix(project_root).unwrap_or(path); + relative.to_string_lossy().replace('\\', "/") +} + +pub fn publish_model_artifacts(plan: &PlannedModelArtifacts) -> Result<(), String> { + let original_static = std::fs::read(&plan.static_path).ok(); + let original_animation = std::fs::read(&plan.animation_path).ok(); + let result = write_if_changed(&plan.static_path, &plan.static_bytes) + .and_then(|_| write_if_changed(&plan.animation_path, &plan.animation_bytes)); + if let Err(error) = result { + restore_artifact(&plan.static_path, original_static.as_deref()); + restore_artifact(&plan.animation_path, original_animation.as_deref()); + return Err(format!("model artifact publication rolled back: {error}")); + } + Ok(()) +} + +fn restore_artifact(path: &std::path::Path, bytes: Option<&[u8]>) { + if let Some(bytes) = bytes { + let _ = std::fs::write(path, bytes); + } else { + let _ = std::fs::remove_file(path); + } +} + +/// Refreshes both generated contracts derived from one imported model source. +pub fn refresh_model_artifacts( + record: &mut shared::AssetRecord, +) -> Result { + let plan = plan_model_artifacts(record)?; + publish_model_artifacts(&plan)?; + *record = plan.record.clone(); + Ok(plan.static_manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classification_is_folder_independent() { + assert_eq!( + classify_asset(Path::new("assets/Anything/hero.glb")), + AssetKind::Model + ); + assert_eq!( + classify_asset(Path::new("assets/Props/Office/wall.material.ron")), + AssetKind::Material + ); + } + + #[test] + fn managed_and_escaping_paths_are_rejected() { + assert!(validate_asset_path(Path::new("assets/Props/Office")).is_ok()); + assert!(validate_asset_path(Path::new("assets/.index/registry.ron")).is_err()); + assert!(validate_asset_path(Path::new("assets/meshes/generated/a.ron")).is_err()); + assert!(validate_asset_path(Path::new("../outside")).is_err()); + } + + #[test] + fn registry_publication_always_updates_the_runtime_catalog() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-publish-{}", + shared::AssetId::new().as_string() + )); + let document = AssetRegistryDocument { + records: vec![AssetRecord { + id: shared::AssetId::new(), + path: "assets/Props/a.png".into(), + label: "A".into(), + kind: AssetKind::Texture, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::for_kind(AssetKind::Texture), + dependencies: Vec::new(), + }], + ..Default::default() + }; + + publish_content_documents(&root, &document).unwrap(); + + let runtime: RuntimeContentCatalog = + ron::from_str(&fs::read_to_string(root.join(RUNTIME_CATALOG_PATH)).unwrap()).unwrap(); + assert_eq!(runtime.records[0].path, "assets/Props/a.png"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn headless_scan_migrates_legacy_authoring_override_to_stable_slots() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-policy-migration-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/meshes/generated")).unwrap(); + fs::write(root.join("assets/chair.fbx"), b"chair").unwrap(); + fs::write( + root.join("assets/meshes/generated/chair.ron"), + "(parts:[(id:\"draw:0\",material_id:Some(\"material:0\"))])", + ) + .unwrap(); + let previous = AssetRegistryDocument { + records: vec![AssetRecord { + id: shared::AssetId::new(), + path: "assets/chair.fbx".into(), + label: "Chair".into(), + kind: AssetKind::Model, + source_fingerprint: Some(AssetSourceFingerprint::from_bytes(b"chair")), + import_settings: shared::AssetImportSettings::Model(shared::ImportSettings { + material_policy: MaterialImportPolicy::AuthoringOverride, + static_mesh_manifest_path: Some("assets/meshes/generated/chair.ron".into()), + ..Default::default() + }), + dependencies: Vec::new(), + }], + ..Default::default() + }; + + let processed = scan_project(&root, &previous).unwrap(); + let settings = processed.registry.records[0].model_import(); + + assert_eq!( + settings.material_policy, + MaterialImportPolicy::SourceMaterials + ); + assert_eq!(settings.material_slots.len(), 1); + assert_eq!(settings.material_slots[0].slot_id.0, "slot:draw:0"); + assert!(matches!( + settings.material_slots[0].selection, + ModelMaterialSelection::Default + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn scan_refuses_ambiguous_external_moves_instead_of_replacing_stable_identity() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-ambiguous-move-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/New")).unwrap(); + fs::write(root.join("assets/New/desk.png"), b"duplicate pixels").unwrap(); + let fingerprint = AssetSourceFingerprint::from_bytes(b"duplicate pixels"); + let previous = AssetRegistryDocument { + records: ["assets/OldA/desk.png", "assets/OldB/desk.png"] + .into_iter() + .map(|path| AssetRecord { + id: shared::AssetId::new(), + path: path.into(), + label: "Desk".into(), + kind: AssetKind::Texture, + source_fingerprint: Some(fingerprint.clone()), + import_settings: shared::AssetImportSettings::for_kind(AssetKind::Texture), + dependencies: Vec::new(), + }) + .collect(), + ..Default::default() + }; + + let conflicts = find_ambiguous_external_moves(&root, &previous).unwrap(); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].new_path, "assets/New/desk.png"); + assert_eq!( + conflicts[0].candidate_paths, + ["assets/OldA/desk.png", "assets/OldB/desk.png"] + ); + + let error = scan_project(&root, &previous).unwrap_err(); + + assert!(error.contains("ambiguous external move")); + assert!(error.contains("assets/OldA/desk.png")); + assert!(error.contains("assets/OldB/desk.png")); + assert!(error.contains("Content Browser")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn scan_preserves_unique_imported_source_ids_across_external_moves() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-unique-moves-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/New")).unwrap(); + let fixtures = [ + ("model.glb", AssetKind::Model, b"model bytes".as_slice()), + ( + "texture.png", + AssetKind::Texture, + b"texture bytes".as_slice(), + ), + ("audio.ogg", AssetKind::AudioClip, b"audio bytes".as_slice()), + ]; + let mut expected = Vec::new(); + let mut records = Vec::new(); + for (name, kind, bytes) in fixtures { + fs::write(root.join("assets/New").join(name), bytes).unwrap(); + let id = shared::AssetId::new(); + expected.push((format!("assets/New/{name}"), id.clone())); + records.push(AssetRecord { + id, + path: format!("assets/Old/{name}"), + label: name.into(), + kind, + source_fingerprint: Some(AssetSourceFingerprint::from_bytes(bytes)), + import_settings: shared::AssetImportSettings::for_kind(kind), + dependencies: Vec::new(), + }); + } + let previous = AssetRegistryDocument { + records, + ..Default::default() + }; + + let processed = scan_project(&root, &previous).unwrap(); + + assert_eq!(processed.report.moved, 3); + for (path, id) in expected { + assert_eq!( + processed + .registry + .records + .iter() + .find(|record| record.path == path) + .map(|record| &record.id), + Some(&id) + ); + } + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/content_pipeline/src/pbr_grouping.rs b/crates/content_pipeline/src/pbr_grouping.rs new file mode 100644 index 0000000..5b86389 --- /dev/null +++ b/crates/content_pipeline/src/pbr_grouping.rs @@ -0,0 +1,302 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PbrTextureRole { + BaseColor, + Normal, + Roughness, + Metallic, + Occlusion, + PackedOrm, + Height, + Unknown, +} + +impl PbrTextureRole { + pub const ALL: [Self; 8] = [ + Self::BaseColor, + Self::Normal, + Self::Roughness, + Self::Metallic, + Self::Occlusion, + Self::PackedOrm, + Self::Height, + Self::Unknown, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::BaseColor => "Base color", + Self::Normal => "Normal", + Self::Roughness => "Roughness", + Self::Metallic => "Metallic", + Self::Occlusion => "Occlusion", + Self::PackedOrm => "Packed ORM/ARM", + Self::Height => "Height", + Self::Unknown => "Unresolved", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PbrMatchConfidence { + High, + Medium, + Low, + Unresolved, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DetectedPbrTexture { + pub path: PathBuf, + pub group_key: String, + pub role: PbrTextureRole, + pub confidence: PbrMatchConfidence, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DetectedPbrGroup { + pub key: String, + pub textures: Vec, +} + +pub fn detect_pbr_texture_groups( + paths: impl IntoIterator, +) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + for path in paths { + if !is_texture_path(&path) { + continue; + } + let (group_key, role, confidence) = classify_texture_name(&path); + groups + .entry(group_key.clone()) + .or_default() + .push(DetectedPbrTexture { + path, + group_key, + role, + confidence, + }); + } + groups + .into_iter() + .map(|(key, mut textures)| { + textures.sort_by(|left, right| left.path.cmp(&right.path)); + DetectedPbrGroup { key, textures } + }) + .collect() +} + +fn classify_texture_name(path: &Path) -> (String, PbrTextureRole, PbrMatchConfidence) { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("texture") + .to_ascii_lowercase(); + let mut tokens: Vec<&str> = stem + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .collect(); + while tokens + .last() + .is_some_and(|token| is_resolution_token(token)) + { + tokens.pop(); + } + let joined = tokens.join("_"); + let aliases: &[(&[&str], PbrTextureRole, PbrMatchConfidence)] = &[ + ( + &["occlusion", "roughness", "metallic"], + PbrTextureRole::PackedOrm, + PbrMatchConfidence::High, + ), + ( + &["base", "color"], + PbrTextureRole::BaseColor, + PbrMatchConfidence::High, + ), + ( + &["ambient", "occlusion"], + PbrTextureRole::Occlusion, + PbrMatchConfidence::High, + ), + ( + &["metallic", "roughness"], + PbrTextureRole::PackedOrm, + PbrMatchConfidence::Medium, + ), + ( + &["basecolor"], + PbrTextureRole::BaseColor, + PbrMatchConfidence::High, + ), + ( + &["albedo"], + PbrTextureRole::BaseColor, + PbrMatchConfidence::High, + ), + ( + &["diffuse"], + PbrTextureRole::BaseColor, + PbrMatchConfidence::High, + ), + ( + &["diff"], + PbrTextureRole::BaseColor, + PbrMatchConfidence::Medium, + ), + ( + &["normal"], + PbrTextureRole::Normal, + PbrMatchConfidence::High, + ), + ( + &["nor", "gl"], + PbrTextureRole::Normal, + PbrMatchConfidence::High, + ), + ( + &["nor", "dx"], + PbrTextureRole::Normal, + PbrMatchConfidence::High, + ), + (&["nrm"], PbrTextureRole::Normal, PbrMatchConfidence::Medium), + ( + &["roughness"], + PbrTextureRole::Roughness, + PbrMatchConfidence::High, + ), + ( + &["rough"], + PbrTextureRole::Roughness, + PbrMatchConfidence::Medium, + ), + ( + &["metallic"], + PbrTextureRole::Metallic, + PbrMatchConfidence::High, + ), + ( + &["metalness"], + PbrTextureRole::Metallic, + PbrMatchConfidence::High, + ), + ( + &["metal"], + PbrTextureRole::Metallic, + PbrMatchConfidence::Medium, + ), + ( + &["occlusion"], + PbrTextureRole::Occlusion, + PbrMatchConfidence::High, + ), + (&["ao"], PbrTextureRole::Occlusion, PbrMatchConfidence::High), + ( + &["orm"], + PbrTextureRole::PackedOrm, + PbrMatchConfidence::High, + ), + ( + &["arm"], + PbrTextureRole::PackedOrm, + PbrMatchConfidence::High, + ), + ( + &["height"], + PbrTextureRole::Height, + PbrMatchConfidence::High, + ), + ( + &["displacement"], + PbrTextureRole::Height, + PbrMatchConfidence::High, + ), + ( + &["disp"], + PbrTextureRole::Height, + PbrMatchConfidence::Medium, + ), + (&["n"], PbrTextureRole::Normal, PbrMatchConfidence::Low), + (&["r"], PbrTextureRole::Roughness, PbrMatchConfidence::Low), + (&["m"], PbrTextureRole::Metallic, PbrMatchConfidence::Low), + ]; + for (suffix, role, confidence) in aliases { + if tokens.ends_with(suffix) { + let base_len = tokens.len().saturating_sub(suffix.len()); + let group = tokens[..base_len].join("_"); + return ( + if group.is_empty() { + joined.clone() + } else { + group + }, + *role, + *confidence, + ); + } + } + ( + joined, + PbrTextureRole::Unknown, + PbrMatchConfidence::Unresolved, + ) +} + +fn is_resolution_token(token: &str) -> bool { + token + .strip_suffix('k') + .is_some_and(|number| matches!(number, "1" | "2" | "4" | "8" | "16")) +} + +fn is_texture_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "png" | "jpg" | "jpeg" | "webp" | "ktx2" | "dds" | "tga" | "exr" | "hdr" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poly_haven_suffixes_form_one_high_confidence_group() { + let groups = detect_pbr_texture_groups([ + "assets/metal_office_desk_diff_2k.jpg".into(), + "assets/metal_office_desk_nor_gl_2k.jpg".into(), + "assets/metal_office_desk_arm_2k.jpg".into(), + ]); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].key, "metal_office_desk"); + assert!(groups[0] + .textures + .iter() + .any(|texture| texture.role == PbrTextureRole::BaseColor)); + assert!(groups[0] + .textures + .iter() + .any(|texture| texture.role == PbrTextureRole::Normal)); + assert!(groups[0] + .textures + .iter() + .any(|texture| texture.role == PbrTextureRole::PackedOrm)); + } + + #[test] + fn unknown_images_remain_visible_for_manual_resolution() { + let groups = detect_pbr_texture_groups(["assets/wood_preview.png".into()]); + assert_eq!(groups[0].textures[0].role, PbrTextureRole::Unknown); + assert_eq!( + groups[0].textures[0].confidence, + PbrMatchConfidence::Unresolved + ); + } +} diff --git a/crates/content_pipeline/src/static_mesh.rs b/crates/content_pipeline/src/static_mesh.rs new file mode 100644 index 0000000..474c6fb --- /dev/null +++ b/crates/content_pipeline/src/static_mesh.rs @@ -0,0 +1,963 @@ +//! Normalized static mesh artifacts generated from model source files. + +use std::fs; +use std::path::Path; + +use bevy::gltf::GltfAssetLabel; +use bevy::prelude::*; +use bevy_ufbx::label::FbxAssetLabel; +use bevy_ufbx::mesh::group_faces_by_material; +use bevy_ufbx::texture::external_texture_paths; +use bevy_ufbx::utils::convert_matrix; +use serde::{Deserialize, Serialize}; + +use shared::{ + AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelMaterialSelection, + ModelPlacementMode, +}; +use shared::{ + AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialSlot, MaterialSlotSet, + ModelMaterialSlotSelection, OrphanedModelMaterialSelection, StaticMeshRenderer, + StaticMeshRendererEntry, +}; + +use crate::fingerprint::{fingerprint_file, write_pretty_ron_if_changed}; + +pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4; +pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StaticMeshManifest { + pub schema_version: u32, + pub asset_id: String, + pub label: String, + pub source: StaticMeshSource, + pub import: StaticMeshImportSnapshot, + pub metadata: StaticMeshMetadata, + pub parts: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StaticMeshSource { + pub path: String, + pub format: String, + pub fingerprint: StaticMeshSourceFingerprint, + pub dependencies: Vec, +} + +pub type StaticMeshSourceFingerprint = AssetSourceFingerprint; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StaticMeshImportSnapshot { + pub scale: f32, + pub generate_collider: bool, + pub lod0_only: bool, + pub placement_mode: ModelPlacementMode, + pub hierarchy_mode: ModelHierarchyMode, + #[serde(default, skip_serializing_if = "material_policy_is_default")] + pub material_policy: MaterialImportPolicy, + #[serde(default)] + pub material_slots: Vec, + #[serde(default)] + pub orphaned_material_slots: Vec, +} + +fn material_policy_is_default(policy: &MaterialImportPolicy) -> bool { + *policy == MaterialImportPolicy::SourceMaterials +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StaticMeshMetadata { + pub mesh_count: usize, + pub material_count: usize, + pub node_count: usize, + pub animation_count: usize, + pub skin_count: usize, + pub light_count: usize, + pub camera_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StaticMeshPart { + #[serde(default)] + pub id: String, + pub name: String, + pub mesh_label: String, + #[serde(default)] + pub material_id: Option, + pub material_slot_name: String, + pub material_label: Option, + pub local_transform: Transform, + pub source_node: Option, + pub source_mesh: Option, + pub source_material: Option, + /// Whether the source primitive is bound to a skin and therefore must never become a static + /// renderer slot. + #[serde(default)] + pub skinned: bool, +} + +pub fn static_mesh_manifest_path(asset_id: &str) -> String { + format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron") +} + +pub fn part_id_from_label(label: &str) -> String { + format!("mesh:{}", stable_sub_asset_slug(label)) +} + +pub fn material_id_from_label(label: &str) -> String { + format!("material:{}", stable_sub_asset_slug(label)) +} + +fn gltf_draw_id(node_index: Option, mesh_index: usize, primitive_index: usize) -> String { + let node = node_index + .map(|index| index.to_string()) + .unwrap_or_else(|| "unbound".into()); + format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}") +} + +fn fbx_draw_id(node_index: usize, material_index: usize) -> String { + format!("draw:scene0:node{node_index}:material{material_index}") +} + +fn stable_sub_asset_slug(label: &str) -> String { + let mut slug = String::new(); + for ch in label.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + } else if !slug.ends_with('_') { + slug.push('_'); + } + } + slug.trim_matches('_').to_string() +} + +pub fn refresh_static_mesh_artifact( + record: &mut AssetRecord, +) -> Result { + let (manifest, path) = plan_static_mesh_artifact(record)?; + + if write_pretty_ron_if_changed(&path, &manifest) + .map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))? + { + info!( + "Static mesh manifest refreshed: source={} artifact={} parts={}", + record.path, + path, + manifest.parts.len() + ); + } + + Ok(manifest) +} + +pub fn plan_static_mesh_artifact( + record: &mut AssetRecord, +) -> Result<(StaticMeshManifest, String), String> { + let mut manifest = build_static_mesh_manifest(record)?; + reconcile_model_material_slots(&manifest, record.model_import_mut()); + manifest.import.material_policy = MaterialImportPolicy::SourceMaterials; + manifest.import.material_slots = record.model_import().material_slots.clone(); + manifest.import.orphaned_material_slots = record.model_import().orphaned_material_slots.clone(); + let path = static_mesh_manifest_path(&record.id.as_string()); + record.model_import_mut().static_mesh_manifest_path = Some(path.clone()); + manifest.source.dependencies.sort(); + manifest.source.dependencies.dedup(); + record.dependencies = manifest.source.dependencies.clone(); + Ok((manifest, path)) +} + +pub fn load_static_mesh_manifest(path: &str) -> Result { + let text = fs::read_to_string(path).map_err(|err| format!("could not read {path}: {err}"))?; + ron::from_str(&text).map_err(|err| format!("could not parse {path}: {err}")) +} + +pub fn renderer_from_manifest( + manifest: &StaticMeshManifest, + _settings: &ImportSettings, +) -> StaticMeshRenderer { + let parts: Vec<_> = manifest + .parts + .iter() + .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) + .map(|part| StaticMeshRendererEntry { + id: ComponentInstanceId::new(part_effective_id(part)), + name: part.name.clone(), + mesh: EditorAssetRef::new( + manifest.asset_id.clone(), + part_effective_id(part), + part.name.clone(), + ), + material_slot_id: ComponentInstanceId::new(material_slot_id(part)), + local_transform: part.local_transform, + visible: true, + cast_shadows: true, + receive_shadows: true, + }) + .collect(); + let materials = MaterialSlotSet { + slots: manifest + .parts + .iter() + .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) + .map(|part| MaterialSlot { + id: ComponentInstanceId::new(material_slot_id(part)), + name: part.material_slot_name.clone(), + material: None, + }) + .collect(), + orphaned_assignments: Vec::new(), + }; + + StaticMeshRenderer { + slots: parts, + materials, + } +} + +/// Builds the shared material slots for a full imported hierarchy. Unlike static renderer +/// construction this intentionally includes skin-bound and rigid animated draw bindings. +pub fn renderer_materials_from_manifest( + manifest: &StaticMeshManifest, + _settings: &ImportSettings, +) -> MaterialSlotSet { + MaterialSlotSet { + slots: manifest + .parts + .iter() + .map(|part| MaterialSlot { + id: ComponentInstanceId::new(material_slot_id(part)), + name: part.material_slot_name.clone(), + material: None, + }) + .collect(), + orphaned_assignments: Vec::new(), + } +} + +pub fn material_selection<'a>( + settings: &'a ImportSettings, + slot_id: &str, +) -> &'a ModelMaterialSelection { + settings + .material_slots + .iter() + .find(|entry| entry.slot_id.0 == slot_id) + .map(|entry| &entry.selection) + .unwrap_or(&ModelMaterialSelection::Source) +} + +fn reconcile_model_material_slots(manifest: &StaticMeshManifest, settings: &mut ImportSettings) { + let names = manifest + .parts + .iter() + .map(|part| (material_slot_id(part), part.material_slot_name.clone())) + .collect::>(); + if settings.material_policy == MaterialImportPolicy::AuthoringOverride { + for slot_id in names.keys() { + if !settings + .material_slots + .iter() + .any(|selection| selection.slot_id.0 == *slot_id) + { + settings.material_slots.push(ModelMaterialSlotSelection { + slot_id: ComponentInstanceId::new(slot_id.clone()), + selection: ModelMaterialSelection::Default, + }); + } + } + } + settings.material_policy = MaterialImportPolicy::SourceMaterials; + let mut retained = Vec::with_capacity(settings.material_slots.len()); + for selection in settings.material_slots.drain(..) { + if names.contains_key(&selection.slot_id.0) { + retained.push(selection); + } else if let ModelMaterialSelection::Project(material) = selection.selection { + settings + .orphaned_material_slots + .push(OrphanedModelMaterialSelection { + slot_id: selection.slot_id, + last_known_name: "Removed imported material slot".into(), + material, + }); + } + } + retained.sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0)); + settings.material_slots = retained; + settings + .orphaned_material_slots + .retain(|orphan| !names.contains_key(&orphan.slot_id.0)); + settings + .orphaned_material_slots + .sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0)); +} + +fn material_slot_id(part: &StaticMeshPart) -> String { + format!("slot:{}", part_effective_id(part)) +} + +fn part_effective_id(part: &StaticMeshPart) -> String { + if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id.clone() + } +} + +fn build_static_mesh_manifest(record: &AssetRecord) -> Result { + let format = source_format(&record.path)?; + let fingerprint = fingerprint_file(&record.path)?; + let import = StaticMeshImportSnapshot { + scale: record.model_import().scale, + generate_collider: record.model_import().generate_collider, + lod0_only: record.model_import().lod0_only, + placement_mode: record.model_import().placement_mode, + hierarchy_mode: record.model_import().hierarchy_mode, + material_policy: MaterialImportPolicy::SourceMaterials, + material_slots: record.model_import().material_slots.clone(), + orphaned_material_slots: record.model_import().orphaned_material_slots.clone(), + }; + + let mut manifest = match format.as_str() { + "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, import)?, + "fbx" => build_fbx_manifest(record, format, fingerprint, import)?, + _ => return Err(format!("unsupported static mesh source format `{format}`")), + }; + + if manifest.parts.is_empty() { + manifest + .warnings + .push("No renderable static mesh parts were found.".into()); + } + + Ok(manifest) +} + +fn build_gltf_manifest( + record: &AssetRecord, + format: String, + fingerprint: StaticMeshSourceFingerprint, + import: StaticMeshImportSnapshot, +) -> Result { + let gltf = gltf::Gltf::open(&record.path) + .map_err(|err| format!("could not parse glTF {}: {err}", record.path))?; + let mut parts = Vec::new(); + let mut dependencies = Vec::new(); + let mut warnings = Vec::new(); + + for buffer in gltf.document.buffers() { + if let gltf::buffer::Source::Uri(uri) = buffer.source() { + dependencies.push(resolve_dependency(&record.path, uri)); + } + } + for image in gltf.document.images() { + if let gltf::image::Source::Uri { uri, .. } = image.source() { + dependencies.push(resolve_dependency(&record.path, uri)); + } + } + + if let Some(scene) = gltf + .document + .default_scene() + .or_else(|| gltf.document.scenes().next()) + { + for node in scene.nodes() { + collect_gltf_node_parts(node, Mat4::IDENTITY, "", &mut parts); + } + } else { + for mesh in gltf.document.meshes() { + collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts); + } + } + + if gltf.document.animations().count() > 0 { + warnings.push( + "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." + .into(), + ); + } + if gltf.document.skins().count() > 0 { + warnings.push( + "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." + .into(), + ); + } + + parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); + + Ok(StaticMeshManifest { + schema_version: STATIC_MESH_MANIFEST_SCHEMA, + asset_id: record.id.as_string(), + label: record.label.clone(), + source: StaticMeshSource { + path: record.path.clone(), + format, + fingerprint, + dependencies, + }, + import, + metadata: StaticMeshMetadata { + mesh_count: gltf.document.meshes().count(), + material_count: gltf.document.materials().count(), + node_count: gltf.document.nodes().count(), + animation_count: gltf.document.animations().count(), + skin_count: gltf.document.skins().count(), + light_count: 0, + camera_count: gltf.document.cameras().count(), + }, + parts, + warnings, + }) +} + +fn collect_gltf_node_parts( + node: gltf::Node<'_>, + parent_transform: Mat4, + parent_path: &str, + parts: &mut Vec, +) { + let local = Mat4::from_cols_array_2d(&node.transform().matrix()); + let world_transform = parent_transform * local; + let segment = node + .name() + .map(str::to_string) + .unwrap_or_else(|| format!("Node{}", node.index())); + let node_path = if parent_path.is_empty() { + segment + } else { + format!("{parent_path}/{segment}") + }; + let skinned = node.skin().is_some(); + if let Some(mesh) = node.mesh() { + collect_gltf_mesh_parts( + node.name().map(str::to_string), + Some(node.index()), + Some(node_path.clone()), + mesh, + world_transform, + skinned, + parts, + ); + } + for child in node.children() { + collect_gltf_node_parts(child, world_transform, &node_path, parts); + } +} + +fn collect_gltf_mesh_parts( + node_name: Option, + node_index: Option, + node_path: Option, + mesh: gltf::Mesh<'_>, + transform: Mat4, + skinned: bool, + parts: &mut Vec, +) { + let mesh_index = mesh.index(); + let mesh_name = mesh.name().map(str::to_string); + for primitive in mesh.primitives() { + let primitive_index = primitive.index(); + let mesh_label = GltfAssetLabel::Primitive { + mesh: mesh_index, + primitive: primitive_index, + } + .to_string(); + let material = primitive.material(); + let material_label = material + .index() + .map(|index| { + GltfAssetLabel::Material { + index, + is_scale_inverted: false, + } + .to_string() + }) + .or_else(|| Some(GltfAssetLabel::DefaultMaterial.to_string())); + let material_name = material + .name() + .map(str::to_string) + .unwrap_or_else(|| "Default Material".into()); + let name = node_name + .clone() + .or_else(|| mesh_name.clone()) + .unwrap_or_else(|| format!("Mesh {mesh_index}")); + let source_node = node_path + .clone() + .or_else(|| node_index.map(|index| format!("Node{index}"))); + parts.push(StaticMeshPart { + id: gltf_draw_id(node_index, mesh_index, primitive_index), + name: format!("{name} / Primitive {primitive_index}"), + material_id: material_label + .as_ref() + .map(|label| material_id_from_label(label)), + mesh_label, + material_slot_name: material_name.clone(), + material_label, + local_transform: Transform::from_matrix(transform), + source_node, + source_mesh: Some(format!("Mesh{mesh_index}")), + source_material: Some(material_name), + skinned, + }); + } +} + +fn build_fbx_manifest( + record: &AssetRecord, + format: String, + fingerprint: StaticMeshSourceFingerprint, + import: StaticMeshImportSnapshot, +) -> Result { + let bytes = + fs::read(&record.path).map_err(|err| format!("could not read {}: {err}", record.path))?; + let scene = ufbx::load_memory( + &bytes, + ufbx::LoadOpts { + target_unit_meters: 1.0, + target_axes: ufbx::CoordinateAxes::right_handed_y_up(), + filename: ufbx::StringOpt::Ref(&record.path), + ..Default::default() + }, + ) + .map_err(|err| format!("could not parse FBX {}: {err:?}", record.path))?; + + let mut parts = Vec::new(); + let mut warnings = Vec::new(); + for (node_index, node) in scene.nodes.as_ref().iter().enumerate() { + let Some(mesh_ref) = node.mesh.as_ref() else { + continue; + }; + let mesh = mesh_ref.as_ref(); + if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() { + continue; + } + let skinned = !mesh.skin_deformers.as_ref().is_empty(); + + let mut groups: Vec<(usize, Vec)> = + group_faces_by_material(mesh).into_iter().collect(); + groups.sort_by_key(|(material_index, _)| *material_index); + for (material_index, indices) in groups { + if indices.is_empty() { + continue; + } + let material = mesh.materials.get(material_index).map(|mat| mat.as_ref()); + let material_label = material + .and_then(|mat| fbx_material_label(&scene, mat.element.element_id)) + .or_else(|| Some(FbxAssetLabel::DefaultMaterial.to_string())); + let material_name = material + .map(|mat| mat.element.name.to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Default Material".into()); + let node_name = if node.element.name.is_empty() { + format!("Node {node_index}") + } else { + node.element.name.to_string() + }; + let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string(); + parts.push(StaticMeshPart { + id: fbx_draw_id(node_index, material_index), + name: format!("{node_name} / Material {material_index}"), + material_id: material_label + .as_ref() + .map(|label| material_id_from_label(label)), + mesh_label, + material_slot_name: material_name.clone(), + material_label, + local_transform: Transform::from_matrix(convert_matrix(&node.geometry_to_world)), + source_node: Some(format!("Node{node_index}")), + source_mesh: Some(format!("Mesh{node_index}")), + source_material: Some(material_name), + skinned, + }); + } + } + + if !scene.anim_stacks.as_ref().is_empty() { + warnings.push( + "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." + .into(), + ); + } + if !scene.skin_deformers.as_ref().is_empty() { + warnings.push( + "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." + .into(), + ); + } + + parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); + + Ok(StaticMeshManifest { + schema_version: STATIC_MESH_MANIFEST_SCHEMA, + asset_id: record.id.as_string(), + label: record.label.clone(), + source: StaticMeshSource { + path: record.path.clone(), + format, + fingerprint, + dependencies: fbx_dependencies(&record.path, &scene)?, + }, + import, + metadata: StaticMeshMetadata { + mesh_count: scene.meshes.as_ref().len(), + material_count: scene.materials.as_ref().len(), + node_count: scene.nodes.as_ref().len(), + animation_count: scene.anim_stacks.as_ref().len(), + skin_count: scene.skin_deformers.as_ref().len(), + light_count: scene.lights.as_ref().len(), + camera_count: scene.cameras.as_ref().len(), + }, + parts, + warnings, + }) +} + +fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option { + if element_id == 0 { + return None; + } + scene + .materials + .as_ref() + .iter() + .position(|material| material.element.element_id == element_id) + .map(|index| FbxAssetLabel::Material(index).to_string()) +} + +fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result, String> { + let path = Path::new(source_path); + let parent = path.parent().unwrap_or_else(|| Path::new("")); + external_texture_paths(scene) + .map_err(|errors| { + format!( + "unsafe FBX texture reference(s) in {source_path}: {}", + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("; ") + ) + }) + .map(|paths| { + paths + .into_iter() + .map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/")) + .collect() + }) +} + +fn source_format(path: &str) -> Result { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .ok_or_else(|| format!("asset path `{path}` has no extension")) +} + +fn resolve_dependency(source_path: &str, uri: &str) -> String { + if uri.starts_with("data:") || uri.contains("://") { + return uri.to_string(); + } + Path::new(source_path) + .parent() + .unwrap_or_else(|| Path::new("")) + .join(uri) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::{AssetId, MaterialRef}; + + fn test_manifest() -> StaticMeshManifest { + StaticMeshManifest { + schema_version: STATIC_MESH_MANIFEST_SCHEMA, + asset_id: "asset-1".into(), + label: "Crate".into(), + source: StaticMeshSource { + path: "assets/models/crate.glb".into(), + format: "glb".into(), + fingerprint: StaticMeshSourceFingerprint { + byte_len: 42, + content_hash: "a".repeat(64), + }, + dependencies: Vec::new(), + }, + import: StaticMeshImportSnapshot { + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: ModelPlacementMode::StaticAsset, + hierarchy_mode: ModelHierarchyMode::SingleActor, + material_policy: MaterialImportPolicy::SourceMaterials, + material_slots: Vec::new(), + orphaned_material_slots: Vec::new(), + }, + metadata: StaticMeshMetadata { + mesh_count: 1, + material_count: 1, + node_count: 1, + animation_count: 0, + skin_count: 0, + light_count: 0, + camera_count: 0, + }, + parts: vec![StaticMeshPart { + id: "mesh:mesh0_primitive0".into(), + name: "Crate / Primitive 0".into(), + mesh_label: "Mesh0/Primitive0".into(), + material_id: Some("material:material0".into()), + material_slot_name: "Wood".into(), + material_label: Some("Material0".into()), + local_transform: Transform::from_xyz(1.0, 2.0, 3.0), + source_node: Some("Node0".into()), + source_mesh: Some("Mesh0".into()), + source_material: Some("Wood".into()), + skinned: false, + }], + warnings: Vec::new(), + } + } + + #[test] + fn static_mesh_manifest_path_uses_asset_id() { + assert_eq!( + static_mesh_manifest_path("abc"), + "assets/meshes/generated/abc.static_mesh.ron" + ); + } + + #[test] + fn renderer_from_manifest_preserves_labels_and_collider_policy() { + let manifest = test_manifest(); + let settings = ImportSettings { + generate_collider: true, + ..Default::default() + }; + + let renderer = renderer_from_manifest(&manifest, &settings); + + assert_eq!(renderer.slots.len(), 1); + let entry = &renderer.slots[0]; + assert_eq!(entry.mesh.asset_id, manifest.asset_id); + assert_eq!(entry.mesh.sub_asset_id, "mesh:mesh0_primitive0"); + assert!(renderer.materials.slots[0].material.is_none()); + } + + #[test] + fn model_slot_selection_remains_asset_owned() { + let manifest = test_manifest(); + let project = MaterialRef::new( + EditorAssetRef::new("project-material", "", "Paint") + .with_source_path("assets/Props/paint.material.ron"), + ); + let mut settings = ImportSettings::default(); + settings + .material_slots + .push(shared::ModelMaterialSlotSelection { + slot_id: ComponentInstanceId::new("slot:mesh:mesh0_primitive0"), + selection: ModelMaterialSelection::Project(project.clone()), + }); + + let renderer = renderer_from_manifest(&manifest, &settings); + assert!(renderer.materials.slots[0].material.is_none()); + assert_eq!( + material_selection(&settings, "slot:mesh:mesh0_primitive0"), + &ModelMaterialSelection::Project(project) + ); + + settings.material_slots[0].selection = ModelMaterialSelection::Default; + let renderer = renderer_from_manifest(&manifest, &settings); + assert!(renderer.materials.slots[0].material.is_none()); + assert_eq!( + material_selection(&settings, "slot:mesh:mesh0_primitive0"), + &ModelMaterialSelection::Default + ); + } + + #[test] + fn removed_project_selection_becomes_an_orphan() { + let manifest = test_manifest(); + let material = MaterialRef::new(EditorAssetRef::new("paint", "", "Paint")); + let mut settings = ImportSettings::default(); + settings + .material_slots + .push(shared::ModelMaterialSlotSelection { + slot_id: ComponentInstanceId::new("slot:removed"), + selection: ModelMaterialSelection::Project(material.clone()), + }); + + reconcile_model_material_slots(&manifest, &mut settings); + + assert!(settings.material_slots.is_empty()); + assert_eq!(settings.orphaned_material_slots.len(), 1); + assert_eq!(settings.orphaned_material_slots[0].material, material); + } + + #[test] + fn schema_v3_manifest_without_hash_migrates_to_content_fingerprint() { + let root = std::env::temp_dir().join(format!( + "blacksite-static-mesh-legacy-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("legacy.static_mesh.ron"); + let expected = test_manifest(); + let canonical = + ron::ser::to_string_pretty(&expected, ron::ser::PrettyConfig::default()).unwrap(); + let legacy = canonical + .replacen("schema_version: 4", "schema_version: 3", 1) + .lines() + .filter(|line| !line.contains("content_hash:")) + .collect::>() + .join("\n"); + fs::write(&path, legacy).unwrap(); + + assert!(write_pretty_ron_if_changed(&path, &expected).unwrap()); + assert_eq!( + load_static_mesh_manifest(&path.to_string_lossy()).unwrap(), + expected + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn equivalent_static_manifest_preserves_existing_formatting_and_newline() { + let root = std::env::temp_dir().join(format!( + "blacksite-static-mesh-semantic-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("stable.static_mesh.ron"); + let manifest = test_manifest(); + let exact = format!( + "{}\n\n", + ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap() + ); + fs::write(&path, &exact).unwrap(); + + assert!(!write_pretty_ron_if_changed(&path, &manifest).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), exact); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn renderer_from_manifest_can_select_default_per_slot() { + let manifest = test_manifest(); + let settings = ImportSettings { + material_slots: vec![ModelMaterialSlotSelection { + slot_id: ComponentInstanceId::new(format!( + "slot:{}", + part_effective_id(&manifest.parts[0]) + )), + selection: ModelMaterialSelection::Default, + }], + ..Default::default() + }; + + let renderer = renderer_from_manifest(&manifest, &settings); + + assert!(renderer.materials.slots[0].material.is_none()); + assert_eq!( + material_selection(&settings, &renderer.materials.slots[0].id.0), + &ModelMaterialSelection::Default + ); + } + + #[test] + fn legacy_whole_model_policy_migrates_to_explicit_default_slots() { + let manifest = test_manifest(); + let mut settings = ImportSettings { + material_policy: MaterialImportPolicy::AuthoringOverride, + ..Default::default() + }; + + reconcile_model_material_slots(&manifest, &mut settings); + + assert_eq!( + settings.material_policy, + MaterialImportPolicy::SourceMaterials + ); + assert_eq!(settings.material_slots.len(), 1); + assert!(matches!( + settings.material_slots[0].selection, + ModelMaterialSelection::Default + )); + } + + #[test] + fn renderer_from_manifest_excludes_skinned_primitives() { + let mut manifest = test_manifest(); + manifest.parts[0].skinned = true; + + let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); + + assert!(renderer.slots.is_empty()); + } + + #[test] + fn renderer_from_manifest_excludes_node_animated_geometry() { + let mut manifest = test_manifest(); + manifest.metadata.animation_count = 1; + + let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); + + assert!(renderer.slots.is_empty()); + } + + #[test] + fn committed_rigged_fixture_never_builds_static_renderer_slots() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/models/robot_expressive.glb") + .to_string_lossy() + .into_owned(); + let record = AssetRecord { + id: AssetId::new(), + path, + label: "Robot Expressive".into(), + kind: shared::AssetKind::Model, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + }; + + let manifest = build_static_mesh_manifest(&record).unwrap(); + let renderer = renderer_from_manifest(&manifest, record.model_import()); + + assert!(manifest.metadata.skin_count > 0); + assert!(manifest.metadata.animation_count > 0); + assert!(manifest.parts.iter().any(|part| part.skinned)); + assert!(renderer.slots.is_empty()); + } + + #[test] + fn committed_fbx_records_sibling_texture_dependencies() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/models/painted_wooden_chair_02_2k.fbx") + .to_string_lossy() + .into_owned(); + let record = AssetRecord { + id: AssetId::new(), + path, + label: "Painted Chair".into(), + kind: shared::AssetKind::Model, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + }; + + let manifest = build_static_mesh_manifest(&record).unwrap(); + + assert_eq!(manifest.source.dependencies.len(), 3); + assert!(manifest + .source + .dependencies + .iter() + .all(|path| path.contains("assets/models/textures/painted_wooden_chair_02_"))); + } +} diff --git a/crates/content_pipeline/src/texture.rs b/crates/content_pipeline/src/texture.rs new file mode 100644 index 0000000..b4f769c --- /dev/null +++ b/crates/content_pipeline/src/texture.rs @@ -0,0 +1,978 @@ +//! Deterministic, GPU-free texture processing for editor, watcher, CI, and packaging. + +use image::{imageops::FilterType, Rgba, RgbaImage}; +use shared::{ + resolves_srgb, AssetKind, AssetRecord, AssetRegistryDocument, MaterialAsset, MaterialInputSet, + MaterialInstanceAsset, MaterialRuntimeData, MaterialTextureBinding, NormalMapConvention, + TextureAssetSemantic, TextureChannel, TextureColorSpace, TextureCompression, + TextureImportSettings, TextureMipmapMode, TextureRuntimeData, +}; +use std::path::{Path, PathBuf}; + +pub const TEXTURE_PROCESSOR_VERSION: u32 = 1; + +#[derive(Debug, Clone)] +pub struct TextureArtifactPlan { + pub output_path: PathBuf, + pub output_bytes: Vec, + pub runtime: TextureRuntimeData, + pub reused: bool, +} + +#[derive(Debug, Clone)] +pub struct MaterialArtifactPlan { + pub output_path: PathBuf, + pub output_bytes: Vec, + pub runtime: MaterialRuntimeData, + pub reused: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterialPackingSignature(pub String); + +pub fn plan_texture_artifact( + project_root: &Path, + record: &AssetRecord, +) -> Result { + if record.kind != AssetKind::Texture { + return Err(format!("{} is not a Texture asset", record.path)); + } + let source_path = project_root.join(&record.path); + let source_bytes = std::fs::read(&source_path) + .map_err(|error| format!("could not read texture {}: {error}", source_path.display()))?; + let mut settings = record + .texture_import() + .cloned() + .ok_or_else(|| format!("{} has no Texture import settings", record.path))?; + normalize_texture_settings(&record.path, &mut settings); + + let settings_bytes = ron::ser::to_string(&settings) + .map_err(|error| format!("could not hash Texture import settings: {error}"))?; + let mut hasher = blake3::Hasher::new(); + hasher.update(&source_bytes); + hasher.update(settings_bytes.as_bytes()); + hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes()); + let processing_key = hasher.finalize().to_hex()[..24].to_string(); + + let is_srgb = resolves_srgb(&settings); + let extension = match settings.compression { + TextureCompression::Uastc | TextureCompression::Auto => "basis", + TextureCompression::Uncompressed => "ktx2", + }; + let relative_output = PathBuf::from(format!( + "assets/.import-cache/runtime/textures/{}/{processing_key}/texture.{extension}", + record.id.as_string() + )); + let output_path = project_root.join(&relative_output); + let runtime = TextureRuntimeData { + source_path: record.path.clone(), + processed_path: Some(normalize_path(&relative_output)), + processing_key: Some(processing_key), + settings, + is_srgb, + }; + if output_path.is_file() { + return Ok(TextureArtifactPlan { + output_path, + output_bytes: Vec::new(), + runtime, + reused: true, + }); + } + + let mut image = image::load_from_memory(&source_bytes) + .map_err(|error| format!("could not decode texture {}: {error}", record.path))? + .to_rgba8(); + image = resize_to_limit(image, runtime.settings.max_dimension); + if runtime.settings.semantic == TextureAssetSemantic::Normal + && runtime.settings.normal_map_convention == NormalMapConvention::DirectX + { + flip_normal_green(&mut image); + } + + let output_bytes = match runtime.settings.compression { + TextureCompression::Uastc | TextureCompression::Auto => { + let mip_count = + requested_mip_count(image.width(), image.height(), runtime.settings.mipmaps); + encode_uastc(&image, is_srgb, mip_count > 1)? + } + TextureCompression::Uncompressed => { + let mips = generate_mips(&image, runtime.settings.mipmaps); + encode_uncompressed_ktx2(&mips, is_srgb)? + } + }; + Ok(TextureArtifactPlan { + output_path, + output_bytes, + runtime, + reused: false, + }) +} + +pub fn publish_texture_artifact(plan: &TextureArtifactPlan) -> Result { + if plan.reused { + return Ok(false); + } + crate::write_if_changed(&plan.output_path, &plan.output_bytes) +} + +pub fn plan_material_artifact( + project_root: &Path, + record: &AssetRecord, + registry: &AssetRegistryDocument, +) -> Result, String> { + if !matches!( + record.kind, + AssetKind::Material | AssetKind::MaterialInstance + ) { + return Ok(None); + } + let inputs = load_resolved_material_inputs(project_root, record)?; + let scalar_bindings = [ + assigned_texture(&inputs, "occlusion"), + assigned_texture(&inputs, "roughness"), + assigned_texture(&inputs, "metallic"), + ]; + if scalar_bindings.iter().all(|binding| binding.is_none()) { + return Ok(None); + } + + let signature = material_packing_signature_from_inputs(project_root, registry, &inputs)?; + let processing_key = signature.0; + let relative_output = PathBuf::from(format!( + "assets/.import-cache/runtime/materials/{}/{}-arm.basis", + record.id.as_string(), + processing_key + )); + let output_path = project_root.join(&relative_output); + if output_path.is_file() { + return Ok(Some(MaterialArtifactPlan { + output_path, + output_bytes: Vec::new(), + runtime: MaterialRuntimeData { + packed_arm_path: Some(normalize_path(&relative_output)), + processing_key: Some(processing_key), + }, + reused: true, + })); + } + + let loaded = scalar_bindings + .iter() + .map(|binding| { + (*binding) + .map(|binding| load_binding_image(project_root, registry, binding)) + .transpose() + }) + .collect::, _>>()?; + let (width, height) = loaded + .iter() + .flatten() + .map(|(_, image)| (image.width(), image.height())) + .next() + .ok_or_else(|| format!("{} has packed inputs but no readable Texture", record.path))?; + let loaded = loaded + .into_iter() + .map(|entry| { + entry.map(|(bytes, image)| { + let image = if image.width() == width && image.height() == height { + image + } else { + image::imageops::resize(&image, width, height, FilterType::Lanczos3) + }; + (bytes, image) + }) + }) + .collect::>(); + let mut packed = RgbaImage::new(width, height); + for y in 0..height { + for x in 0..width { + let ao = sample_scalar(loaded[0].as_ref(), scalar_bindings[0], x, y, 255); + let roughness = sample_scalar(loaded[1].as_ref(), scalar_bindings[1], x, y, 255); + let metallic = sample_scalar(loaded[2].as_ref(), scalar_bindings[2], x, y, 0); + packed.put_pixel(x, y, Rgba([ao, roughness, metallic, 255])); + } + } + Ok(Some(MaterialArtifactPlan { + output_path, + output_bytes: encode_uastc(&packed, false, true)?, + runtime: MaterialRuntimeData { + packed_arm_path: Some(normalize_path(&relative_output)), + processing_key: Some(processing_key), + }, + reused: false, + })) +} + +pub fn publish_material_artifact(plan: &MaterialArtifactPlan) -> Result { + if plan.reused { + return Ok(false); + } + crate::write_if_changed(&plan.output_path, &plan.output_bytes) +} + +pub fn material_packing_signature( + project_root: &Path, + record: &AssetRecord, + registry: &AssetRegistryDocument, +) -> Result, String> { + if !matches!( + record.kind, + AssetKind::Material | AssetKind::MaterialInstance + ) { + return Ok(None); + } + let inputs = load_resolved_material_inputs(project_root, record)?; + if ["occlusion", "roughness", "metallic"] + .into_iter() + .all(|name| assigned_texture(&inputs, name).is_none()) + { + return Ok(None); + } + material_packing_signature_from_inputs(project_root, registry, &inputs).map(Some) +} + +fn material_packing_signature_from_inputs( + project_root: &Path, + registry: &AssetRegistryDocument, + inputs: &MaterialInputSet, +) -> Result { + let mut hasher = blake3::Hasher::new(); + hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes()); + for name in ["occlusion", "roughness", "metallic"] { + hasher.update(name.as_bytes()); + let Some(binding) = assigned_texture(inputs, name) else { + hasher.update(b"unassigned"); + continue; + }; + hasher.update(format!("{:?}", binding.channel).as_bytes()); + let (path, bytes, settings) = binding_source(project_root, registry, binding)?; + hasher.update(path.as_bytes()); + hasher.update(blake3::hash(&bytes).as_bytes()); + let settings = ron::ser::to_string(&settings) + .map_err(|error| format!("could not hash Texture import settings: {error}"))?; + hasher.update(settings.as_bytes()); + } + Ok(MaterialPackingSignature( + hasher.finalize().to_hex()[..24].to_string(), + )) +} + +fn assigned_texture<'a>( + inputs: &'a MaterialInputSet, + name: &str, +) -> Option<&'a MaterialTextureBinding> { + inputs + .texture(name) + .filter(|binding| binding.texture.is_some()) +} + +pub fn apply_material_plan_to_catalog( + catalog: &mut shared::RuntimeContentCatalog, + asset_id: &shared::AssetId, + plan: &MaterialArtifactPlan, +) -> Result<(), String> { + let record = catalog + .records + .iter_mut() + .find(|record| &record.id == asset_id) + .ok_or_else(|| format!("runtime catalog is missing Material asset {asset_id:?}"))?; + record.material = Some(plan.runtime.clone()); + Ok(()) +} + +pub fn apply_texture_plan_to_catalog( + catalog: &mut shared::RuntimeContentCatalog, + asset_id: &shared::AssetId, + plan: &TextureArtifactPlan, +) -> Result<(), String> { + let record = catalog + .records + .iter_mut() + .find(|record| &record.id == asset_id) + .ok_or_else(|| format!("runtime catalog is missing Texture asset {asset_id:?}"))?; + record.texture = Some(plan.runtime.clone()); + Ok(()) +} + +pub fn normalize_texture_settings(path: &str, settings: &mut TextureImportSettings) { + if settings.semantic == TextureAssetSemantic::Auto { + settings.semantic = infer_texture_semantic(path); + } + if settings.color_space == TextureColorSpace::Auto { + settings.color_space = match settings.semantic { + TextureAssetSemantic::Color | TextureAssetSemantic::Ui => TextureColorSpace::Srgb, + TextureAssetSemantic::Auto + | TextureAssetSemantic::Normal + | TextureAssetSemantic::MaskData + | TextureAssetSemantic::Hdr => TextureColorSpace::Linear, + }; + } + if settings.compression == TextureCompression::Auto { + settings.compression = if settings.semantic == TextureAssetSemantic::Hdr { + TextureCompression::Uncompressed + } else { + TextureCompression::Uastc + }; + } + settings.anisotropy = settings.anisotropy.clamp(1, 16); + settings.max_dimension = settings.max_dimension.filter(|dimension| *dimension > 0); +} + +pub fn infer_texture_semantic(path: &str) -> TextureAssetSemantic { + let lowercase = path.to_ascii_lowercase(); + if lowercase.ends_with(".hdr") || lowercase.ends_with(".exr") { + TextureAssetSemantic::Hdr + } else if ["_normal", "_nor", "_nrm", "normal.", "nor_gl", "nor_dx"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + TextureAssetSemantic::Normal + } else if [ + "_arm", + "_orm", + "_rma", + "_mask", + "_rough", + "_metal", + "_ao", + "occlusion", + "height", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + TextureAssetSemantic::MaskData + } else { + TextureAssetSemantic::Color + } +} + +fn resize_to_limit(image: RgbaImage, max_dimension: Option) -> RgbaImage { + let Some(limit) = max_dimension else { + return image; + }; + let largest = image.width().max(image.height()); + if largest <= limit { + return image; + } + let scale = limit as f64 / largest as f64; + let width = ((image.width() as f64 * scale).round() as u32).max(1); + let height = ((image.height() as f64 * scale).round() as u32).max(1); + image::imageops::resize(&image, width, height, FilterType::Lanczos3) +} + +pub fn load_resolved_material_inputs( + project_root: &Path, + record: &AssetRecord, +) -> Result { + let path = project_root.join(&record.path); + match record.kind { + AssetKind::Material => Ok(load_ron::(&path)?.inputs), + AssetKind::MaterialInstance => { + let instance = load_ron::(&path)?; + let base_path = instance + .base + .0 + .source_path + .as_deref() + .ok_or_else(|| format!("{} has no loadable base Material path", record.path))?; + let mut inputs = load_ron::(&project_root.join(base_path))?.inputs; + merge_input_sets(&mut inputs, &instance.overrides); + Ok(inputs) + } + _ => Err(format!("{} is not a Material asset", record.path)), + } +} + +/// Returns direct Material Instance consumers of one base Material. +/// +/// Blacksite currently supports direct-base instances only, so this is the complete dependency +/// closure for a base Material packing change. +pub fn direct_material_instance_dependents( + project_root: &Path, + registry: &AssetRegistryDocument, + base: &AssetRecord, +) -> Vec { + registry + .records + .iter() + .filter(|record| record.kind == AssetKind::MaterialInstance) + .filter_map(|record| { + let instance = + load_ron::(&project_root.join(&record.path)).ok()?; + reference_targets_record(&instance.base.0, base).then(|| record.clone()) + }) + .collect() +} + +/// Returns project Materials and direct Material Instances whose effective inputs reference a +/// Texture through its stable registry identity (with source path as a guarded legacy fallback). +pub fn material_dependents_of_texture( + project_root: &Path, + registry: &AssetRegistryDocument, + texture: &AssetRecord, +) -> Vec { + registry + .records + .iter() + .filter(|record| { + matches!( + record.kind, + AssetKind::Material | AssetKind::MaterialInstance + ) + }) + .filter_map(|record| { + let inputs = load_resolved_material_inputs(project_root, record).ok()?; + inputs + .textures + .iter() + .filter_map(|binding| binding.texture.as_ref()) + .any(|reference| reference_targets_record(reference, texture)) + .then(|| record.clone()) + }) + .collect() +} + +fn reference_targets_record(reference: &shared::EditorAssetRef, record: &AssetRecord) -> bool { + (!reference.asset_id.is_empty() && reference.asset_id == record.id.as_string()) + || reference.source_path.as_deref() == Some(record.path.as_str()) +} + +fn load_ron(path: &Path) -> Result { + let source = std::fs::read_to_string(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + ron::from_str(&source).map_err(|error| format!("invalid RON in {}: {error}", path.display())) +} + +fn merge_input_sets(base: &mut MaterialInputSet, overrides: &MaterialInputSet) { + for value in &overrides.values { + if let Some(existing) = base + .values + .iter_mut() + .find(|entry| entry.name == value.name) + { + *existing = value.clone(); + } else { + base.values.push(value.clone()); + } + } + for texture in &overrides.textures { + if let Some(existing) = base + .textures + .iter_mut() + .find(|entry| entry.name == texture.name) + { + *existing = texture.clone(); + } else { + base.textures.push(texture.clone()); + } + } +} + +fn load_binding_image( + project_root: &Path, + registry: &AssetRegistryDocument, + binding: &MaterialTextureBinding, +) -> Result<(Vec, RgbaImage), String> { + let (_, bytes, _) = binding_source(project_root, registry, binding)?; + let image = image::load_from_memory(&bytes) + .map_err(|error| format!("could not decode Texture for {}: {error}", binding.name))? + .to_rgba8(); + Ok((bytes, image)) +} + +fn binding_source( + project_root: &Path, + registry: &AssetRegistryDocument, + binding: &MaterialTextureBinding, +) -> Result<(String, Vec, TextureImportSettings), String> { + let reference = binding + .texture + .as_ref() + .ok_or_else(|| format!("Material input {} is unassigned", binding.name))?; + let record = registry.records.iter().find(|record| { + record.id.as_string() == reference.asset_id + || reference + .source_path + .as_deref() + .is_some_and(|path| record.path == path) + }); + let path = record + .map(|record| record.path.clone()) + .or_else(|| reference.source_path.clone()) + .ok_or_else(|| format!("Texture {} has no project path", reference.label))?; + let bytes = std::fs::read(project_root.join(&path)) + .map_err(|error| format!("could not read Texture {path}: {error}"))?; + let mut settings = record + .and_then(|record| record.texture_import().cloned()) + .unwrap_or_default(); + normalize_texture_settings(&path, &mut settings); + Ok((path, bytes, settings)) +} + +fn sample_scalar( + image: Option<&(Vec, RgbaImage)>, + binding: Option<&MaterialTextureBinding>, + x: u32, + y: u32, + fallback: u8, +) -> u8 { + let (Some((_, image)), Some(binding)) = (image, binding) else { + return fallback; + }; + let pixel = image.get_pixel(x, y).0; + match binding.channel { + TextureChannel::R | TextureChannel::Rgb | TextureChannel::Rgba => pixel[0], + TextureChannel::G => pixel[1], + TextureChannel::B => pixel[2], + TextureChannel::A => pixel[3], + } +} + +fn flip_normal_green(image: &mut RgbaImage) { + for pixel in image.pixels_mut() { + pixel.0[1] = 255_u8.saturating_sub(pixel.0[1]); + } +} + +fn requested_mip_count(width: u32, height: u32, mode: TextureMipmapMode) -> u32 { + if mode != TextureMipmapMode::Generate { + return 1; + } + width.max(height).ilog2() + 1 +} + +fn generate_mips(image: &RgbaImage, mode: TextureMipmapMode) -> Vec { + let mut levels = vec![image.clone()]; + if mode != TextureMipmapMode::Generate { + return levels; + } + while levels + .last() + .is_some_and(|level| level.width() > 1 || level.height() > 1) + { + let previous = levels.last().expect("mip chain has a base level"); + let width = (previous.width() / 2).max(1); + let height = (previous.height() / 2).max(1); + levels.push(image::imageops::resize( + previous, + width, + height, + FilterType::Lanczos3, + )); + } + levels +} + +fn encode_uastc( + image: &RgbaImage, + is_srgb: bool, + generate_mipmaps: bool, +) -> Result, String> { + let mut params = basis_universal::CompressorParams::new(); + params.set_basis_format(basis_universal::BasisTextureFormat::UASTC4x4); + params.set_uastc_quality_level(basis_universal::UASTC_QUALITY_DEFAULT); + params.set_generate_mipmaps(generate_mipmaps); + params.set_print_status_to_stdout(false); + params.set_color_space(if is_srgb { + basis_universal::ColorSpace::Srgb + } else { + basis_universal::ColorSpace::Linear + }); + params + .source_image_mut(0) + .init(image.as_raw(), image.width(), image.height(), 4); + let mut compressor = basis_universal::Compressor::new(4); + // SAFETY: the image is tightly packed RGBA8 and all compressor parameters above are valid. + unsafe { + compressor.init(¶ms); + compressor + .process() + .map_err(|error| format!("UASTC compression failed: {error:?}"))?; + } + Ok(compressor.basis_file().to_vec()) +} + +fn encode_uncompressed_ktx2(mips: &[RgbaImage], is_srgb: bool) -> Result, String> { + let Some(base) = mips.first() else { + return Err("cannot encode an empty mip chain".into()); + }; + let level_count = u32::try_from(mips.len()).map_err(|_| "too many mip levels".to_string())?; + let dfd = rgba8_data_format_descriptor(is_srgb); + let header_len = 80_usize; + let level_index_len = mips.len() * 24; + let dfd_offset = header_len + level_index_len; + let data_offset = align_to(dfd_offset + dfd.len(), 8); + let mut level_offsets = Vec::with_capacity(mips.len()); + let mut cursor = data_offset; + for level in mips { + cursor = align_to(cursor, 8); + level_offsets.push(cursor); + cursor += level.as_raw().len(); + } + let mut bytes = Vec::with_capacity(cursor); + bytes.extend_from_slice(&[ + 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A, + ]); + push_u32(&mut bytes, if is_srgb { 43 } else { 37 }); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, base.width()); + push_u32(&mut bytes, base.height()); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, level_count); + push_u32(&mut bytes, 0); + push_u32( + &mut bytes, + u32::try_from(dfd_offset).map_err(|_| "KTX2 is too large".to_string())?, + ); + push_u32(&mut bytes, u32::try_from(dfd.len()).unwrap_or(u32::MAX)); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 0); + push_u64(&mut bytes, 0); + push_u64(&mut bytes, 0); + for (level, offset) in mips.iter().zip(&level_offsets) { + push_u64( + &mut bytes, + u64::try_from(*offset).map_err(|_| "KTX2 is too large".to_string())?, + ); + push_u64(&mut bytes, level.as_raw().len() as u64); + push_u64(&mut bytes, level.as_raw().len() as u64); + } + bytes.extend_from_slice(&dfd); + bytes.resize(data_offset, 0); + for (level, offset) in mips.iter().zip(level_offsets) { + bytes.resize(offset, 0); + bytes.extend_from_slice(level.as_raw()); + } + Ok(bytes) +} + +fn rgba8_data_format_descriptor(is_srgb: bool) -> Vec { + let mut dfd = Vec::with_capacity(92); + push_u32(&mut dfd, 92); + push_u16(&mut dfd, 0); + push_u16(&mut dfd, 0); + push_u16(&mut dfd, 2); + push_u16(&mut dfd, 88); + dfd.extend_from_slice(&[1, 1, if is_srgb { 2 } else { 1 }, 0]); + dfd.extend_from_slice(&[0, 0, 0, 0]); + dfd.extend_from_slice(&[4, 0, 0, 0, 0, 0, 0, 0]); + for (bit_offset, channel) in [(0_u16, 0_u8), (8, 1), (16, 2), (24, 15)] { + push_u16(&mut dfd, bit_offset); + dfd.extend_from_slice(&[7, channel]); + dfd.extend_from_slice(&[0, 0, 0, 0]); + push_u32(&mut dfd, 0); + push_u32(&mut dfd, 255); + } + dfd +} + +fn align_to(value: usize, alignment: usize) -> usize { + value.div_ceil(alignment) * alignment +} + +fn push_u16(bytes: &mut Vec, value: u16) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u32(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u64(bytes: &mut Vec, value: u64) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + use shared::{ + AssetId, AssetImportSettings, EditorAssetRef, MaterialParameter, MaterialParameterValue, + MaterialRef, ShaderRefDesc, + }; + use uuid::Uuid; + + fn test_root(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "blacksite-texture-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + fn record(id: u128, path: &str, kind: AssetKind) -> AssetRecord { + AssetRecord { + id: AssetId(Uuid::from_u128(id)), + path: path.into(), + label: path.into(), + kind, + source_fingerprint: None, + import_settings: if kind == AssetKind::Texture { + AssetImportSettings::Texture(TextureImportSettings::default()) + } else { + AssetImportSettings::None + }, + dependencies: Vec::new(), + } + } + + fn material(label: &str, texture: Option, metallic: f32) -> MaterialAsset { + MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: label.into(), + shader: ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: MaterialInputSet { + values: vec![MaterialParameter { + name: "metallic".into(), + value: MaterialParameterValue::Float(metallic), + }], + textures: texture + .map(|texture| MaterialTextureBinding { + name: "metallic".into(), + texture: Some(texture), + channel: TextureChannel::B, + }) + .into_iter() + .collect(), + }, + } + } + + fn write_ron(path: &Path, value: &impl serde::Serialize) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + } + + #[test] + fn infers_poly_haven_texture_roles() { + assert_eq!( + infer_texture_semantic("metal_office_desk_diff_2k.jpg"), + TextureAssetSemantic::Color + ); + assert_eq!( + infer_texture_semantic("metal_office_desk_nor_gl_2k.png"), + TextureAssetSemantic::Normal + ); + assert_eq!( + infer_texture_semantic("metal_office_desk_arm_2k.jpg"), + TextureAssetSemantic::MaskData + ); + } + + #[test] + fn normal_y_conversion_is_exact() { + let mut image = RgbaImage::from_pixel(1, 1, Rgba([1, 64, 3, 255])); + flip_normal_green(&mut image); + assert_eq!(image.get_pixel(0, 0).0, [1, 191, 3, 255]); + } + + #[test] + fn uncompressed_ktx2_contains_all_mips() { + let image = RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 4])); + let mips = generate_mips(&image, TextureMipmapMode::Generate); + let encoded = encode_uncompressed_ktx2(&mips, false).expect("KTX2 should encode"); + assert_eq!( + &encoded[..12], + &[0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A] + ); + assert_eq!(u32::from_le_bytes(encoded[40..44].try_into().unwrap()), 3); + } + + #[test] + fn existing_texture_artifact_is_reused_before_source_decode() { + let root = test_root("texture-reuse"); + let texture = record(21, "assets/textures/cached.png", AssetKind::Texture); + let source_bytes = b"intentionally not an image"; + std::fs::create_dir_all(root.join("assets/textures")).unwrap(); + std::fs::write(root.join(&texture.path), source_bytes).unwrap(); + + let mut settings = texture.texture_import().cloned().unwrap(); + normalize_texture_settings(&texture.path, &mut settings); + let settings_bytes = ron::ser::to_string(&settings).unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(source_bytes); + hasher.update(settings_bytes.as_bytes()); + hasher.update(&TEXTURE_PROCESSOR_VERSION.to_le_bytes()); + let processing_key = &hasher.finalize().to_hex()[..24]; + let output = root.join(format!( + "assets/.import-cache/runtime/textures/{}/{processing_key}/texture.basis", + texture.id.as_string() + )); + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + std::fs::write(&output, b"cached artifact").unwrap(); + + let plan = plan_texture_artifact(&root, &texture).unwrap(); + assert!(plan.reused); + assert!(plan.output_bytes.is_empty()); + assert_eq!(plan.output_path, output); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn packing_signature_ignores_owner_and_scalar_multipliers() { + let root = test_root("signature"); + let texture = record(1, "assets/textures/arm.bin", AssetKind::Texture); + std::fs::create_dir_all(root.join("assets/textures")).unwrap(); + std::fs::write(root.join(&texture.path), b"stable packed source").unwrap(); + let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM") + .with_source_path(&texture.path); + let first = record(2, "assets/materials/first.ron", AssetKind::Material); + let second = record(3, "assets/materials/second.ron", AssetKind::Material); + write_ron( + &root.join(&first.path), + &material("First", Some(texture_ref.clone()), 0.1), + ); + write_ron( + &root.join(&second.path), + &material("Second", Some(texture_ref), 0.9), + ); + let registry = AssetRegistryDocument { + records: vec![texture, first.clone(), second.clone()], + ..Default::default() + }; + + assert_eq!( + material_packing_signature(&root, &first, ®istry).unwrap(), + material_packing_signature(&root, &second, ®istry).unwrap(), + "owner identity and scalar multipliers must not invalidate identical packed data" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unassigned_scalar_texture_rows_do_not_request_packing() { + let root = test_root("unassigned-packing"); + let material_record = record(22, "assets/materials/plain.ron", AssetKind::Material); + let mut authored = material("Plain", None, 0.5); + authored.inputs.textures.push(MaterialTextureBinding { + name: "occlusion".into(), + texture: None, + channel: TextureChannel::R, + }); + write_ron(&root.join(&material_record.path), &authored); + let registry = AssetRegistryDocument { + records: vec![material_record.clone()], + ..Default::default() + }; + + assert!( + material_packing_signature(&root, &material_record, ®istry) + .unwrap() + .is_none() + ); + assert!(plan_material_artifact(&root, &material_record, ®istry) + .unwrap() + .is_none()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn existing_material_artifact_is_reused_before_texture_decode() { + let root = test_root("material-reuse"); + let texture = record(31, "assets/textures/arm.bin", AssetKind::Texture); + let material_record = record(32, "assets/materials/cached.ron", AssetKind::Material); + std::fs::create_dir_all(root.join("assets/textures")).unwrap(); + std::fs::write(root.join(&texture.path), b"intentionally not an image").unwrap(); + let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM") + .with_source_path(&texture.path); + write_ron( + &root.join(&material_record.path), + &material("Cached", Some(texture_ref), 1.0), + ); + let registry = AssetRegistryDocument { + records: vec![texture, material_record.clone()], + ..Default::default() + }; + let signature = material_packing_signature(&root, &material_record, ®istry) + .unwrap() + .unwrap(); + let output = root.join(format!( + "assets/.import-cache/runtime/materials/{}/{}-arm.basis", + material_record.id.as_string(), + signature.0 + )); + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + std::fs::write(&output, b"cached artifact").unwrap(); + + let plan = plan_material_artifact(&root, &material_record, ®istry) + .unwrap() + .unwrap(); + assert!(plan.reused); + assert!(plan.output_bytes.is_empty()); + assert_eq!(plan.output_path, output); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn dependency_closure_uses_stable_material_and_texture_references() { + let root = test_root("dependencies"); + let texture = record(11, "assets/textures/arm.bin", AssetKind::Texture); + let base = record(12, "assets/materials/base.ron", AssetKind::Material); + let instance = record( + 13, + "assets/materials/base_instance.ron", + AssetKind::MaterialInstance, + ); + let unrelated = record(14, "assets/materials/unrelated.ron", AssetKind::Material); + let texture_ref = EditorAssetRef::new(texture.id.as_string(), "texture:source", "ARM") + .with_source_path(&texture.path); + write_ron( + &root.join(&base.path), + &material("Base", Some(texture_ref), 1.0), + ); + write_ron( + &root.join(&unrelated.path), + &material("Unrelated", None, 1.0), + ); + write_ron( + &root.join(&instance.path), + &MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Base Instance".into(), + base: MaterialRef::new( + EditorAssetRef::new(base.id.as_string(), "material:source", "Base") + .with_source_path(&base.path), + ), + overrides: MaterialInputSet::default(), + }, + ); + let registry = AssetRegistryDocument { + records: vec![texture.clone(), base.clone(), instance.clone(), unrelated], + ..Default::default() + }; + + assert_eq!( + direct_material_instance_dependents(&root, ®istry, &base) + .into_iter() + .map(|record| record.id) + .collect::>(), + vec![instance.id.clone()] + ); + let dependents = material_dependents_of_texture(&root, ®istry, &texture) + .into_iter() + .map(|record| record.id) + .collect::>(); + assert!(dependents.contains(&base.id)); + assert!(dependents.contains(&instance.id)); + assert_eq!(dependents.len(), 2); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/content_pipeline/src/transaction.rs b/crates/content_pipeline/src/transaction.rs new file mode 100644 index 0000000..ea31800 --- /dev/null +++ b/crates/content_pipeline/src/transaction.rs @@ -0,0 +1,1544 @@ +use crate::{validate_asset_path, write_if_changed, REGISTRY_PATH, RUNTIME_CATALOG_PATH}; +use serde::{Deserialize, Serialize}; +use shared::AssetRegistryDocument; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ContentOperation { + CreateFolder { + path: PathBuf, + }, + Move { + source: PathBuf, + destination: PathBuf, + }, + Copy { + source: PathBuf, + destination: PathBuf, + }, + WriteFile { + path: PathBuf, + bytes: Vec, + }, + /// Replace one existing authored file only if its reviewed bytes are still current. + ReplaceFile { + path: PathBuf, + bytes: Vec, + expected_fingerprint: String, + previous_bytes: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReferenceRewrite { + pub document: PathBuf, + pub from: String, + pub to: String, +} + +/// One authored or registry-owned reference that will become unresolved if selected content is +/// removed. This is intentionally read-only: callers use it to build destructive-operation +/// previews and watcher diagnostics. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ContentReferenceUsage { + pub document: PathBuf, + pub reference: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentTransactionPreview { + pub operations: Vec, + pub affected_asset_ids: Vec, + pub reference_rewrites: Vec, + pub collisions: Vec, +} + +/// Returns a deterministic digest for a file or directory tree used by guarded content undo. +pub fn tree_fingerprint(path: &Path) -> Result { + if !path.exists() { + return Err(format!("content path no longer exists: {}", path.display())); + } + let mut hasher = blake3::Hasher::new(); + if path.is_file() { + hasher.update(b"file\0"); + hasher.update( + &fs::read(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?, + ); + } else { + hasher.update(b"directory\0"); + let mut entries: Vec = WalkDir::new(path) + .follow_links(false) + .into_iter() + .collect::, _>>() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))? + .into_iter() + .filter(|entry| entry.path() != path) + .map(|entry| entry.into_path()) + .collect(); + entries.sort(); + for entry in entries { + let relative = entry.strip_prefix(path).unwrap_or(&entry); + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update(&[0]); + if entry.is_file() { + hasher.update(b"file\0"); + hasher.update( + &fs::read(&entry) + .map_err(|error| format!("could not read {}: {error}", entry.display()))?, + ); + } else { + hasher.update(b"directory\0"); + } + } + } + Ok(hasher.finalize().to_hex().to_string()) +} + +impl ContentTransactionPreview { + pub fn is_committable(&self) -> bool { + self.collisions.is_empty() + } +} + +pub fn preview_transaction( + project_root: &Path, + operations: Vec, + registry: &AssetRegistryDocument, +) -> Result { + let mut collisions = Vec::new(); + let mut affected_asset_ids = Vec::new(); + let mut reference_rewrites = Vec::new(); + let mut planned_destinations = HashSet::new(); + for operation in &operations { + match operation { + ContentOperation::CreateFolder { path } => { + validate_asset_path(path)?; + if project_root.join(path).exists() { + collisions.push(path.clone()); + } + if !planned_destinations.insert(normalize(path)) { + collisions.push(path.clone()); + } + } + ContentOperation::WriteFile { path, .. } => { + validate_asset_path(path)?; + if project_root.join(path).exists() { + collisions.push(path.clone()); + } + if !planned_destinations.insert(normalize(path)) { + collisions.push(path.clone()); + } + } + ContentOperation::ReplaceFile { + path, + expected_fingerprint, + .. + } => { + validate_asset_path(path)?; + let destination = project_root.join(path); + validate_regular_tree(&destination)?; + if !destination.is_file() { + return Err(format!( + "replacement target is not a file: {}", + path.display() + )); + } + let actual = tree_fingerprint(&destination)?; + if actual != *expected_fingerprint { + return Err(format!( + "replacement target changed after review: {}", + path.display() + )); + } + if !planned_destinations.insert(normalize(path)) { + collisions.push(path.clone()); + } + } + ContentOperation::Move { + source, + destination, + } + | ContentOperation::Copy { + source, + destination, + } => { + validate_asset_path(source)?; + validate_asset_path(destination)?; + validate_regular_tree(&project_root.join(source))?; + if project_root.join(destination).exists() { + collisions.push(destination.clone()); + } + if !planned_destinations.insert(normalize(destination)) { + collisions.push(destination.clone()); + } + if path_is_or_descendant(&normalize(destination), &normalize(source)) { + return Err(format!( + "destination cannot be inside its source: {} -> {}", + source.display(), + destination.display() + )); + } + let from = normalize(source); + let to = normalize(destination); + for record in ®istry.records { + if path_is_or_descendant(&record.path, &from) { + affected_asset_ids.push(record.id.as_string()); + } + } + if matches!(operation, ContentOperation::Move { .. }) { + reference_rewrites.extend(find_reference_rewrites(project_root, &from, &to)?); + } + } + } + } + affected_asset_ids.sort(); + affected_asset_ids.dedup(); + reference_rewrites.sort_by(|left, right| left.document.cmp(&right.document)); + reference_rewrites.dedup(); + Ok(ContentTransactionPreview { + operations, + affected_asset_ids, + reference_rewrites, + collisions, + }) +} + +/// Commits filesystem, reference-cache, and registry changes as one rollback-capable unit. +pub fn commit_transaction( + project_root: &Path, + preview: &ContentTransactionPreview, + registry: &mut AssetRegistryDocument, +) -> Result<(), String> { + commit_transaction_with_registry_update(project_root, preview, registry, |_| Ok(())) +} + +/// Commits a content transaction and applies a dependent registry edit after new files have +/// received stable asset IDs, but before the registry and runtime catalog are published. +/// Registry-edit failures participate in the same byte-restoring rollback as filesystem errors. +pub fn commit_transaction_with_registry_update( + project_root: &Path, + preview: &ContentTransactionPreview, + registry: &mut AssetRegistryDocument, + registry_update: impl FnOnce(&mut AssetRegistryDocument) -> Result<(), String>, +) -> Result<(), String> { + if !preview.is_committable() { + return Err(format!( + "content transaction has {} collision(s)", + preview.collisions.len() + )); + } + let original_registry = registry.clone(); + let registry_path = project_root.join(REGISTRY_PATH); + let original_registry_bytes = fs::read(®istry_path).ok(); + let runtime_catalog_path = project_root.join(RUNTIME_CATALOG_PATH); + let original_runtime_catalog_bytes = fs::read(&runtime_catalog_path).ok(); + let backup_root = project_root + .join("assets/.index/transactions") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&backup_root) + .map_err(|error| format!("could not create transaction staging: {error}"))?; + let mut applied_operations = vec![false; preview.operations.len()]; + + let result: Result<(), String> = (|| { + for (index, rewrite) in preview.reference_rewrites.iter().enumerate() { + let path = project_root.join(&rewrite.document); + let bytes = fs::read(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + fs::write(backup_root.join(format!("reference-{index}.bak")), &bytes) + .map_err(|error| format!("could not back up {}: {error}", path.display()))?; + let source = String::from_utf8(bytes) + .map_err(|_| format!("reference document is not UTF-8: {}", path.display()))?; + let replaced = replace_quoted_path_prefix(&source, &rewrite.from, &rewrite.to); + write_if_changed(&path, replaced.as_bytes())?; + } + + for (index, operation) in preview.operations.iter().enumerate() { + match operation { + ContentOperation::CreateFolder { path } => { + fs::create_dir_all(project_root.join(path)) + .map_err(|error| format!("could not create {}: {error}", path.display()))?; + applied_operations[index] = true; + } + ContentOperation::Move { + source, + destination, + } => { + let source_path = project_root.join(source); + let destination_path = project_root.join(destination); + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("could not create {}: {error}", parent.display()) + })?; + } + fs::rename(&source_path, &destination_path).map_err(|error| { + format!( + "could not move {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + applied_operations[index] = true; + fs::write( + backup_root.join(format!("move-{index}.ron")), + format!("{}\n{}", source.display(), destination.display()), + ) + .map_err(|error| format!("could not record move rollback: {error}"))?; + rewrite_registry_paths( + registry, + &normalize(source), + &normalize(destination), + false, + ); + } + ContentOperation::Copy { + source, + destination, + } => { + if let Err(error) = + copy_tree(&project_root.join(source), &project_root.join(destination)) + { + let target = project_root.join(destination); + let _ = if target.is_dir() { + fs::remove_dir_all(target) + } else { + fs::remove_file(target) + }; + return Err(error); + } + applied_operations[index] = true; + let id_remap = duplicate_registry_records( + registry, + &normalize(source), + &normalize(destination), + ); + rewrite_copied_reference_tree( + &project_root.join(destination), + &normalize(source), + &normalize(destination), + &id_remap, + )?; + } + ContentOperation::WriteFile { path, bytes } => { + let destination = project_root.join(path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("could not create {}: {error}", parent.display()) + })?; + } + fs::write(&destination, bytes).map_err(|error| { + format!("could not write {}: {error}", destination.display()) + })?; + applied_operations[index] = true; + register_written_asset(registry, path, bytes)?; + } + ContentOperation::ReplaceFile { + path, + bytes, + expected_fingerprint, + previous_bytes, + } => { + let destination = project_root.join(path); + let actual = tree_fingerprint(&destination)?; + if actual != *expected_fingerprint { + return Err(format!( + "replacement target changed after review: {}", + path.display() + )); + } + let original = fs::read(&destination).map_err(|error| { + format!("could not read {}: {error}", destination.display()) + })?; + if original != *previous_bytes { + return Err(format!( + "replacement target bytes changed after review: {}", + path.display() + )); + } + fs::write(backup_root.join(format!("replace-{index}.bak")), original).map_err( + |error| format!("could not back up {}: {error}", destination.display()), + )?; + // A failed write may have truncated the file, so rollback owns it from here. + applied_operations[index] = true; + fs::write(&destination, bytes).map_err(|error| { + format!("could not replace {}: {error}", destination.display()) + })?; + update_replaced_asset(registry, path, bytes)?; + } + } + } + registry_update(registry)?; + registry + .records + .sort_by(|left, right| left.path.cmp(&right.path)); + let bytes = crate::serialize_registry(registry)?; + write_if_changed(®istry_path, &bytes)?; + let runtime_catalog = shared::RuntimeContentCatalog::from(&*registry); + let runtime_bytes = crate::serialize_runtime_catalog(&runtime_catalog)?; + write_if_changed(&runtime_catalog_path, &runtime_bytes)?; + Ok(()) + })(); + + if let Err(error) = result { + rollback_transaction( + project_root, + preview, + &applied_operations, + &backup_root, + original_registry_bytes.as_deref(), + original_runtime_catalog_bytes.as_deref(), + ); + *registry = original_registry; + let _ = fs::remove_dir_all(&backup_root); + return Err(format!("content transaction rolled back: {error}")); + } + fs::remove_dir_all(&backup_root) + .map_err(|error| format!("transaction committed but staging cleanup failed: {error}"))?; + Ok(()) +} + +fn rollback_transaction( + project_root: &Path, + preview: &ContentTransactionPreview, + applied_operations: &[bool], + backup_root: &Path, + original_registry: Option<&[u8]>, + original_runtime_catalog: Option<&[u8]>, +) { + for (index, operation) in preview.operations.iter().enumerate().rev() { + if !applied_operations.get(index).copied().unwrap_or(false) { + continue; + } + match operation { + ContentOperation::CreateFolder { path } => { + let _ = fs::remove_dir(project_root.join(path)); + } + ContentOperation::Move { + source, + destination, + } => { + let _ = fs::rename(project_root.join(destination), project_root.join(source)); + } + ContentOperation::Copy { destination, .. } => { + let target = project_root.join(destination); + let _ = if target.is_dir() { + fs::remove_dir_all(target) + } else { + fs::remove_file(target) + }; + } + ContentOperation::WriteFile { path, .. } => { + let _ = fs::remove_file(project_root.join(path)); + } + ContentOperation::ReplaceFile { path, .. } => { + if let Ok(bytes) = fs::read(backup_root.join(format!("replace-{index}.bak"))) { + let _ = fs::write(project_root.join(path), bytes); + } + } + } + } + for (index, rewrite) in preview.reference_rewrites.iter().enumerate() { + if let Ok(bytes) = fs::read(backup_root.join(format!("reference-{index}.bak"))) { + let _ = fs::write(project_root.join(&rewrite.document), bytes); + } + } + if let Some(bytes) = original_registry { + let _ = fs::write(project_root.join(REGISTRY_PATH), bytes); + } else { + let _ = fs::remove_file(project_root.join(REGISTRY_PATH)); + } + if let Some(bytes) = original_runtime_catalog { + let _ = fs::write(project_root.join(RUNTIME_CATALOG_PATH), bytes); + } else { + let _ = fs::remove_file(project_root.join(RUNTIME_CATALOG_PATH)); + } +} + +fn register_written_asset( + registry: &mut AssetRegistryDocument, + path: &Path, + bytes: &[u8], +) -> Result<(), String> { + let normalized = normalize(path); + if registry + .records + .iter() + .any(|record| record.path == normalized) + { + return Err(format!("registry already contains {}", path.display())); + } + let kind = crate::classify_asset(path); + let label = if kind == shared::AssetKind::Material { + ron::de::from_bytes::(bytes) + .map(|asset| asset.label) + .unwrap_or_else(|_| file_label(path)) + } else if kind == shared::AssetKind::MaterialInstance { + ron::de::from_bytes::(bytes) + .map(|asset| asset.label) + .unwrap_or_else(|_| file_label(path)) + } else { + file_label(path) + }; + registry.records.push(shared::AssetRecord { + id: shared::AssetId::new(), + path: normalized, + label, + kind, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::for_kind(kind), + dependencies: Vec::new(), + }); + Ok(()) +} + +fn update_replaced_asset( + registry: &mut AssetRegistryDocument, + path: &Path, + bytes: &[u8], +) -> Result<(), String> { + let normalized = normalize(path); + let record = registry + .records + .iter_mut() + .find(|record| record.path == normalized) + .ok_or_else(|| { + format!( + "registry does not contain replacement target {}", + path.display() + ) + })?; + record.kind = crate::classify_asset(path); + record.label = if record.kind == shared::AssetKind::Material { + ron::de::from_bytes::(bytes) + .map(|asset| asset.label) + .map_err(|error| format!("replacement Material is invalid: {error}"))? + } else if record.kind == shared::AssetKind::MaterialInstance { + ron::de::from_bytes::(bytes) + .map(|asset| asset.label) + .map_err(|error| format!("replacement Material Instance is invalid: {error}"))? + } else { + file_label(path) + }; + Ok(()) +} + +fn file_label(path: &Path) -> String { + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("Asset") + .to_string() +} + +fn rewrite_registry_paths( + registry: &mut AssetRegistryDocument, + from: &str, + to: &str, + regenerate_ids: bool, +) { + if let Some(reference) = &mut registry.defaults.default_material { + rewrite_material_reference_path(reference, from, to); + } + for record in &mut registry.records { + let moved = path_is_or_descendant(&record.path, from); + rewrite_asset_record_paths(record, from, to); + if moved && regenerate_ids { + record.id = shared::AssetId::new(); + } + } +} + +fn rewrite_asset_record_paths(record: &mut shared::AssetRecord, from: &str, to: &str) { + if path_is_or_descendant(&record.path, from) { + record.path = replace_prefix(&record.path, from, to); + } + for dependency in &mut record.dependencies { + if path_is_or_descendant(dependency, from) { + *dependency = replace_prefix(dependency, from, to); + } + } + if let Some(settings) = record.import_settings.model_mut() { + for slot in &mut settings.material_slots { + if let shared::ModelMaterialSelection::Project(reference) = &mut slot.selection { + rewrite_material_reference_path(reference, from, to); + } + } + for orphan in &mut settings.orphaned_material_slots { + rewrite_editor_asset_reference_path(&mut orphan.material.0, from, to); + } + rewrite_optional_path(&mut settings.static_mesh_manifest_path, from, to); + rewrite_optional_path(&mut settings.animation_manifest_path, from, to); + } +} + +fn rewrite_material_reference_path(reference: &mut shared::MaterialRef, from: &str, to: &str) { + rewrite_editor_asset_reference_path(&mut reference.0, from, to); +} + +fn rewrite_editor_asset_reference_path( + reference: &mut shared::EditorAssetRef, + from: &str, + to: &str, +) { + rewrite_optional_path(&mut reference.source_path, from, to); +} + +fn rewrite_optional_path(path: &mut Option, from: &str, to: &str) { + if let Some(path) = path { + if path_is_or_descendant(path, from) { + *path = replace_prefix(path, from, to); + } + } +} + +fn duplicate_registry_records( + registry: &mut AssetRegistryDocument, + from: &str, + to: &str, +) -> HashMap { + let mut duplicates = registry + .records + .iter() + .filter(|record| path_is_or_descendant(&record.path, from)) + .cloned() + .collect::>(); + let id_remap = duplicates + .iter() + .map(|record| (record.id.as_string(), shared::AssetId::new())) + .collect::>(); + for record in &mut duplicates { + let old_id = record.id.as_string(); + record.id = id_remap + .get(&old_id) + .expect("every duplicated record has a replacement ID") + .clone(); + rewrite_asset_record_paths(record, from, to); + remap_asset_record_references(record, &id_remap); + if let Some(settings) = record.import_settings.model_mut() { + settings.static_mesh_manifest_path = None; + settings.animation_manifest_path = None; + } + } + registry.records.extend(duplicates); + id_remap +} + +fn remap_asset_record_references( + record: &mut shared::AssetRecord, + ids: &HashMap, +) { + if let Some(settings) = record.import_settings.model_mut() { + for slot in &mut settings.material_slots { + if let shared::ModelMaterialSelection::Project(reference) = &mut slot.selection { + remap_editor_asset_reference(&mut reference.0, ids); + } + } + for orphan in &mut settings.orphaned_material_slots { + remap_editor_asset_reference(&mut orphan.material.0, ids); + } + } +} + +fn remap_editor_asset_reference( + reference: &mut shared::EditorAssetRef, + ids: &HashMap, +) { + if let Some(replacement) = ids.get(&reference.asset_id) { + reference.asset_id = replacement.as_string(); + } +} + +fn rewrite_copied_reference_tree( + destination: &Path, + from: &str, + to: &str, + id_remap: &HashMap, +) -> Result<(), String> { + for entry in WalkDir::new(destination).follow_links(false) { + let entry = entry.map_err(|error| { + format!( + "could not inspect copied references in {}: {error}", + destination.display() + ) + })?; + if !entry.file_type().is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("ron") + { + continue; + } + let bytes = fs::read(entry.path()) + .map_err(|error| format!("could not read {}: {error}", entry.path().display()))?; + let mut source = String::from_utf8(bytes).map_err(|_| { + format!( + "copied reference document is not UTF-8: {}", + entry.path().display() + ) + })?; + source = replace_quoted_path_prefix(&source, from, to); + for (old_id, new_id) in id_remap { + source = source.replace( + &format!("\"{old_id}\""), + &format!("\"{}\"", new_id.as_string()), + ); + } + write_if_changed(entry.path(), source.as_bytes())?; + } + Ok(()) +} + +fn find_reference_rewrites( + project_root: &Path, + from: &str, + to: &str, +) -> Result, String> { + let mut rewrites = Vec::new(); + for entry in WalkDir::new(project_root.join("assets")).follow_links(false) { + let entry = entry.map_err(|error| format!("could not inspect references: {error}"))?; + if !entry.file_type().is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("ron") + { + continue; + } + let relative = entry + .path() + .strip_prefix(project_root) + .map_err(|error| error.to_string())?; + if relative.starts_with("assets/.index") || relative.starts_with("assets/.trash") { + continue; + } + if fs::read_to_string(entry.path()) + .is_ok_and(|source| contains_quoted_path_prefix(&source, from)) + { + rewrites.push(ReferenceRewrite { + document: relative.to_path_buf(), + from: from.into(), + to: to.into(), + }); + } + } + Ok(rewrites) +} + +fn push_material_reference_usage( + usages: &mut Vec, + document: &Path, + owner: &shared::AssetRecord, + reference: &shared::MaterialRef, + sources: &[String], + selected_ids: &[String], +) { + let selected_by_id = selected_ids.iter().any(|id| id == &reference.0.asset_id); + let selected_by_path = reference.0.source_path.as_deref().is_some_and(|path| { + sources + .iter() + .any(|source| path_is_or_descendant(path, source)) + }); + if selected_by_id || selected_by_path { + usages.push(ContentReferenceUsage { + document: document.to_path_buf(), + reference: format!("model {}", owner.path), + }); + } +} + +/// Finds external usages of selected files or folders. References inside the selected tree are +/// excluded because they are removed as part of the same operation. +pub fn find_reference_usages( + project_root: &Path, + sources: &[PathBuf], + registry: &AssetRegistryDocument, +) -> Result, String> { + let sources = sources + .iter() + .map(|path| normalize(path)) + .collect::>(); + for source in &sources { + validate_asset_path(Path::new(source))?; + } + let selected_records = registry + .records + .iter() + .filter(|record| { + sources + .iter() + .any(|source| path_is_or_descendant(&record.path, source)) + }) + .collect::>(); + let selected_ids = selected_records + .iter() + .map(|record| record.id.as_string()) + .collect::>(); + let mut usages = Vec::new(); + + for entry in WalkDir::new(project_root.join("assets")).follow_links(false) { + let entry = entry.map_err(|error| format!("could not inspect references: {error}"))?; + if !entry.file_type().is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("ron") + { + continue; + } + let relative = entry + .path() + .strip_prefix(project_root) + .map_err(|error| error.to_string())?; + let relative_normalized = normalize(relative); + if relative.starts_with("assets/.index") + || relative.starts_with("assets/.trash") + || sources + .iter() + .any(|source| path_is_or_descendant(&relative_normalized, source)) + { + continue; + } + let Ok(source) = fs::read_to_string(entry.path()) else { + continue; + }; + for selected in &sources { + if contains_quoted_path_prefix(&source, selected) { + usages.push(ContentReferenceUsage { + document: relative.to_path_buf(), + reference: selected.clone(), + }); + } + } + for selected_id in &selected_ids { + if source.contains(&format!("\"{selected_id}\"")) { + usages.push(ContentReferenceUsage { + document: relative.to_path_buf(), + reference: selected_id.clone(), + }); + } + } + } + + let registry_path = PathBuf::from(REGISTRY_PATH); + for owner in ®istry.records { + if sources + .iter() + .any(|source| path_is_or_descendant(&owner.path, source)) + { + continue; + } + let Some(settings) = owner.import_settings.model() else { + continue; + }; + for slot in &settings.material_slots { + if let shared::ModelMaterialSelection::Project(reference) = &slot.selection { + push_material_reference_usage( + &mut usages, + ®istry_path, + owner, + reference, + &sources, + &selected_ids, + ); + } + } + for orphan in &settings.orphaned_material_slots { + push_material_reference_usage( + &mut usages, + ®istry_path, + owner, + &orphan.material, + &sources, + &selected_ids, + ); + } + for dependency in &owner.dependencies { + if sources + .iter() + .any(|source| path_is_or_descendant(dependency, source)) + { + usages.push(ContentReferenceUsage { + document: registry_path.clone(), + reference: format!("dependency of {}", owner.path), + }); + } + } + } + if let Some(reference) = ®istry.defaults.default_material { + let selected_by_id = selected_ids.iter().any(|id| id == &reference.0.asset_id); + let selected_by_path = reference.0.source_path.as_deref().is_some_and(|path| { + sources + .iter() + .any(|source| path_is_or_descendant(path, source)) + }); + if selected_by_id || selected_by_path { + usages.push(ContentReferenceUsage { + document: registry_path, + reference: "project default material".into(), + }); + } + } + usages.sort_by(|left, right| { + left.document + .cmp(&right.document) + .then_with(|| left.reference.cmp(&right.reference)) + }); + usages.dedup(); + Ok(usages) +} + +fn validate_regular_tree(path: &Path) -> Result<(), String> { + if !path.exists() { + return Err(format!("content source does not exist: {}", path.display())); + } + for entry in WalkDir::new(path).follow_links(false) { + let entry = + entry.map_err(|error| format!("could not inspect {}: {error}", path.display()))?; + if entry.file_type().is_symlink() { + return Err(format!( + "content transactions reject symlinks: {}", + entry.path().display() + )); + } + } + Ok(()) +} + +fn contains_quoted_path_prefix(source: &str, path: &str) -> bool { + source.contains(&format!("\"{path}\"")) || source.contains(&format!("\"{path}/")) +} + +fn replace_quoted_path_prefix(source: &str, from: &str, to: &str) -> String { + source + .replace(&format!("\"{from}\""), &format!("\"{to}\"")) + .replace(&format!("\"{from}/"), &format!("\"{to}/")) +} + +fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> { + if source.is_file() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + fs::copy(source, destination).map_err(|error| error.to_string())?; + return Ok(()); + } + for entry in WalkDir::new(source).follow_links(false) { + let entry = entry.map_err(|error| error.to_string())?; + let relative = entry + .path() + .strip_prefix(source) + .map_err(|error| error.to_string())?; + let target = destination.join(relative); + if entry.file_type().is_dir() { + fs::create_dir_all(&target).map_err(|error| error.to_string())?; + } else if entry.file_type().is_file() { + fs::copy(entry.path(), &target).map_err(|error| error.to_string())?; + } else { + return Err(format!( + "unsupported filesystem entry: {}", + entry.path().display() + )); + } + } + Ok(()) +} + +fn path_is_or_descendant(candidate: &str, parent: &str) -> bool { + candidate == parent + || candidate + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn replace_prefix(candidate: &str, from: &str, to: &str) -> String { + format!("{to}{}", &candidate[from.len()..]) +} + +fn normalize(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::{AssetImportSettings, AssetKind, AssetRecord, ImportSettings}; + + fn fixture() -> (PathBuf, AssetRegistryDocument) { + let root = std::env::temp_dir().join(format!( + "blacksite-content-transaction-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(root.join("assets/Props/Office")).unwrap(); + fs::create_dir_all(root.join("assets/levels")).unwrap(); + fs::create_dir_all(root.join("assets/.index")).unwrap(); + fs::write(root.join("assets/Props/Office/desk.glb"), b"desk").unwrap(); + fs::write( + root.join("assets/levels/main.scn.ron"), + r#"(source_path:"assets/Props/Office/desk.glb")"#, + ) + .unwrap(); + let registry = AssetRegistryDocument { + records: vec![AssetRecord { + id: shared::AssetId::new(), + path: "assets/Props/Office/desk.glb".into(), + label: "Desk".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + }], + ..Default::default() + }; + fs::write( + root.join(REGISTRY_PATH), + crate::serialize_registry(®istry).unwrap(), + ) + .unwrap(); + (root, registry) + } + + #[test] + fn quoted_path_rewrite_respects_path_component_boundaries() { + let source = r#"(exact:"assets/Props",child:"assets/Props/Desk.glb",neighbor:"assets/Props2/Desk.glb")"#; + + assert!(contains_quoted_path_prefix(source, "assets/Props")); + assert!(!contains_quoted_path_prefix(source, "assets/Prop")); + assert_eq!( + replace_quoted_path_prefix(source, "assets/Props", "assets/Furniture"), + r#"(exact:"assets/Furniture",child:"assets/Furniture/Desk.glb",neighbor:"assets/Props2/Desk.glb")"# + ); + } + + #[test] + fn move_preserves_id_and_rewrites_cached_references() { + let (root, mut registry) = fixture(); + let id = registry.records[0].id.clone(); + let material = shared::MaterialRef::new( + shared::EditorAssetRef::new("material-id", "", "Desk") + .with_source_path("assets/Props/Office/desk.material.ron"), + ); + registry.defaults.default_material = Some(material.clone()); + let settings = registry.records[0].model_import_mut(); + settings + .material_slots + .push(shared::ModelMaterialSlotSelection { + slot_id: shared::ComponentInstanceId::new("slot:desk"), + selection: shared::ModelMaterialSelection::Project(material.clone()), + }); + settings + .orphaned_material_slots + .push(shared::OrphanedModelMaterialSelection { + slot_id: shared::ComponentInstanceId::new("slot:removed"), + last_known_name: "Removed".into(), + material, + }); + settings.static_mesh_manifest_path = + Some("assets/Props/Office/generated.static_mesh.ron".into()); + settings.animation_manifest_path = + Some("assets/Props/Office/generated.animation.ron".into()); + let preview = preview_transaction( + &root, + vec![ContentOperation::Move { + source: "assets/Props/Office".into(), + destination: "assets/Furniture/Office".into(), + }], + ®istry, + ) + .unwrap(); + assert!(preview.is_committable()); + assert_eq!(preview.reference_rewrites.len(), 1); + commit_transaction(&root, &preview, &mut registry).unwrap(); + assert_eq!(registry.records[0].id, id); + assert_eq!(registry.records[0].path, "assets/Furniture/Office/desk.glb"); + let project_material = match ®istry.records[0].model_import().material_slots[0].selection + { + shared::ModelMaterialSelection::Project(reference) => reference, + selection => panic!("expected project material, got {selection:?}"), + }; + assert_eq!( + project_material.0.source_path.as_deref(), + Some("assets/Furniture/Office/desk.material.ron") + ); + assert_eq!( + registry.records[0].model_import().orphaned_material_slots[0] + .material + .0 + .source_path + .as_deref(), + Some("assets/Furniture/Office/desk.material.ron") + ); + assert_eq!( + registry + .defaults + .default_material + .as_ref() + .and_then(|reference| reference.0.source_path.as_deref()), + Some("assets/Furniture/Office/desk.material.ron") + ); + assert_eq!( + registry.records[0] + .model_import() + .static_mesh_manifest_path + .as_deref(), + Some("assets/Furniture/Office/generated.static_mesh.ron") + ); + assert_eq!( + registry.records[0] + .model_import() + .animation_manifest_path + .as_deref(), + Some("assets/Furniture/Office/generated.animation.ron") + ); + assert!(fs::read_to_string(root.join("assets/levels/main.scn.ron")) + .unwrap() + .contains("assets/Furniture/Office/desk.glb")); + let registry_source = fs::read_to_string(root.join(REGISTRY_PATH)).unwrap(); + assert!(!registry_source.contains("assets/Props/Office")); + let catalog_source = fs::read_to_string(root.join(RUNTIME_CATALOG_PATH)).unwrap(); + assert!(!catalog_source.contains("assets/Props/Office")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reference_usage_preview_reports_external_documents_and_registry_consumers() { + let (root, mut registry) = fixture(); + let selected_id = registry.records[0].id.clone(); + let material_reference = shared::MaterialRef::new( + shared::EditorAssetRef::new(selected_id.as_string(), "", "Desk") + .with_source_path("assets/Props/Office/desk.glb"), + ); + registry.defaults.default_material = Some(material_reference.clone()); + registry.records.push(AssetRecord { + id: shared::AssetId::new(), + path: "assets/consumer.gltf".into(), + label: "Consumer".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: AssetImportSettings::Model(ImportSettings { + material_slots: vec![shared::ModelMaterialSlotSelection { + slot_id: shared::ComponentInstanceId::new("slot:0"), + selection: shared::ModelMaterialSelection::Project(material_reference), + }], + ..Default::default() + }), + dependencies: vec!["assets/Props/Office/desk.glb".into()], + }); + fs::write(root.join("assets/consumer.gltf"), b"consumer").unwrap(); + fs::write( + root.join("assets/Props/Office/internal.ron"), + format!("(asset_id:\"{}\")", selected_id.as_string()), + ) + .unwrap(); + + let usages = + find_reference_usages(&root, &[PathBuf::from("assets/Props/Office")], ®istry) + .unwrap(); + + assert!(usages.iter().any(|usage| { + usage.document == Path::new("assets/levels/main.scn.ron") + && usage.reference == "assets/Props/Office" + })); + assert!(usages.iter().any(|usage| { + usage.document == Path::new(REGISTRY_PATH) + && usage.reference == "project default material" + })); + assert!(usages.iter().any(|usage| { + usage.document == Path::new(REGISTRY_PATH) + && usage.reference == "model assets/consumer.gltf" + })); + assert!(usages.iter().any(|usage| { + usage.document == Path::new(REGISTRY_PATH) + && usage.reference == "dependency of assets/consumer.gltf" + })); + assert!(!usages + .iter() + .any(|usage| usage.document == Path::new("assets/Props/Office/internal.ron"))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn copy_assigns_new_ids_and_rewrites_only_internal_duplicate_references() { + let (root, mut registry) = fixture(); + let material_id = shared::AssetId::new(); + let material_path = "assets/Props/Office/desk.material.ron"; + fs::write( + root.join(material_path), + format!( + r#"(asset_id:"{}",source_path:"{material_path}")"#, + material_id.as_string() + ), + ) + .unwrap(); + registry.records.push(AssetRecord { + id: material_id.clone(), + path: material_path.into(), + label: "Desk Material".into(), + kind: AssetKind::Material, + source_fingerprint: None, + import_settings: AssetImportSettings::None, + dependencies: Vec::new(), + }); + registry.records[0].model_import_mut().material_slots.push( + shared::ModelMaterialSlotSelection { + slot_id: shared::ComponentInstanceId::new("slot:desk"), + selection: shared::ModelMaterialSelection::Project(shared::MaterialRef::new( + shared::EditorAssetRef::new(material_id.as_string(), "", "Desk Material") + .with_source_path(material_path), + )), + }, + ); + registry.records[0] + .model_import_mut() + .static_mesh_manifest_path = + Some("assets/meshes/generated/original.static_mesh.ron".into()); + registry.records[0] + .model_import_mut() + .animation_manifest_path = + Some("assets/animations/generated/original.animation.ron".into()); + let source_model_id = registry.records[0].id.clone(); + + let preview = preview_transaction( + &root, + vec![ContentOperation::Copy { + source: "assets/Props/Office".into(), + destination: "assets/Furniture/Office Copy".into(), + }], + ®istry, + ) + .unwrap(); + commit_transaction(&root, &preview, &mut registry).unwrap(); + + let copied_model = registry + .records + .iter() + .find(|record| record.path == "assets/Furniture/Office Copy/desk.glb") + .unwrap(); + let copied_material = registry + .records + .iter() + .find(|record| record.path == "assets/Furniture/Office Copy/desk.material.ron") + .unwrap(); + assert_ne!(copied_model.id, source_model_id); + assert_ne!(copied_material.id, material_id); + assert!(copied_model + .model_import() + .static_mesh_manifest_path + .is_none()); + assert!(copied_model + .model_import() + .animation_manifest_path + .is_none()); + let copied_reference = match &copied_model.model_import().material_slots[0].selection { + shared::ModelMaterialSelection::Project(reference) => reference, + selection => panic!("expected project material, got {selection:?}"), + }; + assert_eq!(copied_reference.0.asset_id, copied_material.id.as_string()); + assert_eq!( + copied_reference.0.source_path.as_deref(), + Some("assets/Furniture/Office Copy/desk.material.ron") + ); + let copied_source = + fs::read_to_string(root.join("assets/Furniture/Office Copy/desk.material.ron")) + .unwrap(); + assert!(copied_source.contains("assets/Furniture/Office Copy/desk.material.ron")); + assert!(copied_source.contains(&copied_material.id.as_string())); + let original_source = fs::read_to_string(root.join(material_path)).unwrap(); + assert!(original_source.contains(material_path)); + assert!(original_source.contains(&material_id.as_string())); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn collisions_are_previewed_without_mutation() { + let (root, registry) = fixture(); + fs::create_dir_all(root.join("assets/Existing")).unwrap(); + let preview = preview_transaction( + &root, + vec![ContentOperation::Move { + source: "assets/Props/Office".into(), + destination: "assets/Existing".into(), + }], + ®istry, + ) + .unwrap(); + assert!(!preview.is_committable()); + assert!(root.join("assets/Props/Office/desk.glb").is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_batch_destinations_are_reported_as_collisions() { + let (root, registry) = fixture(); + fs::write(root.join("assets/Props/chair.glb"), b"chair").unwrap(); + let destination = PathBuf::from("assets/Furniture/item.glb"); + let preview = preview_transaction( + &root, + vec![ + ContentOperation::Copy { + source: "assets/Props/Office/desk.glb".into(), + destination: destination.clone(), + }, + ContentOperation::Copy { + source: "assets/Props/chair.glb".into(), + destination: destination.clone(), + }, + ], + ®istry, + ) + .unwrap(); + assert_eq!(preview.collisions, vec![destination]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_batch_rolls_back_only_operations_that_ran() { + let (root, mut registry) = fixture(); + fs::write(root.join("assets/Props/chair.glb"), b"chair").unwrap(); + let preview = preview_transaction( + &root, + vec![ + ContentOperation::Move { + source: "assets/Props/Office/desk.glb".into(), + destination: "assets/Furniture/desk.glb".into(), + }, + ContentOperation::Move { + source: "assets/Props/chair.glb".into(), + destination: "assets/Furniture/chair.glb".into(), + }, + ], + ®istry, + ) + .unwrap(); + fs::remove_file(root.join("assets/Props/chair.glb")).unwrap(); + + let error = commit_transaction(&root, &preview, &mut registry).unwrap_err(); + assert!(error.contains("rolled back")); + assert_eq!( + fs::read(root.join("assets/Props/Office/desk.glb")).unwrap(), + b"desk" + ); + assert!(!root.join("assets/Furniture/desk.glb").exists()); + assert!(!root.join("assets/Furniture/chair.glb").exists()); + assert_eq!(registry.records[0].path, "assets/Props/Office/desk.glb"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn tree_fingerprint_detects_external_file_changes() { + let (root, _registry) = fixture(); + let folder = root.join("assets/Props/Office"); + let before = tree_fingerprint(&folder).unwrap(); + fs::write(folder.join("desk.glb"), b"changed").unwrap(); + let after = tree_fingerprint(&folder).unwrap(); + assert_ne!(before, after); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn write_file_publishes_bytes_and_registry_record_atomically() { + let (root, mut registry) = fixture(); + let material = shared::MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Desk Steel".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let bytes = ron::ser::to_string(&material).unwrap().into_bytes(); + let path = PathBuf::from("assets/Props/desk_steel.material.ron"); + let preview = preview_transaction( + &root, + vec![ContentOperation::WriteFile { + path: path.clone(), + bytes: bytes.clone(), + }], + ®istry, + ) + .unwrap(); + commit_transaction(&root, &preview, &mut registry).unwrap(); + + assert_eq!(fs::read(root.join(&path)).unwrap(), bytes); + let record = registry + .records + .iter() + .find(|record| record.path == "assets/Props/desk_steel.material.ron") + .unwrap(); + assert_eq!(record.label, "Desk Steel"); + assert_eq!(record.kind, shared::AssetKind::Material); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn dependent_registry_update_sees_written_asset_and_publishes_atomically() { + let (root, mut registry) = fixture(); + let path = PathBuf::from("assets/Props/desk_steel.material.ron"); + let material = shared::MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Desk Steel".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let preview = preview_transaction( + &root, + vec![ContentOperation::WriteFile { + path: path.clone(), + bytes: ron::ser::to_string(&material).unwrap().into_bytes(), + }], + ®istry, + ) + .unwrap(); + + commit_transaction_with_registry_update(&root, &preview, &mut registry, |document| { + let material_id = document + .records + .iter() + .find(|record| record.path == "assets/Props/desk_steel.material.ron") + .map(|record| record.id.as_string()) + .ok_or_else(|| "written material was not registered".to_string())?; + document.records[0].dependencies.push(material_id); + Ok(()) + }) + .unwrap(); + + assert_eq!(registry.records[0].dependencies.len(), 1); + let published = fs::read_to_string(root.join(REGISTRY_PATH)).unwrap(); + assert!(published.contains(®istry.records[0].dependencies[0])); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_dependent_registry_update_rolls_back_written_assets() { + let (root, mut registry) = fixture(); + let original = registry.clone(); + let path = PathBuf::from("assets/Props/invalid.material.ron"); + let preview = preview_transaction( + &root, + vec![ContentOperation::WriteFile { + path: path.clone(), + bytes: b"invalid".to_vec(), + }], + ®istry, + ) + .unwrap(); + + let error = commit_transaction_with_registry_update(&root, &preview, &mut registry, |_| { + Err("dependent registry edit failed".into()) + }) + .unwrap_err(); + + assert!(error.contains("dependent registry edit failed")); + assert!(!root.join(path).exists()); + assert_eq!(registry, original); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reviewed_replace_preserves_id_and_rolls_back_exact_bytes() { + let (root, mut registry) = fixture(); + let path = PathBuf::from("assets/Props/desk_steel.material.ron"); + let old_material = shared::MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Edited Desk Steel".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let previous_bytes = ron::ser::to_string(&old_material).unwrap().into_bytes(); + fs::write(root.join(&path), &previous_bytes).unwrap(); + let id = shared::AssetId::new(); + registry.records.push(shared::AssetRecord { + id: id.clone(), + path: normalize(&path), + label: old_material.label.clone(), + kind: shared::AssetKind::Material, + source_fingerprint: None, + import_settings: Default::default(), + dependencies: Vec::new(), + }); + let original_registry = registry.clone(); + let mut replacement = old_material.clone(); + replacement.label = "Reimported Desk Steel".into(); + let replacement_bytes = ron::ser::to_string(&replacement).unwrap().into_bytes(); + let preview = preview_transaction( + &root, + vec![ContentOperation::ReplaceFile { + path: path.clone(), + bytes: replacement_bytes, + expected_fingerprint: tree_fingerprint(&root.join(&path)).unwrap(), + previous_bytes: previous_bytes.clone(), + }], + ®istry, + ) + .unwrap(); + + let error = commit_transaction_with_registry_update(&root, &preview, &mut registry, |_| { + Err("dependent mapping failed".into()) + }) + .unwrap_err(); + + assert!(error.contains("dependent mapping failed")); + assert_eq!(fs::read(root.join(&path)).unwrap(), previous_bytes); + assert_eq!(registry, original_registry); + assert_eq!( + registry + .records + .iter() + .find(|record| record.path == normalize(&path)) + .unwrap() + .id, + id + ); + commit_transaction(&root, &preview, &mut registry).unwrap(); + assert_eq!( + fs::read(root.join(&path)).unwrap(), + ron::ser::to_string(&replacement).unwrap().into_bytes() + ); + let replaced_record = registry + .records + .iter() + .find(|record| record.path == normalize(&path)) + .unwrap(); + assert_eq!(replaced_record.id, id); + assert_eq!(replaced_record.label, "Reimported Desk Steel"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reviewed_replace_rejects_external_changes_before_publish() { + let (root, mut registry) = fixture(); + let path = PathBuf::from("assets/Props/desk_steel.material.ron"); + let previous_bytes = b"reviewed bytes".to_vec(); + fs::write(root.join(&path), &previous_bytes).unwrap(); + registry.records.push(shared::AssetRecord { + id: shared::AssetId::new(), + path: normalize(&path), + label: "Desk Steel".into(), + kind: shared::AssetKind::Material, + source_fingerprint: None, + import_settings: Default::default(), + dependencies: Vec::new(), + }); + let preview = preview_transaction( + &root, + vec![ContentOperation::ReplaceFile { + path: path.clone(), + bytes: b"replacement".to_vec(), + expected_fingerprint: tree_fingerprint(&root.join(&path)).unwrap(), + previous_bytes, + }], + ®istry, + ) + .unwrap(); + fs::write(root.join(&path), b"external edit").unwrap(); + + let error = commit_transaction(&root, &preview, &mut registry).unwrap_err(); + + assert!(error.contains("changed after review")); + assert_eq!(fs::read(root.join(path)).unwrap(), b"external edit"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/content_pipeline/src/trash.rs b/crates/content_pipeline/src/trash.rs new file mode 100644 index 0000000..3eea81e --- /dev/null +++ b/crates/content_pipeline/src/trash.rs @@ -0,0 +1,605 @@ +use crate::{ + serialize_registry, serialize_runtime_catalog, validate_asset_path, write_if_changed, + REGISTRY_PATH, RUNTIME_CATALOG_PATH, +}; +use serde::{Deserialize, Serialize}; +use shared::{AssetRecord, AssetRegistryDocument}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TRASH_ROOT: &str = "assets/.trash"; +const TRASH_MANIFEST: &str = ".blacksite-trash.ron"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TrashManifest { + pub schema_version: u32, + pub batch_id: String, + pub original_paths: Vec, + #[serde(default)] + pub derived_paths: Vec, + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrashBatchSummary { + pub path: PathBuf, + pub batch_id: String, + pub original_paths: Vec, +} + +pub fn trash_content( + project_root: &Path, + original_paths: &[PathBuf], + registry: &mut AssetRegistryDocument, +) -> Result { + let originals = normalize_originals(project_root, original_paths)?; + let batch_id = trash_batch_id(); + let batch_path = PathBuf::from(TRASH_ROOT).join(&batch_id); + let absolute_batch = project_root.join(&batch_path); + fs::create_dir_all(&absolute_batch) + .map_err(|error| format!("could not create {}: {error}", absolute_batch.display()))?; + let records = registry + .records + .iter() + .filter(|record| originals.iter().any(|path| descendant(&record.path, path))) + .cloned() + .collect::>(); + let derived_paths = collect_derived_paths(project_root, &records, &originals)?; + let manifest = TrashManifest { + schema_version: 2, + batch_id: batch_id.clone(), + original_paths: originals.clone(), + derived_paths, + records, + }; + let original_registry = registry.clone(); + let registry_path = project_root.join(REGISTRY_PATH); + let original_registry_bytes = fs::read(®istry_path).ok(); + let runtime_path = project_root.join(RUNTIME_CATALOG_PATH); + let original_runtime_bytes = fs::read(&runtime_path).ok(); + let mut moved = Vec::new(); + let result: Result<(), String> = (|| { + for original in &originals { + let source = project_root.join(original); + let destination = absolute_batch.join(original); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::rename(&source, &destination).map_err(|error| { + format!( + "could not move {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + moved.push((source, destination)); + } + for derived in &manifest.derived_paths { + let source = project_root.join(derived); + let destination = absolute_batch.join(derived); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::rename(&source, &destination).map_err(|error| { + format!( + "could not move derived artifact {} to trash: {error}", + source.display() + ) + })?; + moved.push((source, destination)); + } + let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize trash manifest: {error}"))?; + fs::write(absolute_batch.join(TRASH_MANIFEST), manifest_ron) + .map_err(|error| format!("could not write trash manifest: {error}"))?; + registry + .records + .retain(|record| !originals.iter().any(|path| descendant(&record.path, path))); + write_if_changed(®istry_path, &serialize_registry(registry)?)?; + write_runtime_catalog(&runtime_path, registry)?; + Ok(()) + })(); + if let Err(error) = result { + rollback_moves(moved); + restore_registry(®istry_path, original_registry_bytes.as_deref()); + restore_registry(&runtime_path, original_runtime_bytes.as_deref()); + *registry = original_registry; + let _ = fs::remove_dir_all(&absolute_batch); + return Err(format!("trash transaction rolled back: {error}")); + } + Ok(TrashBatchSummary { + path: batch_path, + batch_id, + original_paths: originals, + }) +} + +pub fn list_trash_batches(project_root: &Path) -> Result, String> { + let root = project_root.join(TRASH_ROOT); + if !root.exists() { + return Ok(Vec::new()); + } + let mut batches = Vec::new(); + for entry in fs::read_dir(&root) + .map_err(|error| format!("could not inspect {}: {error}", root.display()))? + { + let entry = entry.map_err(|error| format!("could not inspect trash entry: {error}"))?; + if !entry + .file_type() + .map_err(|error| format!("could not inspect trash entry: {error}"))? + .is_dir() + { + continue; + } + let absolute = entry.path(); + let manifest = load_or_infer_manifest(&absolute)?; + batches.push(TrashBatchSummary { + path: PathBuf::from(TRASH_ROOT).join(&manifest.batch_id), + batch_id: manifest.batch_id, + original_paths: manifest.original_paths, + }); + } + batches.sort_by(|left, right| right.batch_id.cmp(&left.batch_id)); + Ok(batches) +} + +pub fn restore_trash_batch( + project_root: &Path, + batch_path: &Path, + registry: &mut AssetRegistryDocument, +) -> Result { + let normalized_batch = normalize(batch_path); + if !normalized_batch.starts_with("assets/.trash/") { + return Err("restore path must identify a managed trash batch".into()); + } + let absolute_batch = project_root.join(batch_path); + let manifest = load_or_infer_manifest(&absolute_batch)?; + for original in &manifest.original_paths { + validate_asset_path(Path::new(original))?; + if project_root.join(original).exists() { + return Err(format!("restore collision: {original} already exists")); + } + if !trashed_source(&absolute_batch, original).exists() { + return Err(format!("trash batch is missing {original}")); + } + } + for derived in &manifest.derived_paths { + validate_derived_path(Path::new(derived))?; + if project_root.join(derived).exists() { + return Err(format!("restore collision: {derived} already exists")); + } + if !trashed_source(&absolute_batch, derived).exists() { + return Err(format!("trash batch is missing derived artifact {derived}")); + } + } + for record in &manifest.records { + if registry + .records + .iter() + .any(|existing| existing.path == record.path || existing.id == record.id) + { + return Err(format!( + "registry collision prevents restoring {}", + record.path + )); + } + } + + let original_registry = registry.clone(); + let registry_path = project_root.join(REGISTRY_PATH); + let original_registry_bytes = fs::read(®istry_path).ok(); + let runtime_path = project_root.join(RUNTIME_CATALOG_PATH); + let original_runtime_bytes = fs::read(&runtime_path).ok(); + let mut moved = Vec::new(); + let result: Result<(), String> = (|| { + for original in &manifest.original_paths { + let source = trashed_source(&absolute_batch, original); + let destination = project_root.join(original); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::rename(&source, &destination).map_err(|error| { + format!( + "could not restore {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + moved.push((source, destination)); + } + for derived in &manifest.derived_paths { + let source = trashed_source(&absolute_batch, derived); + let destination = project_root.join(derived); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::rename(&source, &destination).map_err(|error| { + format!( + "could not restore derived artifact {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + moved.push((source, destination)); + } + registry.records.extend(manifest.records.iter().cloned()); + registry + .records + .sort_by(|left, right| left.path.cmp(&right.path)); + write_if_changed(®istry_path, &serialize_registry(registry)?)?; + write_runtime_catalog(&runtime_path, registry)?; + Ok(()) + })(); + if let Err(error) = result { + rollback_moves(moved); + restore_registry(®istry_path, original_registry_bytes.as_deref()); + restore_registry(&runtime_path, original_runtime_bytes.as_deref()); + *registry = original_registry; + return Err(format!("restore transaction rolled back: {error}")); + } + let _ = fs::remove_dir_all(&absolute_batch); + Ok(TrashBatchSummary { + path: batch_path.to_path_buf(), + batch_id: manifest.batch_id, + original_paths: manifest.original_paths, + }) +} + +fn normalize_originals(project_root: &Path, paths: &[PathBuf]) -> Result, String> { + if paths.is_empty() { + return Err("no content paths were selected for trash".into()); + } + let mut originals: Vec = Vec::new(); + for path in paths { + validate_asset_path(path)?; + let normalized = normalize(path); + if !project_root.join(path).exists() { + return Err(format!("content path does not exist: {}", path.display())); + } + if originals + .iter() + .any(|parent| descendant(&normalized, parent)) + { + continue; + } + originals.retain(|child| !descendant(child, &normalized)); + originals.push(normalized); + } + originals.sort(); + Ok(originals) +} + +fn load_or_infer_manifest(batch: &Path) -> Result { + let manifest_path = batch.join(TRASH_MANIFEST); + if let Ok(text) = fs::read_to_string(&manifest_path) { + return ron::from_str(&text).map_err(|error| { + format!( + "invalid trash manifest {}: {error}", + manifest_path.display() + ) + }); + } + let batch_id = batch + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("legacy") + .to_string(); + let scan_root = if batch.join("assets").is_dir() { + batch.join("assets") + } else { + batch.to_path_buf() + }; + let mut original_paths = Vec::new(); + for entry in fs::read_dir(&scan_root) + .map_err(|error| format!("could not inspect legacy trash batch: {error}"))? + { + let entry = + entry.map_err(|error| format!("could not inspect legacy trash item: {error}"))?; + if entry.file_name() == TRASH_MANIFEST { + continue; + } + original_paths.push(format!("assets/{}", entry.file_name().to_string_lossy())); + } + original_paths.sort(); + Ok(TrashManifest { + schema_version: 0, + batch_id, + original_paths, + derived_paths: Vec::new(), + records: Vec::new(), + }) +} + +fn collect_derived_paths( + project_root: &Path, + records: &[AssetRecord], + originals: &[String], +) -> Result, String> { + let mut derived = Vec::new(); + for record in records { + let Some(settings) = record.import_settings.model() else { + continue; + }; + for path in [ + settings.static_mesh_manifest_path.as_deref(), + settings.animation_manifest_path.as_deref(), + ] + .into_iter() + .flatten() + { + validate_derived_path(Path::new(path))?; + if originals.iter().any(|original| descendant(path, original)) { + continue; + } + let absolute = project_root.join(path); + let Ok(metadata) = fs::symlink_metadata(&absolute) else { + continue; + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "derived artifact may not be a symlink: {}", + absolute.display() + )); + } + if metadata.is_file() { + derived.push(path.to_string()); + } + } + } + derived.sort(); + derived.dedup(); + Ok(derived) +} + +fn validate_derived_path(path: &Path) -> Result<(), String> { + if validate_asset_path(path).is_ok() { + return Ok(()); + } + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err("derived artifact paths must remain project-relative".into()); + } + let normalized = normalize(path); + if descendant(&normalized, "assets/meshes/generated") + || descendant(&normalized, "assets/animations/generated") + { + Ok(()) + } else { + Err(format!( + "unsupported derived artifact path outside generated manifest roots: {normalized}" + )) + } +} + +fn trashed_source(batch: &Path, original: &str) -> PathBuf { + let preserved = batch.join(original); + if preserved.exists() { + preserved + } else { + batch.join(original.strip_prefix("assets/").unwrap_or(original)) + } +} + +fn rollback_moves(moved: Vec<(PathBuf, PathBuf)>) { + for (source, destination) in moved.into_iter().rev() { + if let Some(parent) = source.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::rename(destination, source); + } +} + +fn restore_registry(path: &Path, bytes: Option<&[u8]>) { + if let Some(bytes) = bytes { + let _ = fs::write(path, bytes); + } else { + let _ = fs::remove_file(path); + } +} + +fn write_runtime_catalog(path: &Path, registry: &AssetRegistryDocument) -> Result<(), String> { + let catalog = shared::RuntimeContentCatalog::from(registry); + write_if_changed(path, &serialize_runtime_catalog(&catalog)?)?; + Ok(()) +} + +fn trash_batch_id() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| format!("{}-{:09}", duration.as_secs(), duration.subsec_nanos())) + .unwrap_or_else(|_| "now".into()) +} + +fn normalize(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn descendant(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trash_and_restore_preserve_bytes_and_registry_ids() { + let root = std::env::temp_dir().join(format!( + "blacksite-trash-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/Props")).unwrap(); + fs::create_dir_all(root.join("assets/.index")).unwrap(); + fs::write(root.join("assets/Props/box.png"), b"asset bytes").unwrap(); + let record = AssetRecord { + id: shared::AssetId::new(), + path: "assets/Props/box.png".into(), + label: "Box".into(), + kind: shared::AssetKind::Texture, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::for_kind(shared::AssetKind::Texture), + dependencies: Vec::new(), + }; + let mut registry = AssetRegistryDocument { + records: vec![record.clone()], + ..Default::default() + }; + fs::write( + root.join(REGISTRY_PATH), + serialize_registry(®istry).unwrap(), + ) + .unwrap(); + + let batch = trash_content( + &root, + &[PathBuf::from("assets/Props/box.png")], + &mut registry, + ) + .unwrap(); + assert!(!root.join("assets/Props/box.png").exists()); + assert!(registry.records.is_empty()); + restore_trash_batch(&root, &batch.path, &mut registry).unwrap(); + assert_eq!( + fs::read(root.join("assets/Props/box.png")).unwrap(), + b"asset bytes" + ); + assert_eq!(registry.records, vec![record]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn trash_and_restore_include_model_derived_manifests() { + let root = std::env::temp_dir().join(format!( + "blacksite-trash-derived-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/Props")).unwrap(); + fs::create_dir_all(root.join("assets/meshes/generated")).unwrap(); + fs::create_dir_all(root.join("assets/animations/generated")).unwrap(); + fs::create_dir_all(root.join("assets/.index")).unwrap(); + fs::write(root.join("assets/Props/desk.glb"), b"model bytes").unwrap(); + fs::write( + root.join("assets/meshes/generated/desk.static_mesh.ron"), + b"static manifest", + ) + .unwrap(); + fs::write( + root.join("assets/animations/generated/desk.animation.ron"), + b"animation manifest", + ) + .unwrap(); + let mut record = AssetRecord { + id: shared::AssetId::new(), + path: "assets/Props/desk.glb".into(), + label: "Desk".into(), + kind: shared::AssetKind::Model, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::Model(shared::ImportSettings::default()), + dependencies: Vec::new(), + }; + record.model_import_mut().static_mesh_manifest_path = + Some("assets/meshes/generated/desk.static_mesh.ron".into()); + record.model_import_mut().animation_manifest_path = + Some("assets/animations/generated/desk.animation.ron".into()); + let mut registry = AssetRegistryDocument { + records: vec![record.clone()], + ..Default::default() + }; + fs::write( + root.join(REGISTRY_PATH), + serialize_registry(®istry).unwrap(), + ) + .unwrap(); + + let batch = trash_content( + &root, + &[PathBuf::from("assets/Props/desk.glb")], + &mut registry, + ) + .unwrap(); + + assert!(!root.join("assets/Props/desk.glb").exists()); + assert!(!root + .join("assets/meshes/generated/desk.static_mesh.ron") + .exists()); + assert!(!root + .join("assets/animations/generated/desk.animation.ron") + .exists()); + let manifest: TrashManifest = ron::from_str( + &fs::read_to_string(root.join(&batch.path).join(TRASH_MANIFEST)).unwrap(), + ) + .unwrap(); + assert_eq!(manifest.schema_version, 2); + assert_eq!( + manifest.derived_paths, + vec![ + "assets/animations/generated/desk.animation.ron", + "assets/meshes/generated/desk.static_mesh.ron", + ] + ); + + restore_trash_batch(&root, &batch.path, &mut registry).unwrap(); + assert_eq!( + fs::read(root.join("assets/Props/desk.glb")).unwrap(), + b"model bytes" + ); + assert_eq!( + fs::read(root.join("assets/meshes/generated/desk.static_mesh.ron")).unwrap(), + b"static manifest" + ); + assert_eq!( + fs::read(root.join("assets/animations/generated/desk.animation.ron")).unwrap(), + b"animation manifest" + ); + assert_eq!(registry.records, vec![record]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn restore_collision_keeps_trash_untouched() { + let root = std::env::temp_dir().join(format!( + "blacksite-trash-{}", + shared::AssetId::new().as_string() + )); + fs::create_dir_all(root.join("assets/Props")).unwrap(); + fs::create_dir_all(root.join("assets/.index")).unwrap(); + fs::write(root.join("assets/Props/box.png"), b"original").unwrap(); + let mut registry = AssetRegistryDocument::default(); + fs::write( + root.join(REGISTRY_PATH), + serialize_registry(®istry).unwrap(), + ) + .unwrap(); + let batch = trash_content( + &root, + &[PathBuf::from("assets/Props/box.png")], + &mut registry, + ) + .unwrap(); + fs::write(root.join("assets/Props/box.png"), b"external").unwrap(); + + assert!(restore_trash_batch(&root, &batch.path, &mut registry).is_err()); + assert_eq!( + fs::read(root.join("assets/Props/box.png")).unwrap(), + b"external" + ); + assert!(root.join(&batch.path).join("assets/Props/box.png").exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/content_pipeline/src/watcher.rs b/crates/content_pipeline/src/watcher.rs new file mode 100644 index 0000000..fa80649 --- /dev/null +++ b/crates/content_pipeline/src/watcher.rs @@ -0,0 +1,187 @@ +//! Debounced project-content watching shared by editor hosts and automation. + +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +pub struct DebouncedContentChanges { + paths: BTreeSet, + last_change: Option, + suppressed: usize, +} + +impl DebouncedContentChanges { + pub fn begin_transaction(&mut self) { + self.suppressed += 1; + } + + pub fn end_transaction(&mut self) { + self.suppressed = self.suppressed.saturating_sub(1); + } + + pub fn record(&mut self, path: PathBuf, now: Instant) { + if self.suppressed == 0 { + self.paths.insert(path); + self.last_change = Some(now); + } + } + + pub fn take_ready(&mut self, now: Instant, debounce: Duration) -> Vec { + if self.suppressed > 0 + || self + .last_change + .is_none_or(|last_change| now.duration_since(last_change) < debounce) + { + return Vec::new(); + } + self.last_change = None; + std::mem::take(&mut self.paths).into_iter().collect() + } +} + +pub struct ContentWatchService { + _watcher: RecommendedWatcher, + events: Receiver>, + changes: DebouncedContentChanges, + assets_root: PathBuf, + suppressed_paths: BTreeMap, +} + +impl ContentWatchService { + pub fn watch_project(project_root: &Path) -> Result { + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .map_err(|error| format!("could not create content watcher: {error}"))?; + let assets = project_root.join(crate::ASSETS_DIRECTORY); + watcher + .watch(&assets, RecursiveMode::Recursive) + .map_err(|error| format!("could not watch {}: {error}", assets.display()))?; + Ok(Self { + _watcher: watcher, + events, + changes: DebouncedContentChanges::default(), + assets_root: assets, + suppressed_paths: BTreeMap::new(), + }) + } + + pub fn begin_transaction(&mut self) { + self.changes.begin_transaction(); + } + + pub fn end_transaction(&mut self) { + while self.events.try_recv().is_ok() {} + self.changes.end_transaction(); + } + + /// Ignores watcher noise for one host-authored file while direct runtime consumers observe + /// its new revision. This prevents an inline Material slider edit from starting a full project + /// processing pass without hiding unrelated external changes. + pub fn suppress_path(&mut self, path: &Path, duration: Duration) { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + self.assets_root + .parent() + .unwrap_or(&self.assets_root) + .join(path) + }; + self.suppressed_paths + .insert(path, Instant::now() + duration); + } + + /// Drains host events and returns one deterministic path set after the debounce window. + pub fn poll(&mut self, debounce: Duration) -> Result, String> { + let now = Instant::now(); + self.suppressed_paths.retain(|_, deadline| *deadline >= now); + while let Ok(event) = self.events.try_recv() { + let event = event.map_err(|error| format!("content watch failed: {error}"))?; + for path in event.paths { + if self + .suppressed_paths + .get(&path) + .is_some_and(|deadline| *deadline >= now) + { + continue; + } + if self.is_user_content_path(&path) { + self.changes.record(path, now); + } + } + } + Ok(self.changes.take_ready(now, debounce)) + } + + fn is_user_content_path(&self, path: &Path) -> bool { + is_user_content_path(&self.assets_root, path) + } +} + +fn is_user_content_path(assets_root: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(assets_root) else { + return false; + }; + !crate::is_managed_path(relative) + && relative != Path::new("content.catalog.ron") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("tmp") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debounce_deduplicates_and_transaction_suppression_discards_noise() { + let start = Instant::now(); + let mut changes = DebouncedContentChanges::default(); + changes.record(PathBuf::from("assets/a.png"), start); + changes.record(PathBuf::from("assets/a.png"), start); + assert!(changes + .take_ready(start, Duration::from_millis(50)) + .is_empty()); + assert_eq!( + changes.take_ready(start + Duration::from_millis(51), Duration::from_millis(50)), + vec![PathBuf::from("assets/a.png")] + ); + + changes.begin_transaction(); + changes.record(PathBuf::from("assets/internal.ron"), start); + changes.end_transaction(); + assert!(changes + .take_ready(start + Duration::from_secs(1), Duration::ZERO) + .is_empty()); + } + + #[test] + fn watcher_ignores_managed_and_generated_publication_noise() { + let root = Path::new("/project/assets"); + assert!(is_user_content_path( + root, + Path::new("/project/assets/Props/a.png") + )); + assert!(!is_user_content_path( + root, + Path::new("/project/assets/.index/registry.ron") + )); + assert!(!is_user_content_path( + root, + Path::new("/project/assets/content.catalog.ron") + )); + assert!(!is_user_content_path( + root, + Path::new("/project/assets/meshes/generated/model.static_mesh.ron") + )); + assert!(!is_user_content_path( + root, + Path::new("/project/assets/Props/a.tmp") + )); + } +} diff --git a/crates/editor/AGENTS.md b/crates/editor/AGENTS.md new file mode 100644 index 0000000..22c4d95 --- /dev/null +++ b/crates/editor/AGENTS.md @@ -0,0 +1,21 @@ +# Editor subtree rules + +- Define an explicit interaction-state matrix before implementing new editor UI behavior. +- Keep egui rendering thin. Move selection models, transaction planning, path rewriting, material + resolution, import review, and reusable logic into lightweight modules or crates. +- Test an invariant in its lightest owning crate; do not put it in `editor` merely because the UI + calls it. +- A small UI change does not justify full-workspace testing. +- Use `cargo check -p editor --lib` through the development lane in the fast loop unless a binary + target is directly affected. +- Use focused editor tests only for editor-owned behavior. +- Use named native scenarios for interaction and visual acceptance. +- Do not use all-features or hot-reload/dynamic-linking combinations for ordinary editor QA unless + that feature is under test. +- Screenshots alone do not establish UX completion; exercise the interaction. +- Reuse established editor components and visual language instead of creating bespoke panel + controls. +- Treat sustainable extension seams and debt-at-the-point-of-change as a feature requirement. + Inspector and Content Browser shells dispatch; they do not own domain behavior. Do not grow a + frozen-baseline module, and run `scripts/codex/architecture_audit.py check` after structural UI + changes. diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index be885a7..7361fa1 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -26,11 +26,13 @@ hot-reload = ["game/hot-reload", "dep:hot-lib-reloader", "dep:notify"] avian3d.workspace = true bevy = { workspace = true, default-features = true, features = ["bevy_remote"] } bevy_solari.workspace = true +blacksite_surface.workspace = true bevy_egui.workspace = true bevy-inspector-egui.workspace = true egui_dock.workspace = true egui_phosphor_icons.workspace = true bevy_ufbx.workspace = true +content_pipeline.workspace = true ufbx = "0.9" game.workspace = true polyanya.workspace = true diff --git a/crates/editor/assets/fonts/source-sans/LICENSE.md b/crates/editor/assets/fonts/source-sans/LICENSE.md new file mode 100644 index 0000000..69fa3e4 --- /dev/null +++ b/crates/editor/assets/fonts/source-sans/LICENSE.md @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/editor/assets/fonts/source-sans/SourceSansPro-Bold.ttf b/crates/editor/assets/fonts/source-sans/SourceSansPro-Bold.ttf new file mode 100644 index 0000000..4d80fce Binary files /dev/null and b/crates/editor/assets/fonts/source-sans/SourceSansPro-Bold.ttf differ diff --git a/crates/editor/assets/fonts/source-sans/SourceSansPro-Regular.ttf b/crates/editor/assets/fonts/source-sans/SourceSansPro-Regular.ttf new file mode 100644 index 0000000..a8eae16 Binary files /dev/null and b/crates/editor/assets/fonts/source-sans/SourceSansPro-Regular.ttf differ diff --git a/crates/editor/src/assets/animation.rs b/crates/editor/src/assets/animation.rs index acf10a7..1dbdab3 100644 --- a/crates/editor/src/assets/animation.rs +++ b/crates/editor/src/assets/animation.rs @@ -1,904 +1,3 @@ -//! Generated animation artifacts for imported model sources. +//! Compatibility re-exports for UI-independent animation artifact processing. -use std::collections::BTreeSet; -use std::fs; -use std::path::Path; - -use serde::Deserialize; -use shared::{ - animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationClipRecord, - AnimationDiagnosticSeverity, AnimationEventDesc, AnimationImportDiagnostic, AnimationManifest, - AnimationManifestSource, AnimationSkeletonRecord, AnimationSkeletonSignature, - AnimationSourceFingerprint, ANIMATION_ARTIFACT_DIR, ANIMATION_MANIFEST_SCHEMA_VERSION, -}; - -use crate::asset_db::AssetRecord; -use crate::assets::fingerprint::write_pretty_ron_if_changed; - -pub fn animation_manifest_path(asset_id: &str) -> String { - format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron") -} - -pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result { - let mut manifest = build_animation_manifest(record)?; - manifest.source.dependencies.sort(); - manifest.source.dependencies.dedup(); - - let path = animation_manifest_path(&record.id.as_string()); - record.import_settings.animation_manifest_path = Some(path.clone()); - record - .dependencies - .extend(manifest.source.dependencies.iter().cloned()); - record.dependencies.sort(); - record.dependencies.dedup(); - - if write_pretty_ron_if_changed(&path, &manifest) - .map_err(|error| format!("could not publish animation manifest {path}: {error}"))? - { - bevy::log::info!( - "Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}", - record.path, - path, - manifest.skeletons.len(), - manifest.clips.len(), - manifest.runtime_supported - ); - } - - Ok(manifest) -} - -pub fn load_animation_manifest(path: &str) -> Result { - let text = - fs::read_to_string(path).map_err(|error| format!("could not read {path}: {error}"))?; - ron::from_str(&text).map_err(|error| format!("could not parse {path}: {error}")) -} - -pub fn build_animation_manifest(record: &AssetRecord) -> Result { - let bytes = fs::read(&record.path) - .map_err(|error| format!("could not read {}: {error}", record.path))?; - let fingerprint = source_fingerprint(&bytes); - let format = source_format(&record.path)?; - match format.as_str() { - "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes), - "fbx" => build_fbx_manifest(record, format, fingerprint, &bytes), - _ => Err(format!("unsupported animation source format `{format}`")), - } -} - -#[derive(Debug, Clone)] -struct SkeletonCandidate { - compatible_nodes: BTreeSet, - signature: AnimationSkeletonSignature, -} - -fn build_gltf_manifest( - record: &AssetRecord, - format: String, - fingerprint: AnimationSourceFingerprint, - bytes: &[u8], -) -> Result { - let gltf = gltf::Gltf::from_slice(bytes) - .map_err(|error| format!("could not parse glTF {}: {error}", record.path))?; - let base = Path::new(&record.path).parent(); - let buffers = gltf::import_buffers(&gltf.document, base, gltf.blob.clone()) - .map_err(|error| format!("could not load glTF buffers for {}: {error}", record.path))?; - - let mut dependencies = gltf - .document - .buffers() - .filter_map(|buffer| match buffer.source() { - gltf::buffer::Source::Uri(uri) if !uri.starts_with("data:") => { - Some(resolve_dependency(&record.path, uri)) - } - _ => None, - }) - .collect::>(); - dependencies.sort(); - dependencies.dedup(); - - let node_identity_names = gltf - .document - .nodes() - .map(|node| bevy_node_segment(node.name(), node.index())) - .collect::>(); - let readable_node_names = gltf - .document - .nodes() - .map(|node| normalized_node_segment(node.name(), node.index())) - .collect::>(); - let mut parents = vec![None; node_identity_names.len()]; - for node in gltf.document.nodes() { - for child in node.children() { - parents[child.index()] = Some(node.index()); - } - } - - let mut skeletons = Vec::new(); - let mut candidates = Vec::new(); - for skin in gltf.document.skins() { - let source_index = skin.index(); - let label = skin - .name() - .filter(|name| !name.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("Skeleton {source_index}")); - let joint_indices = skin.joints().map(|joint| joint.index()).collect::>(); - let joint_identity_paths = joint_indices - .iter() - .map(|index| node_path_segments(*index, &node_identity_names, &parents)) - .collect::>(); - let joint_paths = joint_indices - .iter() - .map(|index| node_path(*index, &readable_node_names, &parents)) - .collect::>(); - let bind_poses = skin - .reader(|buffer| Some(buffers[buffer.index()].0.as_slice())) - .read_inverse_bind_matrices() - .map(|matrices| matrices.map(gltf_matrix_bytes).collect::>()) - .unwrap_or_else(|| vec![identity_matrix_bytes(); joint_paths.len()]); - let signature = skeleton_signature(&joint_identity_paths, &bind_poses); - let mut compatible_nodes = joint_indices.into_iter().collect::>(); - if let Some(root) = skin.skeleton() { - compatible_nodes.insert(root.index()); - } - candidates.push(SkeletonCandidate { - compatible_nodes, - signature: signature.clone(), - }); - skeletons.push(AnimationSkeletonRecord { - id: animation_skeleton_sub_asset_id(source_index, &label), - label, - source_index, - signature, - joint_paths, - }); - } - - let mut diagnostics = Vec::new(); - let mut clips = Vec::new(); - let mut animation_roots = BTreeSet::new(); - for animation in gltf.document.animations() { - let source_index = animation.index(); - let label = animation - .name() - .filter(|name| !name.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("Animation {source_index}")); - let mut duration_seconds = 0.0_f32; - let mut target_nodes = BTreeSet::new(); - for channel in animation.channels() { - let target_index = channel.target().node().index(); - target_nodes.insert(target_index); - animation_roots.insert(top_level_node(target_index, &parents)); - if let Some(inputs) = channel - .reader(|buffer| Some(buffers[buffer.index()].0.as_slice())) - .read_inputs() - { - for input in inputs { - if input.is_finite() { - duration_seconds = duration_seconds.max(input); - } - } - } - } - let target_skeleton_signature = matching_skeleton_signature(&target_nodes, &candidates); - if !candidates.is_empty() && target_skeleton_signature.is_none() { - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Warning, - code: "animation.clip_skeleton_unresolved".into(), - message: format!( - "clip `{label}` does not target a uniquely identifiable imported skeleton" - ), - repair: "Export the clip with channels targeting one skeleton, or split unrelated rigs into separate glTF assets.".into(), - }); - } - let events = gltf_animation_events(&animation, duration_seconds, &label, &mut diagnostics); - clips.push(AnimationClipRecord { - id: animation_clip_sub_asset_id(source_index, &label), - label, - source_index, - duration_seconds, - target_skeleton_signature, - events, - }); - } - - let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone(); - validate_default_animation_clip( - default_animation_clip_id.as_deref(), - &clips, - &mut diagnostics, - ); - - let runtime_supported = animation_roots.len() <= 1; - if !runtime_supported { - let roots = animation_roots - .iter() - .map(|index| format!("`{}` (node {index})", node_identity_names[*index])) - .collect::>() - .join(", "); - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Error, - code: "animation.multiple_roots_unsupported".into(), - message: format!( - "animation channels target {} distinct top-level roots ({roots}); Bevy requires a separate AnimationPlayer for each root", - animation_roots.len() - ), - repair: "Split the source into one animated rig per glTF asset, or export every clip under one common top-level root, then reimport.".into(), - }); - } - - Ok(AnimationManifest { - schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, - asset_id: record.id.as_string(), - label: record.label.clone(), - default_animation_clip_id, - source: AnimationManifestSource { - path: record.path.clone(), - format, - fingerprint, - dependencies, - }, - runtime_supported, - skeletons, - clips, - diagnostics, - }) -} - -fn build_fbx_manifest( - record: &AssetRecord, - format: String, - fingerprint: AnimationSourceFingerprint, - bytes: &[u8], -) -> Result { - let scene = ufbx::load_memory( - bytes, - ufbx::LoadOpts { - target_unit_meters: 1.0, - target_axes: ufbx::CoordinateAxes::right_handed_y_up(), - ..Default::default() - }, - ) - .map_err(|error| format!("could not parse FBX {}: {error:?}", record.path))?; - - let mut skeletons = Vec::new(); - for (source_index, skin) in scene.skin_deformers.as_ref().iter().enumerate() { - let label = if skin.element.name.is_empty() { - format!("Skeleton {source_index}") - } else { - skin.element.name.to_string() - }; - let mut joint_identity_paths = Vec::new(); - let mut joint_paths = Vec::new(); - let mut bind_poses = Vec::new(); - for cluster in skin.clusters.as_ref() { - let Some(bone) = cluster.bone_node.as_ref() else { - continue; - }; - let segments = fbx_node_path_segments(bone.as_ref()); - joint_paths.push(segments.join("/")); - joint_identity_paths.push(segments); - bind_poses.push(fbx_matrix_bytes(&cluster.bind_to_world)); - } - let signature = skeleton_signature(&joint_identity_paths, &bind_poses); - skeletons.push(AnimationSkeletonRecord { - id: animation_skeleton_sub_asset_id(source_index, &label), - label, - source_index, - signature, - joint_paths, - }); - } - - let fallback_signature = (skeletons.len() == 1).then(|| skeletons[0].signature.clone()); - let clips = scene - .anim_stacks - .as_ref() - .iter() - .enumerate() - .map(|(source_index, stack)| { - let label = if stack.element.name.is_empty() { - format!("Animation {source_index}") - } else { - stack.element.name.to_string() - }; - AnimationClipRecord { - id: animation_clip_sub_asset_id(source_index, &label), - label, - source_index, - duration_seconds: (stack.time_end - stack.time_begin).max(0.0) as f32, - target_skeleton_signature: fallback_signature.clone(), - events: Vec::new(), - } - }) - .collect::>(); - - let (runtime_supported, mut diagnostics) = fbx_runtime_support( - scene.anim_stacks.as_ref().len(), - scene.skin_deformers.as_ref().len(), - ); - let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone(); - validate_default_animation_clip( - default_animation_clip_id.as_deref(), - &clips, - &mut diagnostics, - ); - - Ok(AnimationManifest { - schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, - asset_id: record.id.as_string(), - label: record.label.clone(), - default_animation_clip_id, - source: AnimationManifestSource { - path: record.path.clone(), - format, - fingerprint, - dependencies: Vec::new(), - }, - runtime_supported, - skeletons, - clips, - diagnostics, - }) -} - -fn validate_default_animation_clip( - default_clip_id: Option<&str>, - clips: &[AnimationClipRecord], - diagnostics: &mut Vec, -) { - let Some(default_clip_id) = default_clip_id.filter(|id| !id.trim().is_empty()) else { - return; - }; - if clips.iter().any(|clip| clip.id == default_clip_id) { - return; - } - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Error, - code: "animation.default_clip_missing".into(), - message: format!( - "configured default animation clip `{default_clip_id}` no longer exists in the imported source" - ), - repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(), - }); -} - -fn fbx_runtime_support( - animation_count: usize, - skin_count: usize, -) -> (bool, Vec) { - let mut diagnostics = Vec::new(); - if animation_count > 0 || skin_count > 0 { - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Error, - code: "animation.fbx_runtime_unsupported".into(), - message: format!( - "FBX contains {animation_count} animation stack(s) and {skin_count} skin deformer(s), but Blacksite's current FBX loader cannot build a runtime skeleton or clips" - ), - repair: "Export the animated source as glTF 2.0 (.glb or .gltf). Keep FBX only for static mesh import until hierarchy, SkinnedMesh, and AnimationClip conversion are implemented.".into(), - }); - } - (false, diagnostics) -} - -#[derive(Debug, Default, Deserialize)] -struct GltfAnimationExtras { - #[serde(default)] - blacksite_events: Vec, -} - -#[derive(Debug, Deserialize)] -struct GltfAnimationEvent { - id: String, - time_seconds: f32, - #[serde(default)] - payload: Option, -} - -fn gltf_animation_events( - animation: &gltf::Animation<'_>, - duration_seconds: f32, - clip_label: &str, - diagnostics: &mut Vec, -) -> Vec { - let Some(raw) = animation.extras().as_ref() else { - return Vec::new(); - }; - let extras = match serde_json::from_str::(raw.get()) { - Ok(extras) => extras, - Err(error) => { - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Warning, - code: "animation.events_invalid".into(), - message: format!("clip `{clip_label}` has invalid animation event metadata: {error}"), - repair: "Use an animation extras object with `blacksite_events` entries containing `id`, `time_seconds`, and optional string `payload`.".into(), - }); - return Vec::new(); - } - }; - - let mut events = Vec::new(); - for event in extras.blacksite_events { - let valid_time = event.time_seconds.is_finite() - && event.time_seconds >= 0.0 - && event.time_seconds <= duration_seconds; - if event.id.trim().is_empty() || !valid_time { - diagnostics.push(AnimationImportDiagnostic { - severity: AnimationDiagnosticSeverity::Warning, - code: "animation.event_out_of_range".into(), - message: format!( - "clip `{clip_label}` contains an event with an empty ID or time outside 0..={duration_seconds:.3}s" - ), - repair: "Give every event a stable non-empty ID and place it within the imported clip duration.".into(), - }); - continue; - } - events.push(AnimationEventDesc { - id: event.id, - time_seconds: event.time_seconds, - payload: event.payload, - }); - } - events.sort_by(|left, right| { - left.time_seconds - .total_cmp(&right.time_seconds) - .then(left.id.cmp(&right.id)) - .then(left.payload.cmp(&right.payload)) - }); - events -} - -fn matching_skeleton_signature( - target_nodes: &BTreeSet, - candidates: &[SkeletonCandidate], -) -> Option { - let scored = candidates - .iter() - .map(|candidate| { - ( - candidate - .compatible_nodes - .intersection(target_nodes) - .count(), - candidate, - ) - }) - .filter(|(score, _)| *score > 0) - .collect::>(); - let best_score = scored.iter().map(|(score, _)| *score).max()?; - let mut best = scored - .into_iter() - .filter(|(score, _)| *score == best_score) - .map(|(_, candidate)| candidate); - let candidate = best.next()?; - best.all(|other| other.signature == candidate.signature) - .then(|| candidate.signature.clone()) -} - -fn node_path(index: usize, names: &[String], parents: &[Option]) -> String { - node_path_segments(index, names, parents).join("/") -} - -fn node_path_segments(index: usize, names: &[String], parents: &[Option]) -> Vec { - let mut segments = Vec::new(); - let mut current = Some(index); - while let Some(node_index) = current { - segments.push(names[node_index].clone()); - current = parents[node_index]; - } - segments.reverse(); - segments -} - -fn top_level_node(index: usize, parents: &[Option]) -> usize { - let mut current = index; - while let Some(parent) = parents[current] { - current = parent; - } - current -} - -fn bevy_node_segment(name: Option<&str>, index: usize) -> String { - name.map(str::to_string) - .unwrap_or_else(|| format!("GltfNode{index}")) -} - -fn normalized_node_segment(name: Option<&str>, index: usize) -> String { - name.map(str::trim) - .filter(|name| !name.is_empty()) - .map(|name| name.replace(['/', '\\'], "_")) - .unwrap_or_else(|| format!("Node{index}")) -} - -fn fbx_node_path_segments(node: &ufbx::Node) -> Vec { - let mut segments = Vec::new(); - let mut current = Some(node); - while let Some(node) = current { - segments.push(normalized_node_segment( - (!node.element.name.is_empty()).then(|| node.element.name.as_ref()), - node.element.element_id as usize, - )); - current = node.parent.as_ref().map(|parent| parent.as_ref()); - } - segments.reverse(); - segments -} - -fn skeleton_signature( - joint_path_segments: &[Vec], - bind_pose_bytes: &[Vec], -) -> AnimationSkeletonSignature { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"blacksite-animation-skeleton-v2\0"); - hasher.update(&(joint_path_segments.len() as u64).to_le_bytes()); - for path in joint_path_segments { - hasher.update(&(path.len() as u64).to_le_bytes()); - for segment in path { - let bytes = segment.as_bytes(); - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(bytes); - } - } - hasher.update(&(bind_pose_bytes.len() as u64).to_le_bytes()); - for bytes in bind_pose_bytes { - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(bytes); - } - AnimationSkeletonSignature::new(hasher.finalize().to_hex().to_string()) -} - -fn gltf_matrix_bytes(matrix: [[f32; 4]; 4]) -> Vec { - matrix - .into_iter() - .flatten() - .flat_map(f32::to_le_bytes) - .collect() -} - -fn identity_matrix_bytes() -> Vec { - gltf_matrix_bytes([ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ]) -} - -fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec { - [ - matrix.m00, matrix.m10, matrix.m20, matrix.m01, matrix.m11, matrix.m21, matrix.m02, - matrix.m12, matrix.m22, matrix.m03, matrix.m13, matrix.m23, - ] - .into_iter() - .flat_map(f64::to_le_bytes) - .collect() -} - -fn source_fingerprint(bytes: &[u8]) -> AnimationSourceFingerprint { - AnimationSourceFingerprint::from_bytes(bytes) -} - -fn source_format(path: &str) -> Result { - Path::new(path) - .extension() - .and_then(|extension| extension.to_str()) - .map(|extension| extension.to_ascii_lowercase()) - .ok_or_else(|| format!("asset path `{path}` has no extension")) -} - -fn resolve_dependency(source_path: &str, uri: &str) -> String { - Path::new(source_path) - .parent() - .unwrap_or_else(|| Path::new("")) - .join(uri) - .to_string_lossy() - .replace('\\', "/") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::asset_db::{AssetId, ImportSettings}; - use uuid::Uuid; - - fn fixture_record(path: &Path) -> AssetRecord { - AssetRecord { - id: AssetId(Uuid::nil()), - path: path.to_string_lossy().replace('\\', "/"), - label: "Animated Hero".into(), - kind_tag: "Model".into(), - source_fingerprint: None, - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - } - } - - fn write_animated_gltf(root: &Path) -> std::path::PathBuf { - fs::create_dir_all(root).unwrap(); - let mut buffer = Vec::new(); - for value in [0.0_f32, 1.25] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - fs::write(root.join("animated.bin"), buffer).unwrap(); - let gltf = r#"{ - "asset":{"version":"2.0"}, - "scene":0, - "scenes":[{"nodes":[0]}], - "nodes":[{"name":"Armature","children":[1]},{"name":"Hip"}], - "skins":[{"name":"Hero Rig","skeleton":0,"joints":[1]}], - "buffers":[{"uri":"animated.bin","byteLength":40}], - "bufferViews":[ - {"buffer":0,"byteOffset":0,"byteLength":8}, - {"buffer":0,"byteOffset":8,"byteLength":32} - ], - "accessors":[ - {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.25]}, - {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"} - ], - "animations":[{ - "name":"Idle Loop", - "samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}], - "channels":[{"sampler":0,"target":{"node":1,"path":"rotation"}}], - "extras":{"blacksite_events":[{"id":"footstep.left","time_seconds":0.5,"payload":"stone"}]} - }] - }"#; - let path = root.join("animated.gltf"); - fs::write(&path, gltf).unwrap(); - path - } - - fn write_multi_root_animated_gltf(root: &Path) -> std::path::PathBuf { - fs::create_dir_all(root).unwrap(); - let mut buffer = Vec::new(); - for value in [0.0_f32, 1.0] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - fs::write(root.join("multi-root.bin"), buffer).unwrap(); - let gltf = r#"{ - "asset":{"version":"2.0"}, - "scene":0, - "scenes":[{"nodes":[0,2]}], - "nodes":[ - {"name":"Character A","children":[1]}, - {"name":"Joint A"}, - {"name":"Character B","children":[3]}, - {"name":"Joint B"} - ], - "buffers":[{"uri":"multi-root.bin","byteLength":40}], - "bufferViews":[ - {"buffer":0,"byteOffset":0,"byteLength":8}, - {"buffer":0,"byteOffset":8,"byteLength":32} - ], - "accessors":[ - {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.0]}, - {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"} - ], - "animations":[{ - "name":"Two Players Required", - "samplers":[ - {"input":0,"output":1,"interpolation":"LINEAR"}, - {"input":0,"output":1,"interpolation":"LINEAR"} - ], - "channels":[ - {"sampler":0,"target":{"node":1,"path":"rotation"}}, - {"sampler":1,"target":{"node":3,"path":"rotation"}} - ] - }] - }"#; - let path = root.join("multi-root.gltf"); - fs::write(&path, gltf).unwrap(); - path - } - - #[test] - fn animation_manifest_path_uses_registry_uuid() { - assert_eq!( - animation_manifest_path("abc"), - "assets/animations/generated/abc.animation.ron" - ); - } - - #[test] - fn gltf_extraction_is_deterministic_and_preserves_duration_events_and_signature() { - let root = std::env::temp_dir().join(format!("blacksite-animation-{}", Uuid::new_v4())); - let path = write_animated_gltf(&root); - let record = fixture_record(&path); - - let first = build_animation_manifest(&record).unwrap(); - let second = build_animation_manifest(&record).unwrap(); - - assert_eq!(first, second); - assert_eq!(first.schema_version, ANIMATION_MANIFEST_SCHEMA_VERSION); - assert!(first.default_animation_clip_id.is_none()); - assert!(first.runtime_supported); - assert_eq!(first.source.dependencies.len(), 1); - assert_eq!(first.skeletons.len(), 1); - assert_eq!(first.clips.len(), 1); - assert_eq!(first.skeletons[0].id, "animation:skeleton:0:hero_rig"); - assert!(!first.skeletons[0].signature.is_empty()); - assert_eq!(first.clips[0].id, "animation:clip:0:idle_loop"); - assert_eq!(first.clips[0].duration_seconds, 1.25); - assert_eq!( - first.clips[0].target_skeleton_signature, - Some(first.skeletons[0].signature.clone()) - ); - assert_eq!(first.clips[0].events.len(), 1); - assert_eq!(first.clips[0].events[0].id, "footstep.left"); - assert_eq!(first.clips[0].events[0].time_seconds, 0.5); - assert_eq!(first.diagnostics, Vec::new()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn legacy_mtime_metadata_does_not_rewrite_equivalent_animation_manifest() { - let root = - std::env::temp_dir().join(format!("blacksite-animation-legacy-{}", Uuid::new_v4())); - let path = write_animated_gltf(&root); - let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); - let canonical = - ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(); - let marker = format!("byte_len: {},", manifest.source.fingerprint.byte_len); - let legacy = canonical.replacen( - &marker, - &format!("{marker}\n modified_unix_secs: 123456,"), - 1, - ); - assert_ne!(legacy, canonical); - let artifact = root.join("legacy.animation.ron"); - fs::write(&artifact, &legacy).unwrap(); - - assert!(!write_pretty_ron_if_changed(&artifact, &manifest).unwrap()); - assert_eq!(fs::read_to_string(&artifact).unwrap(), legacy); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() { - let root = - std::env::temp_dir().join(format!("blacksite-animation-default-{}", Uuid::new_v4())); - let path = write_animated_gltf(&root); - let mut record = fixture_record(&path); - record.import_settings.default_animation_clip_id = - Some("animation:clip:0:idle_loop".into()); - - let manifest = build_animation_manifest(&record).unwrap(); - assert_eq!( - manifest.default_animation_clip_id.as_deref(), - Some("animation:clip:0:idle_loop") - ); - assert!(manifest.diagnostics.is_empty()); - - record.import_settings.default_animation_clip_id = Some("animation:clip:99:removed".into()); - let stale = build_animation_manifest(&record).unwrap(); - assert!(stale.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "animation.default_clip_missing" - && diagnostic.severity == AnimationDiagnosticSeverity::Error - })); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn exact_signature_changes_with_joint_path_or_bind_pose() { - let identity = identity_matrix_bytes(); - let first = skeleton_signature( - &[vec!["Armature".into(), "Hip".into()]], - std::slice::from_ref(&identity), - ); - let renamed = skeleton_signature( - &[vec!["Armature".into(), "Pelvis".into()]], - std::slice::from_ref(&identity), - ); - let mut changed_pose = identity; - changed_pose[0] ^= 1; - let rebound = skeleton_signature(&[vec!["Armature".into(), "Hip".into()]], &[changed_pose]); - assert_ne!(first, renamed); - assert_ne!(first, rebound); - } - - #[test] - fn exact_signature_preserves_bevy_node_bytes_and_segment_boundaries() { - let identity = identity_matrix_bytes(); - let slash_in_name = vec![vec!["Armature".into(), "Hip/Joint".into()]]; - let slash_as_boundary = vec![vec!["Armature/Hip".into(), "Joint".into()]]; - - assert_eq!(slash_in_name[0].join("/"), slash_as_boundary[0].join("/")); - assert_ne!( - skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)), - skeleton_signature(&slash_as_boundary, std::slice::from_ref(&identity)) - ); - - let backslash = vec![vec!["Armature".into(), "Hip\\Joint".into()]]; - assert_eq!( - normalized_node_segment(Some("Hip/Joint"), 1), - normalized_node_segment(Some("Hip\\Joint"), 1) - ); - assert_ne!( - skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)), - skeleton_signature(&backslash, std::slice::from_ref(&identity)) - ); - assert_eq!(bevy_node_segment(Some(" /Joint "), 7), " /Joint "); - assert_eq!(bevy_node_segment(None, 7), "GltfNode7"); - } - - #[test] - fn gltf_with_multiple_animation_roots_is_runtime_unsupported() { - let root = - std::env::temp_dir().join(format!("blacksite-animation-multi-root-{}", Uuid::new_v4())); - let path = write_multi_root_animated_gltf(&root); - let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); - - assert!(!manifest.runtime_supported); - let diagnostic = manifest - .diagnostics - .iter() - .find(|diagnostic| diagnostic.code == "animation.multiple_roots_unsupported") - .expect("multi-root glTF should include an actionable blocking diagnostic"); - assert_eq!(diagnostic.severity, AnimationDiagnosticSeverity::Error); - assert!(diagnostic.message.contains("2 distinct top-level roots")); - assert!(diagnostic.repair.contains("one common top-level root")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn animated_or_skinned_fbx_is_runtime_unsupported_with_repair_guidance() { - let (supported, diagnostics) = fbx_runtime_support(2, 1); - assert!(!supported); - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].severity, AnimationDiagnosticSeverity::Error); - assert_eq!(diagnostics[0].code, "animation.fbx_runtime_unsupported"); - assert!(diagnostics[0].repair.contains("glTF 2.0")); - - let (supported, diagnostics) = fbx_runtime_support(0, 0); - assert!(!supported); - assert!(diagnostics.is_empty()); - } - - #[test] - fn committed_robot_fixture_exposes_a_skin_and_multiple_named_states() { - let path = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models/robot_expressive.glb"); - let manifest = build_animation_manifest(&fixture_record(&path)).unwrap(); - - assert!(manifest.runtime_supported); - assert_eq!(manifest.skeletons.len(), 2); - assert_eq!(manifest.skeletons[0].id, "animation:skeleton:0:skeleton_0"); - assert_eq!(manifest.skeletons[1].id, "animation:skeleton:1:skeleton_1"); - assert!(manifest - .skeletons - .iter() - .all(|skeleton| skeleton.joint_paths.len() == 43)); - let signature = &manifest.skeletons[0].signature; - assert!(manifest - .skeletons - .iter() - .all(|skeleton| &skeleton.signature == signature)); - assert_eq!(manifest.clips.len(), 14); - for expected in ["Idle", "Walking", "Running", "Jump", "Wave"] { - assert!( - manifest.clips.iter().any(|clip| clip.label == expected), - "missing expected fixture clip {expected}" - ); - } - assert!(manifest - .clips - .iter() - .all(|clip| clip.duration_seconds.is_finite() && clip.duration_seconds > 0.0)); - assert!(manifest - .clips - .iter() - .all(|clip| { clip.target_skeleton_signature.as_ref() == Some(signature) })); - assert!(manifest.diagnostics.is_empty()); - } -} +pub use content_pipeline::animation::*; diff --git a/crates/editor/src/assets/asset_db.rs b/crates/editor/src/assets/asset_db.rs index 0a34de4..e7dc904 100644 --- a/crates/editor/src/assets/asset_db.rs +++ b/crates/editor/src/assets/asset_db.rs @@ -1,277 +1,63 @@ //! Project asset registry (stable IDs + import metadata). Phase 5 foundation. use bevy::prelude::*; -use serde::{Deserialize, Serialize}; -use shared::AssetSourceFingerprint; -use std::collections::{HashMap, HashSet}; +use shared::{ + parse_asset_registry, AssetRegistryDocument, ProjectContentDefaults, + ASSET_REGISTRY_SCHEMA_VERSION, +}; +pub use shared::{ + AssetId, AssetKind, AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, + ModelMaterialSelection, ModelMaterialSlotSelection, ModelPlacementMode, +}; +use std::path::Path; +#[cfg(test)] use uuid::Uuid; -use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed}; +use crate::assets::fingerprint::fingerprint_file; -/// Stable asset identity for dependency tracking and prefab references. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct AssetId(pub Uuid); - -impl AssetId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - - pub fn as_string(&self) -> String { - self.0.to_string() - } +#[derive(Resource, Debug)] +pub struct AssetRegistry { + pub schema_version: u32, + pub defaults: ProjectContentDefaults, + pub records: Vec, + pub index_dirty: bool, + pub migration_required: bool, } -impl Default for AssetId { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum ModelPlacementMode { - /// Place renderable content through the appropriate normalized renderer. Imports containing - /// skins or animation route to `SkinnedMeshRenderer`; unrigged, non-animated content routes to - /// `StaticMeshRenderer`. - #[default] - StaticAsset, - /// Instantiate the complete source scene as a generic imported model. - SceneInstance, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum ModelHierarchyMode { - #[default] - SingleActor, - SourceHierarchy, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum MaterialImportPolicy { - #[default] - SourceMaterials, - AuthoringOverride, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ImportSettings { - pub scale: f32, - pub generate_collider: bool, - pub lod0_only: bool, - #[serde(default)] - pub placement_mode: ModelPlacementMode, - #[serde(default)] - pub hierarchy_mode: ModelHierarchyMode, - #[serde(default)] - pub material_policy: MaterialImportPolicy, - #[serde(default)] - pub static_mesh_manifest_path: Option, - #[serde(default)] - pub animation_manifest_path: Option, - /// Stable animation clip sub-asset ID used as this model's edit-mode rest presentation. - /// `None` preserves the imported node pose and never guesses a clip. - #[serde(default)] - pub default_animation_clip_id: Option, -} - -impl Default for ImportSettings { +impl Default for AssetRegistry { fn default() -> Self { Self { - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: ModelPlacementMode::default(), - hierarchy_mode: ModelHierarchyMode::default(), - material_policy: MaterialImportPolicy::default(), - static_mesh_manifest_path: None, - animation_manifest_path: None, - default_animation_clip_id: None, + schema_version: ASSET_REGISTRY_SCHEMA_VERSION, + defaults: ProjectContentDefaults::default(), + records: Vec::new(), + index_dirty: false, + migration_required: false, } } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AssetRecord { - pub id: AssetId, - pub path: String, - pub label: String, - pub kind_tag: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_fingerprint: Option, - #[serde(default)] - pub import_settings: ImportSettings, - #[serde(default)] - pub dependencies: Vec, -} - -#[derive(Resource, Debug, Default)] -pub struct AssetRegistry { - pub records: Vec, - pub index_dirty: bool, +impl AssetRegistry { + pub fn document(&self) -> AssetRegistryDocument { + AssetRegistryDocument { + schema_version: self.schema_version, + defaults: self.defaults.clone(), + records: self.records.clone(), + } + } } pub struct AssetDbPlugin; impl Plugin for AssetDbPlugin { fn build(&self, app: &mut App) { - app.insert_resource(load_registry()) - .add_systems(Update, sync_registry_from_browser); + let registry = load_registry(); + app.insert_resource(registry.defaults.clone()) + .insert_resource(registry); } } -fn sync_registry_from_browser( - assets: Res, - mut registry: ResMut, -) { - if !assets.is_changed() { - return; - } - - let previous_records = std::mem::take(&mut registry.records); - let mut existing: HashMap = previous_records - .iter() - .cloned() - .map(|record| (record.path.clone(), record)) - .collect(); - let current_paths = assets - .assets - .iter() - .filter_map(|asset| asset.path.clone()) - .collect::>(); - - let mut next_records = Vec::new(); - let mut seen_paths = HashMap::new(); - - for asset in &assets.assets { - let Some(path) = asset.path.clone() else { - continue; - }; - if path.is_empty() || seen_paths.contains_key(&path) { - continue; - } - seen_paths.insert(path.clone(), ()); - - let kind_tag = format!("{:?}", asset.kind); - let source_fingerprint = if imported_source_kind(&kind_tag) { - match fingerprint_file(&path) { - Ok(fingerprint) => Some(fingerprint), - Err(error) => { - warn!("Imported source fingerprint failed for {path}: {error}"); - None - } - } - } else { - None - }; - let mut record = if let Some(mut prior) = existing.remove(&path).or_else(|| { - source_fingerprint.as_ref().and_then(|fingerprint| { - take_uniquely_moved_import_record( - &mut existing, - ¤t_paths, - &kind_tag, - fingerprint, - ) - }) - }) { - if prior.path != path { - info!( - "Asset registry preserved imported source identity {} across move {} -> {}", - prior.id.as_string(), - prior.path, - path - ); - prior.path = path.clone(); - } - prior.label = asset.label.clone(); - prior.kind_tag = kind_tag.clone(); - prior - } else { - AssetRecord { - id: AssetId::new(), - path: path.clone(), - label: asset.label.clone(), - kind_tag: kind_tag.clone(), - source_fingerprint: None, - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - } - }; - if imported_source_kind(&kind_tag) { - if let Some(source_fingerprint) = source_fingerprint { - record.source_fingerprint = Some(source_fingerprint); - } - } else { - record.source_fingerprint = None; - } - if record.kind_tag == "Model" { - if let Err(error) = super::refresh_model_artifacts(&mut record) { - warn!( - "Model artifact refresh failed for {} (asset id {}): {error}", - record.path, - record.id.as_string() - ); - } - } - next_records.push(record); - } - - sort_registry_records(&mut next_records); - let changed = previous_records != next_records; - registry.records = next_records; - if changed { - registry.index_dirty = true; - } - - if registry.index_dirty { - if let Err(error) = save_registry(®istry) { - warn!("Asset registry save failed: {error}"); - } else { - registry.index_dirty = false; - } - } -} - -fn imported_source_kind(kind_tag: &str) -> bool { - matches!(kind_tag, "Model" | "Texture" | "AudioClip") -} - -fn sort_registry_records(records: &mut [AssetRecord]) { - records.sort_by(|left, right| left.path.cmp(&right.path)); -} - -fn take_uniquely_moved_import_record( - existing: &mut HashMap, - current_paths: &HashSet, - kind_tag: &str, - fingerprint: &AssetSourceFingerprint, -) -> Option { - if !imported_source_kind(kind_tag) { - return None; - } - let candidates = existing - .iter() - .filter_map(|(old_path, record)| { - if current_paths.contains(old_path) || record.kind_tag != kind_tag { - return None; - } - recorded_source_fingerprint(record) - .is_some_and(|prior| prior == *fingerprint) - .then(|| old_path.clone()) - }) - .collect::>(); - if candidates.len() != 1 { - return None; - } - existing.remove(&candidates[0]) -} - -fn recorded_source_fingerprint(record: &AssetRecord) -> Option { - record.source_fingerprint.clone().or_else(|| { - let manifest_path = record.import_settings.animation_manifest_path.as_deref()?; - super::animation::load_animation_manifest(manifest_path) - .ok() - .map(|manifest| manifest.source.fingerprint) - }) +fn imported_source_kind(kind: AssetKind) -> bool { + kind.is_imported_source() } pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option { @@ -305,27 +91,37 @@ pub fn ensure_asset_record( path: impl Into, label: impl Into, kind_tag: impl Into, +) -> Result { + ensure_asset_record_at(registry, Path::new("."), path, label, kind_tag) +} + +pub fn ensure_asset_record_at( + registry: &mut AssetRegistry, + project_root: &Path, + path: impl Into, + label: impl Into, + kind_tag: impl Into, ) -> Result { let path = path.into(); if let Some(record) = find_asset_by_path(registry, &path) { return Ok(record); } - let kind_tag = kind_tag.into(); - let source_fingerprint = imported_source_kind(&kind_tag) - .then(|| fingerprint_file(&path).ok()) + let kind = AssetKind::from_legacy_tag(&kind_tag.into()); + let source_fingerprint = imported_source_kind(kind) + .then(|| fingerprint_file(project_root.join(&path)).ok()) .flatten(); let record = AssetRecord { - id: AssetId::new(), + id: shared::AssetId::new(), path, label: label.into(), - kind_tag, + kind, source_fingerprint, - import_settings: ImportSettings::default(), + import_settings: shared::AssetImportSettings::for_kind(kind), dependencies: Vec::new(), }; registry.records.push(record.clone()); registry.index_dirty = true; - if let Err(error) = save_registry(registry) { + if let Err(error) = save_registry_at(registry, project_root) { registry.records.pop(); return Err(error); } @@ -345,10 +141,10 @@ pub fn update_import_settings( else { return false; }; - if record.import_settings == settings { + if record.import_settings.model() == Some(&settings) { return false; } - record.import_settings = settings; + record.import_settings = settings.into(); registry.index_dirty = true; true } @@ -358,19 +154,29 @@ pub fn registry_index_path() -> &'static str { } pub fn save_registry(registry: &AssetRegistry) -> Result<(), String> { - write_pretty_ron_if_changed(registry_index_path(), ®istry.records).map(|_| ()) + save_registry_at(registry, Path::new(".")) +} + +pub fn save_registry_at(registry: &AssetRegistry, project_root: &Path) -> Result<(), String> { + content_pipeline::publish_content_documents(project_root, ®istry.document()) } pub fn load_registry() -> AssetRegistry { let path = registry_index_path(); match std::fs::read_to_string(path) { - Ok(text) => { - let records: Vec = ron::from_str(&text).unwrap_or_default(); - AssetRegistry { - records, + Ok(text) => match parse_asset_registry(&text) { + Ok(loaded) => AssetRegistry { + schema_version: loaded.document.schema_version, + defaults: loaded.document.defaults, + records: loaded.document.records, index_dirty: false, + migration_required: loaded.migration_required, + }, + Err(error) => { + warn!("Asset registry load failed: {error}"); + AssetRegistry::default() } - } + }, Err(_) => AssetRegistry::default(), } } @@ -378,188 +184,64 @@ pub fn load_registry() -> AssetRegistry { #[cfg(test)] mod tests { use super::*; - use shared::{ - AnimationManifest, AnimationManifestSource, AnimationSourceFingerprint, - ANIMATION_MANIFEST_SCHEMA_VERSION, - }; #[test] fn imported_source_policy_covers_model_texture_and_audio_only() { - for kind in ["Model", "Texture", "AudioClip"] { - assert!(imported_source_kind(kind), "missing imported kind {kind}"); + for kind in [AssetKind::Model, AssetKind::Texture, AssetKind::AudioClip] { + assert!(imported_source_kind(kind), "missing imported kind {kind:?}"); } - for kind in ["Material", "Level", "Prefab", "PostProcessEffect"] { + for kind in [ + AssetKind::Material, + AssetKind::Level, + AssetKind::Prefab, + AssetKind::PostProcessEffect, + ] { assert!( !imported_source_kind(kind), - "authored kind {kind} was hashed" + "authored kind {kind:?} was hashed" ); } } #[test] - fn registry_publication_order_is_stable_across_discovery_orders() { - let records = [ - ("assets/textures/z.png", "Texture"), - ("assets/audio/a.ogg", "AudioClip"), - ("assets/models/m.glb", "Model"), - ] - .into_iter() - .map(|(path, kind_tag)| AssetRecord { - id: AssetId::new(), - path: path.into(), - label: path.into(), - kind_tag: kind_tag.into(), - source_fingerprint: None, - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - }) - .collect::>(); - let mut forward = records.clone(); - let mut reverse = records.into_iter().rev().collect::>(); - - sort_registry_records(&mut forward); - sort_registry_records(&mut reverse); - - assert_eq!(forward, reverse); + fn shared_classification_preserves_schema_classified_material_instance_kind() { + let root = std::env::temp_dir().join(format!( + "blacksite-browser-material-instance-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("user_named_variant.ron"); + let instance = shared::MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "User Named Variant".into(), + base: shared::MaterialRef::new(shared::EditorAssetRef::new( + "base-id", + "material:source", + "Base", + )), + overrides: shared::MaterialInputSet::default(), + }; + std::fs::write(&path, ron::ser::to_string(&instance).unwrap()).unwrap(); assert_eq!( - forward - .iter() - .map(|record| record.path.as_str()) - .collect::>(), - [ - "assets/audio/a.ogg", - "assets/models/m.glb", - "assets/textures/z.png", - ] + content_pipeline::classify_asset_file(&path), + AssetKind::MaterialInstance ); + std::fs::remove_dir_all(root).unwrap(); } #[test] fn legacy_registry_record_loads_without_forcing_a_new_identity() { - let source = r#"( - id: ("00000000-0000-0000-0000-000000000000"), - path: "assets/textures/legacy.png", - label: "Legacy", - kind_tag: "Texture", - )"#; - let record: AssetRecord = ron::from_str(source).unwrap(); + let source = r#"[(id:("00000000-0000-0000-0000-000000000000"),path:"assets/textures/legacy.png",label:"Legacy",kind_tag:"Texture")]"#; + let loaded = parse_asset_registry(source).unwrap(); + let record = &loaded.document.records[0]; assert_eq!(record.id, AssetId(Uuid::nil())); assert!(record.source_fingerprint.is_none()); - assert_eq!(record.import_settings, ImportSettings::default()); - } - - #[test] - fn content_match_preserves_every_imported_source_kind_across_move() { - let fingerprint = AssetSourceFingerprint::from_bytes(b"stable imported bytes"); - for kind in ["Model", "Texture", "AudioClip"] { - let record = AssetRecord { - id: AssetId::new(), - path: format!("assets/old/{kind}"), - label: kind.into(), - kind_tag: kind.into(), - source_fingerprint: Some(fingerprint.clone()), - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - }; - let expected_id = record.id.clone(); - let mut existing = HashMap::from([(record.path.clone(), record)]); - - let moved = take_uniquely_moved_import_record( - &mut existing, - &HashSet::new(), - kind, - &fingerprint, - ) - .expect("unique content identity should preserve the record"); - - assert_eq!(moved.id, expected_id); - assert!(existing.is_empty()); - } - } - - fn moved_model_fixture(bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf, AssetRecord) { - let key = Uuid::new_v4(); - let source_path = std::env::temp_dir().join(format!("blacksite-moved-model-{key}.glb")); - let manifest_path = - std::env::temp_dir().join(format!("blacksite-moved-model-{key}.animation.ron")); - std::fs::write(&source_path, bytes).unwrap(); - let id = AssetId::new(); - let old_path = format!("assets/models/old-{key}.glb"); - let manifest = AnimationManifest { - schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, - asset_id: id.as_string(), - label: "Moved Model".into(), - default_animation_clip_id: None, - source: AnimationManifestSource { - path: old_path.clone(), - format: "glb".into(), - fingerprint: AnimationSourceFingerprint { - byte_len: bytes.len() as u64, - content_hash: blake3::hash(bytes).to_hex().to_string(), - }, - dependencies: Vec::new(), - }, - runtime_supported: true, - skeletons: Vec::new(), - clips: Vec::new(), - diagnostics: Vec::new(), - }; - std::fs::write( - &manifest_path, - ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(), - ) - .unwrap(); - let record = AssetRecord { - id, - path: old_path, - label: "Moved Model".into(), - kind_tag: "Model".into(), - source_fingerprint: None, - import_settings: ImportSettings { - animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()), - ..Default::default() - }, - dependencies: Vec::new(), - }; - (source_path, manifest_path, record) - } - - #[test] - fn unique_content_match_preserves_model_record_across_move() { - let (source_path, manifest_path, record) = moved_model_fixture(b"stable model bytes"); - let expected_id = record.id.clone(); - let mut existing = HashMap::from([(record.path.clone(), record)]); - let fingerprint = fingerprint_file(&source_path).unwrap(); - - let moved = take_uniquely_moved_import_record( - &mut existing, - &HashSet::new(), - "Model", - &fingerprint, - ) - .expect("unique moved model should retain its registry record"); - - assert_eq!(moved.id, expected_id); - assert!(existing.is_empty()); - let _ = std::fs::remove_file(source_path); - let _ = std::fs::remove_file(manifest_path); - } - - #[test] - fn existing_source_path_is_not_reconciled_as_a_copy_move() { - let (source_path, manifest_path, record) = moved_model_fixture(b"copied model bytes"); - let old_path = record.path.clone(); - let mut existing = HashMap::from([(old_path.clone(), record)]); - let current_paths = HashSet::from([old_path]); - let fingerprint = fingerprint_file(&source_path).unwrap(); - - let moved = - take_uniquely_moved_import_record(&mut existing, ¤t_paths, "Model", &fingerprint); - - assert!(moved.is_none()); - assert_eq!(existing.len(), 1); - let _ = std::fs::remove_file(source_path); - let _ = std::fs::remove_file(manifest_path); + assert_eq!( + record.import_settings, + shared::AssetImportSettings::for_kind(AssetKind::Texture) + ); + assert_eq!(record.kind, AssetKind::Texture); + assert!(loaded.migration_required); } } diff --git a/crates/editor/src/assets/catalog.rs b/crates/editor/src/assets/catalog.rs index f2f40f0..9e3a5ac 100644 --- a/crates/editor/src/assets/catalog.rs +++ b/crates/editor/src/assets/catalog.rs @@ -1,18 +1,21 @@ +use std::collections::{BTreeSet, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; use bevy::prelude::*; use settings::ProjectSettings; use shared::{ - ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc, - MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabRef, Primitive, PrimitiveShape, - RigidBodyDesc, AUDIO_CLIP_SUB_ASSET_ID, + ActorKind, AuthoringLightKind, ColliderDesc, EditorVisibility, LightDesc, ModelRef, + PostProcessVolumeDesc, PrefabRef, Primitive, PrimitiveShape, RigidBodyDesc, + AUDIO_CLIP_SUB_ASSET_ID, }; use walkdir::WalkDir; use crate::asset_db::{ ensure_asset_record, find_asset_by_id, find_asset_by_path, find_asset_mut_by_path, - AssetRegistry, MaterialImportPolicy, ModelPlacementMode, + AssetRegistry, ModelPlacementMode, }; use crate::assets::animation::load_animation_manifest; use crate::assets::static_mesh::{load_static_mesh_manifest, renderer_from_manifest}; @@ -38,6 +41,7 @@ pub enum AssetSubAssetKind { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum AssetSelection { Builtin(String), + Folder(String), File(String), SubAsset { parent_path: String, @@ -59,14 +63,16 @@ impl AssetSelection { pub fn parent_path(&self) -> Option<&str> { match self { AssetSelection::Builtin(_) => None, - AssetSelection::File(path) => Some(path), + AssetSelection::Folder(path) | AssetSelection::File(path) => Some(path), AssetSelection::SubAsset { parent_path, .. } => Some(parent_path), } } pub fn display_label(&self) -> &str { match self { - AssetSelection::Builtin(label) | AssetSelection::File(label) => label, + AssetSelection::Builtin(label) + | AssetSelection::Folder(label) + | AssetSelection::File(label) => label, AssetSelection::SubAsset { label, .. } => label, } } @@ -108,9 +114,16 @@ pub struct EditorAssets { pub folders: Vec, pub assets: Vec, pub current_folder: String, + /// Primary selection used by the Details pane and single-item operators. pub selected: Option, + /// Ordered file-manager selection. The primary selection is always the last item. + pub selections: Vec, + pub selection_anchor: Option, pub dragging: Option, pub status: String, + /// Advances only when the visible filesystem catalog is rescanned. Selection and Details + /// changes must not trigger registry/fingerprint work. + pub catalog_revision: u64, } impl Default for EditorAssets { @@ -120,8 +133,11 @@ impl Default for EditorAssets { assets: Vec::new(), current_folder: ASSETS_ROOT.to_string(), selected: None, + selections: Vec::new(), + selection_anchor: None, dragging: None, status: "Assets not scanned yet".to_string(), + catalog_revision: 0, }; assets.refresh(); assets @@ -130,6 +146,7 @@ impl Default for EditorAssets { impl EditorAssets { pub fn refresh(&mut self) { + self.catalog_revision = self.catalog_revision.wrapping_add(1); self.folders.clear(); self.assets.clear(); @@ -147,10 +164,24 @@ impl EditorAssets { self.current_folder = ASSETS_ROOT.to_string(); } + let folders = &self.folders; + let assets = &self.assets; + self.selections + .retain(|selection| selection_exists_in(folders, assets, selection)); self.selected = self .selected .take() - .filter(|selection| self.asset_for_selection(selection).is_some()); + .filter(|selection| selection_exists_in(folders, assets, selection)) + .or_else(|| self.selections.last().cloned()); + if let Some(primary) = self.selected.clone() { + if !self.selections.contains(&primary) { + self.selections.push(primary); + } + } + self.selection_anchor = self + .selection_anchor + .take() + .filter(|selection| selection_exists_in(folders, assets, selection)); let folder_count = self.folders.len(); self.status = format!( @@ -174,6 +205,7 @@ impl EditorAssets { .assets .iter() .find(|asset| asset.path.as_deref() == Some(path.as_str())), + AssetSelection::Folder(_) => None, AssetSelection::SubAsset { parent_path, .. } => self .assets .iter() @@ -198,20 +230,109 @@ impl EditorAssets { } pub fn select(&mut self, selection: AssetSelection) { - if let Some(asset) = self.asset_for_selection(&selection).cloned() { + if self.selection_exists(&selection) { let selected_label = match &selection { AssetSelection::SubAsset { label, kind, .. } => { format!("{kind:?}: {label}") } - _ => asset.label.clone(), + AssetSelection::Folder(path) => Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_string(), + _ => self + .asset_for_selection(&selection) + .map(|asset| asset.label.clone()) + .unwrap_or_else(|| selection.display_label().to_string()), }; - self.selected = Some(selection); + self.selected = Some(selection.clone()); + self.selections = vec![selection.clone()]; + self.selection_anchor = Some(selection); self.status = format!("Selected {selected_label}"); } } + pub fn selection_exists(&self, selection: &AssetSelection) -> bool { + selection_exists_in(&self.folders, &self.assets, selection) + } + + pub fn is_selected(&self, selection: &AssetSelection) -> bool { + self.selections.contains(selection) + } + + pub fn clear_selection(&mut self) { + self.selected = None; + self.selections.clear(); + self.selection_anchor = None; + self.status = "Selection cleared".to_string(); + } + + pub fn toggle_selection(&mut self, selection: AssetSelection) { + if !self.selection_exists(&selection) { + return; + } + if let Some(index) = self.selections.iter().position(|item| item == &selection) { + self.selections.remove(index); + self.selected = self.selections.last().cloned(); + } else { + self.selections.push(selection.clone()); + self.selected = Some(selection.clone()); + } + self.selection_anchor = Some(selection); + self.status = format!("{} item(s) selected", self.selections.len()); + } + + pub fn select_range( + &mut self, + target: AssetSelection, + ordered: &[AssetSelection], + additive: bool, + ) { + if !self.selection_exists(&target) { + return; + } + let anchor = self + .selection_anchor + .as_ref() + .and_then(|anchor| ordered.iter().position(|item| item == anchor)) + .unwrap_or_else(|| ordered.iter().position(|item| item == &target).unwrap_or(0)); + let target_index = ordered + .iter() + .position(|item| item == &target) + .unwrap_or(anchor); + let (start, end) = if anchor <= target_index { + (anchor, target_index) + } else { + (target_index, anchor) + }; + if !additive { + self.selections.clear(); + } + for selection in &ordered[start..=end] { + if self.selection_exists(selection) && !self.selections.contains(selection) { + self.selections.push(selection.clone()); + } + } + self.selected = Some(target); + self.status = format!("{} item(s) selected", self.selections.len()); + } + + pub fn select_all(&mut self, ordered: &[AssetSelection]) { + self.selections = ordered + .iter() + .filter(|selection| self.selection_exists(selection)) + .cloned() + .collect(); + self.selected = self.selections.last().cloned(); + self.selection_anchor = self.selections.first().cloned(); + self.status = format!("{} item(s) selected", self.selections.len()); + } + pub fn start_drag(&mut self, selection: AssetSelection) { - if self.asset_for_selection(&selection).is_some() { + if self.selection_exists(&selection) { + if !self.is_selected(&selection) { + self.select(selection.clone()); + } self.dragging = Some(selection); } } @@ -221,31 +342,37 @@ impl EditorAssets { } } +fn selection_exists_in( + folders: &[AssetFolder], + assets: &[EditorAsset], + selection: &AssetSelection, +) -> bool { + match selection { + AssetSelection::Folder(path) => folders.iter().any(|folder| folder.path == *path), + AssetSelection::Builtin(label) => assets + .iter() + .any(|asset| asset.path.is_none() && asset.label == *label), + AssetSelection::File(path) => assets + .iter() + .any(|asset| asset.path.as_deref() == Some(path.as_str())), + AssetSelection::SubAsset { parent_path, .. } => assets + .iter() + .any(|asset| asset.path.as_deref() == Some(parent_path.as_str())), + } +} + pub struct EditorAssetsPlugin; -impl Plugin for EditorAssetsPlugin { - fn build(&self, app: &mut App) { - app.init_resource::() - .init_resource::() - .add_observer(super::prefab_overrides::invalidate_prefab_health_on_ready) - .add_systems( - Update, - ( - refresh_assets_on_settings_change, - super::prefab_overrides::guard_prefab_reimports, - ), - ); - } -} +#[path = "catalog/content_watch.rs"] +mod content_watch; -fn refresh_assets_on_settings_change( - settings: Res, - mut assets: ResMut, -) { - if settings.is_changed() { - assets.refresh(); - } -} +pub(crate) use content_watch::{ + begin_content_watch_transaction, commit_external_move_repairs, end_content_watch_transaction, + refresh_content_browser, suppress_content_watch_path, ExternalMoveRepairChoice, + ExternalMoveRepairReview, PendingExternalMoveRepair, +}; +#[cfg(test)] +use content_watch::{process_or_queue_content_refresh, registry_after_external_move_repairs}; fn builtin_assets() -> Vec { vec![ @@ -291,11 +418,20 @@ fn builtin_assets() -> Vec { folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::PostProcessVolume, }, + EditorAsset { + label: blacksite_surface::DEFAULT_GRID_LABEL.to_string(), + path: None, + folder_path: BUILTINS_FOLDER.to_string(), + kind: EditorAssetKind::Material, + }, ] } fn register_folder_tree(folders: &mut Vec, root: &str) { let normalized = normalize_path(root); + if is_managed_catalog_folder(&normalized) { + return; + } if folders.iter().any(|folder| folder.path == normalized) { return; } @@ -325,6 +461,14 @@ fn register_folder_tree(folders: &mut Vec, root: &str) { } } +fn is_managed_catalog_folder(path: &str) -> bool { + let path = Path::new(path); + let Ok(relative) = path.strip_prefix(ASSETS_ROOT) else { + return false; + }; + !relative.as_os_str().is_empty() && content_pipeline::is_managed_path(relative) +} + fn scan_assets_directory(assets: &mut Vec) { let root_path = Path::new(ASSETS_ROOT); if !root_path.exists() { @@ -359,54 +503,31 @@ fn should_skip_asset_path(path: &Path) -> bool { } let normalized = normalize_asset_path(path); - normalized == "assets/project.ron" - || normalized.starts_with("assets/.index/") - || normalized.starts_with("assets/.trash/") - || normalized.starts_with(crate::assets::static_mesh::STATIC_MESH_ARTIFACT_DIR) + let managed = Path::new(&normalized) + .strip_prefix(ASSETS_ROOT) + .is_ok_and(content_pipeline::is_managed_path); + normalized == "assets/project.ron" || managed } fn asset_from_file_path(path: &Path) -> Option { - let extension = path.extension()?.to_str()?.to_ascii_lowercase(); let normalized_path = normalize_asset_path(path); - let (kinds, extensions) = if is_audio_asset_extension(&extension) { - ( - vec![EditorAssetKind::AudioClip], - AUDIO_ASSET_EXTENSIONS.to_vec(), - ) - } else { - match extension.as_str() { - "gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]), - "png" | "jpg" | "jpeg" | "webp" | "ktx2" => ( - vec![EditorAssetKind::Texture], - vec!["png", "jpg", "jpeg", "webp", "ktx2"], - ), - "ron" | "mat" | "material" => { - if normalized_path.contains("/materials/") { - ( - vec![EditorAssetKind::Material], - vec!["ron", "mat", "material"], - ) - } else if normalized_path.contains("/post_fx/") { - (vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) - } else if normalized_path.contains("/rendering_profiles/") { - (vec![EditorAssetKind::RenderingProfile], vec!["ron"]) - } else if normalized_path.contains("/shaders/") { - (vec![EditorAssetKind::ShaderSchema], vec!["ron"]) - } else if normalized_path.ends_with(".scn.ron") - && (normalized_path.starts_with("assets/prefabs/") - || normalized_path.contains("/assets/prefabs/")) - { - (vec![EditorAssetKind::Prefab], vec!["ron"]) - } else { - (vec![EditorAssetKind::Level], vec!["ron"]) - } - } - _ => return None, - } + let mut kind = match content_pipeline::classify_asset_file(path) { + shared::AssetKind::Model => EditorAssetKind::Model, + shared::AssetKind::Texture => EditorAssetKind::Texture, + shared::AssetKind::Material => EditorAssetKind::Material, + shared::AssetKind::MaterialInstance => EditorAssetKind::Material, + shared::AssetKind::AudioClip => EditorAssetKind::AudioClip, + shared::AssetKind::Level => EditorAssetKind::Level, + shared::AssetKind::Prefab => EditorAssetKind::Prefab, + shared::AssetKind::PostProcessEffect => EditorAssetKind::PostProcessEffect, + shared::AssetKind::RenderingProfile => EditorAssetKind::RenderingProfile, + shared::AssetKind::ShaderSchema => EditorAssetKind::ShaderSchema, + shared::AssetKind::Unknown => return None, }; - - if !has_extension(path, &extensions) { - return None; + // Registry-v1 prefabs share the scene suffix. Preserve their classification until the explicit + // project upgrader gives them an unambiguous registry-v2 kind. + if matches!(kind, EditorAssetKind::Level) && normalized_path.starts_with("assets/prefabs/") { + kind = EditorAssetKind::Prefab; } let path_string = normalized_path; @@ -416,8 +537,6 @@ fn asset_from_file_path(path: &Path) -> Option { .and_then(|stem| stem.to_str()) .unwrap_or(&path_string) .to_string(); - let kind = kinds[0].clone(); - Some(EditorAsset { label, path: Some(path_string), @@ -458,22 +577,6 @@ fn folder_name(path: &str) -> String { .to_string() } -fn has_extension(path: &Path, extensions: &[&str]) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - extensions - .iter() - .any(|candidate| ext.eq_ignore_ascii_case(candidate)) - }) -} - -fn is_audio_asset_extension(extension: &str) -> bool { - AUDIO_ASSET_EXTENSIONS - .iter() - .any(|candidate| extension.eq_ignore_ascii_case(candidate)) -} - pub fn audio_clip_ref_for_asset( world: &mut World, asset: &EditorAsset, @@ -575,7 +678,6 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option Primitive::sphere(size.x * 0.5), PrimitiveShape::Ramp => Primitive::ramp(size), }); - snapshot.material = Some(default_material_for(shape)); snapshot.rigid_body = Some(RigidBodyDesc::default()); snapshot.collider = Some(match shape { PrimitiveShape::Sphere => ColliderDesc::static_sphere(size.x * 0.5), @@ -675,14 +777,12 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) if let Some(record) = record { match asset.kind { EditorAssetKind::Model => { + let settings = record.model_import(); if let Some(model) = snapshot.model.as_mut() { model.asset_id = record.id.as_string(); } - snapshot.transform.scale *= record.import_settings.scale; - if matches!( - record.import_settings.placement_mode, - ModelPlacementMode::StaticAsset - ) { + snapshot.transform.scale *= settings.scale; + if matches!(settings.placement_mode, ModelPlacementMode::StaticAsset) { if let Some((renderer, requires_skinned_renderer)) = static_mesh_renderer_for_asset(world, path) { @@ -699,15 +799,14 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) ), } } else { - let collider = record - .import_settings + let collider = settings .generate_collider .then(|| static_mesh_collider_for_renderer(&renderer)); apply_static_mesh_placement_mode( &mut snapshot, renderer, collider, - record.import_settings.hierarchy_mode, + settings.hierarchy_mode, ); } } else { @@ -778,10 +877,8 @@ pub fn spawn_subasset_at( let record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, parent_path))?; - let manifest_path = record - .import_settings - .static_mesh_manifest_path - .as_deref()?; + let settings = record.model_import(); + let manifest_path = settings.static_mesh_manifest_path.as_deref()?; let manifest = load_static_mesh_manifest(manifest_path).ok()?; let part = manifest .parts @@ -798,34 +895,18 @@ pub fn spawn_subasset_at( sub_asset_id.clone(), label.clone(), ); - let material = matches!( - record.import_settings.material_policy, - MaterialImportPolicy::SourceMaterials - ) - .then(|| { - part_effective_material_id_for_selection(part).map(|id| { - shared::EditorAssetRef::new( - manifest.asset_id.clone(), - id, - part.material_slot_name.clone(), - ) - }) - }) - .flatten(); + let model_slot_id = format!("slot:{}", part_effective_id_for_selection(part)); let slot = shared::StaticMeshRendererEntry { id: shared::ComponentInstanceId::new("slot:0"), name: label.clone(), mesh: mesh_ref.clone(), - material_slot_id: shared::ComponentInstanceId::new(format!( - "slot:{}", - part_effective_id_for_selection(part) - )), - material, + material_slot_id: shared::ComponentInstanceId::new(model_slot_id.clone()), local_transform: part.local_transform, visible: true, cast_shadows: true, receive_shadows: true, }; + let static_renderer = shared::StaticMeshRenderer::single(slot); let mut snapshot = EditorEntitySnapshot { actor_id: None, @@ -835,7 +916,7 @@ pub fn spawn_subasset_at( transform: Transform::from_translation(translation + Vec3::Y * 0.1), primitive: None, brush: None, - static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)), + static_mesh_renderer: Some(static_renderer), skinned_mesh_renderer: None, material: None, material_override: None, @@ -869,14 +950,13 @@ pub fn spawn_subasset_at( component_states: None, children: Vec::new(), }; - snapshot.transform.scale *= record.import_settings.scale; + snapshot.transform.scale *= settings.scale; if part_requires_skinned_renderer { snapshot.actor_kind = ActorKind::SkinnedMesh; snapshot.static_mesh_renderer = None; snapshot.skinned_mesh_renderer = Some(shared::SkinnedMeshRenderer { materials: crate::assets::static_mesh::renderer_materials_from_manifest( - &manifest, - &record.import_settings, + &manifest, settings, ), ..shared::SkinnedMeshRenderer::new(parent_path.clone()) .with_asset_id(record.id.as_string()) @@ -890,7 +970,7 @@ pub fn spawn_subasset_at( } return Some(spawn_with_history(world, snapshot)); } - if record.import_settings.generate_collider { + if settings.generate_collider { snapshot.rigid_body = Some(RigidBodyDesc::default()); snapshot.collider = Some(ColliderDesc::static_mesh(vec![mesh_ref])); } @@ -907,7 +987,7 @@ pub struct AnimationClipAuthoringData { pub skeleton: shared::EditorAssetRef, pub skeleton_signature: shared::AnimationSkeletonSignature, pub state: shared::AnimationStateDesc, - pub renderer_materials: shared::RendererMaterialSet, + pub renderer_materials: shared::MaterialSlotSet, } pub fn animation_clip_authoring_data( @@ -927,8 +1007,8 @@ pub fn animation_clip_authoring_data( .get_resource::() .and_then(|registry| find_asset_by_path(registry, parent_path)) .ok_or_else(|| format!("model `{parent_path}` is missing from the asset registry"))?; - let manifest_path = record - .import_settings + let settings = record.model_import(); + let manifest_path = settings .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{parent_path}` has no generated animation manifest"))?; @@ -973,23 +1053,19 @@ pub fn animation_clip_authoring_data( let clip_ref = shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone()) .with_source_path(parent_path); let state_id = animation_state_id(&clip.label, clip.source_index); - let renderer_materials = record - .import_settings + let renderer_materials = settings .static_mesh_manifest_path .as_deref() .and_then(|path| load_static_mesh_manifest(path).ok()) .map(|render_manifest| { - crate::assets::static_mesh::renderer_materials_from_manifest( - &render_manifest, - &record.import_settings, - ) + crate::assets::static_mesh::renderer_materials_from_manifest(&render_manifest, settings) }) .unwrap_or_default(); Ok(AnimationClipAuthoringData { model_asset_id: record.id.as_string(), model_path: parent_path.clone(), - model_label: record.label, - model_scale: record.import_settings.scale, + model_label: record.label.clone(), + model_scale: settings.scale, skeleton: skeleton_ref, skeleton_signature: skeleton.signature.clone(), state: shared::AnimationStateDesc { @@ -1018,7 +1094,7 @@ pub fn animation_skeleton_signature_for_ref( ) })?; let manifest_path = record - .import_settings + .model_import() .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{}` has no animation manifest", record.path))?; @@ -1058,7 +1134,7 @@ pub fn compatible_animation_skeleton_for_model( })?; let model_path = record.path.as_str(); let manifest_path = record - .import_settings + .model_import() .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{model_path}` has no animation manifest"))?; @@ -1101,11 +1177,12 @@ fn animation_state_id(label: &str, source_index: usize) -> String { fn default_animation_controller_for_record( record: &crate::asset_db::AssetRecord, ) -> Result, String> { - let Some(manifest_path) = record.import_settings.animation_manifest_path.as_deref() else { + let settings = record.model_import(); + let Some(manifest_path) = settings.animation_manifest_path.as_deref() else { return Ok(None); }; let manifest = load_animation_manifest(manifest_path)?; - if manifest.default_animation_clip_id != record.import_settings.default_animation_clip_id { + if manifest.default_animation_clip_id != settings.default_animation_clip_id { return Err( "model import settings and generated animation manifest disagree; reimport the model" .into(), @@ -1331,15 +1408,12 @@ fn apply_static_mesh_placement_mode( #[cfg(test)] fn apply_skinned_mesh_placement_mode(snapshot: &mut EditorEntitySnapshot) { - apply_skinned_mesh_placement_mode_with_materials( - snapshot, - shared::RendererMaterialSet::default(), - ); + apply_skinned_mesh_placement_mode_with_materials(snapshot, shared::MaterialSlotSet::default()); } fn apply_skinned_mesh_placement_mode_with_materials( snapshot: &mut EditorEntitySnapshot, - materials: shared::RendererMaterialSet, + materials: shared::MaterialSlotSet, ) { let Some(model) = snapshot.model.take() else { return; @@ -1376,16 +1450,6 @@ fn part_effective_id_for_selection(part: &crate::assets::static_mesh::StaticMesh } } -fn part_effective_material_id_for_selection( - part: &crate::assets::static_mesh::StaticMeshPart, -) -> Option { - part.material_id.clone().or_else(|| { - part.material_label - .as_ref() - .map(|label| crate::assets::static_mesh::material_id_from_label(label)) - }) -} - fn static_mesh_renderer_for_asset( world: &mut World, path: &str, @@ -1396,16 +1460,15 @@ fn static_mesh_renderer_for_asset( if let Some(record) = find_asset_mut_by_path(&mut registry, path) { match super::refresh_model_artifacts(record) { Ok(manifest) => { + let settings = record.model_import(); let requires_skinned_renderer = manifest.metadata.animation_count > 0 || manifest.metadata.skin_count > 0 || manifest.parts.iter().any(|part| part.skinned); - let mut authored_renderer = - renderer_from_manifest(&manifest, &record.import_settings); + let mut authored_renderer = renderer_from_manifest(&manifest, settings); if requires_skinned_renderer { authored_renderer.materials = crate::assets::static_mesh::renderer_materials_from_manifest( - &manifest, - &record.import_settings, + &manifest, settings, ); } renderer = Some((authored_renderer, requires_skinned_renderer)); @@ -1413,7 +1476,7 @@ fn static_mesh_renderer_for_asset( } Err(error) => { warn!("Model artifact refresh failed for {path}: {error}"); - manifest_path = record.import_settings.static_mesh_manifest_path.clone(); + manifest_path = record.model_import().static_mesh_manifest_path.clone(); } } } @@ -1430,75 +1493,43 @@ fn static_mesh_renderer_for_asset( let manifest_path = manifest_path?; let registry = world.get_resource::()?; let record = find_asset_by_path(registry, path)?; + let settings = record.model_import(); let manifest = load_static_mesh_manifest(&manifest_path).ok()?; let requires_skinned_renderer = manifest.metadata.animation_count > 0 || manifest.metadata.skin_count > 0 || manifest.parts.iter().any(|part| part.skinned); - let mut renderer = renderer_from_manifest(&manifest, &record.import_settings); + let mut renderer = renderer_from_manifest(&manifest, settings); if requires_skinned_renderer { - renderer.materials = crate::assets::static_mesh::renderer_materials_from_manifest( - &manifest, - &record.import_settings, - ); + renderer.materials = + crate::assets::static_mesh::renderer_materials_from_manifest(&manifest, settings); } Some((renderer, requires_skinned_renderer)) } -pub fn import_external_assets(paths: &[PathBuf]) -> Result { - let mut copied = 0; - for source in paths { - let Some(file_name) = source.file_name() else { - continue; - }; - let Some(extension) = source.extension().and_then(|ext| ext.to_str()) else { - continue; - }; - let dest_dir = import_dir_for_extension(extension); - if extension.eq_ignore_ascii_case("fbx") { - super::import::copy_fbx_bundle(source, Path::new(dest_dir))?; - copied += 1; - continue; - } - fs::create_dir_all(dest_dir) - .map_err(|err| format!("could not create {dest_dir}: {err}"))?; - let dest = Path::new(dest_dir).join(file_name); - fs::copy(source, &dest).map_err(|err| { - format!( - "could not copy {} to {}: {err}", - source.display(), - dest.display() - ) - })?; - copied += 1; - } - Ok(copied) -} - -fn import_dir_for_extension(extension: &str) -> &'static str { - if is_audio_asset_extension(extension) { - return "assets/audio"; - } - match extension.to_ascii_lowercase().as_str() { - "gltf" | "glb" | "fbx" => "assets/models", - "png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures", - "ron" => "assets/levels", - "mat" | "material" => "assets/materials", - _ => "assets", - } -} - -fn default_material_for(shape: &PrimitiveShape) -> MaterialDesc { - match shape { - PrimitiveShape::Box => MaterialDesc::default(), - PrimitiveShape::Sphere => MaterialDesc::new(ColorDesc::srgb(0.9, 0.9, 0.92), 1.0, 0.25), - PrimitiveShape::Ramp => MaterialDesc::new(ColorDesc::srgb(0.30, 0.45, 0.70), 0.05, 0.55), - } -} +pub use content_pipeline::{ + commit_external_assets_import, import_external_assets, import_external_assets_to, + plan_external_assets_import, plan_external_assets_import_at, rollback_external_assets_import, + ExternalAssetImportEntry, ExternalAssetImportPlan, +}; pub fn asset_cache_key(asset: &EditorAsset) -> String { + let kind = match asset.kind { + EditorAssetKind::Primitive(_) => "primitive", + EditorAssetKind::Light(_) => "light", + EditorAssetKind::Model => "model", + EditorAssetKind::Texture => "texture", + EditorAssetKind::Material => "material", + EditorAssetKind::AudioClip => "audio", + EditorAssetKind::Level => "level", + EditorAssetKind::Prefab => "prefab", + EditorAssetKind::PostProcessVolume => "post-process-volume", + EditorAssetKind::PostProcessEffect => "post-process-effect", + EditorAssetKind::RenderingProfile => "rendering-profile", + EditorAssetKind::ShaderSchema => "shader-schema", + }; match &asset.path { - Some(path) => path.clone(), - None => format!("builtin:{}", asset.label), + Some(path) => format!("{kind}:{path}"), + None => format!("builtin:{kind}:{}", asset.label), } } @@ -1508,6 +1539,119 @@ mod tests { use crate::asset_db::{AssetId, AssetRecord}; use uuid::Uuid; + const TINY_RGBA_PNG: &[u8] = include_bytes!("../../assets/icons/01_actor_empty.png"); + + fn selection_fixture() -> EditorAssets { + EditorAssets { + folders: vec![ + AssetFolder { + path: "assets".into(), + name: "assets".into(), + parent: None, + }, + AssetFolder { + path: "assets/Props".into(), + name: "Props".into(), + parent: Some("assets".into()), + }, + ], + assets: vec![ + EditorAsset { + label: "A".into(), + path: Some("assets/a.glb".into()), + folder_path: "assets".into(), + kind: EditorAssetKind::Model, + }, + EditorAsset { + label: "B".into(), + path: Some("assets/b.glb".into()), + folder_path: "assets".into(), + kind: EditorAssetKind::Model, + }, + ], + current_folder: "assets".into(), + selected: None, + selections: Vec::new(), + selection_anchor: None, + dragging: None, + status: String::new(), + catalog_revision: 1, + } + } + + #[test] + fn file_manager_selection_supports_toggle_ranges_and_folders() { + let mut assets = selection_fixture(); + let ordered = vec![ + AssetSelection::Folder("assets/Props".into()), + AssetSelection::File("assets/a.glb".into()), + AssetSelection::File("assets/b.glb".into()), + ]; + assets.select(ordered[0].clone()); + assets.select_range(ordered[2].clone(), &ordered, false); + assert_eq!(assets.selections, ordered); + assert_eq!(assets.selected, Some(ordered[2].clone())); + + assets.toggle_selection(ordered[1].clone()); + assert_eq!( + assets.selections, + vec![ordered[0].clone(), ordered[2].clone()] + ); + assets.clear_selection(); + assert!(assets.selected.is_none()); + assert!(assets.selections.is_empty()); + } + + #[test] + fn selection_changes_do_not_advance_the_filesystem_catalog_revision() { + let mut assets = selection_fixture(); + let revision = assets.catalog_revision; + assets.select(AssetSelection::File("assets/a.glb".into())); + assets.toggle_selection(AssetSelection::File("assets/b.glb".into())); + assets.clear_selection(); + + assert_eq!(assets.catalog_revision, revision); + assets.refresh(); + assert_eq!(assets.catalog_revision, revision.wrapping_add(1)); + } + + #[test] + fn dragging_an_unselected_item_replaces_selection_but_preserves_a_selected_batch() { + let mut assets = selection_fixture(); + let first = AssetSelection::File("assets/a.glb".into()); + let second = AssetSelection::File("assets/b.glb".into()); + + assets.select(first.clone()); + assets.start_drag(second.clone()); + assert_eq!(assets.selections, vec![second.clone()]); + assert_eq!(assets.dragging, Some(second.clone())); + + assets.select(first.clone()); + assets.toggle_selection(second); + let batch = assets.selections.clone(); + assets.start_drag(first); + assert_eq!(assets.selections, batch); + } + + #[test] + fn managed_content_directories_never_enter_the_visible_folder_catalog() { + for path in [ + "assets/.index", + "assets/.index/transactions", + "assets/.trash", + "assets/.thumbnails", + "assets/.import-cache", + "assets/meshes/generated", + "assets/animations/generated", + "assets/navigation/generated", + ] { + assert!(is_managed_catalog_folder(path), "{path}"); + } + for path in ["assets", "assets/Props", "assets/meshes"] { + assert!(!is_managed_catalog_folder(path), "{path}"); + } + } + #[test] fn scene_assets_are_classified_by_authoring_folder() { let level = asset_from_file_path(Path::new("assets/levels/arena.scn.ron")).unwrap(); @@ -1518,18 +1662,35 @@ mod tests { } #[test] - fn bevy_audio_extensions_are_classified_and_imported_to_audio() { + fn thumbnail_cache_keys_are_typed_even_when_paths_match() { + let at_path = |kind| EditorAsset { + label: "Shared Path".into(), + path: Some("assets/Props/shared.asset".into()), + folder_path: "assets/Props".into(), + kind, + }; + + let model = asset_cache_key(&at_path(EditorAssetKind::Model)); + let texture = asset_cache_key(&at_path(EditorAssetKind::Texture)); + let material = asset_cache_key(&at_path(EditorAssetKind::Material)); + + assert_eq!(model, "model:assets/Props/shared.asset"); + assert_ne!(model, texture); + assert_ne!(model, material); + assert_ne!(texture, material); + } + + #[test] + fn bevy_audio_extensions_are_classified_in_any_folder() { for extension in AUDIO_ASSET_EXTENSIONS { - let path = format!("assets/audio/impact.{extension}"); + let path = format!("assets/Props/Office/impact.{extension}"); let asset = asset_from_file_path(Path::new(&path)).unwrap(); assert_eq!(asset.kind, EditorAssetKind::AudioClip); - assert_eq!(asset.folder_path, "assets/audio"); - assert_eq!(import_dir_for_extension(extension), "assets/audio"); + assert_eq!(asset.folder_path, "assets/Props/Office"); assert!(IMPORTABLE_ASSET_EXTENSIONS.contains(extension)); } assert!(asset_from_file_path(Path::new("assets/audio/impact.m4a")).is_none()); - assert_eq!(import_dir_for_extension("WAV"), "assets/audio"); } #[test] @@ -1547,12 +1708,13 @@ mod tests { id: stable_id.clone(), path: asset.path.clone().unwrap(), label: asset.label.clone(), - kind_tag: "AudioClip".into(), + kind: shared::AssetKind::AudioClip, source_fingerprint: None, import_settings: Default::default(), dependencies: Vec::new(), }], index_dirty: false, + ..Default::default() }); world.init_resource::(); world.init_resource::(); @@ -1662,13 +1824,13 @@ mod tests { id: AssetId(key), path: "assets/models/robot.glb".into(), label: "Robot".into(), - kind_tag: "Model".into(), + kind: shared::AssetKind::Model, source_fingerprint: None, - import_settings: crate::asset_db::ImportSettings { + import_settings: shared::AssetImportSettings::Model(crate::asset_db::ImportSettings { animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()), default_animation_clip_id: Some(clip_id.clone()), ..Default::default() - }, + }), dependencies: Vec::new(), }; @@ -1685,4 +1847,389 @@ mod tests { std::fs::remove_file(manifest_path).unwrap(); } + + fn import_plan_fixture(label: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!( + "blacksite-import-review-{label}-{}", + Uuid::new_v4() + )); + let project = root.join("project"); + let destination = PathBuf::from("assets/Chosen/Office"); + std::fs::create_dir_all(project.join(&destination)).unwrap(); + let source = root.join("source.png"); + std::fs::write(&source, b"reviewed source").unwrap(); + (root, project, source) + } + + #[test] + fn destination_first_import_plan_is_non_mutating_until_commit() { + let (root, project, source) = import_plan_fixture("commit"); + let destination = Path::new("assets/Chosen/Office"); + let plan = + plan_external_assets_import_at(&project, std::slice::from_ref(&source), destination) + .unwrap(); + + assert!(plan.can_commit()); + assert_eq!(plan.entries.len(), 1); + assert_eq!( + plan.entries[0].targets, + vec![PathBuf::from("assets/Chosen/Office/source.png")] + ); + assert!(!project.join(&plan.entries[0].targets[0]).exists()); + + assert_eq!(commit_external_assets_import(&plan).unwrap(), 1); + assert_eq!( + std::fs::read(project.join("assets/Chosen/Office/source.png")).unwrap(), + b"reviewed source" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_review_reports_collisions_without_overwriting_destination() { + let (root, project, source) = import_plan_fixture("collision"); + let target = project.join("assets/Chosen/Office/source.png"); + std::fs::write(&target, b"authored destination").unwrap(); + let plan = plan_external_assets_import_at( + &project, + std::slice::from_ref(&source), + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + + assert!(!plan.can_commit()); + assert_eq!(plan.conflicts.len(), 1); + assert!(commit_external_assets_import(&plan) + .unwrap_err() + .contains("unresolved conflicts")); + assert_eq!(std::fs::read(&target).unwrap(), b"authored destination"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_commit_rejects_source_changes_after_review() { + let (root, project, source) = import_plan_fixture("source-change"); + let plan = plan_external_assets_import_at( + &project, + std::slice::from_ref(&source), + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + std::fs::write(&source, b"changed outside editor").unwrap(); + + assert!(commit_external_assets_import(&plan) + .unwrap_err() + .contains("changed after review")); + assert!(!project.join("assets/Chosen/Office/source.png").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_commit_rejects_registry_and_runtime_catalog_changes_after_review() { + for (label, guarded_path, expected_error) in [ + ( + "registry-change", + content_pipeline::REGISTRY_PATH, + "asset registry changed after import review", + ), + ( + "runtime-catalog-change", + content_pipeline::RUNTIME_CATALOG_PATH, + "runtime content catalog changed after import review", + ), + ] { + let (root, project, source) = import_plan_fixture(label); + let guarded_path = project.join(guarded_path); + std::fs::create_dir_all(guarded_path.parent().unwrap()).unwrap(); + std::fs::write(&guarded_path, b"reviewed catalog").unwrap(); + let plan = plan_external_assets_import_at( + &project, + std::slice::from_ref(&source), + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + std::fs::write(&guarded_path, b"external catalog edit").unwrap(); + + assert_eq!( + commit_external_assets_import(&plan).unwrap_err(), + expected_error + ); + assert!(!project.join("assets/Chosen/Office/source.png").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn reviewed_import_rollback_removes_published_files_but_never_adopted_assets() { + let (root, project, source) = import_plan_fixture("rollback"); + let plan = plan_external_assets_import_at( + &project, + std::slice::from_ref(&source), + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + commit_external_assets_import(&plan).unwrap(); + let target = project.join("assets/Chosen/Office/source.png"); + assert!(target.exists()); + + rollback_external_assets_import(&plan).unwrap(); + assert!(!target.exists()); + assert_eq!(std::fs::read(&source).unwrap(), b"reviewed source"); + + let adopted = project.join("assets/Chosen/Office/adopted.png"); + std::fs::write(&adopted, b"project authored").unwrap(); + let adopted_plan = plan_external_assets_import_at( + &project, + std::slice::from_ref(&adopted), + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + commit_external_assets_import(&adopted_plan).unwrap(); + rollback_external_assets_import(&adopted_plan).unwrap(); + assert_eq!(std::fs::read(&adopted).unwrap(), b"project authored"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn gltf_import_review_fingerprints_every_relative_dependency() { + let root = + std::env::temp_dir().join(format!("blacksite-import-review-gltf-{}", Uuid::new_v4())); + let project = root.join("project"); + let source = root.join("source"); + std::fs::create_dir_all(project.join("assets/Chosen/Office")).unwrap(); + std::fs::create_dir_all(source.join("textures")).unwrap(); + std::fs::write(source.join("mesh.bin"), [0_u8; 12]).unwrap(); + std::fs::write(source.join("textures/base color.png"), b"png").unwrap(); + std::fs::write( + source.join("mesh.gltf"), + r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"mesh.bin","byteLength":12}],"images":[{"uri":"textures/base%20color.png"}]}"#, + ) + .unwrap(); + let plan = plan_external_assets_import_at( + &project, + &[source.join("mesh.gltf")], + Path::new("assets/Chosen/Office"), + ) + .unwrap(); + + assert_eq!(plan.entries[0].source_files.len(), 3); + assert_eq!(plan.entries[0].targets.len(), 3); + std::fs::write(source.join("mesh.bin"), [1_u8; 12]).unwrap(); + assert!(commit_external_assets_import(&plan) + .unwrap_err() + .contains("mesh.bin changed after review")); + assert!(!project.join("assets/Chosen/Office/mesh.gltf").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + fn external_move_repair_fixture() -> ExternalMoveRepairReview { + let fingerprint = shared::AssetSourceFingerprint::from_bytes(TINY_RGBA_PNG); + let records = ["assets/OldA/desk.png", "assets/OldB/desk.png"] + .into_iter() + .map(|path| AssetRecord { + id: AssetId::new(), + path: path.into(), + label: "Desk".into(), + kind: shared::AssetKind::Texture, + source_fingerprint: Some(fingerprint.clone()), + import_settings: shared::AssetImportSettings::for_kind(shared::AssetKind::Texture), + dependencies: Vec::new(), + }) + .collect(); + ExternalMoveRepairReview { + root: PathBuf::from("/unused"), + previous: shared::AssetRegistryDocument { + records, + ..Default::default() + }, + conflicts: vec![content_pipeline::ExternalMoveConflict { + new_path: "assets/New/desk.png".into(), + candidate_paths: vec!["assets/OldA/desk.png".into(), "assets/OldB/desk.png".into()], + kind: shared::AssetKind::Texture, + fingerprint, + }], + choices: vec![Some(ExternalMoveRepairChoice::Preserve( + "assets/OldA/desk.png".into(), + ))], + open: true, + } + } + + #[test] + fn external_move_repair_preserves_only_the_chosen_stable_identity() { + let mut review = external_move_repair_fixture(); + let root = + std::env::temp_dir().join(format!("blacksite-external-move-repair-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets/New")).unwrap(); + std::fs::write(root.join("assets/New/desk.png"), TINY_RGBA_PNG).unwrap(); + review.root.clone_from(&root); + let old_id = review.previous.records[0].id.clone(); + let repaired = registry_after_external_move_repairs(&review).unwrap(); + + let moved = repaired + .records + .iter() + .find(|record| record.path == "assets/New/desk.png") + .unwrap(); + assert_eq!(moved.id, old_id); + assert!(moved.source_fingerprint.is_some()); + let unchosen = repaired + .records + .iter() + .find(|record| record.path == "assets/OldB/desk.png") + .unwrap(); + assert!(unchosen.source_fingerprint.is_none()); + + let processed = content_pipeline::scan_project(&root, &repaired).unwrap(); + assert_eq!(processed.registry.records.len(), 1); + assert_eq!(processed.registry.records[0].id, old_id); + assert_eq!(processed.registry.records[0].path, "assets/New/desk.png"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn external_move_repair_register_new_clears_every_ambiguous_candidate() { + let mut review = external_move_repair_fixture(); + review.choices[0] = Some(ExternalMoveRepairChoice::RegisterNew); + let repaired = registry_after_external_move_repairs(&review).unwrap(); + + assert!(repaired + .records + .iter() + .all(|record| record.source_fingerprint.is_none())); + assert!(repaired + .records + .iter() + .all(|record| record.path != "assets/New/desk.png")); + } + + #[test] + fn external_move_repair_commit_publishes_registry_catalog_and_world_together() { + let mut review = external_move_repair_fixture(); + let root = + std::env::temp_dir().join(format!("blacksite-external-move-commit-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets/New")).unwrap(); + std::fs::write(root.join("assets/New/desk.png"), TINY_RGBA_PNG).unwrap(); + review.root.clone_from(&root); + let expected_id = review.previous.records[0].id.clone(); + let mut world = World::new(); + world.insert_resource(AssetRegistry { + schema_version: review.previous.schema_version, + defaults: review.previous.defaults.clone(), + records: review.previous.records.clone(), + index_dirty: false, + migration_required: false, + }); + world.insert_resource(PendingExternalMoveRepair { + review: Some(review), + }); + world.insert_resource(EditorAssets::default()); + world.insert_resource(crate::assets::AssetThumbnailCache::default()); + world.insert_resource(crate::scene_io::SceneIo::default()); + + commit_external_move_repairs(&mut world).unwrap(); + + let document = world.resource::().document(); + assert_eq!(document.records.len(), 1); + assert_eq!(document.records[0].id, expected_id); + assert_eq!(document.records[0].path, "assets/New/desk.png"); + assert!(world + .resource::() + .review + .is_none()); + assert!(root.join(content_pipeline::REGISTRY_PATH).is_file()); + assert!(root.join(content_pipeline::RUNTIME_CATALOG_PATH).is_file()); + let published_source = + std::fs::read_to_string(root.join(content_pipeline::REGISTRY_PATH)).unwrap(); + let published = shared::parse_asset_registry(&published_source) + .unwrap() + .document; + assert_eq!(published, document); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn startup_move_inspection_queues_ambiguous_identity_review_without_registry_mutation() { + let mut review = external_move_repair_fixture(); + let root = std::env::temp_dir().join(format!( + "blacksite-external-move-startup-{}", + Uuid::new_v4() + )); + std::fs::create_dir_all(root.join("assets/New")).unwrap(); + std::fs::write(root.join("assets/New/desk.png"), TINY_RGBA_PNG).unwrap(); + review.root.clone_from(&root); + let original = review.previous.clone(); + let mut world = World::new(); + world.insert_resource(AssetRegistry { + schema_version: original.schema_version, + defaults: original.defaults.clone(), + records: original.records.clone(), + index_dirty: false, + migration_required: false, + }); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(EditorAssets::default()); + world.init_resource::(); + + process_or_queue_content_refresh(&mut world, root.clone(), 0, "Startup content refresh") + .unwrap(); + + assert_eq!(world.resource::().document(), original); + let pending = world.resource::(); + let queued = pending.review.as_ref().unwrap(); + assert!(queued.open); + assert_eq!(queued.root, root); + assert_eq!(queued.conflicts, review.conflicts); + assert!(world + .resource::() + .status + .contains("require identity repair")); + assert!(!root.join(content_pipeline::REGISTRY_PATH).exists()); + assert!(!root.join(content_pipeline::RUNTIME_CATALOG_PATH).exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn shared_startup_refresh_registers_new_content_and_publishes_both_catalogs() { + let root = std::env::temp_dir().join(format!( + "blacksite-shared-startup-refresh-{}", + Uuid::new_v4() + )); + std::fs::create_dir_all(root.join("assets/Props")).unwrap(); + std::fs::write(root.join("assets/Props/new.png"), TINY_RGBA_PNG).unwrap(); + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(EditorAssets::default()); + world.init_resource::(); + + content_watch::process_content_refresh_for_test(&mut world, root.clone()).unwrap(); + + let registry = world.resource::().document(); + assert_eq!(registry.records.len(), 1); + assert_eq!(registry.records[0].path, "assets/Props/new.png"); + assert_eq!(registry.records[0].kind, shared::AssetKind::Texture); + assert!(world + .resource::() + .review + .is_none()); + assert!(root.join(content_pipeline::REGISTRY_PATH).is_file()); + assert!(root.join(content_pipeline::RUNTIME_CATALOG_PATH).is_file()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn external_move_repair_rejects_reusing_one_identity_for_two_new_paths() { + let mut review = external_move_repair_fixture(); + let mut second = review.conflicts[0].clone(); + second.new_path = "assets/Newer/desk.png".into(); + review.conflicts.push(second); + review.choices.push(review.choices[0].clone()); + + assert!(registry_after_external_move_repairs(&review) + .unwrap_err() + .contains("cannot be assigned to more than one new path")); + } } diff --git a/crates/editor/src/assets/catalog/content_watch.rs b/crates/editor/src/assets/catalog/content_watch.rs new file mode 100644 index 0000000..cffb2b1 --- /dev/null +++ b/crates/editor/src/assets/catalog/content_watch.rs @@ -0,0 +1,548 @@ +use super::*; + +#[derive(Resource)] +pub(super) struct EditorContentWatch { + root: PathBuf, + service: Mutex>, +} + +#[derive(Resource, Default)] +pub(super) struct ExternalContentRefreshQueue { + active: Option, + pending: Option, +} + +pub(super) struct ExternalContentRefreshJob { + handle: std::thread::JoinHandle>, +} + +pub(super) struct PendingExternalContentRefresh { + root: PathBuf, + changed_path_count: usize, + status_prefix: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExternalMoveRepairChoice { + Preserve(String), + RegisterNew, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExternalMoveRepairReview { + pub(crate) root: PathBuf, + pub(crate) previous: shared::AssetRegistryDocument, + pub(crate) conflicts: Vec, + pub(crate) choices: Vec>, + pub(crate) open: bool, +} + +#[derive(Resource, Debug, Default, Clone)] +pub(crate) struct PendingExternalMoveRepair { + pub(crate) review: Option, +} + +impl Plugin for EditorAssetsPlugin { + fn build(&self, app: &mut App) { + let root = PathBuf::from( + &app.world() + .resource::() + .root, + ); + let service = match content_pipeline::ContentWatchService::watch_project(&root) { + Ok(service) => Some(service), + Err(error) => { + warn!("Live content watching is unavailable: {error}"); + None + } + }; + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(EditorContentWatch { + root, + service: Mutex::new(service), + }) + .init_resource::() + .add_observer(crate::assets::prefab_overrides::invalidate_prefab_health_on_ready) + .add_systems( + Update, + ( + refresh_assets_on_settings_change, + crate::assets::prefab_overrides::guard_prefab_reimports, + ), + ) + .add_systems(Startup, refresh_content_on_startup) + .add_systems( + Update, + (poll_content_watcher, drive_external_content_refresh).chain(), + ); + } +} + +pub(crate) fn begin_content_watch_transaction(world: &World) { + let Some(watcher) = world.get_resource::() else { + return; + }; + if let Ok(mut service) = watcher.service.lock() { + if let Some(service) = service.as_mut() { + service.begin_transaction(); + } + } +} + +pub(crate) fn end_content_watch_transaction(world: &World) { + let Some(watcher) = world.get_resource::() else { + return; + }; + if let Ok(mut service) = watcher.service.lock() { + if let Some(service) = service.as_mut() { + service.end_transaction(); + } + } +} + +pub(crate) fn suppress_content_watch_path(world: &World, path: &Path) { + let Some(watcher) = world.get_resource::() else { + return; + }; + if let Ok(mut service) = watcher.service.lock() { + if let Some(service) = service.as_mut() { + service.suppress_path(path, Duration::from_millis(750)); + } + } +} + +pub(super) fn poll_content_watcher(world: &mut World) { + let Some(watcher) = world.get_resource::() else { + return; + }; + let root = watcher.root.clone(); + let changes = { + let Ok(mut service) = watcher.service.lock() else { + return; + }; + let Some(service) = service.as_mut() else { + return; + }; + match service.poll(Duration::from_millis(150)) { + Ok(changes) => changes, + Err(error) => { + warn!("Content watcher failed: {error}"); + return; + } + } + }; + if changes.is_empty() { + return; + } + if let Some(mut cache) = world.get_resource_mut::() { + cache.invalidate_disk_documents(); + } + + if let Err(error) = + process_or_queue_content_refresh(world, root, changes.len(), "Live content refresh") + { + world.resource_mut::().status = + format!("Live content refresh failed: {error}"); + } +} + +pub(super) fn refresh_content_on_startup(world: &mut World) { + let root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .unwrap_or_else(|| PathBuf::from(".")); + if let Err(error) = process_or_queue_content_refresh(world, root, 0, "Startup content refresh") + { + warn!("Startup content refresh failed: {error}"); + } +} + +pub(super) fn process_or_queue_content_refresh( + world: &mut World, + root: PathBuf, + changed_path_count: usize, + status_prefix: &str, +) -> Result<(), String> { + let previous = world.resource::().document(); + if queue_external_move_repair(world, root.clone(), previous.clone())? { + world.resource_mut::().refresh(); + crate::assets::invalidate_on_catalog_refresh(world); + return Ok(()); + } + let mut queue = world.resource_mut::(); + if queue.active.is_some() { + let pending = queue + .pending + .get_or_insert_with(|| PendingExternalContentRefresh { + root: root.clone(), + changed_path_count: 0, + status_prefix: status_prefix.to_string(), + }); + pending.root = root; + pending.changed_path_count = pending + .changed_path_count + .saturating_add(changed_path_count); + pending.status_prefix = status_prefix.to_string(); + return Ok(()); + } + let status_prefix = status_prefix.to_string(); + let handle = std::thread::spawn(move || { + process_content_refresh(&root, &previous, changed_path_count, &status_prefix) + }); + queue.active = Some(ExternalContentRefreshJob { handle }); + Ok(()) +} + +pub(super) fn drive_external_content_refresh(world: &mut World) { + let completed = { + let mut queue = world.resource_mut::(); + if queue + .active + .as_ref() + .is_some_and(|job| job.handle.is_finished()) + { + queue.active.take() + } else { + None + } + }; + let Some(job) = completed else { + return; + }; + match job + .handle + .join() + .unwrap_or_else(|_| Err("content refresh worker panicked".into())) + { + Ok((document, status)) => { + apply_processed_content(world, document); + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = status; + } + } + Err(error) => { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = format!("Content refresh failed: {error}"); + } + } + } + let pending = world + .resource_mut::() + .pending + .take(); + if let Some(pending) = pending { + let _ = process_or_queue_content_refresh( + world, + pending.root, + pending.changed_path_count, + &pending.status_prefix, + ); + } +} + +#[cfg(test)] +pub(super) fn process_content_refresh_for_test( + world: &mut World, + root: PathBuf, +) -> Result<(), String> { + world.init_resource::(); + process_or_queue_content_refresh(world, root, 0, "Startup content refresh")?; + for _ in 0..1_000 { + drive_external_content_refresh(world); + let queue = world.resource::(); + if queue.active.is_none() && queue.pending.is_none() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Err("content refresh worker did not finish within one second".into()) +} + +pub(super) fn queue_external_move_repair( + world: &mut World, + root: PathBuf, + previous: shared::AssetRegistryDocument, +) -> Result { + let conflicts = content_pipeline::find_ambiguous_external_moves(&root, &previous)?; + if conflicts.is_empty() { + return Ok(false); + } + let count = conflicts.len(); + world.resource_mut::().review = Some(ExternalMoveRepairReview { + root, + previous, + choices: vec![None; count], + conflicts, + open: true, + }); + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = format!( + "{count} ambiguous external move(s) require identity repair in the Content Browser" + ); + } + Ok(true) +} + +pub(crate) fn refresh_content_browser(world: &mut World) { + let root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .unwrap_or_else(|| PathBuf::from(".")); + match process_or_queue_content_refresh(world, root, 0, "Manual content refresh") { + Ok(()) => {} + Err(error) => { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = format!("Manual content refresh failed: {error}"); + } + } + } +} + +pub(super) fn process_content_refresh( + root: &Path, + previous: &shared::AssetRegistryDocument, + changed_path_count: usize, + status_prefix: &str, +) -> Result<(shared::AssetRegistryDocument, String), String> { + let mut processed = content_pipeline::scan_project(root, previous)?; + let current_ids = processed + .registry + .records + .iter() + .map(|record| record.id.as_string()) + .collect::>(); + let removed_paths = previous + .records + .iter() + .filter(|record| !current_ids.contains(&record.id.as_string())) + .map(|record| PathBuf::from(&record.path)) + .collect::>(); + let removed_usages = if removed_paths.is_empty() { + Vec::new() + } else { + content_pipeline::find_reference_usages(root, &removed_paths, previous) + .map_err(|error| format!("could not inspect removed-asset usages: {error}"))? + }; + let mut model_plans = Vec::new(); + for record in processed + .registry + .records + .iter_mut() + .filter(|record| record.kind == shared::AssetKind::Model) + { + let plan = crate::assets::plan_model_artifacts_at(root, record) + .map_err(|error| format!("model processing failed for {}: {error}", record.path))?; + *record = plan.record.clone(); + model_plans.push(plan); + } + processed.runtime_catalog = shared::RuntimeContentCatalog::from(&processed.registry); + let mut texture_plans = Vec::new(); + for record in processed + .registry + .records + .iter() + .filter(|record| record.kind == shared::AssetKind::Texture) + { + let plan = content_pipeline::plan_texture_artifact(root, record) + .map_err(|error| format!("Texture processing failed for {}: {error}", record.path))?; + content_pipeline::apply_texture_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + texture_plans.push(plan); + } + let mut material_plans = Vec::new(); + for record in processed.registry.records.iter().filter(|record| { + matches!( + record.kind, + shared::AssetKind::Material | shared::AssetKind::MaterialInstance + ) + }) { + if let Some(plan) = + content_pipeline::plan_material_artifact(root, record, &processed.registry)? + { + content_pipeline::apply_material_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + material_plans.push(plan); + } + } + let mut publication_paths = model_plans + .iter() + .flat_map(|plan| [plan.static_path.clone(), plan.animation_path.clone()]) + .collect::>(); + publication_paths.extend(texture_plans.iter().map(|plan| plan.output_path.clone())); + publication_paths.extend(material_plans.iter().map(|plan| plan.output_path.clone())); + publication_paths.push(root.join(content_pipeline::REGISTRY_PATH)); + publication_paths.push(root.join(content_pipeline::RUNTIME_CATALOG_PATH)); + publication_paths.sort(); + publication_paths.dedup(); + let backups = publication_paths + .iter() + .map(|path| (path.clone(), fs::read(path).ok())) + .collect::>(); + let publication = (|| { + for plan in &model_plans { + crate::assets::publish_model_artifacts(plan)?; + } + for plan in &texture_plans { + content_pipeline::publish_texture_artifact(plan)?; + } + for plan in &material_plans { + content_pipeline::publish_material_artifact(plan)?; + } + content_pipeline::publish_content_documents_with_catalog( + root, + &processed.registry, + &processed.runtime_catalog, + ) + })(); + if let Err(error) = publication { + for (path, bytes) in &backups { + if let Some(bytes) = bytes { + let _ = fs::write(path, bytes); + } else { + let _ = fs::remove_file(path); + } + } + return Err(format!("content publication rolled back: {error}")); + } + + let mut status = format!( + "{status_prefix}: {changed_path_count} changed path(s), {} model artifact set(s), {} Texture artifact(s), {} packed Material artifact(s), {} added, {} moved, {} removed", + model_plans.len(), + texture_plans.len(), + material_plans.len(), + processed.report.added, + processed.report.moved, + processed.report.removed + ); + if !removed_usages.is_empty() { + let documents = removed_usages + .iter() + .map(|usage| usage.document.display().to_string()) + .collect::>() + .into_iter() + .take(3) + .collect::>() + .join(", "); + status.push_str(&format!( + "; {} reference(s) now unresolved in {}", + removed_usages.len(), + documents + )); + } + Ok((processed.registry, status)) +} + +pub(super) fn apply_processed_content(world: &mut World, document: shared::AssetRegistryDocument) { + if world.contains_resource::() { + crate::asset_documents::adopt_external_registry_document(world, &document); + } else { + let mut registry = world.resource_mut::(); + registry.schema_version = document.schema_version; + registry.defaults = document.defaults; + registry.records = document.records; + registry.index_dirty = false; + registry.migration_required = false; + } + world.resource_mut::().refresh(); + crate::assets::invalidate_on_catalog_refresh(world); +} + +pub(crate) fn commit_external_move_repairs(world: &mut World) -> Result<(), String> { + let review = world + .resource::() + .review + .clone() + .ok_or_else(|| "no external move repair is pending".to_string())?; + if review.choices.iter().any(Option::is_none) { + return Err("choose an identity for every ambiguous external move".into()); + } + let current_registry = world.resource::().document(); + if current_registry != review.previous { + return Err("asset registry changed after the external move review opened".into()); + } + let current_conflicts = + content_pipeline::find_ambiguous_external_moves(&review.root, &review.previous)?; + if current_conflicts != review.conflicts { + let count = current_conflicts.len(); + world.resource_mut::().review = (!current_conflicts.is_empty()) + .then(|| ExternalMoveRepairReview { + root: review.root, + previous: current_registry, + choices: vec![None; count], + conflicts: current_conflicts, + open: true, + }); + return Err("external files changed after review; inspect the refreshed conflicts".into()); + } + + let repaired = registry_after_external_move_repairs(&review)?; + + let (document, status) = + process_content_refresh(&review.root, &repaired, 0, "External move repair")?; + apply_processed_content(world, document); + world.resource_mut::().review = None; + world.resource_mut::().status = status; + Ok(()) +} + +pub(super) fn registry_after_external_move_repairs( + review: &ExternalMoveRepairReview, +) -> Result { + if review.choices.len() != review.conflicts.len() || review.choices.iter().any(Option::is_none) + { + return Err("choose an identity for every ambiguous external move".into()); + } + + let mut repaired = review.previous.clone(); + let mut preserved = HashSet::new(); + for (conflict, choice) in review.conflicts.iter().zip(review.choices.iter()) { + if let Some(ExternalMoveRepairChoice::Preserve(old_path)) = choice { + if !conflict.candidate_paths.contains(old_path) { + return Err(format!( + "selected identity {old_path} is not a candidate for {}", + conflict.new_path + )); + } + if !preserved.insert(old_path.clone()) { + return Err(format!( + "stable identity {old_path} cannot be assigned to more than one new path" + )); + } + let record = repaired + .records + .iter_mut() + .find(|record| record.path == *old_path) + .ok_or_else(|| format!("candidate registry record disappeared: {old_path}"))?; + record.path.clone_from(&conflict.new_path); + } + } + let candidate_paths = review + .conflicts + .iter() + .flat_map(|conflict| conflict.candidate_paths.iter()) + .cloned() + .collect::>(); + for record in &mut repaired.records { + if candidate_paths.contains(&record.path) && !preserved.contains(&record.path) { + record.source_fingerprint = None; + } + } + Ok(repaired) +} + +pub(super) fn refresh_assets_on_settings_change( + settings: Res, + mut assets: ResMut, +) { + if settings.is_changed() { + assets.refresh(); + } +} diff --git a/crates/editor/src/assets/fingerprint.rs b/crates/editor/src/assets/fingerprint.rs index e2a0330..dda0d15 100644 --- a/crates/editor/src/assets/fingerprint.rs +++ b/crates/editor/src/assets/fingerprint.rs @@ -1,114 +1,3 @@ -use std::fs; -use std::path::Path; +//! Compatibility re-exports for the shared content-pipeline fingerprint helpers. -use serde::de::DeserializeOwned; -use serde::Serialize; -use shared::AssetSourceFingerprint; - -pub(crate) fn fingerprint_file(path: impl AsRef) -> Result { - let path = path.as_ref(); - let bytes = fs::read(path) - .map_err(|error| format!("could not read imported source {}: {error}", path.display()))?; - Ok(AssetSourceFingerprint::from_bytes(&bytes)) -} - -/// Writes canonical pretty RON only when the parsed document changes semantically. -/// -/// Equivalent existing bytes, including custom formatting and final-newline policy, stay intact. -pub(crate) fn write_pretty_ron_if_changed( - path: impl AsRef, - value: &T, -) -> Result -where - T: DeserializeOwned + PartialEq + Serialize, -{ - let path = path.as_ref(); - if fs::read_to_string(path) - .ok() - .and_then(|text| ron::from_str::(&text).ok()) - .is_some_and(|existing| existing == *value) - { - return Ok(false); - } - - let text = ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default()) - .map_err(|error| format!("could not serialize RON: {error}"))?; - if fs::read(path).ok().as_deref() == Some(text.as_bytes()) { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("could not create {}: {error}", parent.display()))?; - } - fs::write(path, text) - .map_err(|error| format!("could not write {}: {error}", path.display()))?; - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - use std::fs::{File, FileTimes}; - use std::time::{Duration, SystemTime}; - use uuid::Uuid; - - #[derive(Debug, Deserialize, PartialEq, Serialize)] - struct Fixture { - count: u32, - label: String, - } - - fn fixture_path(name: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "blacksite-fingerprint-{name}-{}.ron", - Uuid::new_v4() - )) - } - - #[test] - fn metadata_only_drift_does_not_change_content_identity() { - let path = fixture_path("mtime"); - fs::write(&path, b"stable source bytes").unwrap(); - let before = fingerprint_file(&path).unwrap(); - File::options() - .write(true) - .open(&path) - .unwrap() - .set_times( - FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)), - ) - .unwrap(); - - assert_eq!(fingerprint_file(&path).unwrap(), before); - fs::remove_file(path).unwrap(); - } - - #[test] - fn same_size_byte_change_updates_content_identity() { - let path = fixture_path("same-size"); - fs::write(&path, b"source-a").unwrap(); - let before = fingerprint_file(&path).unwrap(); - fs::write(&path, b"source-b").unwrap(); - let after = fingerprint_file(&path).unwrap(); - - assert_eq!(before.byte_len, after.byte_len); - assert_ne!(before.content_hash, after.content_hash); - fs::remove_file(path).unwrap(); - } - - #[test] - fn equivalent_ron_preserves_exact_existing_bytes() { - let path = fixture_path("semantic"); - let existing = b"( label: \"stable\", count: 7, )\n\n"; - fs::write(&path, existing).unwrap(); - let value = Fixture { - count: 7, - label: "stable".into(), - }; - - assert!(!write_pretty_ron_if_changed(&path, &value).unwrap()); - assert_eq!(fs::read(&path).unwrap(), existing); - fs::remove_file(path).unwrap(); - } -} +pub(crate) use content_pipeline::fingerprint_file; diff --git a/crates/editor/src/assets/import.rs b/crates/editor/src/assets/import.rs index 4e333f5..ea931fc 100644 --- a/crates/editor/src/assets/import.rs +++ b/crates/editor/src/assets/import.rs @@ -1,564 +1,7 @@ -//! External asset bundle inspection and transactional import. +//! Editor-facing re-exports of the shared UI-independent content importer. -use std::collections::BTreeMap; -use std::fs; -use std::path::{Component, Path, PathBuf}; - -use bevy_ufbx::texture::external_texture_paths; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct FbxDependencyInspection { - pub relative_paths: Vec, - pub resolved_paths: Vec, - pub missing_paths: Vec, -} - -/// Parses every external FBX texture reference without loading it through Bevy. -pub(crate) fn inspect_fbx_dependencies(source: &Path) -> Result { - let bytes = fs::read(source) - .map_err(|error| format!("could not read FBX {}: {error}", source.display()))?; - let filename_hint = source.to_string_lossy(); - let scene = ufbx::load_memory( - &bytes, - ufbx::LoadOpts { - target_unit_meters: 1.0, - target_axes: ufbx::CoordinateAxes::right_handed_y_up(), - filename: ufbx::StringOpt::Ref(&filename_hint), - ..Default::default() - }, - ) - .map_err(|error| format!("could not parse FBX {}: {error:?}", source.display()))?; - let relative_paths = external_texture_paths(&scene).map_err(|errors| { - format!( - "unsafe FBX texture reference(s) in {}: {}", - source.display(), - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("; ") - ) - })?; - let source_root = source.parent().unwrap_or_else(|| Path::new("")); - let canonical_root = fs::canonicalize(source_root).map_err(|error| { - format!( - "could not resolve FBX source directory {}: {error}", - source_root.display() - ) - })?; - let mut resolved_paths = Vec::new(); - let mut missing_paths = Vec::new(); - for relative_path in &relative_paths { - let resolved = source_root.join(relative_path); - if !resolved.is_file() { - missing_paths.push(resolved.clone()); - resolved_paths.push(resolved); - continue; - } - let canonical = fs::canonicalize(&resolved).map_err(|error| { - format!( - "could not resolve FBX dependency {}: {error}", - resolved.display() - ) - })?; - if !canonical.starts_with(&canonical_root) { - return Err(format!( - "FBX dependency {} resolves outside source directory {}", - resolved.display(), - source_root.display() - )); - } - resolved_paths.push(resolved); - } - missing_paths.sort(); - Ok(FbxDependencyInspection { - relative_paths, - resolved_paths, - missing_paths, - }) -} - -/// Returns one stable browser-facing error for all missing FBX source textures. -pub(crate) fn validate_fbx_dependencies(source: &Path) -> Result<(), String> { - let inspection = inspect_fbx_dependencies(source)?; - if inspection.missing_paths.is_empty() { - return Ok(()); - } - Err(format!( - "missing {} FBX source texture(s): {}", - inspection.missing_paths.len(), - inspection - .missing_paths - .iter() - .map(|path| path.to_string_lossy().replace('\\', "/")) - .collect::>() - .join(", ") - )) -} - -/// Copies an FBX and every referenced external texture as one staged filesystem transaction. -pub fn copy_fbx_bundle(source: &Path, dest_dir: &Path) -> Result<(), String> { - let inspection = inspect_fbx_dependencies(source)?; - if !inspection.missing_paths.is_empty() { - return Err(format!( - "FBX import is missing {} required texture(s): {}", - inspection.missing_paths.len(), - inspection - .missing_paths - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", ") - )); - } - let file_name = source - .file_name() - .ok_or_else(|| "FBX import path has no file name".to_string())?; - let mut entries = BTreeMap::new(); - entries.insert(PathBuf::from(file_name), source.to_path_buf()); - for (relative, resolved) in inspection - .relative_paths - .iter() - .zip(inspection.resolved_paths.iter()) - { - let relative = PathBuf::from(relative); - if let Some(existing) = entries.insert(relative.clone(), resolved.clone()) { - if existing != *resolved { - return Err(format!( - "FBX import maps multiple source files to {}", - relative.display() - )); - } - } - } - copy_bundle_transactionally(&entries, dest_dir) -} - -fn copy_bundle_transactionally( - entries: &BTreeMap, - dest_dir: &Path, -) -> Result<(), String> { - fs::create_dir_all(dest_dir) - .map_err(|error| format!("could not create {}: {error}", dest_dir.display()))?; - let canonical_dest_dir = fs::canonicalize(dest_dir).map_err(|error| { - format!( - "could not resolve FBX import destination {}: {error}", - dest_dir.display() - ) - })?; - for relative in entries.keys() { - validate_bundle_relative_path(relative)?; - validate_existing_destination_parents(dest_dir, &canonical_dest_dir, relative)?; - let target = dest_dir.join(relative); - if target.is_dir() { - return Err(format!( - "FBX import target {} is an existing directory", - target.display() - )); - } - } - - let stage_root = dest_dir.join(format!(".blacksite-import-{}", uuid::Uuid::new_v4())); - let staged_root = stage_root.join("new"); - let backup_root = stage_root.join("backup"); - let stage_result = (|| { - for (relative, source) in entries { - let staged = staged_root.join(relative); - if let Some(parent) = staged.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "could not create import staging {}: {error}", - parent.display() - ) - })?; - } - fs::copy(source, &staged).map_err(|error| { - format!( - "could not stage FBX bundle file {} as {}: {error}", - source.display(), - relative.display() - ) - })?; - } - commit_staged_bundle( - entries, - dest_dir, - &canonical_dest_dir, - &staged_root, - &backup_root, - ) - })(); - let cleanup_result = fs::remove_dir_all(&stage_root); - match (stage_result, cleanup_result) { - (Err(error), _) => Err(error), - (Ok(()), Ok(())) => Ok(()), - (Ok(()), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - (Ok(()), Err(error)) => { - bevy::log::warn!( - "FBX bundle imported but staging cleanup failed for {}: {error}", - stage_root.display() - ); - Ok(()) - } - } -} - -fn validate_bundle_relative_path(relative: &Path) -> Result<(), String> { - if relative.as_os_str().is_empty() - || !relative - .components() - .all(|component| matches!(component, Component::Normal(_))) - { - return Err(format!( - "FBX import target path {} is not a safe relative path", - relative.display() - )); - } - Ok(()) -} - -fn validate_existing_destination_parents( - dest_dir: &Path, - canonical_dest_dir: &Path, - relative: &Path, -) -> Result<(), String> { - let mut current = dest_dir.to_path_buf(); - let Some(parent) = relative.parent() else { - return Ok(()); - }; - for component in parent.components() { - let Component::Normal(segment) = component else { - return Err(format!( - "FBX import target path {} is not a safe relative path", - relative.display() - )); - }; - current.push(segment); - let metadata = match fs::symlink_metadata(¤t) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, - Err(error) => { - return Err(format!( - "could not inspect FBX import target {}: {error}", - current.display() - )); - } - }; - if metadata.file_type().is_symlink() { - return Err(format!( - "FBX import target parent {} is a symbolic link", - current.display() - )); - } - if !metadata.is_dir() { - return Err(format!( - "FBX import target parent {} is not a directory", - current.display() - )); - } - let canonical = fs::canonicalize(¤t).map_err(|error| { - format!( - "could not resolve FBX import target parent {}: {error}", - current.display() - ) - })?; - if !canonical.starts_with(canonical_dest_dir) { - return Err(format!( - "FBX import target parent {} resolves outside destination {}", - current.display(), - dest_dir.display() - )); - } - } - Ok(()) -} - -fn commit_staged_bundle( - entries: &BTreeMap, - dest_dir: &Path, - canonical_dest_dir: &Path, - staged_root: &Path, - backup_root: &Path, -) -> Result<(), String> { - let mut installed = Vec::new(); - let mut backups = Vec::new(); - for relative in entries.keys() { - let target = dest_dir.join(relative); - let staged = staged_root.join(relative); - if let Some(parent) = target.parent() { - if let Err(error) = fs::create_dir_all(parent) { - return rollback_bundle( - &installed, - &backups, - format!( - "could not create import target {}: {error}", - parent.display() - ), - ); - } - let canonical_parent = match fs::canonicalize(parent) { - Ok(canonical_parent) => canonical_parent, - Err(error) => { - return rollback_bundle( - &installed, - &backups, - format!( - "could not resolve import target {}: {error}", - parent.display() - ), - ); - } - }; - if !canonical_parent.starts_with(canonical_dest_dir) { - return rollback_bundle( - &installed, - &backups, - format!( - "FBX import target {} resolves outside destination {}", - parent.display(), - dest_dir.display() - ), - ); - } - } - if target.exists() { - let backup = backup_root.join(relative); - if let Some(parent) = backup.parent() { - if let Err(error) = fs::create_dir_all(parent) { - return rollback_bundle( - &installed, - &backups, - format!( - "could not create import backup {}: {error}", - parent.display() - ), - ); - } - } - if let Err(error) = fs::rename(&target, &backup) { - return rollback_bundle( - &installed, - &backups, - format!( - "could not back up import target {}: {error}", - target.display() - ), - ); - } - backups.push((backup, target.clone())); - } - if let Err(error) = fs::rename(&staged, &target) { - return rollback_bundle( - &installed, - &backups, - format!( - "could not publish import target {}: {error}", - target.display() - ), - ); - } - installed.push(target); - } - Ok(()) -} - -fn rollback_bundle( - installed: &[PathBuf], - backups: &[(PathBuf, PathBuf)], - cause: String, -) -> Result<(), String> { - let mut rollback_errors = Vec::new(); - for target in installed.iter().rev() { - if let Err(error) = fs::remove_file(target) { - if error.kind() != std::io::ErrorKind::NotFound { - rollback_errors.push(format!("remove {}: {error}", target.display())); - } - } - } - for (backup, target) in backups.iter().rev() { - if let Err(error) = fs::rename(backup, target) { - rollback_errors.push(format!( - "restore {} to {}: {error}", - backup.display(), - target.display() - )); - } - } - if rollback_errors.is_empty() { - Err(cause) - } else { - Err(format!( - "{cause}; rollback also failed: {}", - rollback_errors.join("; ") - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_root(label: &str) -> PathBuf { - std::env::temp_dir().join(format!("blacksite-fbx-{label}-{}", uuid::Uuid::new_v4())) - } - - fn committed_chair_bytes() -> Vec { - fs::read( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../assets/models/painted_wooden_chair_02_2k.fbx"), - ) - .unwrap() - } - - fn replace_equal_length(bytes: &mut [u8], from: &[u8], to: &[u8]) -> usize { - assert_eq!(from.len(), to.len()); - let mut count = 0; - let mut offset = 0; - while let Some(index) = bytes[offset..] - .windows(from.len()) - .position(|window| window == from) - { - let start = offset + index; - bytes[start..start + from.len()].copy_from_slice(to); - offset = start + from.len(); - count += 1; - } - count - } - - fn write_textures(root: &Path, folder: &str) { - let folder = root.join(folder); - fs::create_dir_all(&folder).unwrap(); - for name in [ - "painted_wooden_chair_02_diff_2k.jpg", - "painted_wooden_chair_02_nor_gl_2k.exr", - "painted_wooden_chair_02_rough_2k.exr", - ] { - fs::write(folder.join(name), name.as_bytes()).unwrap(); - } - } - - #[test] - fn committed_chair_reports_one_stable_missing_dependency_state() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../assets/models/painted_wooden_chair_02_2k.fbx"); - - let error = validate_fbx_dependencies(&path).unwrap_err(); - - assert!(error.starts_with("missing 3 FBX source texture(s):")); - assert_eq!(error.matches("painted_wooden_chair_02_").count(), 3); - } - - #[test] - fn sibling_texture_bundle_is_copied_with_relative_layout() { - let root = temp_root("sibling"); - let source_root = root.join("source"); - let destination = root.join("destination"); - fs::create_dir_all(&source_root).unwrap(); - let source = source_root.join("chair.fbx"); - fs::write(&source, committed_chair_bytes()).unwrap(); - write_textures(&source_root, "textures"); - - copy_fbx_bundle(&source, &destination).unwrap(); - - assert!(destination.join("chair.fbx").is_file()); - assert!(destination - .join("textures/painted_wooden_chair_02_diff_2k.jpg") - .is_file()); - assert!(!destination.read_dir().unwrap().any(|entry| entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with(".blacksite-import-"))); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn fbm_texture_bundle_is_copied_with_relative_layout() { - let root = temp_root("fbm"); - let source_root = root.join("source"); - let destination = root.join("destination"); - fs::create_dir_all(&source_root).unwrap(); - let source = source_root.join("chair.fbx"); - let mut bytes = committed_chair_bytes(); - assert!(replace_equal_length(&mut bytes, b"textures/", b"test.fbm/") > 0); - fs::write(&source, bytes).unwrap(); - write_textures(&source_root, "test.fbm"); - - copy_fbx_bundle(&source, &destination).unwrap(); - - assert!(destination - .join("test.fbm/painted_wooden_chair_02_diff_2k.jpg") - .is_file()); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn traversal_reference_is_rejected_before_destination_changes() { - let root = temp_root("traversal"); - let source_root = root.join("source"); - let destination = root.join("destination"); - fs::create_dir_all(&source_root).unwrap(); - fs::create_dir_all(&destination).unwrap(); - let source = source_root.join("chair.fbx"); - let mut bytes = committed_chair_bytes(); - assert!(replace_equal_length(&mut bytes, b"textures/", b"../evil//") > 0); - fs::write(&source, bytes).unwrap(); - fs::write(destination.join("chair.fbx"), b"original").unwrap(); - - let error = copy_fbx_bundle(&source, &destination).unwrap_err(); - - assert!(error.contains("parent traversal")); - assert_eq!( - fs::read(destination.join("chair.fbx")).unwrap(), - b"original" - ); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn missing_bundle_preserves_existing_destination() { - let root = temp_root("missing"); - let source_root = root.join("source"); - let destination = root.join("destination"); - fs::create_dir_all(&source_root).unwrap(); - fs::create_dir_all(&destination).unwrap(); - let source = source_root.join("chair.fbx"); - fs::write(&source, committed_chair_bytes()).unwrap(); - fs::write(destination.join("chair.fbx"), b"original").unwrap(); - - let error = copy_fbx_bundle(&source, &destination).unwrap_err(); - - assert!(error.contains("missing 3 required texture(s)")); - assert_eq!( - fs::read(destination.join("chair.fbx")).unwrap(), - b"original" - ); - fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn destination_symlink_escape_is_rejected_before_external_changes() { - use std::os::unix::fs::symlink; - - let root = temp_root("destination-symlink"); - let source_root = root.join("source"); - let destination = root.join("destination"); - let outside = root.join("outside"); - fs::create_dir_all(&source_root).unwrap(); - fs::create_dir_all(&destination).unwrap(); - fs::create_dir_all(&outside).unwrap(); - let source = source_root.join("chair.fbx"); - fs::write(&source, committed_chair_bytes()).unwrap(); - write_textures(&source_root, "textures"); - symlink(&outside, destination.join("textures")).unwrap(); - - let error = copy_fbx_bundle(&source, &destination).unwrap_err(); - - assert!(error.contains("symbolic link")); - assert!(!destination.join("chair.fbx").exists()); - assert!(!outside.join("painted_wooden_chair_02_diff_2k.jpg").exists()); - fs::remove_dir_all(root).unwrap(); - } -} +pub use content_pipeline::{ + extract_gltf_materials, find_matching_extracted_material, plan_fbx_material_extraction, + plan_gltf_material_extraction, plan_model_material_extraction, validate_fbx_dependencies, + ExtractedGltfMaterial, PlannedGltfMaterial, +}; diff --git a/crates/editor/src/assets/materials.rs b/crates/editor/src/assets/materials.rs index ccb9d87..7df3117 100644 --- a/crates/editor/src/assets/materials.rs +++ b/crates/editor/src/assets/materials.rs @@ -3,8 +3,7 @@ use bevy::prelude::*; use bevy_egui::egui; use shared::{ - ComponentInstanceId, LevelObject, MaterialAsset, MaterialDesc, MaterialRef, - RendererMaterialSlot, + ComponentInstanceId, LevelObject, MaterialAsset, MaterialDesc, MaterialRef, MaterialSlot, }; use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets}; @@ -92,6 +91,22 @@ pub fn apply_material_asset_to_selection( renderer_changes += 1; continue; } + if let Some(mut primitive) = world.get::(entity).cloned() { + primitive.surface.material = Some(material_ref.clone()); + reflected_component_transaction( + world, + entity, + "Assign Primitive Material", + shared::AUTHORING_COMPONENT_PRIMITIVE, + shared::COMPONENT_PRIMITIVE, + move |world, entity| { + world.entity_mut(entity).insert(primitive); + Ok(()) + }, + )?; + renderer_changes += 1; + continue; + } if let Some(desc) = desc.as_ref() { legacy_changes.push((entity, desc.clone())); } @@ -117,10 +132,9 @@ pub(crate) fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRend }; } if renderer.materials.slot(&part.material_slot_id).is_none() { - renderer.materials.slots.push(RendererMaterialSlot { + renderer.materials.slots.push(MaterialSlot { id: part.material_slot_id.clone(), name: part.name.clone(), - source_material: part.material.clone().map(MaterialRef::new), material: None, }); } diff --git a/crates/editor/src/assets/mod.rs b/crates/editor/src/assets/mod.rs index 48ca69d..4516997 100644 --- a/crates/editor/src/assets/mod.rs +++ b/crates/editor/src/assets/mod.rs @@ -12,26 +12,18 @@ pub mod static_mesh; pub mod thumbnails; pub use catalog::*; +pub use import::{ + extract_gltf_materials, find_matching_extracted_material, plan_fbx_material_extraction, + plan_gltf_material_extraction, plan_model_material_extraction, ExtractedGltfMaterial, + PlannedGltfMaterial, +}; pub use thumbnails::{ - draw_asset_cell_with, gltf_skinned_primitive_labels, invalidate_on_catalog_refresh, kind_icon, - prefetch_asset_thumbnails, prefetch_folder_thumbnails, AssetThumbnailCache, + gltf_skinned_primitive_labels, invalidate_on_catalog_refresh, kind_icon, + prefetch_asset_thumbnails, prefetch_folder_thumbnails, retry_thumbnail, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, ThumbnailStudio, ThumbnailsPlugin, }; -/// Refreshes both generated contracts derived from one imported model source. -pub fn refresh_model_artifacts( - record: &mut asset_db::AssetRecord, -) -> Result { - let static_mesh = static_mesh::refresh_static_mesh_artifact(record); - let animation = animation::refresh_animation_artifact(record); - match (static_mesh, animation) { - (Ok(static_mesh), Ok(_)) => Ok(static_mesh), - (Err(static_error), Ok(_)) => Err(format!("static mesh processor failed: {static_error}")), - (Ok(_), Err(animation_error)) => { - Err(format!("animation processor failed: {animation_error}")) - } - (Err(static_error), Err(animation_error)) => Err(format!( - "static mesh processor failed: {static_error}; animation processor failed: {animation_error}" - )), - } -} +pub use content_pipeline::{ + plan_model_artifacts, plan_model_artifacts_at, publish_model_artifacts, + refresh_model_artifacts, PlannedModelArtifacts, +}; diff --git a/crates/editor/src/assets/operators.rs b/crates/editor/src/assets/operators.rs index 1f4129d..83f3f8a 100644 --- a/crates/editor/src/assets/operators.rs +++ b/crates/editor/src/assets/operators.rs @@ -263,12 +263,12 @@ mod tests { id: asset_id, path: model_path.clone(), label: "Operator Fixture".into(), - kind_tag: "Model".into(), + kind: shared::AssetKind::Model, source_fingerprint: None, - import_settings: ImportSettings { + import_settings: shared::AssetImportSettings::Model(ImportSettings { animation_manifest_path: Some(path.to_string_lossy().into_owned()), ..Default::default() - }, + }), dependencies: Vec::new(), }; let selection = AssetSelection::SubAsset { @@ -313,6 +313,7 @@ mod tests { world.insert_resource(AssetRegistry { records: vec![record], index_dirty: false, + ..Default::default() }); let harness = OperatorInvariantHarness::capture(&mut world); @@ -351,12 +352,13 @@ mod tests { id: AssetId::new(), path: path.clone(), label: "Impact".into(), - kind_tag: "AudioClip".into(), + kind: shared::AssetKind::AudioClip, source_fingerprint: None, import_settings: Default::default(), dependencies: Vec::new(), }], index_dirty: false, + ..Default::default() }); let entity = world .spawn(( @@ -398,6 +400,7 @@ mod tests { world.insert_resource(AssetRegistry { records: vec![record], index_dirty: false, + ..Default::default() }); let entity = world .spawn(( @@ -483,13 +486,14 @@ mod tests { let fixture = shared::MaterialAsset { schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, label: "Concrete".into(), - shader: None, + shader: shared::ShaderRefDesc::default(), shader_ref: None, render_state: Default::default(), - material: MaterialDesc { + provenance: None, + inputs: shared::MaterialInputSet::from_material_desc(&MaterialDesc { roughness: 0.85, ..Default::default() - }, + }), }; std::fs::write( &material_path, @@ -502,12 +506,13 @@ mod tests { id: AssetId::new(), path: material_path.clone(), label: "Concrete".into(), - kind_tag: "Material".into(), + kind: shared::AssetKind::Material, source_fingerprint: None, import_settings: Default::default(), dependencies: Vec::new(), }], index_dirty: false, + ..Default::default() }); let first = world.spawn((LevelObject, MaterialDesc::default())).id(); let second = world.spawn((LevelObject, MaterialDesc::default())).id(); diff --git a/crates/editor/src/assets/static_mesh.rs b/crates/editor/src/assets/static_mesh.rs index 32bece9..97eaabc 100644 --- a/crates/editor/src/assets/static_mesh.rs +++ b/crates/editor/src/assets/static_mesh.rs @@ -1,855 +1,3 @@ -//! Normalized static mesh artifacts generated from model source files. +//! Compatibility re-exports for UI-independent static-mesh artifact processing. -use std::fs; -use std::path::Path; - -use bevy::gltf::GltfAssetLabel; -use bevy::prelude::*; -use bevy_ufbx::label::FbxAssetLabel; -use bevy_ufbx::mesh::group_faces_by_material; -use bevy_ufbx::texture::external_texture_paths; -use bevy_ufbx::utils::convert_matrix; -use serde::{Deserialize, Serialize}; - -use crate::asset_db::{ - AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode, -}; -use shared::{ - AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, - RendererMaterialSlot, StaticMeshRenderer, StaticMeshRendererEntry, -}; - -use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed}; - -pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4; -pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StaticMeshManifest { - pub schema_version: u32, - pub asset_id: String, - pub label: String, - pub source: StaticMeshSource, - pub import: StaticMeshImportSnapshot, - pub metadata: StaticMeshMetadata, - pub parts: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StaticMeshSource { - pub path: String, - pub format: String, - pub fingerprint: StaticMeshSourceFingerprint, - pub dependencies: Vec, -} - -pub type StaticMeshSourceFingerprint = AssetSourceFingerprint; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StaticMeshImportSnapshot { - pub scale: f32, - pub generate_collider: bool, - pub lod0_only: bool, - pub placement_mode: ModelPlacementMode, - pub hierarchy_mode: ModelHierarchyMode, - pub material_policy: MaterialImportPolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StaticMeshMetadata { - pub mesh_count: usize, - pub material_count: usize, - pub node_count: usize, - pub animation_count: usize, - pub skin_count: usize, - pub light_count: usize, - pub camera_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StaticMeshPart { - #[serde(default)] - pub id: String, - pub name: String, - pub mesh_label: String, - #[serde(default)] - pub material_id: Option, - pub material_slot_name: String, - pub material_label: Option, - pub local_transform: Transform, - pub source_node: Option, - pub source_mesh: Option, - pub source_material: Option, - /// Whether the source primitive is bound to a skin and therefore must never become a static - /// renderer slot. - #[serde(default)] - pub skinned: bool, -} - -pub fn static_mesh_manifest_path(asset_id: &str) -> String { - format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron") -} - -pub fn part_id_from_label(label: &str) -> String { - format!("mesh:{}", stable_sub_asset_slug(label)) -} - -pub fn material_id_from_label(label: &str) -> String { - format!("material:{}", stable_sub_asset_slug(label)) -} - -fn gltf_draw_id(node_index: Option, mesh_index: usize, primitive_index: usize) -> String { - let node = node_index - .map(|index| index.to_string()) - .unwrap_or_else(|| "unbound".into()); - format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}") -} - -fn fbx_draw_id(node_index: usize, material_index: usize) -> String { - format!("draw:scene0:node{node_index}:material{material_index}") -} - -fn stable_sub_asset_slug(label: &str) -> String { - let mut slug = String::new(); - for ch in label.chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); - } else if !slug.ends_with('_') { - slug.push('_'); - } - } - slug.trim_matches('_').to_string() -} - -pub fn refresh_static_mesh_artifact( - record: &mut AssetRecord, -) -> Result { - let mut manifest = build_static_mesh_manifest(record)?; - let path = static_mesh_manifest_path(&record.id.as_string()); - record.import_settings.static_mesh_manifest_path = Some(path.clone()); - manifest.source.dependencies.sort(); - manifest.source.dependencies.dedup(); - record.dependencies = manifest.source.dependencies.clone(); - - if write_pretty_ron_if_changed(&path, &manifest) - .map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))? - { - info!( - "Static mesh manifest refreshed: source={} artifact={} parts={}", - record.path, - path, - manifest.parts.len() - ); - } - - Ok(manifest) -} - -pub fn load_static_mesh_manifest(path: &str) -> Result { - let text = fs::read_to_string(path).map_err(|err| format!("could not read {path}: {err}"))?; - ron::from_str(&text).map_err(|err| format!("could not parse {path}: {err}")) -} - -pub fn renderer_from_manifest( - manifest: &StaticMeshManifest, - settings: &ImportSettings, -) -> StaticMeshRenderer { - let use_source_materials = matches!( - settings.material_policy, - MaterialImportPolicy::SourceMaterials - ); - - let parts: Vec<_> = manifest - .parts - .iter() - .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) - .map(|part| StaticMeshRendererEntry { - id: ComponentInstanceId::new(part_effective_id(part)), - name: part.name.clone(), - mesh: EditorAssetRef::new( - manifest.asset_id.clone(), - part_effective_id(part), - part.name.clone(), - ), - material_slot_id: ComponentInstanceId::new(material_slot_id(part)), - material: use_source_materials - .then(|| { - part_effective_material_id(part).map(|id| { - EditorAssetRef::new( - manifest.asset_id.clone(), - id, - part.material_slot_name.clone(), - ) - }) - }) - .flatten(), - local_transform: part.local_transform, - visible: true, - cast_shadows: true, - receive_shadows: true, - }) - .collect(); - let materials = RendererMaterialSet { - slots: manifest - .parts - .iter() - .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) - .map(|part| RendererMaterialSlot { - id: ComponentInstanceId::new(material_slot_id(part)), - name: part.material_slot_name.clone(), - source_material: use_source_materials - .then(|| { - part_effective_material_id(part).map(|id| { - MaterialRef::new(EditorAssetRef::new( - manifest.asset_id.clone(), - id, - part.material_slot_name.clone(), - )) - }) - }) - .flatten(), - material: None, - }) - .collect(), - orphaned_assignments: Vec::new(), - }; - - StaticMeshRenderer { - slots: parts, - materials, - } -} - -/// Builds the shared material slots for a full imported hierarchy. Unlike static renderer -/// construction this intentionally includes skin-bound and rigid animated draw bindings. -pub fn renderer_materials_from_manifest( - manifest: &StaticMeshManifest, - settings: &ImportSettings, -) -> RendererMaterialSet { - let use_source_materials = matches!( - settings.material_policy, - MaterialImportPolicy::SourceMaterials - ); - RendererMaterialSet { - slots: manifest - .parts - .iter() - .map(|part| RendererMaterialSlot { - id: ComponentInstanceId::new(material_slot_id(part)), - name: part.material_slot_name.clone(), - source_material: use_source_materials - .then(|| { - part_effective_material_id(part).map(|id| { - MaterialRef::new(EditorAssetRef::new( - manifest.asset_id.clone(), - id, - part.material_slot_name.clone(), - )) - }) - }) - .flatten(), - material: None, - }) - .collect(), - orphaned_assignments: Vec::new(), - } -} - -fn material_slot_id(part: &StaticMeshPart) -> String { - format!("slot:{}", part_effective_id(part)) -} - -fn part_effective_id(part: &StaticMeshPart) -> String { - if part.id.trim().is_empty() { - part_id_from_label(&part.mesh_label) - } else { - part.id.clone() - } -} - -fn part_effective_material_id(part: &StaticMeshPart) -> Option { - part.material_id.clone().or_else(|| { - part.material_label - .as_ref() - .map(|label| material_id_from_label(label)) - }) -} - -fn build_static_mesh_manifest(record: &AssetRecord) -> Result { - let format = source_format(&record.path)?; - let fingerprint = fingerprint_file(&record.path)?; - let import = StaticMeshImportSnapshot { - scale: record.import_settings.scale, - generate_collider: record.import_settings.generate_collider, - lod0_only: record.import_settings.lod0_only, - placement_mode: record.import_settings.placement_mode, - hierarchy_mode: record.import_settings.hierarchy_mode, - material_policy: record.import_settings.material_policy, - }; - - let mut manifest = match format.as_str() { - "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, import)?, - "fbx" => build_fbx_manifest(record, format, fingerprint, import)?, - _ => return Err(format!("unsupported static mesh source format `{format}`")), - }; - - if manifest.parts.is_empty() { - manifest - .warnings - .push("No renderable static mesh parts were found.".into()); - } - - Ok(manifest) -} - -fn build_gltf_manifest( - record: &AssetRecord, - format: String, - fingerprint: StaticMeshSourceFingerprint, - import: StaticMeshImportSnapshot, -) -> Result { - let gltf = gltf::Gltf::open(&record.path) - .map_err(|err| format!("could not parse glTF {}: {err}", record.path))?; - let mut parts = Vec::new(); - let mut dependencies = Vec::new(); - let mut warnings = Vec::new(); - - for buffer in gltf.document.buffers() { - if let gltf::buffer::Source::Uri(uri) = buffer.source() { - dependencies.push(resolve_dependency(&record.path, uri)); - } - } - for image in gltf.document.images() { - if let gltf::image::Source::Uri { uri, .. } = image.source() { - dependencies.push(resolve_dependency(&record.path, uri)); - } - } - - if let Some(scene) = gltf - .document - .default_scene() - .or_else(|| gltf.document.scenes().next()) - { - for node in scene.nodes() { - collect_gltf_node_parts(node, Mat4::IDENTITY, "", &mut parts); - } - } else { - for mesh in gltf.document.meshes() { - collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts); - } - } - - if gltf.document.animations().count() > 0 { - warnings.push( - "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." - .into(), - ); - } - if gltf.document.skins().count() > 0 { - warnings.push( - "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." - .into(), - ); - } - - parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); - - Ok(StaticMeshManifest { - schema_version: STATIC_MESH_MANIFEST_SCHEMA, - asset_id: record.id.as_string(), - label: record.label.clone(), - source: StaticMeshSource { - path: record.path.clone(), - format, - fingerprint, - dependencies, - }, - import, - metadata: StaticMeshMetadata { - mesh_count: gltf.document.meshes().count(), - material_count: gltf.document.materials().count(), - node_count: gltf.document.nodes().count(), - animation_count: gltf.document.animations().count(), - skin_count: gltf.document.skins().count(), - light_count: 0, - camera_count: gltf.document.cameras().count(), - }, - parts, - warnings, - }) -} - -fn collect_gltf_node_parts( - node: gltf::Node<'_>, - parent_transform: Mat4, - parent_path: &str, - parts: &mut Vec, -) { - let local = Mat4::from_cols_array_2d(&node.transform().matrix()); - let world_transform = parent_transform * local; - let segment = node - .name() - .map(str::to_string) - .unwrap_or_else(|| format!("Node{}", node.index())); - let node_path = if parent_path.is_empty() { - segment - } else { - format!("{parent_path}/{segment}") - }; - let skinned = node.skin().is_some(); - if let Some(mesh) = node.mesh() { - collect_gltf_mesh_parts( - node.name().map(str::to_string), - Some(node.index()), - Some(node_path.clone()), - mesh, - world_transform, - skinned, - parts, - ); - } - for child in node.children() { - collect_gltf_node_parts(child, world_transform, &node_path, parts); - } -} - -fn collect_gltf_mesh_parts( - node_name: Option, - node_index: Option, - node_path: Option, - mesh: gltf::Mesh<'_>, - transform: Mat4, - skinned: bool, - parts: &mut Vec, -) { - let mesh_index = mesh.index(); - let mesh_name = mesh.name().map(str::to_string); - for primitive in mesh.primitives() { - let primitive_index = primitive.index(); - let mesh_label = GltfAssetLabel::Primitive { - mesh: mesh_index, - primitive: primitive_index, - } - .to_string(); - let material = primitive.material(); - let material_label = material - .index() - .map(|index| { - GltfAssetLabel::Material { - index, - is_scale_inverted: false, - } - .to_string() - }) - .or_else(|| Some(GltfAssetLabel::DefaultMaterial.to_string())); - let material_name = material - .name() - .map(str::to_string) - .unwrap_or_else(|| "Default Material".into()); - let name = node_name - .clone() - .or_else(|| mesh_name.clone()) - .unwrap_or_else(|| format!("Mesh {mesh_index}")); - let source_node = node_path - .clone() - .or_else(|| node_index.map(|index| format!("Node{index}"))); - parts.push(StaticMeshPart { - id: gltf_draw_id(node_index, mesh_index, primitive_index), - name: format!("{name} / Primitive {primitive_index}"), - material_id: material_label - .as_ref() - .map(|label| material_id_from_label(label)), - mesh_label, - material_slot_name: material_name.clone(), - material_label, - local_transform: Transform::from_matrix(transform), - source_node, - source_mesh: Some(format!("Mesh{mesh_index}")), - source_material: Some(material_name), - skinned, - }); - } -} - -fn build_fbx_manifest( - record: &AssetRecord, - format: String, - fingerprint: StaticMeshSourceFingerprint, - import: StaticMeshImportSnapshot, -) -> Result { - let bytes = - fs::read(&record.path).map_err(|err| format!("could not read {}: {err}", record.path))?; - let scene = ufbx::load_memory( - &bytes, - ufbx::LoadOpts { - target_unit_meters: 1.0, - target_axes: ufbx::CoordinateAxes::right_handed_y_up(), - filename: ufbx::StringOpt::Ref(&record.path), - ..Default::default() - }, - ) - .map_err(|err| format!("could not parse FBX {}: {err:?}", record.path))?; - - let mut parts = Vec::new(); - let mut warnings = Vec::new(); - for (node_index, node) in scene.nodes.as_ref().iter().enumerate() { - let Some(mesh_ref) = node.mesh.as_ref() else { - continue; - }; - let mesh = mesh_ref.as_ref(); - if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() { - continue; - } - let skinned = !mesh.skin_deformers.as_ref().is_empty(); - - let mut groups: Vec<(usize, Vec)> = - group_faces_by_material(mesh).into_iter().collect(); - groups.sort_by_key(|(material_index, _)| *material_index); - for (material_index, indices) in groups { - if indices.is_empty() { - continue; - } - let material = mesh.materials.get(material_index).map(|mat| mat.as_ref()); - let material_label = material - .and_then(|mat| fbx_material_label(&scene, mat.element.element_id)) - .or_else(|| Some(FbxAssetLabel::DefaultMaterial.to_string())); - let material_name = material - .map(|mat| mat.element.name.to_string()) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| "Default Material".into()); - let node_name = if node.element.name.is_empty() { - format!("Node {node_index}") - } else { - node.element.name.to_string() - }; - let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string(); - parts.push(StaticMeshPart { - id: fbx_draw_id(node_index, material_index), - name: format!("{node_name} / Material {material_index}"), - material_id: material_label - .as_ref() - .map(|label| material_id_from_label(label)), - mesh_label, - material_slot_name: material_name.clone(), - material_label, - local_transform: Transform::from_matrix(convert_matrix(&node.geometry_to_world)), - source_node: Some(format!("Node{node_index}")), - source_mesh: Some(format!("Mesh{node_index}")), - source_material: Some(material_name), - skinned, - }); - } - } - - if !scene.anim_stacks.as_ref().is_empty() { - warnings.push( - "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." - .into(), - ); - } - if !scene.skin_deformers.as_ref().is_empty() { - warnings.push( - "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." - .into(), - ); - } - - parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); - - Ok(StaticMeshManifest { - schema_version: STATIC_MESH_MANIFEST_SCHEMA, - asset_id: record.id.as_string(), - label: record.label.clone(), - source: StaticMeshSource { - path: record.path.clone(), - format, - fingerprint, - dependencies: fbx_dependencies(&record.path, &scene)?, - }, - import, - metadata: StaticMeshMetadata { - mesh_count: scene.meshes.as_ref().len(), - material_count: scene.materials.as_ref().len(), - node_count: scene.nodes.as_ref().len(), - animation_count: scene.anim_stacks.as_ref().len(), - skin_count: scene.skin_deformers.as_ref().len(), - light_count: scene.lights.as_ref().len(), - camera_count: scene.cameras.as_ref().len(), - }, - parts, - warnings, - }) -} - -fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option { - if element_id == 0 { - return None; - } - scene - .materials - .as_ref() - .iter() - .position(|material| material.element.element_id == element_id) - .map(|index| FbxAssetLabel::Material(index).to_string()) -} - -fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result, String> { - let path = Path::new(source_path); - let parent = path.parent().unwrap_or_else(|| Path::new("")); - external_texture_paths(scene) - .map_err(|errors| { - format!( - "unsafe FBX texture reference(s) in {source_path}: {}", - errors - .into_iter() - .map(|error| error.to_string()) - .collect::>() - .join("; ") - ) - }) - .map(|paths| { - paths - .into_iter() - .map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/")) - .collect() - }) -} - -fn source_format(path: &str) -> Result { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .ok_or_else(|| format!("asset path `{path}` has no extension")) -} - -fn resolve_dependency(source_path: &str, uri: &str) -> String { - if uri.starts_with("data:") || uri.contains("://") { - return uri.to_string(); - } - Path::new(source_path) - .parent() - .unwrap_or_else(|| Path::new("")) - .join(uri) - .to_string_lossy() - .replace('\\', "/") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::asset_db::AssetId; - - fn test_manifest() -> StaticMeshManifest { - StaticMeshManifest { - schema_version: STATIC_MESH_MANIFEST_SCHEMA, - asset_id: "asset-1".into(), - label: "Crate".into(), - source: StaticMeshSource { - path: "assets/models/crate.glb".into(), - format: "glb".into(), - fingerprint: StaticMeshSourceFingerprint { - byte_len: 42, - content_hash: "a".repeat(64), - }, - dependencies: Vec::new(), - }, - import: StaticMeshImportSnapshot { - scale: 1.0, - generate_collider: true, - lod0_only: true, - placement_mode: ModelPlacementMode::StaticAsset, - hierarchy_mode: ModelHierarchyMode::SingleActor, - material_policy: MaterialImportPolicy::SourceMaterials, - }, - metadata: StaticMeshMetadata { - mesh_count: 1, - material_count: 1, - node_count: 1, - animation_count: 0, - skin_count: 0, - light_count: 0, - camera_count: 0, - }, - parts: vec![StaticMeshPart { - id: "mesh:mesh0_primitive0".into(), - name: "Crate / Primitive 0".into(), - mesh_label: "Mesh0/Primitive0".into(), - material_id: Some("material:material0".into()), - material_slot_name: "Wood".into(), - material_label: Some("Material0".into()), - local_transform: Transform::from_xyz(1.0, 2.0, 3.0), - source_node: Some("Node0".into()), - source_mesh: Some("Mesh0".into()), - source_material: Some("Wood".into()), - skinned: false, - }], - warnings: Vec::new(), - } - } - - #[test] - fn static_mesh_manifest_path_uses_asset_id() { - assert_eq!( - static_mesh_manifest_path("abc"), - "assets/meshes/generated/abc.static_mesh.ron" - ); - } - - #[test] - fn renderer_from_manifest_preserves_labels_and_collider_policy() { - let manifest = test_manifest(); - let settings = ImportSettings { - generate_collider: true, - ..Default::default() - }; - - let renderer = renderer_from_manifest(&manifest, &settings); - - assert_eq!(renderer.slots.len(), 1); - let entry = &renderer.slots[0]; - assert_eq!(entry.mesh.asset_id, manifest.asset_id); - assert_eq!(entry.mesh.sub_asset_id, "mesh:mesh0_primitive0"); - assert_eq!( - entry - .material - .as_ref() - .map(|material| material.sub_asset_id.as_str()), - Some("material:material0") - ); - } - - #[test] - fn schema_v3_manifest_without_hash_migrates_to_content_fingerprint() { - let root = std::env::temp_dir().join(format!( - "blacksite-static-mesh-legacy-{}", - uuid::Uuid::new_v4() - )); - fs::create_dir_all(&root).unwrap(); - let path = root.join("legacy.static_mesh.ron"); - let expected = test_manifest(); - let canonical = - ron::ser::to_string_pretty(&expected, ron::ser::PrettyConfig::default()).unwrap(); - let legacy = canonical - .replacen("schema_version: 4", "schema_version: 3", 1) - .lines() - .filter(|line| !line.contains("content_hash:")) - .collect::>() - .join("\n"); - fs::write(&path, legacy).unwrap(); - - assert!(write_pretty_ron_if_changed(&path, &expected).unwrap()); - assert_eq!( - load_static_mesh_manifest(&path.to_string_lossy()).unwrap(), - expected - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn equivalent_static_manifest_preserves_existing_formatting_and_newline() { - let root = std::env::temp_dir().join(format!( - "blacksite-static-mesh-semantic-{}", - uuid::Uuid::new_v4() - )); - fs::create_dir_all(&root).unwrap(); - let path = root.join("stable.static_mesh.ron"); - let manifest = test_manifest(); - let exact = format!( - "{}\n\n", - ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap() - ); - fs::write(&path, &exact).unwrap(); - - assert!(!write_pretty_ron_if_changed(&path, &manifest).unwrap()); - assert_eq!(fs::read_to_string(&path).unwrap(), exact); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn renderer_from_manifest_can_ignore_source_materials() { - let manifest = test_manifest(); - let settings = ImportSettings { - material_policy: MaterialImportPolicy::AuthoringOverride, - ..Default::default() - }; - - let renderer = renderer_from_manifest(&manifest, &settings); - - assert!(renderer.slots[0].material.is_none()); - } - - #[test] - fn renderer_from_manifest_excludes_skinned_primitives() { - let mut manifest = test_manifest(); - manifest.parts[0].skinned = true; - - let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); - - assert!(renderer.slots.is_empty()); - } - - #[test] - fn renderer_from_manifest_excludes_node_animated_geometry() { - let mut manifest = test_manifest(); - manifest.metadata.animation_count = 1; - - let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); - - assert!(renderer.slots.is_empty()); - } - - #[test] - fn committed_rigged_fixture_never_builds_static_renderer_slots() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../assets/models/robot_expressive.glb") - .to_string_lossy() - .into_owned(); - let record = AssetRecord { - id: AssetId::new(), - path, - label: "Robot Expressive".into(), - kind_tag: "Model".into(), - source_fingerprint: None, - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - }; - - let manifest = build_static_mesh_manifest(&record).unwrap(); - let renderer = renderer_from_manifest(&manifest, &record.import_settings); - - assert!(manifest.metadata.skin_count > 0); - assert!(manifest.metadata.animation_count > 0); - assert!(manifest.parts.iter().any(|part| part.skinned)); - assert!(renderer.slots.is_empty()); - } - - #[test] - fn committed_fbx_records_sibling_texture_dependencies() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../assets/models/painted_wooden_chair_02_2k.fbx") - .to_string_lossy() - .into_owned(); - let record = AssetRecord { - id: AssetId::new(), - path, - label: "Painted Chair".into(), - kind_tag: "Model".into(), - source_fingerprint: None, - import_settings: ImportSettings::default(), - dependencies: Vec::new(), - }; - - let manifest = build_static_mesh_manifest(&record).unwrap(); - - assert_eq!(manifest.source.dependencies.len(), 3); - assert!(manifest - .source - .dependencies - .iter() - .all(|path| path.contains("assets/models/textures/painted_wooden_chair_02_"))); - } -} +pub use content_pipeline::static_mesh::*; diff --git a/crates/editor/src/assets/thumbnails/cache.rs b/crates/editor/src/assets/thumbnails/cache.rs index 66fbd60..836aed6 100644 --- a/crates/editor/src/assets/thumbnails/cache.rs +++ b/crates/editor/src/assets/thumbnails/cache.rs @@ -3,12 +3,12 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use bevy::asset::LoadState; +use bevy::asset::{AssetId, LoadState}; use bevy::prelude::*; use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures}; use egui_phosphor_icons::icons; -use super::sources::gltf::{gltf_base_color_texture_path, validate_gltf_dependencies}; +use super::sources::gltf::validate_gltf_dependencies; use super::studio::{model_file_exists, ThumbnailStudio}; use super::ThumbnailJobSource; use crate::assets::{ @@ -31,6 +31,7 @@ struct ThumbnailFailure { #[derive(Resource, Default)] pub struct AssetThumbnailCache { pub texture_ids: HashMap, + registered_images: HashMap>, pending: HashMap>, pub(crate) studio_pending: HashSet, failed: HashMap, @@ -64,11 +65,15 @@ impl AssetThumbnailCache { None } - pub fn retry(&mut self, key: &str) { + /// Clears one thumbnail and returns the image registration when this key was its last owner. + /// The caller must remove that registration from `EguiUserTextures` after releasing the cache + /// borrow. + pub fn retry(&mut self, key: &str) -> Option> { self.failed.remove(key); self.pending.remove(key); self.studio_pending.remove(key); self.texture_ids.remove(key); + self.release_registered_image(key) } pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) { @@ -88,13 +93,7 @@ impl AssetThumbnailCache { self.pending.insert(key, handle); } - pub fn request_model( - &mut self, - key: String, - model_path: String, - asset_server: &AssetServer, - studio: &mut ThumbnailStudio, - ) { + pub fn request_model(&mut self, key: String, model_path: String, studio: &mut ThumbnailStudio) { if matches!( self.state(&key), Some(ThumbnailState::Ready | ThumbnailState::Pending) @@ -109,11 +108,6 @@ impl AssetThumbnailCache { return; } - if let Some(texture_path) = gltf_base_color_texture_path(&model_path) { - self.request_texture(key, texture_path, asset_server); - return; - } - if studio.enqueue(key.clone(), model_path) { self.studio_pending.insert(key); } @@ -263,10 +257,7 @@ impl AssetThumbnailCache { ) { self.studio_pending.remove(key); self.failed.remove(key); - let texture_id = textures - .image_id(&image) - .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(image))); - self.texture_ids.insert(key.to_string(), texture_id); + self.register_thumbnail(key, image, textures); } pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) { @@ -281,12 +272,48 @@ impl AssetThumbnailCache { ); } - pub fn invalidate_all(&mut self) { + /// Invalidates every cache entry and returns each distinct Egui image registration exactly + /// once so the world-level invalidation path can relinquish GPU ownership. + pub fn invalidate_all(&mut self) -> Vec> { self.texture_ids.clear(); self.pending.clear(); self.studio_pending.clear(); self.failed.clear(); self.prefetched_folder = None; + self.registered_images + .drain() + .map(|(_, image)| image) + .collect::>() + .into_iter() + .collect() + } + + fn register_thumbnail( + &mut self, + key: &str, + image: Handle, + textures: &mut EguiUserTextures, + ) { + let image_id = image.id(); + if self.registered_images.get(key).copied() != Some(image_id) { + if let Some(previous) = self.release_registered_image(key) { + textures.remove_image(previous); + } + self.registered_images.insert(key.to_string(), image_id); + } + let texture_id = textures + .image_id(image_id) + .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(image))); + self.texture_ids.insert(key.to_string(), texture_id); + } + + fn release_registered_image(&mut self, key: &str) -> Option> { + let image = self.registered_images.remove(key)?; + (!self + .registered_images + .values() + .any(|registered| *registered == image)) + .then_some(image) } pub fn snapshot(&self) -> ThumbnailCacheSnapshot { @@ -335,10 +362,7 @@ fn register_loaded_thumbnails( for (key, handle) in ready { cache.pending.remove(&key); cache.failed.remove(&key); - let texture_id = textures - .image_id(&handle) - .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(handle))); - cache.texture_ids.insert(key, texture_id); + cache.register_thumbnail(&key, handle, &mut textures); } } @@ -359,99 +383,6 @@ pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon { } } -pub fn draw_asset_cell_with( - ui: &mut egui::Ui, - asset: &EditorAsset, - texture_id: Option, - pending: bool, - failed: Option<&str>, - selected: bool, - thumbnail_size: f32, -) -> egui::Response { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0); - let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); - - let fill = if selected { - crate::ui::theme::SELECTION_BG_MUTED - } else if response.hovered() { - crate::ui::theme::ELEVATED_BG - } else { - crate::ui::theme::WIDGET_BG - }; - ui.painter().rect( - rect, - 4.0, - fill, - egui::Stroke::new( - 1.0, - if selected { - crate::ui::theme::ACCENT_HOVER - } else { - crate::ui::theme::BORDER - }, - ), - egui::StrokeKind::Inside, - ); - - let thumb_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(10.0, 8.0), - egui::vec2(thumb_size, thumb_size), - ); - - if let Some(texture_id) = texture_id { - ui.painter().image( - texture_id, - thumb_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } else if pending { - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - icons::CIRCLE_NOTCH.as_str(), - egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())), - crate::ui::theme::TEXT_DIM, - ); - } else if failed.is_some() { - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - icons::WARNING_CIRCLE.as_str(), - egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())), - crate::ui::theme::TEXT_DIM, - ); - } else { - let icon = kind_icon(&asset.kind); - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - icon.as_str(), - egui::FontId::new(28.0, egui::FontFamily::Name("phosphor-regular".into())), - crate::ui::theme::TEXT, - ); - } - - ui.painter().text( - egui::pos2(rect.center().x, rect.max.y - 6.0), - egui::Align2::CENTER_BOTTOM, - asset.label.as_str(), - egui::FontId::new(11.0, egui::FontFamily::Proportional), - if selected { - crate::ui::theme::TEXT_SELECTED - } else { - crate::ui::theme::TEXT - }, - ); - - if let Some(reason) = failed { - response.on_hover_text(reason) - } else { - response - } -} - #[derive(Clone)] pub struct ThumbnailCacheSnapshot { pub texture_ids: HashMap, @@ -533,9 +464,7 @@ pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) { for (key, path, kind) in requests { match kind { EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server), - EditorAssetKind::Model => { - cache.request_model(key, path, &asset_server, &mut studio) - } + EditorAssetKind::Model => cache.request_model(key, path, &mut studio), EditorAssetKind::Material => { cache.request_material_asset(key, path, &mut studio) } @@ -567,9 +496,7 @@ pub fn prefetch_asset_thumbnails(world: &mut World, assets: &[EditorAsset]) { for (key, path, kind) in requests { match kind { EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server), - EditorAssetKind::Model => { - cache.request_model(key, path, &asset_server, &mut studio) - } + EditorAssetKind::Model => cache.request_model(key, path, &mut studio), EditorAssetKind::Material => { cache.request_material_asset(key, path, &mut studio) } @@ -580,8 +507,23 @@ pub fn prefetch_asset_thumbnails(world: &mut World, assets: &[EditorAsset]) { }); } +/// Evicts one thumbnail while releasing the backing Egui image if no other cache key shares it. +pub fn retry_thumbnail(world: &mut World, key: &str) { + let registration = world.resource_mut::().retry(key); + if let Some(registration) = registration { + if let Some(mut textures) = world.get_resource_mut::() { + textures.remove_image(registration); + } + } +} + pub fn invalidate_on_catalog_refresh(world: &mut World) { - world.resource_mut::().invalidate_all(); + let registrations = world.resource_mut::().invalidate_all(); + if let Some(mut textures) = world.get_resource_mut::() { + for registration in registrations { + textures.remove_image(registration); + } + } if !world.contains_resource::() { return; } @@ -644,4 +586,66 @@ mod tests { assert_eq!(reason, format!("missing texture: {path}")); assert!(cache.pending.is_empty()); } + + #[test] + fn poly_haven_desk_model_queues_geometry_instead_of_its_albedo_texture() { + let model_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/Furniture/Office/metal_office_desk_2k.gltf") + .to_string_lossy() + .into_owned(); + let mut cache = AssetThumbnailCache::default(); + let mut studio = ThumbnailStudio::for_test(); + + cache.request_model("model:desk".into(), model_path.clone(), &mut studio); + + let jobs = studio.queued_jobs_for_test(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].cache_key, "model:desk"); + assert!(matches!( + &jobs[0].source, + ThumbnailJobSource::Model { model_path: queued } if queued == &model_path + )); + assert!(!jobs[0].source.label().contains("_diff_")); + } + + #[test] + fn endurance_guard_repeated_thumbnail_replacement_and_invalidation_release_egui_images() { + let mut cache = AssetThumbnailCache::default(); + let mut textures = EguiUserTextures::default(); + let mut images = Assets::::default(); + + for revision in 0..32 { + let image = images.add(Image::default()); + let image_id = image.id(); + cache.complete_studio_thumbnail("material:shared", image, &mut textures); + assert!(textures.image_id(image_id).is_some(), "revision {revision}"); + assert_eq!(cache.registered_images.len(), 1); + } + + let registrations = cache.invalidate_all(); + assert_eq!(registrations.len(), 1); + for registration in registrations { + assert!(textures.remove_image(registration).is_some()); + } + assert!(cache.registered_images.is_empty()); + assert!(cache.texture_ids.is_empty()); + } + + #[test] + fn endurance_guard_shared_thumbnail_image_is_released_only_after_the_last_key() { + let mut cache = AssetThumbnailCache::default(); + let mut textures = EguiUserTextures::default(); + let mut images = Assets::::default(); + let image = images.add(Image::default()); + let image_id = image.id(); + + cache.complete_studio_thumbnail("first", image.clone(), &mut textures); + cache.complete_studio_thumbnail("second", image, &mut textures); + assert!(cache.retry("first").is_none()); + assert!(textures.image_id(image_id).is_some()); + let registration = cache.retry("second").expect("last owner releases image"); + assert_eq!(registration, image_id); + textures.remove_image(registration); + assert!(textures.image_id(image_id).is_none()); + } } diff --git a/crates/editor/src/assets/thumbnails/mod.rs b/crates/editor/src/assets/thumbnails/mod.rs index 411d4d9..5ab3ee0 100644 --- a/crates/editor/src/assets/thumbnails/mod.rs +++ b/crates/editor/src/assets/thumbnails/mod.rs @@ -1,4 +1,4 @@ -//! Unified asset thumbnail pipeline (texture fast-path + model studio). +//! Unified asset thumbnail pipeline (typed texture loads + model render studio). use bevy::prelude::*; @@ -8,9 +8,9 @@ mod sources; mod studio; pub use cache::{ - draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_asset_thumbnails, - prefetch_folder_thumbnails, AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, - ThumbnailState, + invalidate_on_catalog_refresh, kind_icon, prefetch_asset_thumbnails, + prefetch_folder_thumbnails, retry_thumbnail, AssetThumbnailCache, AssetThumbnailsPlugin, + ThumbnailCacheSnapshot, ThumbnailState, }; pub use job::{ThumbnailJob, ThumbnailJobSource}; pub use sources::{ diff --git a/crates/editor/src/assets/thumbnails/sources/gltf.rs b/crates/editor/src/assets/thumbnails/sources/gltf.rs index 9c36594..603f5c1 100644 --- a/crates/editor/src/assets/thumbnails/sources/gltf.rs +++ b/crates/editor/src/assets/thumbnails/sources/gltf.rs @@ -1,35 +1,8 @@ -//! glTF/GLB fast-path texture extraction for asset browser thumbnails. +//! glTF/GLB dependency validation and thumbnail source inspection. use std::collections::HashSet; use std::path::Path; -/// Returns an asset-server path for the first base-color texture referenced by a glTF/GLB file. -/// -/// Embedded GLB buffers are skipped so callers can fall back to the render studio. -pub fn gltf_base_color_texture_path(model_asset_path: &str) -> Option { - let model_path = Path::new(model_asset_path); - if !is_gltf_path(model_path) { - return None; - } - - let (document, ..) = gltf::import(model_path).ok()?; - for material in document.materials() { - let Some(tex_info) = material.pbr_metallic_roughness().base_color_texture() else { - continue; - }; - let texture = document.textures().nth(tex_info.texture().index())?; - let image = document.images().nth(texture.source().index())?; - let uri = match image.source() { - gltf::image::Source::Uri { uri, .. } => uri, - gltf::image::Source::View { .. } => continue, - }; - if let Some(path) = resolve_texture_uri(model_path, uri) { - return Some(path); - } - } - None -} - /// Verifies local external buffers and images before Bevy starts an async glTF load. pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> { let model_path = Path::new(model_asset_path); @@ -99,15 +72,6 @@ fn is_gltf_path(path: &Path) -> bool { .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "gltf" | "glb")) } -fn resolve_texture_uri(model_path: &Path, uri: &str) -> Option { - if uri.starts_with("data:") { - return None; - } - let model_dir = model_path.parent()?; - let texture_path = model_dir.join(uri); - normalize_asset_path(&texture_path) -} - fn normalize_asset_path(path: &Path) -> Option { let raw = path.to_string_lossy().replace('\\', "/"); if raw.starts_with("assets/") { @@ -124,7 +88,6 @@ fn normalize_asset_path(path: &Path) -> Option { mod tests { use super::*; use std::fs; - use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; #[test] @@ -135,15 +98,6 @@ mod tests { ); } - #[test] - fn resolve_texture_uri_relative_to_model() { - let model = PathBuf::from("assets/models/chair.gltf"); - assert_eq!( - resolve_texture_uri(&model, "textures/chair_albedo.png"), - Some("assets/models/textures/chair_albedo.png".into()) - ); - } - #[test] fn dependency_validation_reports_missing_external_buffer() { let nonce = SystemTime::now() diff --git a/crates/editor/src/assets/thumbnails/studio.rs b/crates/editor/src/assets/thumbnails/studio.rs index a4d1baa..684514b 100644 --- a/crates/editor/src/assets/thumbnails/studio.rs +++ b/crates/editor/src/assets/thumbnails/studio.rs @@ -129,6 +129,24 @@ impl ThumbnailStudio { active_root: self.active.take().map(|active| active.root), } } + + #[cfg(test)] + pub(crate) fn for_test() -> Self { + Self { + camera: Entity::PLACEHOLDER, + render_image: Handle::default(), + _lights: Vec::new(), + queue: VecDeque::new(), + active: None, + cooldown_frames: 0, + render_pipeline_warmed: false, + } + } + + #[cfg(test)] + pub(crate) fn queued_jobs_for_test(&self) -> Vec { + self.queue.iter().cloned().collect() + } } pub struct StudioCleanup { diff --git a/crates/editor/src/infra.rs b/crates/editor/src/infra.rs index e9560d0..95f7cb4 100644 --- a/crates/editor/src/infra.rs +++ b/crates/editor/src/infra.rs @@ -20,6 +20,37 @@ fn make_editor_only_non_pickable( editor_only: Query>, ) { for entity in &editor_only { - commands.entity(entity).insert(Pickable::IGNORE); + // Editor helpers can be created and retired within the same update (for + // example while a thumbnail studio refreshes). A regular deferred + // insert panics if another system queued the despawn first. + commands.entity(entity).try_insert(Pickable::IGNORE); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn despawn_added_editor_only( + mut commands: Commands, + editor_only: Query>, + ) { + for entity in &editor_only { + commands.entity(entity).despawn(); + } + } + + #[test] + fn same_frame_helper_retirement_does_not_panic_during_pickable_tagging() { + let mut app = App::new(); + app.add_plugins(EditorInfraPlugin).add_systems( + Update, + despawn_added_editor_only.before(make_editor_only_non_pickable), + ); + let helper = app.world_mut().spawn(EditorOnly).id(); + + app.update(); + + assert!(app.world().get_entity(helper).is_err()); } } diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 8453b0a..639787f 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -22,6 +22,7 @@ pub use ext::extensibility; pub use ext::hot_reload; pub use play::net_editor; pub use play::state; +pub use project::asset_documents; pub use project::collaboration; pub use project::diagnostics_bundle; pub use project::launcher; @@ -74,6 +75,7 @@ use operators::OperatorPlugin; use physics_placement::PhysicsPlacementPlugin; use play::audio_preview::AudioPreviewPlugin; use play::PlaySessionPlugin; +use project::asset_documents::AuthoredAssetDocumentsPlugin; use project::collaboration::CollaborationPlugin; use project::native_dialog::NativeDialogPlugin; use project::samples::SampleCatalogPlugin; @@ -108,6 +110,7 @@ impl PluginGroup for EditorPluginGroup { .add(scene_schema::SceneSchemaPlugin) .add(net_editor::NetEditorPlugin) .add(AssetDbPlugin) + .add(AuthoredAssetDocumentsPlugin) .add(OperatorPlugin) .add(ExtensibilityPlugin) .add(SettingsUiPlugin) diff --git a/crates/editor/src/project/asset_documents.rs b/crates/editor/src/project/asset_documents.rs new file mode 100644 index 0000000..d95cb47 --- /dev/null +++ b/crates/editor/src/project/asset_documents.rs @@ -0,0 +1,51 @@ +//! Editor-owned authored asset documents and explicit save boundaries. +//! +//! Interactive UI mutates these documents and live runtime handles only. Source publication, +//! collaboration checks, watcher suppression, and derived processing happen exclusively here. + +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use bevy::prelude::*; +use blacksite_surface::LiveMaterialDocument; +use serde::{Deserialize, Serialize}; +use shared::{ + AssetId, AssetKind, AssetRegistryDocument, MaterialAsset, MaterialInstanceAsset, + ModelImportSettings, ProjectContentDefaults, RuntimeContentCatalog, TextureImportSettings, +}; + +use crate::asset_db::{AssetRecord, AssetRegistry}; +use crate::assets::EditorAssets; +use crate::project::collaboration::{ + publish_authored_file, FileRevision, FileSnapshot, FileWriteIntent, +}; +use crate::project_io::ProjectWorkspace; +use crate::scene::recovery::{atomic_write, default_state_root}; +use crate::scene_io::{SceneIo, SceneIoRequest}; + +mod processing_impact; +pub use processing_impact::DerivedProcessingImpact; +use processing_impact::{instance_impact, material_impact}; + +#[path = "asset_documents/document_store/live_updates.rs"] +mod live_updates; +#[path = "asset_documents/document_store/processing.rs"] +mod processing; +#[path = "asset_documents/document_store/publication.rs"] +mod publication; +#[path = "asset_documents/document_store/recovery.rs"] +mod recovery; +#[path = "asset_documents/document_store/service.rs"] +mod service; +#[path = "asset_documents/document_store/store.rs"] +mod store; + +pub use live_updates::*; +use processing::*; +pub use publication::*; +pub use recovery::*; +pub use service::*; +pub use store::*; diff --git a/crates/editor/src/project/asset_documents/document_store/live_updates.rs b/crates/editor/src/project/asset_documents/document_store/live_updates.rs new file mode 100644 index 0000000..0e68e18 --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/live_updates.rs @@ -0,0 +1,111 @@ +use super::*; + +/// Applies one interactive Material edit to both the authored document and the renderer overlay. +/// No filesystem, watcher, Git, thumbnail, or derived-processing work is performed here. +pub fn update_material_document( + world: &mut World, + key: &AuthoredAssetDocumentKey, + asset: MaterialAsset, +) { + let revision = { + let mut store = world.resource_mut::(); + store.update_material(key, asset.clone()); + store + .documents + .get(key) + .map(|document| document.revision) + .unwrap_or_default() + }; + if let Some(mut overlay) = + world.get_resource_mut::() + { + overlay.update_material(key.asset_id.clone(), revision, asset); + } +} + +/// Applies one interactive Material Instance edit to the same live authority as Materials. +pub fn update_material_instance_document( + world: &mut World, + key: &AuthoredAssetDocumentKey, + instance: MaterialInstanceAsset, +) { + let revision = { + let mut store = world.resource_mut::(); + store.update_material_instance(key, instance.clone()); + store + .documents + .get(key) + .map(|document| document.revision) + .unwrap_or_default() + }; + if let Some(mut overlay) = + world.get_resource_mut::() + { + overlay.update_instance(key.asset_id.clone(), revision, instance); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn material(label: &str) -> MaterialAsset { + MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: label.into(), + shader: Default::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + } + } + + #[test] + fn live_edit_marks_one_document_dirty_without_writing_or_queueing_processing() { + let path = std::env::temp_dir().join(format!( + "blacksite-live-edit-no-publish-{}.material.ron", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, b"disk baseline").unwrap(); + let key = AuthoredAssetDocumentKey::new( + "live-edit-material", + AuthoredAssetDocumentKind::Material, + ); + let mut store = AuthoredAssetDocumentStore::default(); + store.documents.insert( + key.clone(), + AuthoredAssetDocument::new( + key.clone(), + path.to_string_lossy().into_owned(), + AuthoredAssetValue::Material(material("Clean")), + FileSnapshot::capture(&path).unwrap(), + ), + ); + let mut world = World::new(); + world.insert_resource(store); + world.init_resource::(); + world.init_resource::(); + + update_material_document(&mut world, &key, material("Live edit")); + + assert_eq!(std::fs::read(&path).unwrap(), b"disk baseline"); + let snapshot = world + .resource::() + .snapshot(&key) + .unwrap(); + assert!(snapshot.dirty); + assert_eq!(snapshot.state, AuthoredDocumentState::Dirty); + let queue = world.resource::(); + assert!(queue.pending.is_empty()); + assert!(queue.active.is_none()); + assert_eq!( + world + .resource::() + .revision_for_asset(&key.asset_id), + Some(1) + ); + + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/editor/src/project/asset_documents/document_store/processing.rs b/crates/editor/src/project/asset_documents/document_store/processing.rs new file mode 100644 index 0000000..18d5b3b --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/processing.rs @@ -0,0 +1,304 @@ +use super::*; + +pub(super) fn enqueue_processing(world: &mut World, key: &AuthoredAssetDocumentKey, revision: u64) { + let root = project_root(world); + let registry = world + .resource::() + .registry_clean + .clone() + .unwrap_or_else(|| world.resource::().document()); + let request = match key.kind { + AuthoredAssetDocumentKind::Material | AuthoredAssetDocumentKind::MaterialInstance => { + let Some(record) = registry + .records + .iter() + .find(|record| record.id.as_string() == key.asset_id) + .cloned() + else { + return; + }; + ProcessingRequest::Material { + key: key.clone(), + revision, + root, + record, + registry, + } + } + AuthoredAssetDocumentKind::TextureImportSettings => { + let Some(record) = registry + .records + .iter() + .find(|record| record.id.as_string() == key.asset_id) + .cloned() + else { + return; + }; + ProcessingRequest::Texture { + key: key.clone(), + revision, + root, + record, + registry, + } + } + AuthoredAssetDocumentKind::ModelImportSettings => { + let Some(record) = registry + .records + .iter() + .find(|record| record.id.as_string() == key.asset_id) + .cloned() + else { + return; + }; + ProcessingRequest::Model { + key: key.clone(), + revision, + root, + record, + registry, + } + } + AuthoredAssetDocumentKind::ProjectContentDefaults => ProcessingRequest::Defaults { + key: key.clone(), + revision, + root, + registry, + }, + }; + let mut queue = world.resource_mut::(); + queue.pending.retain(|pending| pending.key() != key); + queue.pending.push_back(request); +} + +pub(super) fn drive_content_processing(world: &mut World) { + let completed = { + let mut queue = world.resource_mut::(); + if queue + .active + .as_ref() + .is_some_and(|job| job.handle.is_finished()) + { + queue.active.take() + } else { + None + } + }; + if let Some(job) = completed { + let result = job + .handle + .join() + .unwrap_or_else(|_| Err("content processing worker panicked".into())); + finish_processing(world, job.key, job.revision, result); + } + + let request = { + let mut queue = world.resource_mut::(); + if queue.active.is_none() { + queue.pending.pop_front() + } else { + None + } + }; + let Some(request) = request else { + return; + }; + let key = request.key().clone(); + let revision = request.revision(); + if let Some(document) = world + .resource_mut::() + .documents + .get_mut(&key) + { + document.processing = DerivedProcessingState::Processing; + } + let handle = std::thread::spawn(move || process_request(request)); + world.resource_mut::().active = Some(ActiveProcessingJob { + key, + revision, + handle, + }); +} + +pub(super) fn process_request(request: ProcessingRequest) -> Result { + match request { + ProcessingRequest::Material { + root, + record, + registry, + .. + } => { + let mut runtime = runtime_catalog_with_previous_artifacts(&root, ®istry); + let mut affected = vec![record.clone()]; + if record.kind == AssetKind::Material { + affected.extend(content_pipeline::direct_material_instance_dependents( + &root, ®istry, &record, + )); + } + for affected_record in affected { + if let Some(plan) = + content_pipeline::plan_material_artifact(&root, &affected_record, ®istry)? + { + content_pipeline::publish_material_artifact(&plan)?; + content_pipeline::apply_material_plan_to_catalog( + &mut runtime, + &affected_record.id, + &plan, + )?; + } + } + Ok(ProcessingOutput::RuntimeCatalog(runtime)) + } + ProcessingRequest::Texture { + root, + record, + registry, + .. + } => { + let mut runtime = runtime_catalog_with_previous_artifacts(&root, ®istry); + let plan = content_pipeline::plan_texture_artifact(&root, &record)?; + content_pipeline::publish_texture_artifact(&plan)?; + content_pipeline::apply_texture_plan_to_catalog(&mut runtime, &record.id, &plan)?; + for material in + content_pipeline::material_dependents_of_texture(&root, ®istry, &record) + { + if let Some(plan) = + content_pipeline::plan_material_artifact(&root, &material, ®istry)? + { + content_pipeline::publish_material_artifact(&plan)?; + content_pipeline::apply_material_plan_to_catalog( + &mut runtime, + &material.id, + &plan, + )?; + } + } + Ok(ProcessingOutput::RuntimeCatalog(runtime)) + } + ProcessingRequest::Model { + root, + record, + mut registry, + .. + } => { + let plan = content_pipeline::plan_model_artifacts_at(&root, &record)?; + content_pipeline::publish_model_artifacts(&plan)?; + if let Some(current) = registry + .records + .iter_mut() + .find(|entry| entry.id == record.id) + { + *current = plan.record.clone(); + } + let runtime = runtime_catalog_with_previous_artifacts(&root, ®istry); + Ok(ProcessingOutput::Model { registry, runtime }) + } + ProcessingRequest::Defaults { root, registry, .. } => Ok(ProcessingOutput::RuntimeCatalog( + runtime_catalog_with_previous_artifacts(&root, ®istry), + )), + } +} + +pub(super) fn runtime_catalog_with_previous_artifacts( + root: &Path, + registry: &AssetRegistryDocument, +) -> RuntimeContentCatalog { + let mut runtime = RuntimeContentCatalog::from(registry); + let previous = fs::read_to_string(root.join(content_pipeline::RUNTIME_CATALOG_PATH)) + .ok() + .and_then(|source| ron::from_str::(&source).ok()); + if let Some(previous) = previous { + for record in &mut runtime.records { + if let Some(old) = previous.records.iter().find(|old| old.id == record.id) { + if old.texture.is_some() { + record.texture.clone_from(&old.texture); + } + if old.material.is_some() { + record.material.clone_from(&old.material); + } + } + } + } + runtime +} + +pub(super) fn finish_processing( + world: &mut World, + key: AuthoredAssetDocumentKey, + revision: u64, + result: Result, +) { + let current_revision = world + .resource::() + .documents + .get(&key) + .map(|document| document.revision); + if current_revision != Some(revision) { + return; + } + match result { + Ok(output) => { + let root = project_root(world); + let (runtime, processed_registry) = match output { + ProcessingOutput::RuntimeCatalog(runtime) => (runtime, None), + ProcessingOutput::Model { registry, runtime } => (runtime, Some(registry)), + }; + let catalog_path = root.join(content_pipeline::RUNTIME_CATALOG_PATH); + crate::assets::suppress_content_watch_path(world, &catalog_path); + if processed_registry.is_some() { + crate::assets::suppress_content_watch_path( + world, + &root.join(content_pipeline::REGISTRY_PATH), + ); + } + let publication = (|| { + if let Some(registry) = processed_registry.as_ref() { + let registry_path = root.join(content_pipeline::REGISTRY_PATH); + let bytes = content_pipeline::serialize_registry(registry)?; + content_pipeline::write_if_changed(®istry_path, &bytes)?; + } + let bytes = content_pipeline::serialize_runtime_catalog(&runtime)?; + content_pipeline::write_if_changed(&catalog_path, &bytes).map(|_| ()) + })(); + if publication.is_ok() { + if let Some(registry) = processed_registry.as_ref() { + let registry_path = root.join(content_pipeline::REGISTRY_PATH); + let snapshot = FileSnapshot::capture(®istry_path) + .unwrap_or_else(|_| FileSnapshot::missing()); + { + let mut store = world.resource_mut::(); + store.registry_clean = Some(registry.clone()); + store.registry_snapshot = Some(snapshot); + } + apply_registry_to_world(world, registry); + reapply_unsaved_registry_overlays(world); + } + } + let mut store = world.resource_mut::(); + let document = store + .documents + .get_mut(&key) + .expect("processing document exists"); + match publication { + Ok(()) => { + document.processing = DerivedProcessingState::Idle; + document.error = None; + } + Err(error) => { + document.processing = DerivedProcessingState::Failed; + document.error = Some(error); + } + } + } + Err(error) => { + let mut store = world.resource_mut::(); + if let Some(document) = store.documents.get_mut(&key) { + document.processing = DerivedProcessingState::Failed; + document.error = Some(error.clone()); + } + world + .resource_mut::() + .set_status(format!("Derived asset processing failed: {error}")); + } + } +} diff --git a/crates/editor/src/project/asset_documents/document_store/publication.rs b/crates/editor/src/project/asset_documents/document_store/publication.rs new file mode 100644 index 0000000..a60d51d --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/publication.rs @@ -0,0 +1,456 @@ +use super::*; + +pub fn request_contextual_save(world: &mut World) { + let active_dirty = world + .resource::() + .active + .as_ref() + .and_then(|key| { + world + .resource::() + .documents + .get(key) + }) + .is_some_and(AuthoredAssetDocument::dirty); + if active_dirty { + world + .resource_mut::() + .request_save(AssetSaveRequest::Active); + } else if world + .get_resource::() + .is_some_and(|io| io.dirty) + { + match crate::settings_ui::save_project_settings_if_dirty(world) { + Ok(_) => world + .resource_mut::() + .set_status("Saved project settings"), + Err(error) => world + .resource_mut::() + .set_status(format!("Save project settings failed: {error}")), + } + } else { + world.resource_mut::().request = Some(SceneIoRequest::Save); + } +} + +pub fn request_save_all(world: &mut World) { + if let Err(error) = crate::settings_ui::save_project_settings_if_dirty(world) { + world + .resource_mut::() + .set_status(format!("Save All project settings failed: {error}")); + return; + } + world + .resource_mut::() + .request_save(AssetSaveRequest::All); + crate::scene_io::request_scene_save_all(world); +} + +pub fn save_all_authored_assets(world: &mut World) -> Result { + if !world.contains_resource::() { + return Ok(0); + } + save_documents(world, AssetSaveRequest::All) +} + +pub fn discard_all_authored_assets(world: &mut World) { + if !world.contains_resource::() { + return; + } + let keys = world + .resource::() + .documents + .keys() + .cloned() + .collect::>(); + for key in &keys { + discard_document_recovery(world, key); + } + world + .resource_mut::() + .discard_all(); + let clean_materials = world + .resource::() + .documents + .iter() + .filter_map(|(key, document)| match &document.clean { + AuthoredAssetValue::Material(asset) => Some(( + key.asset_id.clone(), + document.revision, + LiveMaterialDocument::Material(asset.clone()), + )), + AuthoredAssetValue::MaterialInstance(instance) => Some(( + key.asset_id.clone(), + document.revision, + LiveMaterialDocument::Instance(instance.clone()), + )), + _ => None, + }) + .collect::>(); + if let Some(mut overlay) = + world.get_resource_mut::() + { + for (asset_id, revision, document) in clean_materials { + match document { + LiveMaterialDocument::Material(asset) => { + overlay.update_material(asset_id, revision, asset); + } + LiveMaterialDocument::Instance(instance) => { + overlay.update_instance(asset_id, revision, instance); + } + } + } + } + if let Some(clean) = world + .resource::() + .registry_clean + .clone() + { + apply_registry_to_world(world, &clean); + } +} + +pub(super) fn drive_asset_save_requests(world: &mut World) { + let request = world + .resource_mut::() + .pending_save + .take(); + let Some(request) = request else { + return; + }; + match save_documents(world, request) { + Ok(0) => world + .resource_mut::() + .set_status("No dirty asset documents to save"), + Ok(count) => world + .resource_mut::() + .set_status(format!("Saved {count} asset document(s)")), + Err(error) => world.resource_mut::().set_status(error), + } +} + +pub(super) fn save_documents( + world: &mut World, + request: AssetSaveRequest, +) -> Result { + let keys = { + let store = world.resource::(); + match request { + AssetSaveRequest::Active => store + .active + .iter() + .filter(|key| { + store + .documents + .get(*key) + .is_some_and(AuthoredAssetDocument::dirty) + }) + .cloned() + .collect::>(), + AssetSaveRequest::All => store + .documents + .iter() + .filter(|(_, document)| document.dirty()) + .map(|(key, _)| key.clone()) + .collect::>(), + } + }; + if keys.is_empty() { + return Ok(0); + } + + let mut registry_keys = Vec::new(); + let mut saved = 0; + for key in keys { + if matches!( + key.kind, + AuthoredAssetDocumentKind::ModelImportSettings + | AuthoredAssetDocumentKind::TextureImportSettings + | AuthoredAssetDocumentKind::ProjectContentDefaults + ) { + registry_keys.push(key); + continue; + } + save_file_document(world, &key)?; + saved += 1; + } + if !registry_keys.is_empty() { + saved += save_registry_documents(world, ®istry_keys)?; + } + Ok(saved) +} + +pub(super) fn save_file_document( + world: &mut World, + key: &AuthoredAssetDocumentKey, +) -> Result<(), String> { + let (path, clean, value, snapshot, revision) = { + let mut store = world.resource_mut::(); + let document = store + .documents + .get_mut(key) + .ok_or_else(|| "authored asset document disappeared before save".to_string())?; + document.state = AuthoredDocumentState::Saving; + ( + document.path.clone(), + document.clean.clone(), + document.current.clone(), + document.disk_snapshot.clone(), + document.revision, + ) + }; + let processing_impact = derived_processing_impact(&clean, &value); + let (bytes, intent) = match &value { + AuthoredAssetValue::Material(asset) => ( + ron::ser::to_string_pretty(asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize Material {path}: {error}"))? + .into_bytes(), + FileWriteIntent::Material, + ), + AuthoredAssetValue::MaterialInstance(asset) => ( + ron::ser::to_string_pretty(asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize Material Instance {path}: {error}"))? + .into_bytes(), + FileWriteIntent::MaterialInstance, + ), + _ => return Err("registry-backed document used the file-document save path".into()), + }; + crate::assets::suppress_content_watch_path(world, Path::new(&path)); + match publish_authored_file(world, Path::new(&path), &bytes, &snapshot, intent) { + Ok(disk_snapshot) => { + let label = match &value { + AuthoredAssetValue::Material(asset) => asset.label.as_str(), + AuthoredAssetValue::MaterialInstance(asset) => asset.label.as_str(), + _ => "", + } + .to_string(); + { + let mut store = world.resource_mut::(); + let document = store.documents.get_mut(key).expect("saved document exists"); + document.clean = value; + document.disk_snapshot = disk_snapshot; + document.state = AuthoredDocumentState::Clean; + document.processing = if processing_impact == DerivedProcessingImpact::None { + DerivedProcessingState::Idle + } else { + DerivedProcessingState::Queued + }; + document.error = None; + document.changed_at = None; + } + if let Some(asset) = world + .resource_mut::() + .assets + .iter_mut() + .find(|asset| asset.path.as_deref() == Some(path.as_str())) + { + asset.label = label; + } + discard_document_recovery(world, key); + if processing_impact != DerivedProcessingImpact::None { + enqueue_processing(world, key, revision); + } + Ok(()) + } + Err(error) => { + let mut store = world.resource_mut::(); + let document = store + .documents + .get_mut(key) + .expect("failed document exists"); + document.state = if error.contains("changed outside") || error.contains("read-only") { + AuthoredDocumentState::ExternalConflict + } else { + AuthoredDocumentState::SaveFailed + }; + document.error = Some(error.clone()); + Err(error) + } + } +} + +pub(super) fn save_registry_documents( + world: &mut World, + keys: &[AuthoredAssetDocumentKey], +) -> Result { + let root = project_root(world); + let path = root.join(content_pipeline::REGISTRY_PATH); + let (mut document, snapshot) = { + let store = world.resource::(); + ( + store + .registry_clean + .clone() + .unwrap_or_else(|| world.resource::().document()), + store + .registry_snapshot + .clone() + .unwrap_or_else(FileSnapshot::missing), + ) + }; + for key in keys { + let store = world.resource::(); + let authored = store + .documents + .get(key) + .ok_or_else(|| "registry-backed document disappeared before save".to_string())?; + apply_registry_value(&mut document, key, &authored.current)?; + } + let bytes = content_pipeline::serialize_registry(&document)?; + crate::assets::suppress_content_watch_path(world, &path); + let intent = FileWriteIntent::AssetRegistry; + let disk_snapshot = match publish_authored_file(world, &path, &bytes, &snapshot, intent) { + Ok(snapshot) => snapshot, + Err(error) => { + let mut store = world.resource_mut::(); + for key in keys { + if let Some(authored) = store.documents.get_mut(key) { + authored.state = AuthoredDocumentState::ExternalConflict; + authored.error = Some(error.clone()); + } + } + return Err(error); + } + }; + let impacts = { + let mut store = world.resource_mut::(); + let impacts = keys + .iter() + .filter_map(|key| { + store.documents.get(key).map(|document| { + ( + key.clone(), + derived_processing_impact(&document.clean, &document.current), + ) + }) + }) + .collect::>(); + store.registry_clean = Some(document.clone()); + store.registry_snapshot = Some(disk_snapshot.clone()); + for key in keys { + if let Some(authored) = store.documents.get_mut(key) { + authored.clean = authored.current.clone(); + authored.disk_snapshot = disk_snapshot.clone(); + authored.state = AuthoredDocumentState::Clean; + authored.processing = if impacts.get(key).copied().unwrap_or_default() + == DerivedProcessingImpact::None + { + DerivedProcessingState::Idle + } else { + DerivedProcessingState::Queued + }; + authored.error = None; + authored.changed_at = None; + } + } + impacts + }; + apply_registry_to_world(world, &document); + reapply_unsaved_registry_overlays(world); + for key in keys { + discard_document_recovery(world, key); + let revision = world + .resource::() + .documents + .get(key) + .map(|document| document.revision) + .unwrap_or_default(); + if impacts.get(key).copied().unwrap_or_default() != DerivedProcessingImpact::None { + enqueue_processing(world, key, revision); + } + } + Ok(keys.len()) +} + +pub(super) fn derived_processing_impact( + clean: &AuthoredAssetValue, + current: &AuthoredAssetValue, +) -> DerivedProcessingImpact { + match (clean, current) { + (AuthoredAssetValue::Material(clean), AuthoredAssetValue::Material(current)) => { + material_impact(clean, current) + } + ( + AuthoredAssetValue::MaterialInstance(clean), + AuthoredAssetValue::MaterialInstance(current), + ) => instance_impact(clean, current), + ( + AuthoredAssetValue::TextureImportSettings(_), + AuthoredAssetValue::TextureImportSettings(_), + ) => DerivedProcessingImpact::TextureAndDependents, + ( + AuthoredAssetValue::ModelImportSettings(_), + AuthoredAssetValue::ModelImportSettings(_), + ) => DerivedProcessingImpact::ModelArtifacts, + ( + AuthoredAssetValue::ProjectContentDefaults(_), + AuthoredAssetValue::ProjectContentDefaults(_), + ) => DerivedProcessingImpact::CatalogDefaults, + _ => DerivedProcessingImpact::None, + } +} + +pub(super) fn apply_registry_value( + document: &mut AssetRegistryDocument, + key: &AuthoredAssetDocumentKey, + value: &AuthoredAssetValue, +) -> Result<(), String> { + match value { + AuthoredAssetValue::ModelImportSettings(settings) => { + let record = document + .records + .iter_mut() + .find(|record| record.id.as_string() == key.asset_id) + .ok_or_else(|| format!("registry is missing model {}", key.asset_id))?; + record.import_settings = settings.clone().into(); + } + AuthoredAssetValue::TextureImportSettings(settings) => { + let record = document + .records + .iter_mut() + .find(|record| record.id.as_string() == key.asset_id) + .ok_or_else(|| format!("registry is missing Texture {}", key.asset_id))?; + record.import_settings = settings.clone().into(); + } + AuthoredAssetValue::ProjectContentDefaults(defaults) => { + document.defaults = defaults.clone(); + } + _ => return Err("file-backed document used the registry save path".into()), + } + Ok(()) +} + +pub(super) fn apply_registry_to_world(world: &mut World, document: &AssetRegistryDocument) { + { + let mut registry = world.resource_mut::(); + registry.schema_version = document.schema_version; + registry.defaults = document.defaults.clone(); + registry.records = document.records.clone(); + registry.index_dirty = false; + registry.migration_required = false; + } + *world.resource_mut::() = document.defaults.clone(); +} + +pub(super) fn reapply_unsaved_registry_overlays(world: &mut World) { + let overlays = world + .resource::() + .documents + .iter() + .filter(|(key, document)| { + document.dirty() + && matches!( + key.kind, + AuthoredAssetDocumentKind::ModelImportSettings + | AuthoredAssetDocumentKind::TextureImportSettings + | AuthoredAssetDocumentKind::ProjectContentDefaults + ) + }) + .map(|(key, document)| (key.clone(), document.current.clone())) + .collect::>(); + let mut effective = world.resource::().document(); + for (key, value) in overlays { + let _ = apply_registry_value(&mut effective, &key, &value); + } + apply_registry_to_world(world, &effective); +} diff --git a/crates/editor/src/project/asset_documents/document_store/recovery.rs b/crates/editor/src/project/asset_documents/document_store/recovery.rs new file mode 100644 index 0000000..682e42f --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/recovery.rs @@ -0,0 +1,608 @@ +use super::*; + +pub(super) fn tick_asset_recovery(world: &mut World) { + let (ready, stale) = { + let now = Instant::now(); + let store = world.resource::(); + let ready = store + .documents + .values() + .filter(|document| { + document.dirty() + && document.recovery_revision != Some(document.revision) + && document + .changed_at + .is_some_and(|changed| now.duration_since(changed) >= RECOVERY_IDLE) + }) + .map(|document| document.key.clone()) + .collect::>(); + let stale = stale_recovery_keys(store); + (ready, stale) + }; + for key in stale { + if let Err(error) = remove_document_recovery(world, &key) { + if let Some(document) = world + .resource_mut::() + .documents + .get_mut(&key) + { + document.error = Some(format!("Recovery cleanup failed: {error}")); + } + continue; + } + if let Some(document) = world + .resource_mut::() + .documents + .get_mut(&key) + { + document.recovery_revision = None; + } + } + for key in ready { + if let Err(error) = write_document_recovery(world, &key) { + if let Some(document) = world + .resource_mut::() + .documents + .get_mut(&key) + { + document.error = Some(format!("Recovery snapshot failed: {error}")); + } + } + } +} + +fn stale_recovery_keys(store: &AuthoredAssetDocumentStore) -> Vec { + store + .documents + .values() + .filter(|document| !document.dirty() && document.recovery_revision.is_some()) + .map(|document| document.key.clone()) + .collect() +} + +pub(super) fn write_document_recovery( + world: &mut World, + key: &AuthoredAssetDocumentKey, +) -> Result<(), String> { + let root = project_root(world); + let state_root = + default_state_root().ok_or_else(|| "editor state directory is unavailable".to_string())?; + let (envelope, revision) = { + let store = world.resource::(); + let document = store + .documents + .get(key) + .ok_or_else(|| "recovery document is missing".to_string())?; + ( + RecoveryEnvelope { + key: key.clone(), + path: document.path.clone(), + baseline_revision: revision_label(&document.disk_snapshot.revision), + value: document.current.clone(), + }, + document.revision, + ) + }; + let directory = recovery_directory(&state_root, &root, key); + fs::create_dir_all(&directory) + .map_err(|error| format!("could not create {}: {error}", directory.display()))?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let path = directory.join(format!("recovery-{timestamp:020}.ron")); + let bytes = ron::ser::to_string_pretty(&envelope, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize asset recovery: {error}"))?; + atomic_write(&path, bytes.as_bytes())?; + prune_recovery(&directory)?; + if let Some(document) = world + .resource_mut::() + .documents + .get_mut(key) + { + document.recovery_revision = Some(revision); + } + Ok(()) +} + +pub(super) fn discover_asset_recovery(world: &mut World) { + let root = project_root(world); + let Some(state_root) = default_state_root() else { + return; + }; + let base = state_root + .join("asset-recovery") + .join(blake3::hash(normalize_path(&root.to_string_lossy()).as_bytes()).to_hex()); + let Ok(entries) = fs::read_dir(base) else { + return; + }; + let mut recovered = Vec::new(); + for directory in entries.filter_map(Result::ok).map(|entry| entry.path()) { + let Ok(files) = fs::read_dir(directory) else { + continue; + }; + let latest = files + .filter_map(Result::ok) + .map(|entry| entry.path()) + .max_by_key(|path| path.file_name().map(|name| name.to_os_string())); + if let Some(envelope) = latest + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|source| ron::from_str::(&source).ok()) + { + if !recovery_requires_prompt( + load_clean_recovery_value(world, &envelope).as_ref(), + &envelope.value, + ) { + let _ = remove_document_recovery(world, &envelope.key); + continue; + } + recovered.push(envelope); + } + } + if !recovered.is_empty() { + let mut store = world.resource_mut::(); + store.pending_recovery = recovered; + store.recovery_prompt_open = true; + } +} + +fn recovery_requires_prompt( + clean: Option<&AuthoredAssetValue>, + recovered: &AuthoredAssetValue, +) -> bool { + clean != Some(recovered) +} + +pub fn asset_recovery_modal(world: &mut World, ctx: &bevy_egui::egui::Context) { + let open = world + .resource::() + .recovery_prompt_open; + if !open { + return; + } + let pending = world + .resource::() + .pending_recovery + .clone(); + let mut restore = false; + let mut discard = false; + let mut review = false; + let mut restore_one = None; + let mut discard_one = None; + let reviewing = world + .resource::() + .recovery_reviewing; + bevy_egui::egui::Modal::new(bevy_egui::egui::Id::new("asset_document_recovery")).show(ctx, |ui| { + ui.heading("Unsaved asset recovery"); + ui.label("Blacksite found editor-local asset edits that were never written to project source files."); + bevy_egui::egui::ScrollArea::vertical() + .max_height(180.0) + .show(ui, |ui| { + for envelope in &pending { + ui.horizontal(|ui| { + ui.strong(envelope.key.kind.slug()); + ui.monospace(&envelope.path); + if reviewing { + if ui.small_button("Restore").clicked() { + restore_one = Some(envelope.key.clone()); + } + if ui.small_button("Discard").clicked() { + discard_one = Some(envelope.key.clone()); + } + } + }); + } + }); + ui.horizontal(|ui| { + restore = ui.button("Restore All").clicked(); + review = ui.button("Review").clicked(); + discard = ui.button("Discard All").clicked(); + }); + }); + if let Some(key) = restore_one { + restore_pending_recovery_key(world, &key, true); + } else if let Some(key) = discard_one { + restore_pending_recovery_key(world, &key, false); + } else if review { + world + .resource_mut::() + .recovery_reviewing = true; + } else if restore { + restore_pending_recovery(world); + } else if discard { + discard_pending_recovery(world); + } +} + +pub(crate) fn reload_registry_documents_after_file_conflict( + world: &mut World, +) -> Result { + let root = project_root(world); + let path = root.join(content_pipeline::REGISTRY_PATH); + let source = fs::read_to_string(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + let loaded = shared::parse_asset_registry(&source)?; + let snapshot = FileSnapshot::from_loaded_bytes(&path, source.as_bytes()); + let registry_kinds = [ + AuthoredAssetDocumentKind::ModelImportSettings, + AuthoredAssetDocumentKind::TextureImportSettings, + AuthoredAssetDocumentKind::ProjectContentDefaults, + ]; + let keys = world + .resource::() + .documents + .keys() + .filter(|key| registry_kinds.contains(&key.kind)) + .cloned() + .collect::>(); + for key in &keys { + discard_document_recovery(world, key); + } + { + let mut store = world.resource_mut::(); + for key in &keys { + store.documents.remove(key); + } + store + .path_index + .retain(|_, key| !registry_kinds.contains(&key.kind)); + store.registry_clean = Some(loaded.document.clone()); + store.registry_snapshot = Some(snapshot); + } + apply_registry_to_world(world, &loaded.document); + Ok(format!("Reloaded asset registry from {}", path.display())) +} + +pub(crate) fn adopt_external_registry_document( + world: &mut World, + document: &AssetRegistryDocument, +) { + if !world.contains_resource::() { + apply_registry_to_world(world, document); + return; + } + let path = project_root(world).join(content_pipeline::REGISTRY_PATH); + let snapshot = FileSnapshot::capture(&path).unwrap_or_else(|_| FileSnapshot::missing()); + { + let mut store = world.resource_mut::(); + store.registry_clean = Some(document.clone()); + store.registry_snapshot = Some(snapshot); + } + apply_registry_to_world(world, document); + reapply_unsaved_registry_overlays(world); +} + +pub(super) fn restore_pending_recovery(world: &mut World) { + let pending = { + let mut store = world.resource_mut::(); + store.recovery_prompt_open = false; + std::mem::take(&mut store.pending_recovery) + }; + for envelope in pending { + restore_recovery_envelope(world, envelope); + } + reapply_unsaved_registry_overlays(world); +} + +pub(super) fn restore_pending_recovery_key( + world: &mut World, + key: &AuthoredAssetDocumentKey, + restore: bool, +) { + let envelope = { + let mut store = world.resource_mut::(); + let Some(index) = store + .pending_recovery + .iter() + .position(|envelope| &envelope.key == key) + else { + return; + }; + let envelope = store.pending_recovery.remove(index); + if store.pending_recovery.is_empty() { + store.recovery_prompt_open = false; + store.recovery_reviewing = false; + } + envelope + }; + if restore { + restore_recovery_envelope(world, envelope); + reapply_unsaved_registry_overlays(world); + } else { + discard_document_recovery(world, &envelope.key); + } +} + +pub(super) fn restore_recovery_envelope(world: &mut World, envelope: RecoveryEnvelope) { + let snapshot_path = source_path_for_recovery(world, &envelope); + let snapshot = + FileSnapshot::capture(&snapshot_path).unwrap_or_else(|_| FileSnapshot::missing()); + let state = if revision_label(&snapshot.revision) == envelope.baseline_revision { + AuthoredDocumentState::Dirty + } else { + AuthoredDocumentState::ExternalConflict + }; + let clean = + load_clean_recovery_value(world, &envelope).unwrap_or_else(|| envelope.value.clone()); + let mut document = + AuthoredAssetDocument::new(envelope.key.clone(), envelope.path.clone(), clean, snapshot); + document.current = envelope.value; + document.revision = 1; + document.state = state; + document.changed_at = Some(Instant::now()); + if state == AuthoredDocumentState::ExternalConflict { + document.error = Some("Source changed since this recovery snapshot was created".into()); + } + let mut store = world.resource_mut::(); + store + .path_index + .insert(normalize_path(&document.path), envelope.key.clone()); + store.documents.insert(envelope.key.clone(), document); + store.active = Some(envelope.key); +} + +pub(super) fn discard_pending_recovery(world: &mut World) { + let keys = { + let mut store = world.resource_mut::(); + store.recovery_prompt_open = false; + store.recovery_reviewing = false; + std::mem::take(&mut store.pending_recovery) + .into_iter() + .map(|envelope| envelope.key) + .collect::>() + }; + for key in keys { + discard_document_recovery(world, &key); + } +} + +pub(super) fn load_clean_recovery_value( + world: &World, + envelope: &RecoveryEnvelope, +) -> Option { + match envelope.key.kind { + AuthoredAssetDocumentKind::Material => MaterialAsset::load_from_path(&envelope.path) + .ok() + .map(AuthoredAssetValue::Material), + AuthoredAssetDocumentKind::MaterialInstance => { + MaterialInstanceAsset::load_from_path(&envelope.path) + .ok() + .map(AuthoredAssetValue::MaterialInstance) + } + AuthoredAssetDocumentKind::ModelImportSettings => world + .resource::() + .records + .iter() + .find(|record| record.id.as_string() == envelope.key.asset_id) + .map(|record| AuthoredAssetValue::ModelImportSettings(record.model_import().clone())), + AuthoredAssetDocumentKind::TextureImportSettings => world + .resource::() + .records + .iter() + .find(|record| record.id.as_string() == envelope.key.asset_id) + .and_then(|record| record.texture_import().cloned()) + .map(AuthoredAssetValue::TextureImportSettings), + AuthoredAssetDocumentKind::ProjectContentDefaults => { + Some(AuthoredAssetValue::ProjectContentDefaults( + world.resource::().defaults.clone(), + )) + } + } +} + +pub(super) fn source_path_for_recovery(world: &World, envelope: &RecoveryEnvelope) -> PathBuf { + match envelope.key.kind { + AuthoredAssetDocumentKind::Material | AuthoredAssetDocumentKind::MaterialInstance => { + PathBuf::from(&envelope.path) + } + _ => project_root(world).join(content_pipeline::REGISTRY_PATH), + } +} + +pub(super) fn discard_document_recovery(world: &World, key: &AuthoredAssetDocumentKey) { + let _ = remove_document_recovery(world, key); +} + +fn remove_document_recovery(world: &World, key: &AuthoredAssetDocumentKey) -> Result<(), String> { + let Some(state_root) = default_state_root() else { + return Ok(()); + }; + let directory = recovery_directory(&state_root, &project_root(world), key); + match fs::remove_dir_all(&directory) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("could not remove {}: {error}", directory.display())), + } +} + +pub(super) fn recovery_directory( + state_root: &Path, + project_root: &Path, + key: &AuthoredAssetDocumentKey, +) -> PathBuf { + let project = blake3::hash(normalize_path(&project_root.to_string_lossy()).as_bytes()); + state_root + .join("asset-recovery") + .join(project.to_hex()) + .join(format!("{}-{}", key.kind.slug(), key.asset_id)) +} + +pub(super) fn prune_recovery(directory: &Path) -> Result<(), String> { + let mut files = fs::read_dir(directory) + .map_err(|error| format!("could not read {}: {error}", directory.display()))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect::>(); + files.sort(); + let remove = files.len().saturating_sub(RECOVERY_GENERATIONS); + for path in files.into_iter().take(remove) { + fs::remove_file(&path) + .map_err(|error| format!("could not prune {}: {error}", path.display()))?; + } + Ok(()) +} + +pub(super) fn revision_label(revision: &FileRevision) -> String { + match revision { + FileRevision::Missing => "missing".into(), + FileRevision::Present(hash) => hash.iter().map(|byte| format!("{byte:02x}")).collect(), + } +} + +pub(super) fn project_root(world: &World) -> PathBuf { + world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +pub(super) fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn material(label: &str) -> MaterialAsset { + MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: label.into(), + shader: Default::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + } + } + + #[test] + fn dirty_state_is_derived_from_the_clean_document() { + let key = AuthoredAssetDocumentKey::new("material-id", AuthoredAssetDocumentKind::Material); + let mut store = AuthoredAssetDocumentStore::default(); + store.documents.insert( + key.clone(), + AuthoredAssetDocument::new( + key.clone(), + "assets/materials/test.ron".into(), + AuthoredAssetValue::Material(material("Clean")), + FileSnapshot::missing(), + ), + ); + + store.update_material(&key, material("Dirty")); + assert!(store.snapshot(&key).unwrap().dirty); + assert_eq!(store.active_key(), Some(&key)); + + store.update_material(&key, material("Clean")); + assert!(!store.snapshot(&key).unwrap().dirty); + assert_eq!( + store.snapshot(&key).unwrap().state, + AuthoredDocumentState::Clean + ); + assert!(store + .documents + .get(&key) + .is_some_and(|document| document.recovery_revision.is_none())); + } + + #[test] + fn returning_to_clean_marks_an_existing_recovery_for_cleanup() { + let key = AuthoredAssetDocumentKey::new("material-id", AuthoredAssetDocumentKind::Material); + let mut store = AuthoredAssetDocumentStore::default(); + store.documents.insert( + key.clone(), + AuthoredAssetDocument::new( + key.clone(), + "assets/materials/test.ron".into(), + AuthoredAssetValue::Material(material("Clean")), + FileSnapshot::missing(), + ), + ); + + store.update_material(&key, material("Dirty")); + let document = store.documents.get_mut(&key).unwrap(); + document.recovery_revision = Some(document.revision); + store.update_material(&key, material("Clean")); + + assert_eq!(stale_recovery_keys(&store), vec![key]); + } + + #[test] + fn recovery_equal_to_authoritative_source_never_prompts() { + let clean = AuthoredAssetValue::Material(material("Clean")); + let dirty = AuthoredAssetValue::Material(material("Dirty")); + + assert!(!recovery_requires_prompt(Some(&clean), &clean)); + assert!(recovery_requires_prompt(Some(&clean), &dirty)); + assert!(recovery_requires_prompt(None, &dirty)); + } + + #[test] + fn dirty_documents_survive_switching_active_context() { + let first = AuthoredAssetDocumentKey::new("first", AuthoredAssetDocumentKind::Material); + let second = AuthoredAssetDocumentKey::new("second", AuthoredAssetDocumentKind::Material); + let mut store = AuthoredAssetDocumentStore::default(); + for key in [&first, &second] { + store.documents.insert( + key.clone(), + AuthoredAssetDocument::new( + key.clone(), + format!("assets/{key:?}.ron"), + AuthoredAssetValue::Material(material("Clean")), + FileSnapshot::missing(), + ), + ); + } + store.update_material(&first, material("First dirty")); + store.update_material(&second, material("Second dirty")); + + assert_eq!(store.dirty_count(), 2); + assert_eq!(store.active_key(), Some(&second)); + } + + #[test] + fn registry_patch_updates_only_the_selected_record() { + let first_id = AssetId::new(); + let second_id = AssetId::new(); + let mut document = AssetRegistryDocument { + records: vec![ + AssetRecord { + id: first_id.clone(), + path: "assets/first.glb".into(), + label: "First".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: ModelImportSettings::default().into(), + dependencies: Vec::new(), + }, + AssetRecord { + id: second_id.clone(), + path: "assets/second.glb".into(), + label: "Second".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: ModelImportSettings::default().into(), + dependencies: Vec::new(), + }, + ], + ..Default::default() + }; + let changed = ModelImportSettings { + scale: 2.0, + ..Default::default() + }; + apply_registry_value( + &mut document, + &AuthoredAssetDocumentKey::new( + first_id.as_string(), + AuthoredAssetDocumentKind::ModelImportSettings, + ), + &AuthoredAssetValue::ModelImportSettings(changed), + ) + .unwrap(); + + assert_eq!(document.records[0].model_import().scale, 2.0); + assert_eq!(document.records[1].model_import().scale, 1.0); + } +} diff --git a/crates/editor/src/project/asset_documents/document_store/service.rs b/crates/editor/src/project/asset_documents/document_store/service.rs new file mode 100644 index 0000000..74179ce --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/service.rs @@ -0,0 +1,320 @@ +use super::*; + +#[derive(Resource, Default)] +pub struct ContentProcessingQueue { + pub(super) pending: VecDeque, + pub(super) active: Option, +} + +#[derive(Debug, Clone)] +pub(super) enum ProcessingRequest { + Material { + key: AuthoredAssetDocumentKey, + revision: u64, + root: PathBuf, + record: AssetRecord, + registry: AssetRegistryDocument, + }, + Texture { + key: AuthoredAssetDocumentKey, + revision: u64, + root: PathBuf, + record: AssetRecord, + registry: AssetRegistryDocument, + }, + Model { + key: AuthoredAssetDocumentKey, + revision: u64, + root: PathBuf, + record: AssetRecord, + registry: AssetRegistryDocument, + }, + Defaults { + key: AuthoredAssetDocumentKey, + revision: u64, + root: PathBuf, + registry: AssetRegistryDocument, + }, +} + +impl ProcessingRequest { + pub(super) fn key(&self) -> &AuthoredAssetDocumentKey { + match self { + Self::Material { key, .. } + | Self::Texture { key, .. } + | Self::Model { key, .. } + | Self::Defaults { key, .. } => key, + } + } + + pub(super) fn revision(&self) -> u64 { + match self { + Self::Material { revision, .. } + | Self::Texture { revision, .. } + | Self::Model { revision, .. } + | Self::Defaults { revision, .. } => *revision, + } + } +} + +pub(super) struct ActiveProcessingJob { + pub(super) key: AuthoredAssetDocumentKey, + pub(super) revision: u64, + pub(super) handle: JoinHandle>, +} + +pub(super) enum ProcessingOutput { + RuntimeCatalog(RuntimeContentCatalog), + Model { + registry: AssetRegistryDocument, + runtime: RuntimeContentCatalog, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct RecoveryEnvelope { + pub(super) key: AuthoredAssetDocumentKey, + pub(super) path: String, + pub(super) baseline_revision: String, + pub(super) value: AuthoredAssetValue, +} + +pub struct AuthoredAssetDocumentsPlugin; + +impl Plugin for AuthoredAssetDocumentsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Startup, discover_asset_recovery) + .add_systems( + Update, + ( + drive_asset_save_requests, + drive_content_processing, + tick_asset_recovery, + ) + .chain(), + ); + } +} + +pub fn ensure_material_document( + world: &mut World, + path: &str, + fallback_label: &str, +) -> AuthoredAssetDocumentKey { + ensure_file_document(world, path, AuthoredAssetDocumentKind::Material, || { + MaterialAsset::load_from_path(path) + .map(AuthoredAssetValue::Material) + .unwrap_or_else(|_| { + AuthoredAssetValue::Material(MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: fallback_label.to_string(), + shader: Default::default(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + provenance: None, + inputs: shared::MaterialInputSet::default(), + }) + }) + }) +} + +pub fn ensure_material_instance_document( + world: &mut World, + path: &str, + fallback_label: &str, +) -> AuthoredAssetDocumentKey { + ensure_file_document( + world, + path, + AuthoredAssetDocumentKind::MaterialInstance, + || { + MaterialInstanceAsset::load_from_path(path) + .map(AuthoredAssetValue::MaterialInstance) + .unwrap_or_else(|_| { + AuthoredAssetValue::MaterialInstance(MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: fallback_label.to_string(), + base: Default::default(), + overrides: Default::default(), + }) + }) + }, + ) +} + +pub(crate) fn reload_file_document_after_conflict( + world: &mut World, + path: &Path, + kind: AuthoredAssetDocumentKind, +) -> Result { + let normalized = normalize_path(&path.to_string_lossy()); + let key = world + .resource::() + .path_index + .get(&normalized) + .filter(|key| key.kind == kind) + .cloned() + .ok_or_else(|| "the conflicted asset document is no longer open".to_string())?; + discard_document_recovery(world, &key); + { + let mut store = world.resource_mut::(); + store.documents.remove(&key); + store.path_index.remove(&normalized); + if store.active.as_ref() == Some(&key) { + store.active = None; + } + } + if let Some(mut overlay) = + world.get_resource_mut::() + { + overlay.remove(&key.asset_id); + } + if let Some(mut cache) = world.get_resource_mut::() { + cache.invalidate_disk_documents(); + } + Ok(format!("Reloaded {} from disk", path.display())) +} + +pub(crate) fn adopt_material_conflict_save_as( + world: &mut World, + original_path: &Path, + catalog_path: &str, + kind: AuthoredAssetDocumentKind, +) -> Result { + let original = normalize_path(&original_path.to_string_lossy()); + if let Some(key) = world + .resource::() + .path_index + .get(&original) + .filter(|key| key.kind == kind) + .cloned() + { + discard_document_recovery(world, &key); + let mut store = world.resource_mut::(); + store.documents.remove(&key); + store.path_index.remove(&original); + if store.active.as_ref() == Some(&key) { + store.active = None; + } + } + match kind { + AuthoredAssetDocumentKind::Material => { + ensure_material_document(world, catalog_path, "Material copy"); + } + AuthoredAssetDocumentKind::MaterialInstance => { + ensure_material_instance_document(world, catalog_path, "Material Instance copy"); + } + _ => return Err("Save As adoption requires a Material document".into()), + } + Ok(format!("Saved material copy to {catalog_path}")) +} + +pub(super) fn ensure_file_document( + world: &mut World, + path: &str, + kind: AuthoredAssetDocumentKind, + load: impl FnOnce() -> AuthoredAssetValue, +) -> AuthoredAssetDocumentKey { + let normalized = normalize_path(path); + if let Some(key) = world + .resource::() + .path_index + .get(&normalized) + .filter(|key| key.kind == kind) + .cloned() + { + return key; + } + let asset_id = world + .resource::() + .records + .iter() + .find(|record| normalize_path(&record.path) == normalized) + .map(|record| record.id.as_string()) + .unwrap_or_else(|| format!("path:{}", blake3::hash(normalized.as_bytes()).to_hex())); + let key = AuthoredAssetDocumentKey::new(asset_id, kind); + let snapshot = + FileSnapshot::capture(Path::new(path)).unwrap_or_else(|_| FileSnapshot::missing()); + let document = AuthoredAssetDocument::new(key.clone(), normalized.clone(), load(), snapshot); + let mut store = world.resource_mut::(); + store.path_index.insert(normalized, key.clone()); + store.documents.insert(key.clone(), document); + key +} + +pub fn ensure_model_import_document( + world: &mut World, + record: &AssetRecord, +) -> AuthoredAssetDocumentKey { + ensure_registry_document( + world, + record.id.clone(), + &record.path, + AuthoredAssetDocumentKind::ModelImportSettings, + AuthoredAssetValue::ModelImportSettings(record.model_import().clone()), + ) +} + +pub fn ensure_texture_import_document( + world: &mut World, + record: &AssetRecord, +) -> AuthoredAssetDocumentKey { + ensure_registry_document( + world, + record.id.clone(), + &record.path, + AuthoredAssetDocumentKind::TextureImportSettings, + AuthoredAssetValue::TextureImportSettings( + record.texture_import().cloned().unwrap_or_default(), + ), + ) +} + +pub fn ensure_content_defaults_document(world: &mut World) -> AuthoredAssetDocumentKey { + let defaults = world.resource::().defaults.clone(); + ensure_registry_document( + world, + AssetId(uuid::Uuid::nil()), + content_pipeline::REGISTRY_PATH, + AuthoredAssetDocumentKind::ProjectContentDefaults, + AuthoredAssetValue::ProjectContentDefaults(defaults), + ) +} + +pub(super) fn ensure_registry_document( + world: &mut World, + asset_id: AssetId, + logical_path: &str, + kind: AuthoredAssetDocumentKind, + value: AuthoredAssetValue, +) -> AuthoredAssetDocumentKey { + let key = AuthoredAssetDocumentKey::new(asset_id.as_string(), kind); + if world + .resource::() + .documents + .contains_key(&key) + { + return key; + } + let root = project_root(world); + let registry_path = root.join(content_pipeline::REGISTRY_PATH); + let snapshot = + FileSnapshot::capture(®istry_path).unwrap_or_else(|_| FileSnapshot::missing()); + let registry = world.resource::().document(); + let document = AuthoredAssetDocument::new( + key.clone(), + normalize_path(logical_path), + value, + snapshot.clone(), + ); + let mut store = world.resource_mut::(); + store.registry_clean.get_or_insert(registry); + store.registry_snapshot.get_or_insert(snapshot); + store + .path_index + .insert(normalize_path(logical_path), key.clone()); + store.documents.insert(key.clone(), document); + key +} diff --git a/crates/editor/src/project/asset_documents/document_store/store.rs b/crates/editor/src/project/asset_documents/document_store/store.rs new file mode 100644 index 0000000..39eadd6 --- /dev/null +++ b/crates/editor/src/project/asset_documents/document_store/store.rs @@ -0,0 +1,338 @@ +use super::*; + +pub(super) const RECOVERY_IDLE: Duration = Duration::from_secs(2); +pub(super) const RECOVERY_GENERATIONS: usize = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AuthoredAssetDocumentKind { + Material, + MaterialInstance, + ModelImportSettings, + TextureImportSettings, + ProjectContentDefaults, +} + +impl AuthoredAssetDocumentKind { + pub(super) fn slug(self) -> &'static str { + match self { + Self::Material => "material", + Self::MaterialInstance => "material-instance", + Self::ModelImportSettings => "model-import", + Self::TextureImportSettings => "texture-import", + Self::ProjectContentDefaults => "content-defaults", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AuthoredAssetDocumentKey { + pub asset_id: String, + pub kind: AuthoredAssetDocumentKind, +} + +impl AuthoredAssetDocumentKey { + pub(super) fn new(asset_id: impl Into, kind: AuthoredAssetDocumentKind) -> Self { + Self { + asset_id: asset_id.into(), + kind, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AuthoredDocumentState { + #[default] + Clean, + Dirty, + Saving, + ExternalConflict, + SaveFailed, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DerivedProcessingState { + #[default] + Idle, + Queued, + Processing, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) enum AuthoredAssetValue { + Material(MaterialAsset), + MaterialInstance(MaterialInstanceAsset), + ModelImportSettings(ModelImportSettings), + TextureImportSettings(TextureImportSettings), + ProjectContentDefaults(ProjectContentDefaults), +} + +impl PartialEq for AuthoredAssetValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Material(left), Self::Material(right)) => left == right, + (Self::MaterialInstance(left), Self::MaterialInstance(right)) => left == right, + (Self::ModelImportSettings(left), Self::ModelImportSettings(right)) => left == right, + (Self::TextureImportSettings(left), Self::TextureImportSettings(right)) => { + left == right + } + (Self::ProjectContentDefaults(left), Self::ProjectContentDefaults(right)) => { + left == right + } + _ => false, + } + } +} + +#[derive(Debug, Clone)] +pub(super) struct AuthoredAssetDocument { + pub(super) key: AuthoredAssetDocumentKey, + pub(super) path: String, + pub(super) clean: AuthoredAssetValue, + pub(super) current: AuthoredAssetValue, + pub(super) disk_snapshot: FileSnapshot, + pub(super) revision: u64, + pub(super) state: AuthoredDocumentState, + pub(super) processing: DerivedProcessingState, + pub(super) error: Option, + pub(super) changed_at: Option, + pub(super) recovery_revision: Option, +} + +impl AuthoredAssetDocument { + pub(super) fn new( + key: AuthoredAssetDocumentKey, + path: String, + value: AuthoredAssetValue, + disk_snapshot: FileSnapshot, + ) -> Self { + Self { + key, + path, + clean: value.clone(), + current: value, + disk_snapshot, + revision: 0, + state: AuthoredDocumentState::Clean, + processing: DerivedProcessingState::Idle, + error: None, + changed_at: None, + recovery_revision: None, + } + } + + pub(super) fn dirty(&self) -> bool { + self.current != self.clean + } + + pub(super) fn mark_edited(&mut self) { + self.revision = self.revision.wrapping_add(1); + self.state = AuthoredDocumentState::Dirty; + self.error = None; + self.changed_at = Some(Instant::now()); + } + + pub(super) fn refresh_state_from_value(&mut self) { + if self.dirty() { + self.mark_edited(); + } else { + self.state = AuthoredDocumentState::Clean; + self.error = None; + self.changed_at = None; + } + } +} + +#[derive(Debug, Clone)] +pub struct AuthoredDocumentSnapshot { + pub key: AuthoredAssetDocumentKey, + pub path: String, + pub state: AuthoredDocumentState, + pub processing: DerivedProcessingState, + pub dirty: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetSaveRequest { + Active, + All, +} + +#[derive(Resource, Debug, Default)] +pub struct AuthoredAssetDocumentStore { + pub(super) documents: HashMap, + pub(super) path_index: HashMap, + pub(super) active: Option, + pub(super) pending_save: Option, + pub(super) registry_clean: Option, + pub(super) registry_snapshot: Option, + pub(super) pending_recovery: Vec, + pub(super) recovery_prompt_open: bool, + pub(super) recovery_reviewing: bool, +} + +impl AuthoredAssetDocumentStore { + pub fn active_key(&self) -> Option<&AuthoredAssetDocumentKey> { + self.active.as_ref() + } + + pub fn set_active_scene(&mut self) { + self.active = None; + } + + pub fn request_save(&mut self, request: AssetSaveRequest) { + self.pending_save = Some(request); + } + + pub fn dirty_count(&self) -> usize { + self.documents + .values() + .filter(|document| document.dirty()) + .count() + } + + pub fn has_dirty_documents(&self) -> bool { + self.dirty_count() > 0 + } + + pub fn has_pending_or_failed_processing(&self) -> bool { + self.documents.values().any(|document| { + matches!( + document.processing, + DerivedProcessingState::Queued + | DerivedProcessingState::Processing + | DerivedProcessingState::Failed + ) + }) + } + + pub fn snapshot_for_path(&self, path: &str) -> Option { + let key = self.path_index.get(&normalize_path(path))?; + self.snapshot(key) + } + + pub fn snapshot(&self, key: &AuthoredAssetDocumentKey) -> Option { + let document = self.documents.get(key)?; + Some(AuthoredDocumentSnapshot { + key: key.clone(), + path: document.path.clone(), + state: document.state, + processing: document.processing, + dirty: document.dirty(), + error: document.error.clone(), + }) + } + + pub fn material(&self, key: &AuthoredAssetDocumentKey) -> Option<&MaterialAsset> { + match &self.documents.get(key)?.current { + AuthoredAssetValue::Material(asset) => Some(asset), + _ => None, + } + } + + pub fn material_instance( + &self, + key: &AuthoredAssetDocumentKey, + ) -> Option<&MaterialInstanceAsset> { + match &self.documents.get(key)?.current { + AuthoredAssetValue::MaterialInstance(asset) => Some(asset), + _ => None, + } + } + + pub fn model_import(&self, key: &AuthoredAssetDocumentKey) -> Option<&ModelImportSettings> { + match &self.documents.get(key)?.current { + AuthoredAssetValue::ModelImportSettings(settings) => Some(settings), + _ => None, + } + } + + pub fn texture_import(&self, key: &AuthoredAssetDocumentKey) -> Option<&TextureImportSettings> { + match &self.documents.get(key)?.current { + AuthoredAssetValue::TextureImportSettings(settings) => Some(settings), + _ => None, + } + } + + pub fn content_defaults( + &self, + key: &AuthoredAssetDocumentKey, + ) -> Option<&ProjectContentDefaults> { + match &self.documents.get(key)?.current { + AuthoredAssetValue::ProjectContentDefaults(defaults) => Some(defaults), + _ => None, + } + } + + pub fn update_material(&mut self, key: &AuthoredAssetDocumentKey, asset: MaterialAsset) { + self.update_value(key, AuthoredAssetValue::Material(asset)); + } + + pub fn update_material_instance( + &mut self, + key: &AuthoredAssetDocumentKey, + asset: MaterialInstanceAsset, + ) { + self.update_value(key, AuthoredAssetValue::MaterialInstance(asset)); + } + + pub fn update_model_import( + &mut self, + key: &AuthoredAssetDocumentKey, + settings: ModelImportSettings, + ) { + self.update_value(key, AuthoredAssetValue::ModelImportSettings(settings)); + } + + pub fn update_texture_import( + &mut self, + key: &AuthoredAssetDocumentKey, + settings: TextureImportSettings, + ) { + self.update_value(key, AuthoredAssetValue::TextureImportSettings(settings)); + } + + pub fn update_content_defaults( + &mut self, + key: &AuthoredAssetDocumentKey, + defaults: ProjectContentDefaults, + ) { + self.update_value(key, AuthoredAssetValue::ProjectContentDefaults(defaults)); + } + + pub(super) fn update_value( + &mut self, + key: &AuthoredAssetDocumentKey, + value: AuthoredAssetValue, + ) { + let Some(document) = self.documents.get_mut(key) else { + return; + }; + if document.current == value { + self.active = Some(key.clone()); + return; + } + document.current = value; + document.refresh_state_from_value(); + self.active = Some(key.clone()); + } + + pub fn discard(&mut self, key: &AuthoredAssetDocumentKey) -> bool { + let Some(document) = self.documents.get_mut(key) else { + return false; + }; + document.current = document.clean.clone(); + document.state = AuthoredDocumentState::Clean; + document.error = None; + document.changed_at = None; + true + } + + pub fn discard_all(&mut self) { + let keys = self.documents.keys().cloned().collect::>(); + for key in keys { + self.discard(&key); + } + } +} diff --git a/crates/editor/src/project/asset_documents/processing_impact.rs b/crates/editor/src/project/asset_documents/processing_impact.rs new file mode 100644 index 0000000..244a82c --- /dev/null +++ b/crates/editor/src/project/asset_documents/processing_impact.rs @@ -0,0 +1,134 @@ +//! Derived-processing classification for authored document saves. + +use shared::{ + EditorAssetRef, MaterialAsset, MaterialInputSet, MaterialInstanceAsset, TextureChannel, +}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DerivedProcessingImpact { + #[default] + None, + MaterialPacking, + TextureAndDependents, + ModelArtifacts, + CatalogDefaults, +} + +pub fn material_impact(clean: &MaterialAsset, current: &MaterialAsset) -> DerivedProcessingImpact { + if packing_bindings(&clean.inputs) != packing_bindings(¤t.inputs) { + DerivedProcessingImpact::MaterialPacking + } else { + DerivedProcessingImpact::None + } +} + +pub fn instance_impact( + clean: &MaterialInstanceAsset, + current: &MaterialInstanceAsset, +) -> DerivedProcessingImpact { + if clean.base != current.base + || packing_bindings(&clean.overrides) != packing_bindings(¤t.overrides) + { + DerivedProcessingImpact::MaterialPacking + } else { + DerivedProcessingImpact::None + } +} + +fn packing_bindings( + inputs: &MaterialInputSet, +) -> Vec<(String, Option, TextureChannel)> { + ["occlusion", "roughness", "metallic"] + .into_iter() + .map(|name| { + let binding = inputs.texture(name); + ( + name.to_string(), + binding.and_then(|binding| binding.texture.clone()), + binding.map(|binding| binding.channel).unwrap_or_default(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::{MaterialParameter, MaterialParameterValue, MaterialTextureBinding}; + + #[test] + fn scalar_values_do_not_require_material_repacking() { + let clean = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Test".into(), + shader: Default::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let mut current = clean.clone(); + current.inputs.values.push(MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(0.25), + }); + assert_eq!( + material_impact(&clean, ¤t), + DerivedProcessingImpact::None + ); + } + + #[test] + fn render_state_and_color_changes_do_not_require_material_repacking() { + let clean = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Test".into(), + shader: Default::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let mut current = clean.clone(); + current.render_state.double_sided = true; + current.inputs.values.push(MaterialParameter { + name: "base_color".into(), + value: MaterialParameterValue::Color(shared::ColorDesc::srgb(0.2, 0.3, 0.4)), + }); + + assert_eq!( + material_impact(&clean, ¤t), + DerivedProcessingImpact::None + ); + } + + #[test] + fn arm_texture_or_channel_changes_require_one_material_pack() { + let clean = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Test".into(), + shader: Default::default(), + shader_ref: None, + render_state: Default::default(), + provenance: None, + inputs: Default::default(), + }; + let mut current = clean.clone(); + current.inputs.textures.push(MaterialTextureBinding { + name: "roughness".into(), + texture: Some(EditorAssetRef::new("arm", "texture:source", "ARM")), + channel: TextureChannel::G, + }); + assert_eq!( + material_impact(&clean, ¤t), + DerivedProcessingImpact::MaterialPacking + ); + + let mut channel_change = current.clone(); + channel_change.inputs.textures[0].channel = TextureChannel::R; + assert_eq!( + material_impact(¤t, &channel_change), + DerivedProcessingImpact::MaterialPacking + ); + } +} diff --git a/crates/editor/src/project/collaboration.rs b/crates/editor/src/project/collaboration.rs index 120fbe0..de40ba6 100644 --- a/crates/editor/src/project/collaboration.rs +++ b/crates/editor/src/project/collaboration.rs @@ -285,6 +285,7 @@ pub(crate) enum FileWriteIntent { Scene { tab_id: u64, entity_count: usize }, Material, MaterialInstance, + AssetRegistry, ProjectSettings, PrefabSource { instance_root: Entity }, PrefabSourceHistory { instance_root: Entity }, @@ -297,6 +298,7 @@ impl FileWriteIntent { Self::Scene { .. } => "scene", Self::Material => "material", Self::MaterialInstance => "material instance", + Self::AssetRegistry => "asset registry settings", Self::ProjectSettings => "project settings", Self::PrefabSource { .. } => "prefab source", Self::PrefabSourceHistory { .. } => "prefab source history", @@ -452,7 +454,7 @@ pub(crate) fn file_status_indicator_ui( let fill = egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 18); let response = egui::Frame::new() .fill(fill) - .stroke(egui::Stroke::new(1.0, color.linear_multiply(0.55))) + .stroke(egui::Stroke::new(1.0_f32, color.linear_multiply(0.55))) .corner_radius(2.0) .inner_margin(egui::Margin::symmetric(5, 1)) .show(ui, |ui| { @@ -679,7 +681,7 @@ pub(crate) fn file_conflict_modal(world: &mut World, ctx: &egui::Context) { ui.add_space(10.0); egui::Frame::new() .fill(crate::ui::theme::PANEL_BG_DARK) - .stroke(egui::Stroke::new(1.0, crate::ui::theme::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, crate::ui::theme::BORDER)) .corner_radius(3.0) .inner_margin(egui::Margin::same(9)) .show(ui, |ui| { @@ -850,7 +852,16 @@ fn resolve_conflict_reload(world: &mut World, conflict: &PendingFileConflict) { crate::scene_io::reload_scene_after_file_conflict(world, *tab_id, &conflict.path) } FileWriteIntent::Material | FileWriteIntent::MaterialInstance => { - crate::ui::reload_material_after_file_conflict(world, &conflict.path, &conflict.intent) + let kind = match conflict.intent { + FileWriteIntent::Material => { + crate::asset_documents::AuthoredAssetDocumentKind::Material + } + _ => crate::asset_documents::AuthoredAssetDocumentKind::MaterialInstance, + }; + crate::asset_documents::reload_file_document_after_conflict(world, &conflict.path, kind) + } + FileWriteIntent::AssetRegistry => { + crate::asset_documents::reload_registry_documents_after_file_conflict(world) } FileWriteIntent::ProjectSettings => { crate::settings_ui::reload_project_settings_after_file_conflict(world, &conflict.path) @@ -973,14 +984,23 @@ fn finish_conflict_save_as( *entity_count, ), FileWriteIntent::Material | FileWriteIntent::MaterialInstance => { - crate::ui::adopt_material_conflict_save_as( + let kind = match conflict.intent { + FileWriteIntent::Material => { + crate::asset_documents::AuthoredAssetDocumentKind::Material + } + _ => crate::asset_documents::AuthoredAssetDocumentKind::MaterialInstance, + }; + crate::asset_documents::adopt_material_conflict_save_as( world, &conflict.path, - material_catalog_path.expect("validated material path"), - disk_snapshot, - &conflict.intent, + &material_catalog_path.expect("validated material path"), + kind, ) } + FileWriteIntent::AssetRegistry => Ok(format!( + "Saved asset registry recovery copy to {}; active registry and dirty documents unchanged", + destination.display() + )), FileWriteIntent::ProjectSettings => { crate::settings_ui::project_settings_conflict_copy_saved(world); Ok(format!( @@ -1616,8 +1636,11 @@ mod tests { assets: Vec::new(), current_folder: "assets".into(), selected: None, + selections: Vec::new(), + selection_anchor: None, dragging: None, status: String::new(), + catalog_revision: 1, }; let mut world = World::new(); world.insert_resource(workspace); diff --git a/crates/editor/src/project/launcher.rs b/crates/editor/src/project/launcher.rs index c0db77b..d903d47 100644 --- a/crates/editor/src/project/launcher.rs +++ b/crates/editor/src/project/launcher.rs @@ -201,7 +201,7 @@ fn project_launcher_ui(world: &mut World, context: &egui::Context, state: &mut P fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLauncherUi) { egui::Frame::new() .fill(PANEL_BG_DARK) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .inner_margin(egui::Margin::same(16)) .show(ui, |ui| { ui.set_min_height(455.0); @@ -303,7 +303,7 @@ fn recent_projects(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLaun if state.recents.is_empty() { egui::Frame::new() .fill(PANEL_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .inner_margin(egui::Margin::same(16)) .show(ui, |ui| { ui.label(egui::RichText::new("No valid recent projects").color(TEXT)); @@ -322,7 +322,7 @@ fn recent_projects(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLaun for summary in recents { let response = egui::Frame::new() .fill(PANEL_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .inner_margin(egui::Margin::same(14)) .show(ui, |ui| { ui.horizontal(|ui| { @@ -363,7 +363,7 @@ fn recent_projects(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLaun ui.painter().rect_stroke( response.response.rect, 0.0, - egui::Stroke::new(1.0, SELECTION), + egui::Stroke::new(1.0_f32, SELECTION), egui::StrokeKind::Inside, ); } @@ -666,14 +666,7 @@ pub fn scaffold_sandbox_project(destination: &Path, name: &str) -> Result() + .content_defaults(&document_key) + .cloned() + .unwrap_or_default(); + let current = defaults.default_material.clone(); + let materials = world + .resource::() + .records + .iter() + .filter(|record| { + matches!( + record.kind, + shared::AssetKind::Material | shared::AssetKind::MaterialInstance + ) + }) + .cloned() + .collect::>(); + let selected_label = current + .as_ref() + .map(|reference| reference.0.label.as_str()) + .unwrap_or(blacksite_surface::DEFAULT_GRID_LABEL); + let mut next = current.clone(); + ui.collapsing("Content", |ui| { + ui.label("Default material"); + egui::ComboBox::from_id_salt("project_default_material") + .selected_text(selected_label) + .show_ui(ui, |ui| { + if ui + .selectable_label(next.is_none(), blacksite_surface::DEFAULT_GRID_LABEL) + .clicked() + { + next = None; + } + for record in &materials { + let selected = next + .as_ref() + .is_some_and(|reference| reference.0.asset_id == record.id.as_string()); + if ui.selectable_label(selected, &record.label).clicked() { + let sub_asset_id = if record.kind == shared::AssetKind::MaterialInstance { + "material:instance" + } else { + "material:source" + }; + next = Some(shared::MaterialRef::new( + shared::EditorAssetRef::new( + record.id.as_string(), + sub_asset_id, + record.label.clone(), + ) + .with_source_path(record.path.clone()), + )); + } + } + }); + ui.small("Clear restores the immutable engine Default Grid fallback."); + if ui + .add_enabled(next.is_some(), egui::Button::new("Clear")) + .clicked() + { + next = None; + } + }); + if next == current { + return; + } + defaults.default_material = next; + world + .resource_mut::() + .update_content_defaults(&document_key, defaults.clone()); + world + .resource_mut::() + .defaults = defaults.clone(); + *world.resource_mut::() = defaults; +} + fn persist_project_settings( world: &mut World, settings: &ProjectSettings, @@ -458,6 +541,34 @@ fn persist_project_settings( Ok((snapshot, source)) } +pub fn save_project_settings_if_dirty(world: &mut World) -> Result { + if !world + .get_resource::() + .is_some_and(|io| io.dirty) + { + return Ok(false); + } + let settings = world.resource::().clone(); + let expected = { + let io = world.resource::(); + io.loaded_source + .as_deref() + .map(|source| FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes())) + .unwrap_or_else(FileSnapshot::missing) + }; + let (snapshot, source) = persist_project_settings(world, &settings, &expected)?; + { + let mut io = world.resource_mut::(); + io.dirty = false; + io.loaded_source = Some(source); + } + if let Some(mut panel) = world.get_resource_mut::() { + panel.disk_snapshot = Some(snapshot); + panel.save_error = None; + } + Ok(true) +} + fn read_project_settings_document( path: &Path, ) -> Result<(ProjectSettings, String, FileSnapshot), String> { diff --git a/crates/editor/src/project/shutdown.rs b/crates/editor/src/project/shutdown.rs index 0ad9428..66de86a 100644 --- a/crates/editor/src/project/shutdown.rs +++ b/crates/editor/src/project/shutdown.rs @@ -103,6 +103,16 @@ impl ShutdownCoordinator { } } +#[cfg(test)] +pub(crate) fn arm_shutdown_save_for_test(world: &mut World) { + world.init_resource::(); + world + .resource_mut::() + .set_intent_for_test(ShutdownIntent::Saving { + source: ShutdownSource::Programmatic, + }); +} + /// Emitted once when the guarded dialog chooses `Save All`. /// /// Scene persistence owns the actual save sequence and must finish it with @@ -145,7 +155,7 @@ pub fn request_project_switch(world: &mut World) -> bool { /// Resolve the dirty-scene confirmation through the shared coordinator. pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) -> bool { - let (save_request, status) = { + let (save_request, discard_assets, status) = { let Some(mut coordinator) = world.get_resource_mut::() else { return false; }; @@ -157,6 +167,7 @@ pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) coordinator.intent = ShutdownIntent::Saving { source }; ( Some(source), + false, Some("Saving all modified scene tabs before shutdown"), ) } @@ -165,24 +176,37 @@ pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) source, require_clean_scenes: false, }; - (None, None) + (None, true, None) } ShutdownDecision::Cancel => { coordinator.intent = ShutdownIntent::Idle; ( None, + false, Some("Shutdown cancelled; unsaved scene tabs retained"), ) } } }; + if discard_assets { + crate::asset_documents::discard_all_authored_assets(world); + } + if let Some(status) = status { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.set_status(status); } } if let Some(source) = save_request { + if let Err(error) = crate::settings_ui::save_project_settings_if_dirty(world) { + complete_shutdown_save(world, ShutdownSaveOutcome::Failed(error)); + return true; + } + if let Err(error) = crate::asset_documents::save_all_authored_assets(world) { + complete_shutdown_save(world, ShutdownSaveOutcome::Failed(error)); + return true; + } world.write_message(ShutdownSaveAllRequested { source }); } true @@ -191,7 +215,9 @@ pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) /// Finish the save-all handoff. A reported success is authorized only after /// every scene tab is observably clean. pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) -> bool { - let scenes_are_clean = !scene_has_unsaved_changes(world); + let documents_are_clean = !scene_has_unsaved_changes(world) + && !asset_documents_have_unsaved_changes(world) + && !project_settings_have_unsaved_changes(world); let Some(mut coordinator) = world.get_resource_mut::() else { return false; }; @@ -200,7 +226,7 @@ pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) - }; match outcome { - ShutdownSaveOutcome::Saved if scenes_are_clean => { + ShutdownSaveOutcome::Saved if documents_are_clean => { coordinator.intent = ShutdownIntent::Authorized { source, require_clean_scenes: true, @@ -210,8 +236,10 @@ pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) - } ShutdownSaveOutcome::Saved => { coordinator.intent = ShutdownIntent::Idle; - coordinator.last_blocker = - Some("Save All completed while unsaved scene tabs remain".into()); + coordinator.last_blocker = Some( + "Save All completed while unsaved scenes, assets, or project settings remain" + .into(), + ); false } ShutdownSaveOutcome::Cancelled => { @@ -271,7 +299,7 @@ fn drive_shutdown(world: &mut World) { | ShutdownIntent::Saving { .. } | ShutdownIntent::ExitSent { .. } => {} ShutdownIntent::WaitingForBroker { source } => { - let unsaved_count = unsaved_scene_count(world); + let unsaved_count = unsaved_document_count(world); if unsaved_count == 0 { world.resource_mut::().intent = ShutdownIntent::Authorized { source, @@ -289,10 +317,10 @@ fn drive_shutdown(world: &mut World) { "quitting" }; let description = if unsaved_count == 1 { - format!("One scene tab has unsaved changes. Save it before {action}?") + format!("One editor document has unsaved changes. Save it before {action}?") } else { format!( - "{unsaved_count} scene tabs have unsaved changes. Save all before {action}?" + "{unsaved_count} editor documents have unsaved changes. Save all before {action}?" ) }; let title = if source == ShutdownSource::SwitchProject { @@ -339,7 +367,11 @@ pub(crate) fn finalize_authorized_shutdown(world: &mut World) { }; (source, require_clean_scenes) }; - if require_clean_scenes && scene_has_unsaved_changes(world) { + if require_clean_scenes + && (scene_has_unsaved_changes(world) + || asset_documents_have_unsaved_changes(world) + || project_settings_have_unsaved_changes(world)) + { world.resource_mut::().intent = ShutdownIntent::WaitingForBroker { source }; return; @@ -386,6 +418,26 @@ fn unsaved_scene_count(world: &World) -> usize { } } +fn unsaved_document_count(world: &World) -> usize { + unsaved_scene_count(world) + + world + .get_resource::() + .map_or(0, |store| store.dirty_count()) + + usize::from(project_settings_have_unsaved_changes(world)) +} + +fn asset_documents_have_unsaved_changes(world: &World) -> bool { + world + .get_resource::() + .is_some_and(|store| store.has_dirty_documents()) +} + +fn project_settings_have_unsaved_changes(world: &World) -> bool { + world + .get_resource::() + .is_some_and(|io| io.dirty) +} + fn decision_from_dialog(result: rfd::MessageDialogResult) -> ShutdownDecision { match result { rfd::MessageDialogResult::Custom(label) if label == SAVE_ALL_LABEL => { diff --git a/crates/editor/src/render_target.rs b/crates/editor/src/render_target.rs index 994eaf7..cbcba46 100644 --- a/crates/editor/src/render_target.rs +++ b/crates/editor/src/render_target.rs @@ -1,7 +1,7 @@ //! Shared HDR render-to-texture helpers for editor viewport panels. use bevy::prelude::*; -use bevy::render::render_resource::TextureFormat; +use bevy::render::render_resource::{Extent3d, TextureFormat}; /// Offscreen render target displayed by the primary editor viewport. #[derive(Debug, Clone)] @@ -13,16 +13,33 @@ pub struct ViewportTarget { #[derive(Resource, Default, Debug, Clone)] pub struct ViewportRenderTarget(pub Option); -/// Allocates or reuses an HDR offscreen color target when the panel size changes. +/// Allocates or resizes the HDR offscreen color target when the panel size changes. +/// +/// The asset identity remains stable across ordinary panel resizing. Besides avoiding needless +/// camera and bind-group churn, this prevents Egui's strong image registration from retaining one +/// full-resolution HDR target for every splitter movement. pub fn ensure_hdr_panel_target( physical_size: UVec2, last_size: &mut Option, + current: Option<&Handle>, images: &mut Assets, ) -> Option> { if *last_size == Some(physical_size) { return None; } *last_size = Some(physical_size); + + if let Some(current) = current { + if let Some(mut image) = images.get_mut(current) { + image.resize(Extent3d { + width: physical_size.x, + height: physical_size.y, + depth_or_array_layers: 1, + }); + return None; + } + } + Some(images.add(Image::new_target_texture( physical_size.x, physical_size.y, @@ -33,3 +50,37 @@ pub fn ensure_hdr_panel_target( /// Standard HDR format for editor panel render targets. pub const PANEL_TARGET_FORMAT: TextureFormat = TextureFormat::Rgba16Float; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_panel_resize_reuses_one_hdr_image_asset() { + let mut images = Assets::::default(); + let mut last_size = None; + let initial = + ensure_hdr_panel_target(UVec2::new(1920, 1080), &mut last_size, None, &mut images) + .expect("first size allocates the viewport target"); + let initial_id = initial.id(); + + for width in [1600, 1280, 900, 1280, 1600, 1920] { + assert!( + ensure_hdr_panel_target( + UVec2::new(width, 1080), + &mut last_size, + Some(&initial), + &mut images, + ) + .is_none(), + "resize should mutate the existing image" + ); + assert_eq!(initial.id(), initial_id); + assert_eq!( + images.get(&initial).expect("target remains live").width(), + width + ); + assert_eq!(images.len(), 1); + } + } +} diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index d37e4d7..7b481b8 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -24,7 +24,10 @@ use shared::{ AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer, }; -use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS}; +use crate::assets::{ + plan_external_assets_import_at, EditorAssets, ExternalAssetImportPlan, + IMPORTABLE_ASSET_EXTENSIONS, +}; use crate::history::{clear_level_objects, snapshot_entity, EditorHistory}; use crate::native_dialog::NativeDialogBroker; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; @@ -48,7 +51,7 @@ pub enum SceneIoRequest { Open, Save, SaveAs, - ImportAssets, + ImportAssets { destination: String }, ExportSelection, SaveSelectionAsPrefab, SwitchProject, @@ -63,6 +66,15 @@ pub enum SceneIoRequest { ReloadComposition, } +#[derive(Debug, Clone)] +pub struct PlannedAssetImportReview { + pub plan: ExternalAssetImportPlan, + pub previous_status: String, +} + +#[derive(Resource, Debug, Default, Clone)] +pub struct PendingAssetImportReview(pub Option); + #[derive(Debug, Clone)] pub struct SceneTab { pub id: u64, @@ -235,6 +247,7 @@ struct RecoveryClock { #[derive(Resource, Debug, Default)] struct SceneSaveAllTransaction { start_requested: bool, + shutdown_requested: bool, active: Option, } @@ -256,6 +269,7 @@ pub struct SceneIoPlugin; impl Plugin for SceneIoPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -280,9 +294,17 @@ fn receive_shutdown_save_all_requests( ) { if requests.read().next().is_some() { transaction.start_requested = true; + transaction.shutdown_requested = true; } } +/// Save every dirty scene tab without entering the guarded shutdown flow. +pub fn request_scene_save_all(world: &mut World) { + let mut transaction = world.resource_mut::(); + transaction.start_requested = true; + transaction.shutdown_requested = false; +} + fn load_startup_scene(world: &mut World) { let active_path = world.resource::().active_path.clone(); let default_path = PathBuf::from( @@ -353,7 +375,7 @@ fn process_scene_io_requests(world: &mut World) { SceneIoRequest::Save => save_active_or_prompt(world), SceneIoRequest::SaveAs => save_with_dialog(world), SceneIoRequest::Open => open_with_dialog(world), - SceneIoRequest::ImportAssets => import_with_dialog(world), + SceneIoRequest::ImportAssets { destination } => import_with_dialog(world, destination), SceneIoRequest::ExportSelection => export_selection_with_dialog(world), SceneIoRequest::SaveSelectionAsPrefab => save_selection_as_prefab(world), SceneIoRequest::SwitchProject => { @@ -1095,7 +1117,13 @@ fn finish_save_all_without_run(world: &mut World, outcome: ShutdownSaveOutcome) ShutdownSaveOutcome::Cancelled => "Save All cancelled; editor remains open".to_string(), ShutdownSaveOutcome::Failed(error) => format!("Save All failed: {error}"), }; - complete_shutdown_save(world, outcome); + let shutdown_requested = { + let mut transaction = world.resource_mut::(); + std::mem::take(&mut transaction.shutdown_requested) + }; + if shutdown_requested { + complete_shutdown_save(world, outcome); + } world.resource_mut::().set_status(status); } @@ -1411,23 +1439,51 @@ fn open_with_dialog(world: &mut World) -> String { } } -fn import_with_dialog(world: &mut World) -> String { +fn import_with_dialog(world: &mut World, destination: String) -> String { + let destination = if destination == crate::assets::BUILTINS_FOLDER { + crate::assets::ASSETS_ROOT.to_string() + } else { + destination + }; + let previous_status = world.resource::().status.clone(); let request = world.resource::().request( || { rfd::FileDialog::new() .add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS) .pick_files() }, - |world, paths| { + move |world, paths| { let status = match paths { - None => "Import cancelled".to_string(), - Some(paths) => match import_external_assets(&paths) { - Ok(count) => { - world.resource_mut::().refresh(); - format!("Imported {count} asset(s)") + None => previous_status.clone(), + Some(paths) => { + let project_root = world + .resource::() + .root + .clone(); + match plan_external_assets_import_at( + Path::new(&project_root), + &paths, + Path::new(&destination), + ) { + Ok(plan) => { + let count = plan.imported_count(); + let conflicts = plan.conflicts.len(); + world.resource_mut::().0 = + Some(PlannedAssetImportReview { + plan, + previous_status: previous_status.clone(), + }); + if conflicts == 0 { + format!("Review {count} planned asset import(s)") + } else { + format!( + "Review {count} planned import(s) with {conflicts} conflict(s)" + ) + } + } + Err(err) => format!("Import planning failed: {err}"), } - Err(err) => format!("Import failed: {err}"), - }, + } }; world.resource_mut::().set_status(status); }, @@ -2665,17 +2721,22 @@ fn update_window_title( workspace: Res, settings: Res, settings_io: Res, + asset_documents: Res, mut window: Single<&mut Window, With>, ) { if !io.is_changed() && !workspace.is_changed() && !settings.is_changed() && !settings_io.is_changed() + && !asset_documents.is_changed() { return; } window.title = crate::project_io::window_title(&io, &workspace, &settings); + if asset_documents.has_dirty_documents() { + window.title.push_str(" [assets*]"); + } } #[cfg(test)] @@ -2687,7 +2748,6 @@ mod tests { use bevy::world_serialization::{ DynamicWorldRoot, WorldInstanceSpawner, WorldSerializationPlugin, }; - struct NoAssetLoads; #[derive(Component, Reflect, Default)] @@ -2742,12 +2802,10 @@ mod tests { } fn arm_shutdown_save(world: &mut World) { - world.init_resource::(); world - .resource_mut::() - .set_intent_for_test(crate::shutdown::ShutdownIntent::Saving { - source: crate::shutdown::ShutdownSource::Programmatic, - }); + .resource_mut::() + .shutdown_requested = true; + crate::shutdown::arm_shutdown_save_for_test(world); } fn prepare_saved_dirty_checkpoint_scene(app: &mut App, root: &Path) -> PathBuf { diff --git a/crates/editor/src/scene/scene_view.rs b/crates/editor/src/scene/scene_view.rs index 5725d33..966b802 100644 --- a/crates/editor/src/scene/scene_view.rs +++ b/crates/editor/src/scene/scene_view.rs @@ -34,10 +34,15 @@ pub(crate) fn ensure_viewport_render_target( mut last_size: Local>, ) { let Some(physical_size) = panel_physical_size(&window, ui_state.viewport_rect) else { + target.0 = None; + *last_size = None; return; }; - if let Some(handle) = ensure_hdr_panel_target(physical_size, &mut last_size, &mut images) { + let current = target.0.as_ref().map(|target| &target.image); + if let Some(handle) = + ensure_hdr_panel_target(physical_size, &mut last_size, current, &mut images) + { target.0 = Some(ViewportTarget { image: handle }); } } diff --git a/crates/editor/src/ui/actor_inspector/layout.rs b/crates/editor/src/ui/actor_inspector/layout.rs new file mode 100644 index 0000000..974cbb3 --- /dev/null +++ b/crates/editor/src/ui/actor_inspector/layout.rs @@ -0,0 +1,63 @@ +//! Testable Inspector body containment geometry. + +use bevy_egui::egui; + +pub(super) fn configure_component_scroll_style(style: &mut egui::Style) { + let scroll = &mut style.spacing.scroll; + scroll.floating = true; + scroll.bar_width = 8.0; + scroll.floating_width = 3.0; + scroll.floating_allocated_width = 4.0; + scroll.content_margin.right = 4; +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct InspectorBodyLayout { + pub body: egui::Rect, + pub clip: egui::Rect, +} + +impl InspectorBodyLayout { + pub(super) fn contained(available: egui::Rect, parent_clip: egui::Rect) -> Option { + let clip = available.intersect(parent_clip); + (available.is_positive() && clip.is_positive()).then_some(Self { + body: available, + clip, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_component_body_never_paints_above_fixed_header() { + let header_bottom = 96.0; + let available = + egui::Rect::from_min_max(egui::pos2(400.0, header_bottom), egui::pos2(1040.0, 900.0)); + let parent_clip = + egui::Rect::from_min_max(egui::pos2(400.0, 48.0), egui::pos2(1040.0, 860.0)); + let layout = InspectorBodyLayout::contained(available, parent_clip).unwrap(); + assert_eq!(layout.body.top(), header_bottom); + assert!(layout.clip.top() >= header_bottom); + assert_eq!(layout.clip.bottom(), 860.0); + } + + #[test] + fn penpot_component_body_rejects_empty_intersection_after_resize() { + let available = egui::Rect::from_min_max(egui::pos2(0.0, 120.0), egui::pos2(200.0, 200.0)); + let parent_clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 100.0)); + assert!(InspectorBodyLayout::contained(available, parent_clip).is_none()); + } + + #[test] + fn component_scrollbar_keeps_a_stable_content_width_when_overflow_changes() { + let mut style = egui::Style::default(); + configure_component_scroll_style(&mut style); + + assert!(style.spacing.scroll.floating); + assert_eq!(style.spacing.scroll.floating_allocated_width, 4.0); + assert_eq!(style.spacing.scroll.content_margin.right, 4); + } +} diff --git a/crates/editor/src/ui/actor_inspector/mod.rs b/crates/editor/src/ui/actor_inspector/mod.rs index 3d17092..447c64b 100644 --- a/crates/editor/src/ui/actor_inspector/mod.rs +++ b/crates/editor/src/ui/actor_inspector/mod.rs @@ -1,5 +1,6 @@ //! Actor-centric inspector (authoring components only). +mod layout; mod transform; use bevy::prelude::*; @@ -13,11 +14,31 @@ use crate::ui::selection_ops::is_linked_read_only; use crate::ui::theme::{panel_heading, BORDER, SELECTION, TEXT, TEXT_DIM, WIDGET_BG}; use crate::ui::widgets::phosphor_icon; +use layout::{configure_component_scroll_style, InspectorBodyLayout}; use transform::transform_inspector_ui; const COMPACT_ACTOR_HEADER_WIDTH: f32 = 360.0; pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let actor_widget_key = stable_actor_widget_key(world, entity); + ui.push_id(("actor_inspector", actor_widget_key.as_str()), |ui| { + draw_actor_inspector_contents(world, ui, entity, &actor_widget_key); + }); +} + +fn stable_actor_widget_key(world: &World, entity: Entity) -> String { + world + .get::(entity) + .map(|actor_id| format!("actor:{}", actor_id.0)) + .unwrap_or_else(|| format!("runtime:{entity:?}")) +} + +fn draw_actor_inspector_contents( + world: &mut World, + ui: &mut egui::Ui, + entity: Entity, + actor_widget_key: &str, +) { let linked_read_only = is_linked_read_only(world, entity); let prefab_member = world.get::(entity).is_some(); let Some(entity_ref) = world.get_entity(entity).ok() else { @@ -35,7 +56,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity egui::Frame::new() .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::symmetric(8, 8)) .show(ui, |ui| { @@ -94,48 +115,73 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity } if prefab_member { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - crate::assets::prefab_overrides::prefab_member_inspector_ui(world, ui, entity); - }); + component_scroll_region(ui, actor_widget_key, true, |ui| { + crate::assets::prefab_overrides::prefab_member_inspector_ui(world, ui, entity); + }); return; } - ui.add_enabled_ui(!linked_read_only, |ui| { + component_scroll_region(ui, actor_widget_key, !linked_read_only, |ui| { + transform_inspector_ui(world, ui, entity); + + match kind { + ActorKind::StaticMesh + | ActorKind::SkinnedMesh + | ActorKind::Brush + | ActorKind::Terrain + | ActorKind::ImportedModel + | ActorKind::Light + | ActorKind::Empty + | ActorKind::PrefabAnchor + | ActorKind::PlayerSpawn + | ActorKind::WeaponSpawn + | ActorKind::TriggerVolume + | ActorKind::PostProcessVolume + | ActorKind::TeamSpawn + | ActorKind::Objective + | ActorKind::AudioSource + | ActorKind::AudioListener + | ActorKind::Navigation => { + inspector::authoring_inspector_ui(world, ui, entity); + } + } + + world.resource_scope(|world, registry: Mut| { + registry.draw_sections(world, ui, entity); + }); + + inspector::add_component_footer(world, ui, entity); + }); + inspector::property_block_promotion_review_ui(world, ui.ctx()); +} + +fn component_scroll_region( + ui: &mut egui::Ui, + actor_widget_key: &str, + enabled: bool, + add_contents: impl FnOnce(&mut egui::Ui), +) { + let available = ui.available_rect_before_wrap(); + let Some(layout) = InspectorBodyLayout::contained(available, ui.clip_rect()) else { + return; + }; + ui.allocate_rect(layout.body, egui::Sense::hover()); + let mut body = ui.new_child( + egui::UiBuilder::new() + .max_rect(layout.body) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + body.set_clip_rect(layout.clip); + // A solid scrollbar changes the component body's available width when content crosses the + // vertical overflow threshold. That can flip responsive component cards between layouts while + // the user resizes the Inspector. Keep the scrollbar floating and reserve only a stable, small + // interaction rail so every component receives the same width at every height. + configure_component_scroll_style(body.style_mut()); + body.add_enabled_ui(enabled, |ui| { egui::ScrollArea::vertical() + .id_salt(("actor_component_scroll", actor_widget_key)) .auto_shrink([false, false]) - .show(ui, |ui| { - transform_inspector_ui(world, ui, entity); - - match kind { - ActorKind::StaticMesh - | ActorKind::SkinnedMesh - | ActorKind::Brush - | ActorKind::Terrain - | ActorKind::ImportedModel - | ActorKind::Light - | ActorKind::Empty - | ActorKind::PrefabAnchor - | ActorKind::PlayerSpawn - | ActorKind::WeaponSpawn - | ActorKind::TriggerVolume - | ActorKind::PostProcessVolume - | ActorKind::TeamSpawn - | ActorKind::Objective - | ActorKind::AudioSource - | ActorKind::AudioListener - | ActorKind::Navigation => { - inspector::authoring_inspector_ui(world, ui, entity); - } - } - - world.resource_scope(|world, registry: Mut| { - registry.draw_sections(world, ui, entity); - }); - - inspector::add_component_footer(world, ui, entity); - }); + .show(ui, add_contents); }); } @@ -164,7 +210,7 @@ pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities if entities.is_empty() { egui::Frame::new() .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::symmetric(10, 10)) .show(ui, |ui| { @@ -182,7 +228,7 @@ pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities egui::Frame::new() .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::symmetric(10, 10)) .show(ui, |ui| { @@ -203,3 +249,20 @@ pub fn draw_multi_actor_inspector(world: &mut World, ui: &mut egui::Ui, entities }); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspector_widget_identity_survives_runtime_entity_replacement() { + let mut world = World::new(); + let first = world.spawn(ActorId::new("persistent-actor")).id(); + let first_key = stable_actor_widget_key(&world, first); + world.despawn(first); + let replacement = world.spawn(ActorId::new("persistent-actor")).id(); + + assert_eq!(first_key, stable_actor_widget_key(&world, replacement)); + assert_eq!(first_key, "actor:persistent-actor"); + } +} diff --git a/crates/editor/src/ui/actor_inspector/transform.rs b/crates/editor/src/ui/actor_inspector/transform.rs index 7ae8d03..e591abd 100644 --- a/crates/editor/src/ui/actor_inspector/transform.rs +++ b/crates/editor/src/ui/actor_inspector/transform.rs @@ -11,7 +11,10 @@ use crate::ui::inspector::{ }; use crate::ui::theme::TEXT_DIM; -const WIDE_TRANSFORM_ROW_WIDTH: f32 = 360.0; +/// Minimum component-body width that can contain the label and all three fixed axis controls. +/// The Inspector's 420 px dock floor leaves a narrower component body, so it must use the wrapped +/// two-line form instead of clipping the Z field beyond the Inspector edge. +const WIDE_TRANSFORM_ROW_WIDTH: f32 = 440.0; const TRANSFORM_LABEL_WIDTH: f32 = 124.0; const AXIS_FIELD_WIDTH: f32 = 76.0; @@ -22,11 +25,10 @@ pub fn transform_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Enti let old = transform; let mut changed = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::ARROWS_OUT_CARDINAL), - ); + let mut options = + ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::ARROWS_OUT_CARDINAL); + options.summary = "Position · Rotation · Scale"; + let card = component_card_context(world, entity, options); let card_response = component_card(ui, &card, |ui| { if transform_row(ui, "Position", &mut transform.translation, 0.1) { changed = true; @@ -61,7 +63,7 @@ pub fn transform_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Enti fn transform_row(ui: &mut egui::Ui, label: &str, value: &mut Vec3, speed: f64) -> bool { let mut changed = false; - if ui.available_width() < WIDE_TRANSFORM_ROW_WIDTH { + if transform_row_is_compact(ui.available_width()) { ui.vertical(|ui| { ui.label(label); ui.horizontal_wrapped(|ui| { @@ -84,6 +86,10 @@ fn transform_row(ui: &mut egui::Ui, label: &str, value: &mut Vec3, speed: f64) - changed } +fn transform_row_is_compact(width: f32) -> bool { + width + f32::EPSILON < WIDE_TRANSFORM_ROW_WIDTH +} + fn axis_drag(ui: &mut egui::Ui, axis: &str, value: &mut f32, speed: f64) -> bool { ui.horizontal(|ui| { ui.label(egui::RichText::new(axis).color(axis_color(axis)).strong()); @@ -107,3 +113,15 @@ fn axis_color(axis: &str) -> egui::Color32 { _ => TEXT_DIM, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transform_rows_wrap_at_the_inspector_floor_without_clipping_z() { + assert!(transform_row_is_compact(364.0)); + assert!(transform_row_is_compact(420.0)); + assert!(!transform_row_is_compact(584.0)); + } +} diff --git a/crates/editor/src/ui/animation_inspector.rs b/crates/editor/src/ui/animation_inspector.rs index 4c19765..c242b33 100644 --- a/crates/editor/src/ui/animation_inspector.rs +++ b/crates/editor/src/ui/animation_inspector.rs @@ -676,7 +676,7 @@ fn manifest_for_actor(world: &World, entity: Entity) -> Result { - Path(Option<&'a str>), - Reference(Option<&'a EditorAssetRef>), -} - -#[derive(Default)] -struct TextureSlotResponse { - selected: Option, - clear: bool, - accepted_drop: bool, } fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { @@ -139,6 +124,65 @@ fn exact_region( add_contents(&mut child) } +fn exact_region_with_pointer( + ui: &mut egui::Ui, + size: egui::Vec2, + layout: egui::Layout, + add_contents: impl FnOnce(&mut egui::Ui, bool) -> R, +) -> (R, egui::Response) { + let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); + let contains_pointer = ui + .ctx() + .pointer_hover_pos() + .is_some_and(|position| rect.contains(position)); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(layout) + .sense(egui::Sense::click()), + ); + let result = add_contents(&mut child, contains_pointer); + let response = child.response(); + (result, response) +} + +fn details_width_for_layout(available_width: f32, tree_width: f32, requested: f32) -> f32 { + let separator_budget = if tree_width > 0.0 { 10.0 } else { 0.0 }; + let max_width = (available_width + - tree_width + - separator_budget + - DETAILS_RESIZE_HANDLE_WIDTH + - MIN_CONTENT_WIDTH) + .max(DETAILS_MIN_WIDTH); + requested.clamp(DETAILS_MIN_WIDTH, max_width) +} + +fn details_resize_handle(ui: &mut egui::Ui, height: f32) -> egui::Response { + let rect = egui::Rect::from_min_size( + ui.next_widget_position(), + egui::vec2(DETAILS_RESIZE_HANDLE_WIDTH, height), + ); + let response = ui.interact( + rect, + egui::Id::new("asset_details_resize_handle"), + egui::Sense::drag(), + ); + ui.advance_cursor_after_rect(rect); + let stroke_color = if response.hovered() || response.dragged() { + ACCENT + } else { + BORDER + }; + ui.painter().vline( + rect.center().x, + rect.y_range().shrink(4.0), + egui::Stroke::new(2.0_f32, stroke_color), + ); + response + .on_hover_cursor(egui::CursorIcon::ResizeHorizontal) + .on_hover_text("Drag to resize asset details; double-click to reset") +} + pub fn asset_browser_ui( world: &mut World, ui: &mut egui::Ui, @@ -147,12 +191,16 @@ pub fn asset_browser_ui( let available_width = ui.available_width().max(1.0); let available_height = ui.available_height().max(1.0); let body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0); + let browser_interactions_enabled = !content_browser_modal_active(world); exact_region( ui, egui::vec2(available_width, available_height), egui::Layout::top_down(egui::Align::Min), |ui| { + if !browser_interactions_enabled { + ui.disable(); + } exact_region( ui, egui::vec2(available_width, TOOLBAR_HEIGHT), @@ -207,11 +255,12 @@ pub fn asset_browser_ui( 0.0 }; let details_width = if show_details { - DETAILS_WIDTH.min((available_width * 0.28).max(DETAILS_MIN_WIDTH)) + details_width_for_layout(available_width, tree_width, state_snapshot.details_width) } else { 0.0 }; - let separator_budget = (show_tree as u8 as f32 + show_details as u8 as f32) * 10.0; + let separator_budget = (show_tree as u8 as f32) * 10.0 + + (show_details as u8 as f32) * DETAILS_RESIZE_HANDLE_WIDTH; let content_width = (available_width - tree_width - details_width - separator_budget) .max(MIN_CONTENT_WIDTH.min(available_width)); @@ -248,11 +297,11 @@ pub fn asset_browser_ui( ui.separator(); } - exact_region( + let (visible_order, content_response) = exact_region_with_pointer( ui, egui::vec2(content_width, body_height), egui::Layout::top_down(egui::Align::Min), - |ui| { + |ui, content_contains_pointer| { content_header(world, ui, ¤t_folder, &folders, &assets); egui::ScrollArea::vertical() .id_salt("asset_content_area") @@ -266,6 +315,14 @@ pub fn asset_browser_ui( ); let visible_assets = visible_assets(&assets, ¤t_folder, &state_snapshot); + let visible_order = + visible_selection_order(&child_folders, &visible_assets); + handle_content_shortcuts( + world, + ui.ctx(), + &visible_order, + content_contains_pointer, + ); prefetch_asset_row_thumbnails(world, &visible_assets); if child_folders.is_empty() && visible_assets.is_empty() { @@ -283,6 +340,7 @@ pub fn asset_browser_ui( &visible_assets, &cache_snapshot, thumbnail_size, + &visible_order, ); } else { asset_list( @@ -293,14 +351,33 @@ pub fn asset_browser_ui( &child_folders, &visible_assets, &cache_snapshot, + &visible_order, ); } - }); + visible_order + }) + .inner }, ); + content_empty_space(world, content_response, ¤t_folder, &visible_order); if show_details { - ui.separator(); + let resize_response = details_resize_handle(ui, body_height); + let requested_width = if resize_response.double_clicked() { + Some(ASSET_DETAILS_DEFAULT_WIDTH) + } else if resize_response.dragged() { + Some(details_width - resize_response.drag_delta().x) + } else { + None + }; + if let Some(requested_width) = requested_width { + world.resource_mut::().details_width = + details_width_for_layout( + available_width, + tree_width, + requested_width, + ); + } exact_region( ui, egui::vec2(details_width, body_height), @@ -338,4393 +415,65 @@ pub fn asset_browser_ui( ); draw_delete_modal(world, ui.ctx()); -} - -#[derive(Clone)] -struct AssetBrowserStateSnapshot { - search: String, - view: AssetBrowserView, - sort: AssetSort, - kind_filter: AssetKindFilter, - thumbnail_size: f32, - recursive: bool, - show_details: bool, -} - -fn state_snapshot(state: &AssetBrowserUiState) -> AssetBrowserStateSnapshot { - AssetBrowserStateSnapshot { - search: state.search.clone(), - view: state.view, - sort: state.sort, - kind_filter: state.kind_filter, - thumbnail_size: state.thumbnail_size, - recursive: state.recursive, - show_details: state.show_details, - } -} - -fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec { - if !matches!(asset.kind, EditorAssetKind::Model) { - return Vec::new(); - } - let Some(parent_path) = asset.path.as_ref() else { - return Vec::new(); - }; - let Some(record) = world - .get_resource::() - .and_then(|registry| find_asset_by_path(registry, parent_path)) - else { - return Vec::new(); - }; - let mut embedded = Vec::new(); - let use_source_materials = matches!( - record.import_settings.material_policy, - MaterialImportPolicy::SourceMaterials - ); - let static_manifest = record - .import_settings - .static_mesh_manifest_path - .as_deref() - .and_then(|path| load_static_mesh_manifest(path).ok()); - let legacy_skinned_primitives = static_manifest - .as_ref() - .filter(|manifest| manifest.schema_version < 2) - .map(|_| crate::assets::gltf_skinned_primitive_labels(parent_path.as_str())) - .unwrap_or_default(); - if let Some(manifest) = static_manifest.as_ref() { - for part in &manifest.parts { - let mesh_id = part_effective_id(part); - let material_label = part.material_label.clone(); - let requires_skinned_hierarchy = manifest.metadata.animation_count > 0 - || part.skinned - || legacy_skinned_primitives.contains(&part.mesh_label); - embedded.push(EmbeddedAsset { - selection: AssetSelection::SubAsset { - parent_path: parent_path.clone(), - sub_asset_id: mesh_id, - label: part.name.clone(), - kind: AssetSubAssetKind::Mesh, - source_path: None, - }, - label: part.name.clone(), - kind: AssetSubAssetKind::Mesh, - detail: if requires_skinned_hierarchy { - "Skinned | dedicated renderer".to_string() - } else { - part.source_mesh - .clone() - .unwrap_or_else(|| part.mesh_label.clone()) - }, - texture_path: None, - thumbnail_key: subasset_thumbnail_key( - parent_path, - AssetSubAssetKind::Mesh, - &part_effective_id(part), - ), - mesh_label: Some(part.mesh_label.clone()), - material_label, - requires_skinned_hierarchy, - }); - } - - let mut material_ids = HashSet::new(); - for part in &manifest.parts { - let Some(material_id) = part_effective_material_id(part) else { - continue; - }; - if !material_ids.insert(material_id.clone()) { - continue; - } - let label = if part.material_slot_name.trim().is_empty() { - part.material_label - .clone() - .unwrap_or_else(|| "Source Material".to_string()) - } else { - part.material_slot_name.clone() - }; - embedded.push(EmbeddedAsset { - selection: AssetSelection::SubAsset { - parent_path: parent_path.clone(), - sub_asset_id: material_id, - label: label.clone(), - kind: AssetSubAssetKind::Material, - source_path: None, - }, - label, - kind: AssetSubAssetKind::Material, - detail: if use_source_materials { - "Embedded source material".to_string() - } else { - "Source material disabled by Authoring Override".to_string() - }, - texture_path: None, - thumbnail_key: subasset_thumbnail_key( - parent_path, - AssetSubAssetKind::Material, - &part_effective_material_id(part).unwrap_or_else(|| { - material_id_from_label( - part.material_label - .as_deref() - .unwrap_or(&part.material_slot_name), - ) - }), - ), - mesh_label: None, - material_label: part.material_label.clone(), - requires_skinned_hierarchy: false, - }); - } - - let mut texture_paths = HashSet::new(); - for dependency in &manifest.source.dependencies { - if !is_texture_path(dependency) || !texture_paths.insert(dependency.clone()) { - continue; - } - let label = Path::new(dependency) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or(dependency) - .to_string(); - embedded.push(EmbeddedAsset { - selection: AssetSelection::SubAsset { - parent_path: parent_path.clone(), - sub_asset_id: format!("texture:{}", stable_subasset_slug(dependency)), - label: label.clone(), - kind: AssetSubAssetKind::Texture, - source_path: Some(dependency.clone()), - }, - label, - kind: AssetSubAssetKind::Texture, - detail: dependency.clone(), - texture_path: Some(dependency.clone()), - thumbnail_key: subasset_thumbnail_key( - parent_path, - AssetSubAssetKind::Texture, - &stable_subasset_slug(dependency), - ), - mesh_label: None, - material_label: None, - requires_skinned_hierarchy: false, - }); - } - } - - let animation_manifest = record - .import_settings - .animation_manifest_path - .as_deref() - .and_then(|path| load_animation_manifest(path).ok()); - if let Some(manifest) = animation_manifest { - for skeleton in manifest.skeletons { - let signature = skeleton.signature.0; - let signature_short = signature.get(..8).unwrap_or(&signature); - embedded.push(EmbeddedAsset { - selection: AssetSelection::SubAsset { - parent_path: parent_path.clone(), - sub_asset_id: skeleton.id.clone(), - label: skeleton.label.clone(), - kind: AssetSubAssetKind::Skeleton, - source_path: Some(parent_path.clone()), - }, - label: skeleton.label, - kind: AssetSubAssetKind::Skeleton, - detail: format!( - "{} joints | rig {}", - skeleton.joint_paths.len(), - signature_short - ), - texture_path: None, - thumbnail_key: subasset_thumbnail_key( - parent_path, - AssetSubAssetKind::Skeleton, - &skeleton.id, - ), - mesh_label: None, - material_label: None, - requires_skinned_hierarchy: false, - }); - } - for clip in manifest.clips { - let event_suffix = if clip.events.is_empty() { - String::new() - } else { - format!(" | {} events", clip.events.len()) - }; - embedded.push(EmbeddedAsset { - selection: AssetSelection::SubAsset { - parent_path: parent_path.clone(), - sub_asset_id: clip.id.clone(), - label: clip.label.clone(), - kind: AssetSubAssetKind::AnimationClip, - source_path: Some(parent_path.clone()), - }, - label: clip.label, - kind: AssetSubAssetKind::AnimationClip, - detail: format!("{:.2}s{event_suffix}", clip.duration_seconds), - texture_path: None, - thumbnail_key: subasset_thumbnail_key( - parent_path, - AssetSubAssetKind::AnimationClip, - &clip.id, - ), - mesh_label: None, - material_label: None, - requires_skinned_hierarchy: false, - }); - } - } - - embedded -} - -fn embedded_asset_for_selection( - world: &World, - selection: &AssetSelection, -) -> Option { - let AssetSelection::SubAsset { parent_path, .. } = selection else { - return None; - }; - let asset = world - .resource::() - .assets - .iter() - .find(|asset| asset.path.as_deref() == Some(parent_path.as_str()))? - .clone(); - embedded_assets_for_asset(world, &asset) - .into_iter() - .find(|embedded| &embedded.selection == selection) -} - -fn has_embedded_assets(world: &World, asset: &EditorAsset) -> bool { - !embedded_assets_for_asset(world, asset).is_empty() -} - -fn part_effective_id(part: &crate::assets::static_mesh::StaticMeshPart) -> String { - if part.id.trim().is_empty() { - part_id_from_label(&part.mesh_label) - } else { - part.id.clone() - } -} - -fn part_effective_material_id(part: &crate::assets::static_mesh::StaticMeshPart) -> Option { - part.material_id.clone().or_else(|| { - part.material_label - .as_ref() - .map(|label| material_id_from_label(label)) - }) -} - -fn is_texture_path(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - matches!( - extension.to_ascii_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "webp" | "ktx2" - ) - }) -} - -fn stable_subasset_slug(label: &str) -> String { - let mut slug = String::new(); - for ch in label.chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); - } else if !slug.ends_with('_') { - slug.push('_'); - } - } - slug.trim_matches('_').to_string() -} - -fn subasset_thumbnail_key( - parent_path: &str, - kind: AssetSubAssetKind, - sub_asset_id: &str, -) -> String { - format!( - "{}#{}:{}", - parent_path, - subasset_kind_label(kind).to_ascii_lowercase(), - sub_asset_id - ) -} - -fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh").clicked() { - world.resource_mut::().refresh(); - invalidate_on_catalog_refresh(world); - } - if icon_button_small(ui, icons::UPLOAD, "Import assets").clicked() { - world.resource_mut::().request = Some(SceneIoRequest::ImportAssets); - } - if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() { - navigate_to_parent(world); - } - - ui.separator(); - - let mut state = world.resource_mut::(); - ui.add( - egui::TextEdit::singleline(&mut state.search) - .hint_text("Search assets...") - .desired_width(fit_width(ui, 120.0, 190.0)), - ); - ui.checkbox(&mut state.recursive, "Subfolders"); - - egui::ComboBox::from_id_salt("asset_kind_filter") - .selected_text(kind_filter_label(state.kind_filter)) - .show_ui(ui, |ui| { - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::All, "All"); - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Model, "Models"); - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Texture, "Textures"); - ui.selectable_value( - &mut state.kind_filter, - AssetKindFilter::Material, - "Materials", - ); - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Audio, "Audio"); - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels"); - ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs"); - ui.selectable_value( - &mut state.kind_filter, - AssetKindFilter::Builtin, - "Built-ins", - ); - }); - - egui::ComboBox::from_id_salt("asset_sort") - .selected_text(sort_label(state.sort)) - .show_ui(ui, |ui| { - ui.selectable_value(&mut state.sort, AssetSort::Name, "Name"); - ui.selectable_value(&mut state.sort, AssetSort::Kind, "Type"); - ui.selectable_value(&mut state.sort, AssetSort::Modified, "Modified"); - ui.selectable_value(&mut state.sort, AssetSort::Size, "Size"); - }); - - ui.separator(); - if tool_button( - ui, - icons::GRID_FOUR, - state.view == AssetBrowserView::Grid, - "Grid view", - ) - .clicked() - { - state.view = AssetBrowserView::Grid; - } - if tool_button( - ui, - icons::LIST, - state.view == AssetBrowserView::List, - "List view", - ) - .clicked() - { - state.view = AssetBrowserView::List; - } - if ui.available_width() > 180.0 { - ui.label(icon_text(icons::IMAGE_SQUARE, 13.0).color(TEXT_DIM)); - ui.add_sized( - [96.0, 20.0], - egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false), - ); - } - ui.checkbox(&mut state.show_details, "Details"); - }); -} - -fn content_header( - world: &mut World, - ui: &mut egui::Ui, - current_folder: &str, - folders: &[FolderSnapshot], - assets: &[AssetRow], -) { - ui.horizontal_wrapped(|ui| { - ui.label(panel_heading("Assets")); - ui.separator(); - breadcrumb(world, ui, current_folder); - }); - let direct_assets = assets - .iter() - .filter(|row| row.asset.folder_path == current_folder) - .count(); - let direct_folders = folders - .iter() - .filter(|folder| folder.parent.as_deref() == Some(current_folder)) - .count(); - ui.small( - egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets")) - .color(TEXT_DIM), - ); -} - -fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]) { - let requests: Vec<(String, String, EditorAssetKind)> = rows - .iter() - .filter(|row| { - matches!( - row.asset.kind, - EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material - ) - }) - .filter_map(|row| { - Some(( - asset_cache_key(&row.asset), - row.asset.path.clone()?, - row.asset.kind.clone(), - )) - }) - .collect(); - if requests.is_empty() { - return; - } - let asset_server = world.resource::().clone(); - world.resource_scope(|world, mut cache: Mut| { - world.resource_scope(|_world, mut studio: Mut| { - for (key, path, kind) in &requests { - match kind { - EditorAssetKind::Texture => { - cache.request_texture(key.clone(), path.clone(), &asset_server); - } - EditorAssetKind::Model => { - cache.request_model(key.clone(), path.clone(), &asset_server, &mut studio); - } - EditorAssetKind::Material => { - cache.request_material_asset(key.clone(), path.clone(), &mut studio); - } - _ => {} - } - } - }); - }); -} - -fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) { - let mut parts = Vec::new(); - let mut cursor = Some(current_folder.to_string()); - while let Some(path) = cursor { - let label = if path == BUILTINS_FOLDER { - "Built-ins".to_string() - } else { - Path::new(&path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(&path) - .to_string() - }; - cursor = path.rfind('/').map(|index| path[..index].to_string()); - parts.push((path, label)); - } - parts.reverse(); - for (index, (path, label)) in parts.iter().enumerate() { - if index > 0 { - ui.label(egui::RichText::new("/").color(TEXT_DIM)); - } - if ui.link(label).clicked() { - world.resource_mut::().current_folder = path.clone(); - } - } -} - -fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) { - let selection = world.resource::().selected.clone(); - let selected_embedded = selection - .as_ref() - .and_then(|selection| embedded_asset_for_selection(world, selection)); - if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() { - ui.add( - egui::Label::new(format!( - "Selected: {}: {}", - subasset_kind_label(kind), - label - )) - .truncate(), - ); - ui.horizontal_wrapped(|ui| match kind { - AssetSubAssetKind::Mesh => { - if selected_embedded - .as_ref() - .is_some_and(|embedded| embedded.requires_skinned_hierarchy) - { - if ui.button("Place Skinned Model").clicked() { - if let Some(selection) = selection.as_ref() { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } - } else if ui.button("Place At Origin").clicked() { - if let Some(selection) = selection.as_ref() { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } - } - AssetSubAssetKind::Texture => { - if ui.button("Apply Texture To Selection").clicked() { - if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) { - apply_texture_operator(world, asset, selected_entities); - } - } - } - AssetSubAssetKind::Material => { - ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM)); - } - AssetSubAssetKind::Skeleton => { - ui.small(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM)); - } - AssetSubAssetKind::AnimationClip => { - let animated_actor = selected_entities - .as_slice() - .iter() - .copied() - .find(|entity| world.get::(*entity).is_some()); - let action = if animated_actor.is_some() { - "Assign To Selected Actor" - } else { - "Create Animated Actor" - }; - if ui.button(action).clicked() { - if let Some(selection) = selection.as_ref() { - if let Some(entity) = animated_actor { - assign_animation_clip_operator(world, selection.clone(), entity); - } else { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } - } - } - }); - } else if let Some(asset) = world.resource::().selected_asset().cloned() { - ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate()); - ui.horizontal_wrapped(|ui| { - if matches!(asset.kind, EditorAssetKind::Texture) - && ui.button("Apply Texture To Selection").clicked() - { - apply_texture_operator(world, asset.clone(), selected_entities); - } - if matches!( - asset.kind, - EditorAssetKind::Primitive(_) - | EditorAssetKind::Light(_) - | EditorAssetKind::Model - | EditorAssetKind::AudioClip - | EditorAssetKind::Prefab - ) && ui.button("Place At Origin").clicked() - { - place_asset_operator(world, asset.clone(), Vec3::ZERO); - } - if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { - open_level_asset(world, &asset); - } - }); - } else { - ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); - } -} - -fn folder_tree_branch( - world: &mut World, - ui: &mut egui::Ui, - folders: &[FolderSnapshot], - node_path: &str, - depth: usize, - current_folder: &str, -) { - let Some(folder) = folders.iter().find(|folder| folder.path == node_path) else { - return; - }; - let path = &folder.path; - let name = &folder.name; - let children: Vec<&FolderSnapshot> = folders - .iter() - .filter(|child| child.parent.as_deref() == Some(path.as_str())) - .collect(); - let expanded = children.is_empty() - || world - .resource::() - .expanded_folders - .contains(path); - - let selected = current_folder == *path; - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 12.0); - if children.is_empty() { - ui.add_sized( - [18.0, 20.0], - egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)), - ); - } else { - let icon = if expanded { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - if ui - .add_sized( - [18.0, 18.0], - egui::Button::new(icon_text(icon, 12.0)).frame(false), - ) - .clicked() - { - let mut state = world.resource_mut::(); - if expanded { - state.expanded_folders.remove(path); - } else { - state.expanded_folders.insert(path.clone()); - } - } - } - ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); - if ui - .add_sized( - [fit_width(ui, 72.0, f32::INFINITY), 20.0], - egui::Button::selectable(selected, name.as_str()), - ) - .clicked() - { - world.resource_mut::().current_folder = path.clone(); - } - }); - - if expanded { - for child in children { - folder_tree_branch(world, ui, folders, &child.path, depth + 1, current_folder); - } - } -} - -fn child_folders_for_content( - folders: &[FolderSnapshot], - current_folder: &str, - search: &str, -) -> Vec { - if !search.trim().is_empty() { - return Vec::new(); - } - let mut children: Vec = folders - .iter() - .filter(|folder| folder.parent.as_deref() == Some(current_folder)) - .cloned() - .collect(); - children.sort_by(|a, b| natural_cmp(&a.name, &b.name)); - children -} - -fn visible_assets( - assets: &[AssetRow], - current_folder: &str, - state: &AssetBrowserStateSnapshot, -) -> Vec { - let search = state.search.trim().to_ascii_lowercase(); - let mut rows: Vec = assets - .iter() - .filter(|row| { - if state.recursive { - row.asset.folder_path == current_folder - || row - .asset - .folder_path - .strip_prefix(current_folder) - .is_some_and(|rest| rest.starts_with('/')) - } else { - row.asset.folder_path == current_folder - } - }) - .filter(|row| asset_matches_kind_filter(&row.asset, state.kind_filter)) - .filter(|row| { - search.is_empty() - || row.asset.label.to_ascii_lowercase().contains(&search) - || row - .asset - .path - .as_deref() - .unwrap_or_default() - .to_ascii_lowercase() - .contains(&search) - }) - .cloned() - .collect(); - - rows.sort_by(|a, b| match state.sort { - AssetSort::Name => natural_cmp(&a.asset.label, &b.asset.label), - AssetSort::Kind => kind_label(&a.asset.kind).cmp(kind_label(&b.asset.kind)), - AssetSort::Modified => b.modified.cmp(&a.modified), - AssetSort::Size => b.file_size.cmp(&a.file_size), - }); - rows -} - -#[expect( - clippy::too_many_arguments, - reason = "asset grid rendering keeps immediate-mode UI inputs explicit" -)] -fn asset_grid( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - folders: &[FolderSnapshot], - assets: &[AssetRow], - cache_snapshot: &ThumbnailCacheSnapshot, - thumbnail_size: f32, -) { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_width = thumb_size + 20.0; - let gap = 6.0; - let columns = ((ui.available_width() + gap) / (cell_width + gap)) - .floor() - .max(1.0) as usize; - - for folder_row in folders.chunks(columns) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for folder in folder_row { - let response = draw_folder_cell(ui, folder, thumbnail_size); - if response.double_clicked() || response.clicked() { - world.resource_mut::().current_folder = folder.path.clone(); - } - } - }); - } - - for asset_row in assets.chunks(columns) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - for row in asset_row { - draw_asset_grid_item( - world, - ui, - selected_entities, - selected, - row, - cache_snapshot, - thumbnail_size, - ); - } - }); - - for row in asset_row { - if row.asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }) { - embedded_asset_shelf( - world, - ui, - selected_entities, - selected, - &row.asset, - cache_snapshot, - thumbnail_size, - ); - } - } - } -} - -fn draw_asset_grid_item( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - row: &AssetRow, - cache_snapshot: &ThumbnailCacheSnapshot, - thumbnail_size: f32, -) { - let selection = &row.selection; - let asset = &row.asset; - let is_selected = selected.as_ref() == Some(selection); - let failure = cache_snapshot.failure_reason(asset); - let has_children = has_embedded_assets(world, asset); - let response = draw_asset_cell_with( - ui, - asset, - cache_snapshot.texture_for(asset), - cache_snapshot.is_pending(asset), - failure, - is_selected, - thumbnail_size, - ); - - if has_children { - let expanded = asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }); - let icon = if expanded { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - let button_rect = egui::Rect::from_min_size( - response.rect.min + egui::vec2(6.0, 6.0), - egui::vec2(18.0, 18.0), - ); - let expand_response = ui.interact( - button_rect, - egui::Id::new(( - "asset_expand", - asset.path.as_deref().unwrap_or(&asset.label), - )), - egui::Sense::click(), - ); - ui.painter().rect( - button_rect, - 3.0, - if expand_response.hovered() { - ELEVATED_BG - } else { - WIDGET_BG.linear_multiply(1.15) - }, - egui::Stroke::new(1.0, BORDER), - egui::StrokeKind::Inside, - ); - ui.painter().text( - button_rect.center(), - egui::Align2::CENTER_CENTER, - icon.as_str(), - egui::FontId::new(11.0, egui::FontFamily::Name(PHOSPHOR.into())), - TEXT, - ); - if expand_response.clicked() { - if let Some(path) = asset.path.as_ref() { - toggle_asset_expanded(world, path); - } - } - } - - if failure.is_some() && response.double_clicked() { - let key = crate::assets::asset_cache_key(asset); - world - .resource_mut::() - .retry(&key); - } - - if response.clicked() { - world - .resource_mut::() - .select(selection.clone()); - } - if response.drag_started() { - world - .resource_mut::() - .start_drag(selection.clone()); - } - response.context_menu(|ui| { - asset_context_menu(world, ui, asset, selected_entities); - }); -} - -fn embedded_asset_shelf( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - parent_asset: &EditorAsset, - cache_snapshot: &ThumbnailCacheSnapshot, - thumbnail_size: f32, -) { - let embedded = embedded_assets_for_asset(world, parent_asset); - if embedded.is_empty() { - return; - } - prefetch_embedded_thumbnails(world, parent_asset, &embedded); - let parent_texture = cache_snapshot.texture_for(parent_asset); - - ui.add_space(4.0); - egui::Frame::new() - .fill(WIDGET_BG.linear_multiply(0.72)) - .stroke(egui::Stroke::new(1.0, BORDER.linear_multiply(0.85))) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label(icon_text(icons::TREE_STRUCTURE, 13.0).color(TEXT_DIM)); - ui.small( - egui::RichText::new(format!("{} contents", parent_asset.label)).color(TEXT_DIM), - ); - }); - egui::ScrollArea::horizontal() - .id_salt(format!( - "embedded_shelf_{}", - parent_asset.path.as_deref().unwrap_or(&parent_asset.label) - )) - .max_height(thumbnail_size.clamp(48.0, 88.0) + 56.0) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(7.0, 5.0); - for embedded_asset in &embedded { - draw_embedded_asset_cell( - world, - ui, - selected_entities, - selected, - embedded_asset, - cache_snapshot, - parent_texture, - thumbnail_size, - ); - } - }); - }); - }); - ui.add_space(4.0); -} - -fn prefetch_embedded_thumbnails( - world: &mut World, - parent_asset: &EditorAsset, - embedded: &[EmbeddedAsset], -) { - let Some(parent_path) = parent_asset.path.clone() else { - return; - }; - let asset_server = world.resource::().clone(); - let requests: Vec = embedded.to_vec(); - world.resource_scope(|world, mut cache: Mut| { - world.resource_scope(|_world, mut studio: Mut| { - for embedded_asset in &requests { - match embedded_asset.kind { - AssetSubAssetKind::Mesh => { - let Some(mesh_label) = embedded_asset.mesh_label.clone() else { - continue; - }; - cache.request_mesh_subasset( - embedded_asset.thumbnail_key.clone(), - parent_path.clone(), - mesh_label, - embedded_asset.material_label.clone(), - embedded_asset.requires_skinned_hierarchy, - &mut studio, - ); - } - AssetSubAssetKind::Material => { - let Some(material_label) = embedded_asset.material_label.clone() else { - continue; - }; - cache.request_source_material( - embedded_asset.thumbnail_key.clone(), - parent_path.clone(), - material_label, - &mut studio, - ); - } - AssetSubAssetKind::Texture => { - let Some(texture_path) = embedded_asset.texture_path.clone() else { - continue; - }; - cache.request_texture( - embedded_asset.thumbnail_key.clone(), - texture_path, - &asset_server, - ); - } - AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {} - } - } - }); - }); -} - -#[expect( - clippy::too_many_arguments, - reason = "embedded asset rendering keeps immediate-mode UI inputs explicit" -)] -fn draw_embedded_asset_cell( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - embedded: &EmbeddedAsset, - cache_snapshot: &ThumbnailCacheSnapshot, - _parent_texture: Option, - thumbnail_size: f32, -) { - let selected = selected.as_ref() == Some(&embedded.selection); - let texture_id = cache_snapshot - .texture_for_key(&embedded.thumbnail_key) - .or_else(|| { - embedded - .texture_path - .as_deref() - .and_then(|path| thumbnail_for_path(world, cache_snapshot, path)) - }); - let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key); - let failure = cache_snapshot.failure_reason_for_key(&embedded.thumbnail_key); - let failed = failure.is_some(); - let thumb_size = thumbnail_size.clamp(44.0, 64.0); - let cell_size = egui::vec2(104.0, thumb_size + 50.0); - let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); - let response = if embedded.requires_skinned_hierarchy { - response.on_hover_text( - "Skinned primitives place through the dedicated renderer so their joint hierarchy remains intact.", - ) - } else { - response - }; - let response = if let Some(reason) = failure { - response.on_hover_text(reason) - } else { - response - }; - let fill = if selected { - crate::ui::theme::SELECTION_BG_MUTED - } else if response.hovered() { - WIDGET_BG.linear_multiply(1.2) - } else { - WIDGET_BG - }; - ui.painter().rect( - rect, - 4.0, - fill, - egui::Stroke::new(1.0, if selected { ACCENT } else { BORDER }), - egui::StrokeKind::Inside, - ); - let thumb_rect = egui::Rect::from_min_size( - egui::pos2(rect.center().x - thumb_size * 0.5, rect.min.y + 8.0), - egui::vec2(thumb_size, thumb_size), - ); - if let Some(texture_id) = texture_id { - ui.painter().image( - texture_id, - thumb_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } else { - ui.painter().rect( - thumb_rect, - 4.0, - ELEVATED_BG.linear_multiply(0.6), - egui::Stroke::new(1.0, BORDER), - egui::StrokeKind::Inside, - ); - let icon = if pending { - icons::CIRCLE_NOTCH - } else if failed { - icons::WARNING_CIRCLE - } else { - subasset_icon(embedded.kind) - }; - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - icon.as_str(), - egui::FontId::new(24.0, egui::FontFamily::Name(PHOSPHOR.into())), - TEXT, - ); - } - - let label = compact_subasset_label(embedded); - let detail = compact_text(&embedded.detail, 18); - let label_pos = egui::pos2(rect.center().x, thumb_rect.max.y + 7.0); - ui.painter().text( - label_pos, - egui::Align2::CENTER_TOP, - label.as_str(), - egui::FontId::new(12.0, egui::FontFamily::Proportional), - if selected { - crate::ui::theme::TEXT_SELECTED - } else { - TEXT - }, - ); - ui.painter().text( - label_pos + egui::vec2(0.0, 17.0), - egui::Align2::CENTER_TOP, - format!("{} | {}", subasset_kind_label(embedded.kind), detail), - egui::FontId::new(10.0, egui::FontFamily::Proportional), - TEXT_DIM, - ); - - if response.clicked() { - world - .resource_mut::() - .select(embedded.selection.clone()); - } - if response.drag_started() && embedded.kind != AssetSubAssetKind::Skeleton { - world - .resource_mut::() - .start_drag(embedded.selection.clone()); - } - response.context_menu(|ui| { - subasset_context_menu(world, ui, embedded, selected_entities); - }); -} - -fn compact_subasset_label(embedded: &EmbeddedAsset) -> String { - let base = match embedded.kind { - AssetSubAssetKind::Mesh => embedded - .label - .split_once(" / ") - .map(|(head, _)| head) - .unwrap_or(&embedded.label), - AssetSubAssetKind::Material - | AssetSubAssetKind::Texture - | AssetSubAssetKind::Skeleton - | AssetSubAssetKind::AnimationClip => embedded.label.as_str(), - }; - compact_text(base, 16) -} - -fn compact_text(text: &str, max_chars: usize) -> String { - let mut chars = text.chars(); - let mut compact = String::new(); - for _ in 0..max_chars { - let Some(ch) = chars.next() else { - return text.to_string(); - }; - compact.push(ch); - } - if chars.next().is_some() { - compact.push_str("..."); - } - compact -} - -fn thumbnail_for_path( - world: &World, - cache_snapshot: &ThumbnailCacheSnapshot, - path: &str, -) -> Option { - let assets = world.resource::(); - assets - .assets - .iter() - .find(|asset| asset.path.as_deref() == Some(path)) - .and_then(|asset| cache_snapshot.texture_for(asset)) -} - -fn toggle_asset_expanded(world: &mut World, path: &str) { - let mut state = world.resource_mut::(); - if !state.expanded_assets.insert(path.to_string()) { - state.expanded_assets.remove(path); - } -} - -fn subasset_icon(kind: AssetSubAssetKind) -> Icon { - match kind { - AssetSubAssetKind::Mesh => icons::CUBE, - AssetSubAssetKind::Material => icons::PALETTE, - AssetSubAssetKind::Texture => icons::IMAGE, - AssetSubAssetKind::Skeleton => icons::BONE, - AssetSubAssetKind::AnimationClip => icons::FILM_STRIP, - } -} - -fn subasset_kind_label(kind: AssetSubAssetKind) -> &'static str { - match kind { - AssetSubAssetKind::Mesh => "Mesh", - AssetSubAssetKind::Material => "Material", - AssetSubAssetKind::Texture => "Texture", - AssetSubAssetKind::Skeleton => "Skeleton", - AssetSubAssetKind::AnimationClip => "Animation Clip", - } -} - -fn asset_list( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - folders: &[FolderSnapshot], - assets: &[AssetRow], - cache_snapshot: &ThumbnailCacheSnapshot, -) { - if ui.available_width() < COMPACT_LIST_WIDTH { - compact_asset_list( - world, - ui, - selected_entities, - selected, - folders, - assets, - cache_snapshot, - ); - return; - } - - ui.horizontal(|ui| { - ui.add_sized([236.0, 18.0], egui::Label::new("Name")); - ui.add_sized([80.0, 18.0], egui::Label::new("Type")); - ui.add_sized([72.0, 18.0], egui::Label::new("Size")); - ui.add_sized([88.0, 18.0], egui::Label::new("Modified")); - ui.label("Path"); - }); - - for folder in folders { - let response = egui::Frame::new() - .fill(WIDGET_BG.linear_multiply(0.9)) - .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.add_sized([20.0, 20.0], egui::Label::new("")); - ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); - ui.add_sized( - [196.0, 20.0], - egui::Button::selectable(false, folder.name.as_str()), - ) - }) - .inner - }) - .inner; - if response.clicked() { - world.resource_mut::().current_folder = folder.path.clone(); - } - } - - for row in assets { - draw_asset_list_item(world, ui, selected_entities, selected, row); - if row.asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }) { - embedded_asset_shelf( - world, - ui, - selected_entities, - selected, - &row.asset, - cache_snapshot, - 56.0, - ); - } - } -} - -fn draw_asset_list_item( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - row: &AssetRow, -) { - let has_children = has_embedded_assets(world, &row.asset); - let expanded = row.asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }); - let is_selected = selected.as_ref() == Some(&row.selection); - let response = egui::Frame::new() - .fill(if is_selected { - crate::ui::theme::SELECTION_BG_MUTED - } else { - WIDGET_BG.linear_multiply(0.9) - }) - .stroke(egui::Stroke::new( - 1.0, - if is_selected { ACCENT } else { BORDER }, - )) - .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| { - ui.horizontal(|ui| { - if has_children { - let icon = if expanded { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - if ui - .add_sized( - [20.0, 20.0], - egui::Button::new(icon_text(icon, 12.0)).frame(false), - ) - .clicked() - { - if let Some(path) = row.asset.path.as_ref() { - toggle_asset_expanded(world, path); - } - } - } else { - ui.add_sized([20.0, 20.0], egui::Label::new("")); - } - ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); - let response = ui.add_sized( - [196.0, 20.0], - egui::Button::selectable(is_selected, row.asset.label.as_str()), - ); - ui.add_sized([80.0, 20.0], egui::Label::new(kind_label(&row.asset.kind))); - ui.add_sized( - [72.0, 20.0], - egui::Label::new( - row.file_size - .map(format_bytes) - .unwrap_or_else(|| "-".to_string()), - ), - ); - ui.add_sized( - [88.0, 20.0], - egui::Label::new( - row.modified - .map(format_modified) - .unwrap_or_else(|| "-".to_string()), - ), - ); - ui.add( - egui::Label::new(row.asset.path.as_deref().unwrap_or("Built-in")).truncate(), - ); - ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { - asset_context_menu(world, ui, &row.asset, selected_entities); - }); - response - }) - .inner - }) - .inner; - - if response.clicked() { - world - .resource_mut::() - .select(row.selection.clone()); - } - if response.drag_started() { - world - .resource_mut::() - .start_drag(row.selection.clone()); - } - response.context_menu(|ui| { - asset_context_menu(world, ui, &row.asset, selected_entities); - }); -} - -fn compact_asset_list( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selected: &Option, - folders: &[FolderSnapshot], - assets: &[AssetRow], - cache_snapshot: &ThumbnailCacheSnapshot, -) { - for folder in folders { - let mut clicked = false; - ui.horizontal(|ui| { - ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); - clicked = ui - .add_sized( - [fit_width(ui, 120.0, f32::INFINITY), 22.0], - egui::Button::selectable(false, folder.name.as_str()), - ) - .clicked(); - }); - if clicked { - world.resource_mut::().current_folder = folder.path.clone(); - } - ui.small(egui::RichText::new("Folder").color(TEXT_DIM)); - ui.add_space(4.0); - } - - for row in assets { - let is_selected = selected.as_ref() == Some(&row.selection); - let has_children = has_embedded_assets(world, &row.asset); - let expanded = row.asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }); - let mut response = None; - ui.horizontal(|ui| { - if has_children { - let icon = if expanded { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }; - if ui - .add_sized( - [20.0, 20.0], - egui::Button::new(icon_text(icon, 12.0)).frame(false), - ) - .clicked() - { - if let Some(path) = row.asset.path.as_ref() { - toggle_asset_expanded(world, path); - } - } - } - ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); - response = Some(ui.add_sized( - [fit_width(ui, 120.0, f32::INFINITY), 22.0], - egui::Button::selectable(is_selected, row.asset.label.as_str()), - )); - ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { - asset_context_menu(world, ui, &row.asset, selected_entities); - }); - }); - let response = response.expect("compact asset list response"); - let size = row - .file_size - .map(format_bytes) - .unwrap_or_else(|| "-".to_string()); - let modified = row - .modified - .map(format_modified) - .unwrap_or_else(|| "-".to_string()); - let path = row.asset.path.as_deref().unwrap_or("Built-in"); - ui.add( - egui::Label::new( - egui::RichText::new(format!( - "{} | {} | {} | {}", - kind_label(&row.asset.kind), - size, - modified, - path - )) - .color(TEXT_DIM), - ) - .truncate(), - ); - ui.add_space(4.0); - - if response.clicked() { - world - .resource_mut::() - .select(row.selection.clone()); - } - if response.drag_started() { - world - .resource_mut::() - .start_drag(row.selection.clone()); - } - response.context_menu(|ui| { - asset_context_menu(world, ui, &row.asset, selected_entities); - }); - if expanded { - embedded_asset_shelf( - world, - ui, - selected_entities, - selected, - &row.asset, - cache_snapshot, - 56.0, - ); - } - } -} - -fn draw_folder_cell( - ui: &mut egui::Ui, - folder: &FolderSnapshot, - thumbnail_size: f32, -) -> egui::Response { - let thumb_size = thumbnail_size.clamp(48.0, 112.0); - let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0); - let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click()); - let fill = if response.hovered() { - ELEVATED_BG - } else { - WIDGET_BG - }; - ui.painter().rect( - rect, - 4.0, - fill, - egui::Stroke::new(1.0, BORDER), - egui::StrokeKind::Inside, - ); - ui.painter().text( - rect.center_top() + egui::vec2(0.0, 10.0 + thumb_size * 0.45), - egui::Align2::CENTER_CENTER, - icons::FOLDER_SIMPLE.as_str(), - egui::FontId::new( - (thumb_size * 0.48).max(24.0), - egui::FontFamily::Name("phosphor-regular".into()), - ), - TEXT, - ); - ui.painter().text( - egui::pos2(rect.center().x, rect.max.y - 6.0), - egui::Align2::CENTER_BOTTOM, - folder.name.as_str(), - egui::FontId::new(11.0, egui::FontFamily::Proportional), - TEXT, - ); - response -} - -fn details_panel(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) { - ui.label(panel_heading("Details")); - ui.separator(); - let selection = world.resource::().selected.clone(); - if let Some(selection @ AssetSelection::SubAsset { .. }) = selection { - subasset_details_panel(world, ui, selected_entities, &selection); - } else if let Some(asset) = world.resource::().selected_asset().cloned() { - top_level_asset_details_panel(world, ui, selected_entities, &asset); - } else { - ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); - } -} - -pub(crate) fn top_level_asset_details_panel( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - asset: &EditorAsset, -) { - asset_details_header(world, ui, asset); - ui.add_space(8.0); - detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); - detail_row(ui, "Folder", asset.folder_path.as_str()); - - if let Some(path) = asset.path.as_deref() { - if let Some(record) = world - .get_resource::() - .and_then(|registry| find_asset_by_path(registry, path)) - { - detail_row(ui, "Asset ID", &record.id.as_string()); - if matches!(asset.kind, EditorAssetKind::Model) { - ui.separator(); - ui.label(panel_heading("Import Settings")); - import_settings_editor(world, ui, asset, path, &record.import_settings); - if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() - { - detail_row(ui, "Static mesh artifact", manifest); - } - } - if !record.dependencies.is_empty() { - ui.separator(); - let missing_count = record - .dependencies - .iter() - .filter(|dependency| !dependency_path(dependency).is_file()) - .count(); - ui.horizontal_wrapped(|ui| { - ui.label(panel_heading("Dependencies")); - if missing_count > 0 { - let optional = matches!( - record.import_settings.material_policy, - MaterialImportPolicy::AuthoringOverride - ); - ui.small( - egui::RichText::new(if optional { - format!("{missing_count} missing | override") - } else { - format!("{missing_count} missing") - }) - .color(if optional { - WARNING - } else { - ERROR - }), - ); - } - }); - for dep in &record.dependencies { - let exists = dependency_path(dep).is_file(); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(if exists { - icons::CHECK_CIRCLE.as_str() - } else { - icons::WARNING_CIRCLE.as_str() - }) - .font(egui::FontId::new( - 12.0, - egui::FontFamily::Name(PHOSPHOR.into()), - )) - .color(if exists { - SUCCESS - } else { - WARNING - }), - ); - ui.add(egui::Label::new(dep).truncate()).on_hover_text(dep); - }); - } - } - } - detail_row( - ui, - "Size", - file_size(path).map(format_bytes).as_deref().unwrap_or("-"), - ); - detail_row( - ui, - "Modified", - modified_time(path) - .map(format_modified) - .as_deref() - .unwrap_or("-"), - ); - if matches!(asset.kind, EditorAssetKind::Material) { - ui.separator(); - ui.label(panel_heading("Material")); - if MaterialInstanceAsset::load_from_path(path).is_ok() { - material_instance_asset_editor(world, ui, asset, path); - } else { - material_asset_editor(world, ui, asset, path); - } - } - if matches!(asset.kind, EditorAssetKind::AudioClip) { - detail_row(ui, "Format", audio_format_label(path)); - let previewing = world - .resource::() - .clip_path - .as_deref() - == Some(path); - ui.horizontal_wrapped(|ui| { - if ui - .add_enabled( - !previewing, - egui::Button::new(format!("{} Audition", icons::PLAY.as_str())), - ) - .clicked() - { - if let Err(error) = - crate::play::audio_preview::audition_audio_asset(world, asset) - { - world.resource_mut::().status = - format!("Audio audition failed: {error}"); - } - } - if ui - .add_enabled( - previewing, - egui::Button::new(format!("{} Stop", icons::STOP.as_str())), - ) - .clicked() - { - crate::play::audio_preview::stop_audio_preview(world); - } - }); - } - } - - ui.separator(); - asset_action_buttons(world, ui, selected_entities, asset); -} - -fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) { - ui.horizontal_wrapped(|ui| { - ui.label( - egui::RichText::new(kind_icon(&asset.kind).as_str()) - .font(egui::FontId::new( - 30.0, - egui::FontFamily::Name(PHOSPHOR.into()), - )) - .color(ACCENT), - ); - ui.vertical(|ui| { - ui.strong(asset.label.as_str()); - ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); - if let (Some(state), Some(path)) = ( - world.get_resource::(), - asset.path.as_deref(), - ) { - let status = state.file_status(Path::new(path)); - let _ = file_status_indicator_ui(ui, &status, Path::new(path)); - } - }); - }); -} - -fn subasset_details_panel( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - selection: &AssetSelection, -) { - let Some(embedded) = embedded_asset_for_selection(world, selection) else { - ui.colored_label(egui::Color32::YELLOW, "Embedded asset is not available."); - return; - }; - let AssetSelection::SubAsset { - parent_path, - sub_asset_id, - .. - } = selection - else { - return; - }; - ui.horizontal_wrapped(|ui| { - ui.label( - egui::RichText::new(subasset_icon(embedded.kind).as_str()) - .font(egui::FontId::new( - 30.0, - egui::FontFamily::Name(PHOSPHOR.into()), - )) - .color(ACCENT), - ); - ui.vertical(|ui| { - ui.strong(embedded.label.as_str()); - ui.small(egui::RichText::new(subasset_kind_label(embedded.kind)).color(TEXT_DIM)); - }); - }); - ui.add_space(8.0); - detail_row(ui, "Parent", parent_path); - detail_row(ui, "ID", sub_asset_id); - detail_row(ui, "Source", &embedded.detail); - - ui.separator(); - match embedded.kind { - AssetSubAssetKind::Mesh => { - if embedded.requires_skinned_hierarchy { - if ui.button("Place Skinned Model At Origin").clicked() { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } else if ui.button("Place At Origin").clicked() { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } - AssetSubAssetKind::Texture => { - if ui.button("Apply Texture To Selection").clicked() { - if let Some(asset) = - texture_asset_from_subasset_selection(world, &Some(selection.clone())) - { - apply_texture_operator(world, asset, selected_entities); - } - } - } - AssetSubAssetKind::Material => { - let status = if embedded.detail.contains("Authoring Override") { - "Authoring Override is active; this source material is not assigned." - } else { - "Source material is assigned through static mesh renderer slots." - }; - ui.small(egui::RichText::new(status).color(TEXT_DIM)); - } - AssetSubAssetKind::Skeleton => { - ui.small( - egui::RichText::new("Skeleton metadata is generated from the model source.") - .color(TEXT_DIM), - ); - } - AssetSubAssetKind::AnimationClip => { - let animated_actor = selected_entities - .as_slice() - .iter() - .copied() - .find(|entity| world.get::(*entity).is_some()); - let action = if animated_actor.is_some() { - "Assign To Selected Actor" - } else { - "Create Animated Actor" - }; - if ui.button(action).clicked() { - if let Some(entity) = animated_actor { - assign_animation_clip_operator(world, selection.clone(), entity); - } else { - place_subasset_operator(world, selection.clone(), Vec3::ZERO); - } - } - } - } - if ui.button("Select Parent Asset").clicked() { - world - .resource_mut::() - .select(AssetSelection::File(parent_path.clone())); - } -} - -fn dependency_path(reference: &str) -> PathBuf { - let path = Path::new(reference); - if path.starts_with("assets") { - path.to_path_buf() - } else { - Path::new("assets").join(path) - } -} - -fn asset_action_buttons( - world: &mut World, - ui: &mut egui::Ui, - selected_entities: &SelectedEntities, - asset: &EditorAsset, -) { - ui.horizontal_wrapped(|ui| { - if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture").clicked() { - apply_texture_operator(world, asset.clone(), selected_entities); - } - if matches!(asset.kind, EditorAssetKind::Material) && ui.button("Apply Material").clicked() - { - apply_material_operator(world, asset.clone(), selected_entities); - } - if matches!( - asset.kind, - EditorAssetKind::Primitive(_) - | EditorAssetKind::Light(_) - | EditorAssetKind::Model - | EditorAssetKind::AudioClip - | EditorAssetKind::Prefab - ) && ui.button("Place At Origin").clicked() - { - place_asset_operator(world, asset.clone(), Vec3::ZERO); - } - if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { - open_level_asset(world, asset); - } - if matches!(asset.kind, EditorAssetKind::Model) - && has_embedded_assets(world, asset) - && ui.button("Toggle Contents").clicked() - { - if let Some(path) = asset.path.as_ref() { - toggle_asset_expanded(world, path); - } - } - if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { - reimport_asset(world, asset); - } - if asset.path.is_some() && ui.button("Move To Trash").clicked() { - request_delete_for_asset(world, asset); - } - }); -} - -fn open_level_asset(world: &mut World, asset: &EditorAsset) { - let Some(path) = asset.path.as_deref() else { - return; - }; - world.resource_mut::().request = Some(SceneIoRequest::OpenPath(PathBuf::from(path))); -} - -fn import_settings_editor( - world: &mut World, - ui: &mut egui::Ui, - asset: &EditorAsset, - path: &str, - current: &ImportSettings, -) { - let animation_clip_options = current - .animation_manifest_path - .as_deref() - .and_then(|manifest_path| load_animation_manifest(manifest_path).ok()) - .map(|manifest| { - manifest - .clips - .into_iter() - .map(|clip| (clip.id, clip.label)) - .collect::>() - }) - .unwrap_or_default(); - { - let mut state = world.resource_mut::(); - let reset = state - .import_draft - .as_ref() - .is_none_or(|draft| draft.path != path); - if reset { - state.import_draft = Some(ImportSettingsDraft { - path: path.to_string(), - settings: current.clone(), - }); - } - } - - let mut apply = None; - let mut revert = false; - { - let mut state = world.resource_mut::(); - let Some(draft) = state.import_draft.as_mut() else { - return; - }; - ui.add(egui::Slider::new(&mut draft.settings.scale, 0.01..=10.0).text("Scale")); - ui.checkbox(&mut draft.settings.generate_collider, "Generate collider"); - ui.checkbox(&mut draft.settings.lod0_only, "LOD0 only"); - egui::ComboBox::from_id_salt("model_placement_mode") - .selected_text(match draft.settings.placement_mode { - ModelPlacementMode::StaticAsset => "Renderable Asset (Auto)", - ModelPlacementMode::SceneInstance => "Scene Instance", - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut draft.settings.placement_mode, - ModelPlacementMode::StaticAsset, - "Renderable Asset (Auto)", - ); - ui.selectable_value( - &mut draft.settings.placement_mode, - ModelPlacementMode::SceneInstance, - "Scene Instance", - ); - }); - egui::ComboBox::from_id_salt("model_hierarchy_mode") - .selected_text(match draft.settings.hierarchy_mode { - ModelHierarchyMode::SingleActor => "One Actor", - ModelHierarchyMode::SourceHierarchy => "Source Hierarchy", - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut draft.settings.hierarchy_mode, - ModelHierarchyMode::SingleActor, - "One Actor", - ); - ui.selectable_value( - &mut draft.settings.hierarchy_mode, - ModelHierarchyMode::SourceHierarchy, - "Source Hierarchy", - ); - }); - egui::ComboBox::from_id_salt("model_material_policy") - .selected_text(match draft.settings.material_policy { - MaterialImportPolicy::SourceMaterials => "Source Materials", - MaterialImportPolicy::AuthoringOverride => "Authoring Override", - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut draft.settings.material_policy, - MaterialImportPolicy::SourceMaterials, - "Source Materials", - ); - ui.selectable_value( - &mut draft.settings.material_policy, - MaterialImportPolicy::AuthoringOverride, - "Authoring Override", - ); - }); - if !animation_clip_options.is_empty() { - let selected_default = draft - .settings - .default_animation_clip_id - .as_deref() - .and_then(|id| { - animation_clip_options - .iter() - .find(|(clip_id, _)| clip_id == id) - .map(|(_, label)| label.as_str()) - }) - .unwrap_or_else(|| { - if draft.settings.default_animation_clip_id.is_some() { - "Missing clip" - } else { - "Imported rest pose" - } - }); - egui::ComboBox::from_id_salt("model_default_animation_clip") - .selected_text(selected_default) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut draft.settings.default_animation_clip_id, - None, - "Imported rest pose", - ); - for (clip_id, label) in &animation_clip_options { - ui.selectable_value( - &mut draft.settings.default_animation_clip_id, - Some(clip_id.clone()), - label, - ); - } - }); - ui.small("Default animation is sampled and paused in edit mode; no clip is guessed."); - } - ui.horizontal(|ui| { - if ui.button("Apply").clicked() { - apply = Some(draft.settings.clone()); - } - if ui.button("Revert").clicked() { - revert = true; - } - }); - } - - if revert { - world.resource_mut::().import_draft = Some(ImportSettingsDraft { - path: path.to_string(), - settings: current.clone(), - }); - } - if let Some(settings) = apply { - apply_import_settings(world, asset, path, settings); - } -} - -fn apply_import_settings( - world: &mut World, - asset: &EditorAsset, - path: &str, - settings: ImportSettings, -) { - let mut status = format!("Updated import settings for {}", asset.label); - if let Some(mut registry) = world.get_resource_mut::() { - update_import_settings(&mut registry, path, settings); - if matches!(asset.kind, EditorAssetKind::Model) { - if let Some(record) = find_asset_mut_by_path(&mut registry, path) { - if let Err(error) = refresh_model_artifacts(record) { - status = format!("Model artifact refresh failed for {}: {error}", asset.label); - warn!("{status}"); - } - } - } - if let Err(error) = save_registry(®istry) { - status = format!("Asset registry save failed: {error}"); - warn!("{status}"); - } else { - registry.index_dirty = false; - } - } - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = status; -} - -fn material_asset_editor(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset, path: &str) { - ensure_material_draft(world, path, &asset.label); - let shader_schemas = shader_schema_assets(world); - let texture_assets = material_texture_candidates(world); - let texture_drop_candidate = world - .resource::() - .dragging_selection() - .and_then(|selection| { - texture_assets - .iter() - .find(|candidate| candidate.selection == *selection) - }) - .cloned(); - let mut apply = false; - let mut revert = false; - let mut create_instance = false; - let mut accepted_texture_drop = false; - { - let mut state = world.resource_mut::(); - let Some(draft) = state.material_draft.as_mut() else { - return; - }; - if let Some(error) = draft.error.as_ref() { - ui.colored_label(egui::Color32::YELLOW, error); - } - compact_text_edit(ui, "Label", &mut draft.asset.label); - material_shader_interface( - ui, - &mut draft.asset, - &shader_schemas, - &texture_assets, - texture_drop_candidate.as_ref(), - &mut accepted_texture_drop, - ); - ui.separator(); - ui.label(panel_heading("Render State")); - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new("Alpha mode")); - egui::ComboBox::from_id_salt("material_alpha_mode") - .selected_text(match draft.asset.render_state.alpha_mode { - MaterialAlphaMode::Opaque => "Opaque", - MaterialAlphaMode::Cutout => "Cutout", - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut draft.asset.render_state.alpha_mode, - MaterialAlphaMode::Opaque, - "Opaque", - ); - ui.selectable_value( - &mut draft.asset.render_state.alpha_mode, - MaterialAlphaMode::Cutout, - "Cutout", - ); - }); - }); - if draft.asset.render_state.alpha_mode == MaterialAlphaMode::Cutout { - ui.add( - egui::Slider::new(&mut draft.asset.render_state.alpha_cutoff, 0.0..=1.0) - .text("Alpha cutoff"), - ); - } - ui.checkbox(&mut draft.asset.render_state.double_sided, "Double sided"); - ui.separator(); - ui.label(panel_heading("Standard Surface")); - color_desc_edit(ui, "Base color", &mut draft.asset.material.base_color); - ui.add(egui::Slider::new(&mut draft.asset.material.metallic, 0.0..=1.0).text("Metallic")); - ui.add(egui::Slider::new(&mut draft.asset.material.roughness, 0.0..=1.0).text("Roughness")); - color_desc_edit(ui, "Emissive", &mut draft.asset.material.emissive_color); - ui.add( - egui::Slider::new(&mut draft.asset.material.emissive_intensity, 0.0..=50_000.0) - .text("Emissive nits"), - ); - accepted_texture_drop |= material_texture_path_ui( - ui, - "Base texture", - "standard_base_color", - &mut draft.asset.material.base_color_texture, - &texture_assets, - texture_drop_candidate.as_ref(), - ); - accepted_texture_drop |= material_texture_path_ui( - ui, - "Emissive texture", - "standard_emissive", - &mut draft.asset.material.emissive_texture, - &texture_assets, - texture_drop_candidate.as_ref(), - ); - accepted_texture_drop |= material_texture_path_ui( - ui, - "Normal map", - "standard_normal_map", - &mut draft.asset.material.normal_map_texture, - &texture_assets, - texture_drop_candidate.as_ref(), - ); - accepted_texture_drop |= material_texture_path_ui( - ui, - "Metal/rough", - "standard_metallic_roughness", - &mut draft.asset.material.metallic_roughness_texture, - &texture_assets, - texture_drop_candidate.as_ref(), - ); - ui.horizontal(|ui| { - if ui.button("Apply").clicked() { - apply = true; - } - if ui.button("Revert").clicked() { - revert = true; - } - if ui.button("Create Instance").clicked() { - create_instance = true; - } - }); - } - - if revert { - world.resource_mut::().material_draft = None; - ensure_material_draft(world, path, &asset.label); - } - if apply { - save_material_draft(world); - } - if create_instance { - create_material_instance_from_base(world, path, &asset.label); - } - if accepted_texture_drop { - world.resource_mut::().clear_drag(); - } -} - -fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) { - let needs_load = world - .resource::() - .material_draft - .as_ref() - .is_none_or(|draft| draft.path != path); - if !needs_load { - return; - } - let loaded = fs::read_to_string(path) - .map_err(|error| format!("could not read {path}: {error}")) - .and_then(|text| { - let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes()); - ron::from_str::(&text) - .map(|asset| (asset, snapshot)) - .map_err(|error| format!("invalid material RON in {path}: {error}")) - }); - let draft = match loaded { - Ok((asset, disk_snapshot)) => MaterialAssetDraft { - path: path.to_string(), - asset, - disk_snapshot, - error: None, - }, - Err(error) => MaterialAssetDraft { - path: path.to_string(), - asset: MaterialAsset { - schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, - label: fallback_label.to_string(), - shader: None, - shader_ref: None, - render_state: shared::MaterialRenderState::default(), - material: MaterialDesc::default(), - }, - disk_snapshot: FileSnapshot::capture(Path::new(path)) - .unwrap_or_else(|_| FileSnapshot::missing()), - error: Some(error), - }, - }; - world.resource_mut::().material_draft = Some(draft); -} - -fn save_material_draft(world: &mut World) { - let Some(draft) = world - .resource::() - .material_draft - .clone() - else { - return; - }; - let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default()) - .map_err(|error| format!("could not serialize material {}: {error}", draft.path)) - .and_then(|text| { - publish_authored_file( - world, - Path::new(&draft.path), - text.as_bytes(), - &draft.disk_snapshot, - FileWriteIntent::Material, - ) - }); - match result { - Ok(disk_snapshot) => { - if let Some(current) = world - .resource_mut::() - .material_draft - .as_mut() - { - current.error = None; - current.disk_snapshot = disk_snapshot; - } - world.resource_mut::().refresh(); - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = format!("Saved material {}", draft.path); - } - Err(error) => { - if let Some(current) = world - .resource_mut::() - .material_draft - .as_mut() - { - current.error = Some(error.clone()); - } - world.resource_mut::().status = error; - } - } -} - -pub(crate) fn create_material_instance_from_base( - world: &mut World, - base_path: &str, - base_label: &str, -) { - let base_reference = world - .get_resource_mut::() - .ok_or_else(|| "Asset registry is unavailable".to_string()) - .and_then(|mut registry| { - ensure_asset_record( - &mut registry, - base_path.to_string(), - base_label.to_string(), - "Material", - ) - .map(|record| { - MaterialRef::new( - EditorAssetRef::new( - record.id.as_string(), - "material:source", - base_label.to_string(), - ) - .with_source_path(base_path), - ) - }) - }); - let base_reference = match base_reference { - Ok(reference) => reference, - Err(error) => { - world.resource_mut::().status = error; - return; - } - }; - let base = Path::new(base_path); - let parent = base - .parent() - .unwrap_or_else(|| Path::new("assets/materials")); - let stem = base - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("material"); - let mut index = 1usize; - let path = loop { - let suffix = if index == 1 { - "instance".to_string() - } else { - format!("instance_{index}") - }; - let candidate = parent.join(format!("{stem}_{suffix}.ron")); - if !candidate.exists() { - break candidate; - } - index += 1; - }; - let instance = MaterialInstanceAsset { - schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, - label: format!("{base_label} Instance"), - base: base_reference, - parameters: Vec::new(), - textures: Vec::new(), - }; - let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) - .map_err(|error| format!("could not serialize material instance: {error}")) - .and_then(|text| { - publish_authored_file( - world, - &path, - text.as_bytes(), - &FileSnapshot::missing(), - FileWriteIntent::MaterialInstance, - ) - }); - if let Err(error) = result { - world.resource_mut::().status = error; - return; - } - let normalized = path.to_string_lossy().replace('\\', "/"); - let registry_error = - if let Some(mut registry) = world.get_resource_mut::() { - ensure_asset_record( - &mut registry, - normalized.clone(), - instance.label.clone(), - "Material", - ) - .map(|_| ()) - .and_then(|_| save_registry(®istry)) - .err() - } else { - Some("Asset registry is unavailable".to_string()) - }; - if let Some(error) = registry_error { - world.resource_mut::().status = - format!("Created {normalized}, but registry update failed: {error}"); - return; - } - world.resource_mut::().refresh(); - world - .resource_mut::() - .select(AssetSelection::File(normalized.clone())); - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = format!("Created material instance {normalized}"); -} - -fn material_base_candidates(world: &World) -> Vec<(String, MaterialRef)> { - let registry = world.get_resource::(); - let mut candidates = world - .resource::() - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Material)) - .filter_map(|asset| { - let path = asset.path.as_deref()?; - MaterialAsset::load_from_path(path).ok()?; - let record = registry.and_then(|registry| find_asset_by_path(registry, path))?; - Some(( - asset.label.clone(), - MaterialRef::new( - EditorAssetRef::new( - record.id.as_string(), - "material:source", - asset.label.clone(), - ) - .with_source_path(path), - ), - )) - }) - .collect::>(); - candidates.sort_by(|left, right| natural_cmp(&left.0, &right.0)); - candidates -} - -fn material_instance_asset_editor( - world: &mut World, - ui: &mut egui::Ui, - asset: &EditorAsset, - path: &str, -) { - ensure_material_instance_draft(world, path, &asset.label); - let base_candidates = material_base_candidates(world); - let texture_assets = material_texture_candidates(world); - let texture_drop_candidate = world - .resource::() - .dragging_selection() - .and_then(|selection| { - texture_assets - .iter() - .find(|candidate| candidate.selection == *selection) - }) - .cloned(); - let base_asset = world - .resource::() - .material_instance_draft - .as_ref() - .and_then(|draft| draft.asset.base.0.source_path.as_deref()) - .and_then(|path| MaterialAsset::load_from_path(path).ok()); - let schema = base_asset.as_ref().and_then(material_schema_for_asset); - let mut apply = false; - let mut revert = false; - let mut accepted_texture_drop = false; - { - let mut state = world.resource_mut::(); - let Some(draft) = state.material_instance_draft.as_mut() else { - return; - }; - if let Some(error) = draft.error.as_ref() { - ui.colored_label(egui::Color32::YELLOW, error); - } - compact_text_edit(ui, "Label", &mut draft.asset.label); - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new("Base material")); - let selected = base_candidates - .iter() - .find(|(_, candidate)| candidate.0.asset_id == draft.asset.base.0.asset_id) - .map(|(label, _)| label.as_str()) - .unwrap_or("Missing base"); - egui::ComboBox::from_id_salt("material_instance_base") - .selected_text(selected) - .show_ui(ui, |ui| { - for (label, reference) in &base_candidates { - if ui - .selectable_label( - reference.0.asset_id == draft.asset.base.0.asset_id, - label, - ) - .clicked() - { - draft.asset.base = reference.clone(); - } - } - }); - }); - ui.small("Overrides are sparse; unchecked properties continue to inherit the base asset."); - ui.separator(); - ui.label(panel_heading("Standard Overrides")); - let base_desc = base_asset - .as_ref() - .map(|asset| &asset.material) - .cloned() - .unwrap_or_default(); - material_instance_parameter_override_ui( - ui, - "Base color", - "base_color", - ShaderPropertyType::Color, - MaterialParameterValue::Color(base_desc.base_color), - &mut draft.asset.parameters, - ); - material_instance_parameter_override_ui( - ui, - "Metallic", - "metallic", - ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - MaterialParameterValue::Float(base_desc.metallic), - &mut draft.asset.parameters, - ); - material_instance_parameter_override_ui( - ui, - "Roughness", - "roughness", - ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - MaterialParameterValue::Float(base_desc.roughness), - &mut draft.asset.parameters, - ); - material_instance_parameter_override_ui( - ui, - "Emissive", - "emissive_color", - ShaderPropertyType::Color, - MaterialParameterValue::Color(base_desc.emissive_color), - &mut draft.asset.parameters, - ); - material_instance_parameter_override_ui( - ui, - "Emissive nits", - "emissive_intensity", - ShaderPropertyType::Float { - min: Some(0.0), - max: Some(50_000.0), - }, - MaterialParameterValue::Float(base_desc.emissive_intensity), - &mut draft.asset.parameters, - ); - if let Some(schema) = schema.as_ref() { - let custom = schema - .parameters - .iter() - .filter(|property| { - !matches!(property.property_type, ShaderPropertyType::Texture) - && !matches!( - property.name.as_str(), - "base_color" - | "metallic" - | "roughness" - | "emissive_color" - | "emissive_intensity" - ) - }) - .collect::>(); - if !custom.is_empty() { - ui.separator(); - ui.label(panel_heading("Shader Overrides")); - for property in custom { - let inherited = base_desc - .parameters - .iter() - .find(|value| value.name == property.name) - .or_else(|| { - schema - .default_values - .iter() - .find(|value| value.name == property.name) - }) - .map(|value| value.value.clone()) - .unwrap_or_else(|| { - default_value_for_shader_property(&property.property_type) - }); - material_instance_parameter_override_ui( - ui, - &property.display_name, - &property.name, - property.property_type.clone(), - inherited, - &mut draft.asset.parameters, - ); - } - } - } - ui.separator(); - ui.label(panel_heading("Texture Overrides")); - let mut texture_properties = vec![ - ("Base color", "base_color_texture"), - ("Normal map", "normal_map_texture"), - ("Metallic / roughness", "metallic_roughness_texture"), - ("Emissive", "emissive_texture"), - ]; - if let Some(schema) = schema.as_ref() { - for property in schema - .parameters - .iter() - .filter(|property| matches!(property.property_type, ShaderPropertyType::Texture)) - { - if !texture_properties - .iter() - .any(|(_, name)| *name == property.name) - { - texture_properties.push((&property.display_name, &property.name)); - } - } - } - for (label, name) in texture_properties { - accepted_texture_drop |= material_instance_texture_override_ui( - ui, - label, - name, - &mut draft.asset.textures, - &texture_assets, - texture_drop_candidate.as_ref(), - ); - } - ui.horizontal(|ui| { - if ui.button("Apply").clicked() { - apply = true; - } - if ui.button("Revert").clicked() { - revert = true; - } - }); - } - if revert { - world - .resource_mut::() - .material_instance_draft = None; - ensure_material_instance_draft(world, path, &asset.label); - } - if apply { - save_material_instance_draft(world); - } - if accepted_texture_drop { - world.resource_mut::().clear_drag(); - } -} - -fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label: &str) { - let needs_load = world - .resource::() - .material_instance_draft - .as_ref() - .is_none_or(|draft| draft.path != path); - if !needs_load { - return; - } - let loaded = fs::read_to_string(path) - .map_err(|error| format!("could not read {path}: {error}")) - .and_then(|text| { - let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes()); - ron::from_str::(&text) - .map(|asset| (asset, snapshot)) - .map_err(|error| format!("invalid material-instance RON in {path}: {error}")) - }); - let draft = match loaded { - Ok((asset, disk_snapshot)) => MaterialInstanceAssetDraft { - path: path.to_string(), - asset, - disk_snapshot, - error: None, - }, - Err(error) => MaterialInstanceAssetDraft { - path: path.to_string(), - asset: MaterialInstanceAsset { - schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, - label: fallback_label.to_string(), - base: MaterialRef::default(), - parameters: Vec::new(), - textures: Vec::new(), - }, - disk_snapshot: FileSnapshot::capture(Path::new(path)) - .unwrap_or_else(|_| FileSnapshot::missing()), - error: Some(error), - }, - }; - world - .resource_mut::() - .material_instance_draft = Some(draft); -} - -fn save_material_instance_draft(world: &mut World) { - let Some(draft) = world - .resource::() - .material_instance_draft - .clone() - else { - return; - }; - let result = if !draft.asset.base.is_resolved() - || draft.asset.base.0.sub_asset_id != "material:source" - { - Err("Material Instance base must resolve directly to a Material asset".to_string()) - } else { - ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default()) - .map_err(|error| format!("could not serialize material instance: {error}")) - .and_then(|text| { - publish_authored_file( - world, - Path::new(&draft.path), - text.as_bytes(), - &draft.disk_snapshot, - FileWriteIntent::MaterialInstance, - ) - }) - }; - match result { - Ok(disk_snapshot) => { - if let Some(current) = world - .resource_mut::() - .material_instance_draft - .as_mut() - { - current.error = None; - current.disk_snapshot = disk_snapshot; - } - world.resource_mut::().refresh(); - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = - format!("Saved material instance {}", draft.path); - } - Err(error) => { - if let Some(current) = world - .resource_mut::() - .material_instance_draft - .as_mut() - { - current.error = Some(error.clone()); - } - world.resource_mut::().status = error; - } - } -} - -fn material_schema_for_asset(asset: &MaterialAsset) -> Option { - asset - .shader_ref - .as_ref() - .and_then(|reference| reference.source_path.as_deref()) - .or(asset.material.shader.schema_path.as_deref()) - .or(asset - .shader - .as_deref() - .filter(|path| path.ends_with(".ron"))) - .and_then(|path| ShaderSchemaAsset::load_from_path(path).ok()) -} - -fn material_instance_parameter_override_ui( - ui: &mut egui::Ui, - label: &str, - name: &str, - property_type: ShaderPropertyType, - inherited: MaterialParameterValue, - parameters: &mut Vec, -) { - let index = parameters.iter().position(|value| value.name == name); - let mut enabled = index.is_some(); - if ui - .checkbox(&mut enabled, format!("Override {label}")) - .changed() - { - if enabled { - parameters.push(MaterialParameter { - name: name.to_string(), - value: inherited, - }); - } else { - parameters.retain(|value| value.name != name); - } - } - if let Some(value) = parameters.iter_mut().find(|value| value.name == name) { - material_parameter_value_ui(ui, label, &property_type, &mut value.value); - } -} - -fn material_instance_texture_override_ui( - ui: &mut egui::Ui, - label: &str, - name: &str, - textures: &mut Vec, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> bool { - let mut enabled = textures.iter().any(|value| value.name == name); - if ui - .checkbox(&mut enabled, format!("Override {label}")) - .changed() - { - if enabled { - textures.push(MaterialTextureBinding { - name: name.to_string(), - texture: None, - }); - } else { - textures.retain(|value| value.name != name); - } - } - - let current = textures - .iter() - .find(|value| value.name == name) - .and_then(|binding| binding.texture.as_ref()) - .cloned(); - let response = texture_slot_ui( - ui, - label, - format!("instance_{name}"), - TextureSlotSelection::Reference(current.as_ref()), - if enabled { "(none)" } else { "(inherits base)" }, - texture_assets, - drop_candidate, - ); - let accepted_drop = response.accepted_drop; - if response.clear { - textures.retain(|value| value.name != name); - } else if let Some(candidate) = response.selected { - set_material_instance_texture_override(textures, name, Some(candidate.reference)); - } - accepted_drop -} - -fn set_material_instance_texture_override( - textures: &mut Vec, - name: &str, - texture: Option, -) { - match texture { - Some(texture) => { - if let Some(binding) = textures.iter_mut().find(|value| value.name == name) { - binding.texture = Some(texture); - } else { - textures.push(MaterialTextureBinding { - name: name.to_string(), - texture: Some(texture), - }); - } - } - None => textures.retain(|value| value.name != name), - } -} - -fn shader_schema_assets(world: &World) -> Vec<(String, String, EditorAssetRef)> { - let registry = world.get_resource::(); - let mut schemas: Vec<(String, String, EditorAssetRef)> = world - .resource::() - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::ShaderSchema)) - .filter_map(|asset| { - let path = asset.path.clone()?; - let record = registry.and_then(|registry| find_asset_by_path(registry, &path))?; - Some(( - asset.label.clone(), - path.clone(), - EditorAssetRef::new(record.id.as_string(), "shader:schema", asset.label.clone()) - .with_source_path(path), - )) - }) - .collect(); - schemas.sort_by(|a, b| natural_cmp(&a.0, &b.0)); - schemas -} - -fn material_texture_candidates(world: &World) -> Vec { - let Some(registry) = world.get_resource::() else { - return Vec::new(); - }; - let assets = &world.resource::().assets; - let mut textures = Vec::new(); - let mut seen = HashSet::new(); - - for asset in assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) - { - let Some(path) = asset.path.as_deref() else { - continue; - }; - let Some(record) = find_asset_by_path(registry, path) else { - continue; - }; - let reference = - EditorAssetRef::new(record.id.as_string(), "texture:source", asset.label.clone()) - .with_source_path(path); - if !seen.insert((reference.asset_id.clone(), reference.sub_asset_id.clone())) { - continue; - } - textures.push(MaterialTextureCandidate { - label: asset.label.clone(), - detail: path.to_string(), - path: path.to_string(), - reference, - selection: AssetSelection::File(path.to_string()), - }); - } - - for asset in assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Model)) - { - let Some(parent_path) = asset.path.as_deref() else { - continue; - }; - let Some(record) = find_asset_by_path(registry, parent_path) else { - continue; - }; - for embedded in embedded_assets_for_asset(world, asset) - .into_iter() - .filter(|embedded| embedded.kind == AssetSubAssetKind::Texture) - { - let AssetSelection::SubAsset { - sub_asset_id, - source_path: Some(source_path), - .. - } = &embedded.selection - else { - continue; - }; - let reference = EditorAssetRef::new( - record.id.as_string(), - sub_asset_id.clone(), - embedded.label.clone(), - ) - .with_source_path(source_path); - if !seen.insert((reference.asset_id.clone(), reference.sub_asset_id.clone())) { - continue; - } - textures.push(MaterialTextureCandidate { - label: embedded.label, - detail: format!("{} | {source_path}", asset.label), - path: source_path.clone(), - reference, - selection: embedded.selection, - }); - } - } - - textures.sort_by(|left, right| { - natural_cmp(&left.label, &right.label) - .then_with(|| natural_cmp(&left.detail, &right.detail)) - }); - textures -} - -fn material_shader_interface( - ui: &mut egui::Ui, - asset: &mut MaterialAsset, - shader_schemas: &[(String, String, EditorAssetRef)], - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - accepted_texture_drop: &mut bool, -) { - shader_kind_picker(ui, &mut asset.material.shader.kind); - if matches!(asset.material.shader.kind, MaterialShaderKind::Custom) { - let mut selected_schema = asset.material.shader.schema_path.clone(); - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new("Schema")); - egui::ComboBox::from_id_salt("asset_material_shader_schema") - .selected_text( - selected_schema - .as_deref() - .and_then(|path| { - shader_schemas - .iter() - .find(|(_, schema_path, _)| schema_path == path) - .map(|(label, _, _)| label.as_str()) - }) - .unwrap_or("(none)"), - ) - .show_ui(ui, |ui| { - if ui - .selectable_label(selected_schema.is_none(), "(none)") - .clicked() - { - selected_schema = None; - } - for (label, path, _) in shader_schemas { - if ui - .selectable_label(selected_schema.as_deref() == Some(path), label) - .clicked() - { - selected_schema = Some(path.clone()); - } - } - }); - }); - if selected_schema != asset.material.shader.schema_path { - asset.material.shader.schema_path = selected_schema.clone(); - if let Some(path) = selected_schema.as_deref() { - asset.shader_ref = shader_schemas - .iter() - .find(|(_, schema_path, _)| schema_path == path) - .map(|(_, _, reference)| reference.clone()); - if let Err(error) = apply_shader_schema(asset, path) { - ui.colored_label(egui::Color32::YELLOW, error); - } - } else { - asset.shader_ref = None; - } - } - optional_path_edit(ui, "WGSL shader", &mut asset.material.shader.shader_path); - if let Some(path) = asset.material.shader.schema_path.clone() { - ui.horizontal(|ui| { - if ui.button("Reload Schema").clicked() { - if let Err(error) = apply_shader_schema(asset, &path) { - ui.colored_label(egui::Color32::YELLOW, error); - } - } - ui.small(egui::RichText::new(path).color(TEXT_DIM)); - }); - } - } else { - asset.shader_ref = None; - asset.material.shader.schema_path = None; - asset.material.shader.shader_path = None; - } - - let schema = asset - .material - .shader - .schema_path - .as_deref() - .and_then(|path| ShaderSchemaAsset::load_from_path(path).ok()); - if let Some(schema) = schema.as_ref() { - ui.separator(); - ui.label(panel_heading("Shader Parameters")); - shader_schema_parameter_editor(ui, &schema.parameters, &mut asset.material.parameters); - shader_schema_texture_editor( - ui, - &schema.parameters, - &mut asset.material.textures, - texture_assets, - drop_candidate, - accepted_texture_drop, - ); - } else if !asset.material.parameters.is_empty() || !asset.material.textures.is_empty() { - ui.separator(); - ui.label(panel_heading("Stored Shader Values")); - raw_material_parameter_editor(ui, &mut asset.material.parameters); - raw_material_texture_editor( - ui, - &mut asset.material.textures, - texture_assets, - drop_candidate, - accepted_texture_drop, - ); - } -} - -fn apply_shader_schema(asset: &mut MaterialAsset, path: &str) -> Result<(), String> { - let schema = ShaderSchemaAsset::load_from_path(path)?; - asset.shader = Some(schema.label.clone()); - asset.material.shader.kind = schema.kind; - asset.material.shader.schema_path = Some(path.to_string()); - if asset.material.shader.shader_path.is_none() { - asset.material.shader.shader_path = schema.wgsl_path.clone(); - } - - for property in &schema.parameters { - if matches!(property.property_type, ShaderPropertyType::Texture) { - if !asset - .material - .textures - .iter() - .any(|texture| texture.name == property.name) - { - let default = schema - .default_textures - .iter() - .find(|texture| texture.name == property.name) - .cloned() - .unwrap_or(MaterialTextureBinding { - name: property.name.clone(), - texture: None, - }); - asset.material.textures.push(default); - } - continue; - } - - let default = schema - .default_values - .iter() - .find(|parameter| parameter.name == property.name) - .map(|parameter| parameter.value.clone()) - .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); - match asset - .material - .parameters - .iter_mut() - .find(|parameter| parameter.name == property.name) - { - Some(parameter) => { - if !parameter_value_matches_property(¶meter.value, &property.property_type) { - parameter.value = default; - } - } - None => asset.material.parameters.push(MaterialParameter { - name: property.name.clone(), - value: default, - }), - } - } - - Ok(()) -} - -fn shader_schema_parameter_editor( - ui: &mut egui::Ui, - properties: &[ShaderPropertyDesc], - parameters: &mut Vec, -) { - for property in properties - .iter() - .filter(|property| !matches!(property.property_type, ShaderPropertyType::Texture)) - { - let value = ensure_material_parameter(parameters, property); - material_parameter_value_ui(ui, &property.display_name, &property.property_type, value); - } -} - -fn shader_schema_texture_editor( - ui: &mut egui::Ui, - properties: &[ShaderPropertyDesc], - textures: &mut Vec, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - accepted_texture_drop: &mut bool, -) { - let texture_props: Vec<&ShaderPropertyDesc> = properties - .iter() - .filter(|property| matches!(property.property_type, ShaderPropertyType::Texture)) - .collect(); - if texture_props.is_empty() { - return; - } - ui.separator(); - ui.label(panel_heading("Shader Textures")); - for property in texture_props { - let texture = ensure_material_texture_binding(textures, &property.name); - *accepted_texture_drop |= texture_binding_ui( - ui, - &property.display_name, - texture, - texture_assets, - drop_candidate, - ); - } -} - -fn raw_material_parameter_editor(ui: &mut egui::Ui, parameters: &mut [MaterialParameter]) { - for parameter in parameters { - let property_type = property_type_for_value(¶meter.value); - material_parameter_value_ui(ui, ¶meter.name, &property_type, &mut parameter.value); - } -} - -fn raw_material_texture_editor( - ui: &mut egui::Ui, - textures: &mut [MaterialTextureBinding], - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, - accepted_texture_drop: &mut bool, -) { - if textures.is_empty() { - return; - } - ui.separator(); - ui.label(panel_heading("Stored Shader Textures")); - for texture in textures { - let label = texture.name.clone(); - *accepted_texture_drop |= - texture_binding_ui(ui, &label, texture, texture_assets, drop_candidate); - } -} - -fn ensure_material_parameter<'a>( - parameters: &'a mut Vec, - property: &ShaderPropertyDesc, -) -> &'a mut MaterialParameterValue { - let index = parameters - .iter() - .position(|parameter| parameter.name == property.name) - .unwrap_or_else(|| { - parameters.push(MaterialParameter { - name: property.name.clone(), - value: default_value_for_shader_property(&property.property_type), - }); - parameters.len() - 1 - }); - if !parameter_value_matches_property(¶meters[index].value, &property.property_type) { - parameters[index].value = default_value_for_shader_property(&property.property_type); - } - &mut parameters[index].value -} - -fn ensure_material_texture_binding<'a>( - textures: &'a mut Vec, - name: &str, -) -> &'a mut MaterialTextureBinding { - let index = textures - .iter() - .position(|texture| texture.name == name) - .unwrap_or_else(|| { - textures.push(MaterialTextureBinding { - name: name.to_string(), - texture: None, - }); - textures.len() - 1 - }); - &mut textures[index] -} - -fn material_parameter_value_ui( - ui: &mut egui::Ui, - label: &str, - property_type: &ShaderPropertyType, - value: &mut MaterialParameterValue, -) { - if !parameter_value_matches_property(value, property_type) { - *value = default_value_for_shader_property(property_type); - } - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - match value { - MaterialParameterValue::Bool(value) => { - ui.checkbox(value, ""); - } - MaterialParameterValue::Float(value) => { - let (min, max) = match property_type { - ShaderPropertyType::Float { min, max } => (*min, *max), - _ => (None, None), - }; - match (min, max) { - (Some(min), Some(max)) => { - ui.add(egui::Slider::new(value, min..=max)); - } - _ => { - ui.add(egui::DragValue::new(value).speed(0.01)); - } - } - } - MaterialParameterValue::Vec2(value) => { - ui.add_sized([64.0, 20.0], egui::DragValue::new(&mut value.x).speed(0.01)); - ui.add_sized([64.0, 20.0], egui::DragValue::new(&mut value.y).speed(0.01)); - } - MaterialParameterValue::Vec3(value) => { - ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.x).speed(0.01)); - ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.y).speed(0.01)); - ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.z).speed(0.01)); - } - MaterialParameterValue::Color(value) => { - let mut rgba = [value.r, value.g, value.b, value.a]; - if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { - *value = ColorDesc { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3], - }; - } - } - MaterialParameterValue::Enum(value) => { - let options = match property_type { - ShaderPropertyType::Enum { options } => options.as_slice(), - _ => &[], - }; - egui::ComboBox::from_id_salt(format!("material_enum_{label}")) - .selected_text(value.as_str()) - .show_ui(ui, |ui| { - for option in options { - ui.selectable_value(value, option.clone(), option); - } - }); - } - } - }); -} - -fn texture_binding_ui( - ui: &mut egui::Ui, - label: &str, - binding: &mut MaterialTextureBinding, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> bool { - let response = texture_slot_ui( - ui, - label, - format!("binding_{}", binding.name), - TextureSlotSelection::Reference(binding.texture.as_ref()), - "(none)", - texture_assets, - drop_candidate, - ); - let accepted_drop = response.accepted_drop; - if response.clear { - binding.texture = None; - } else if let Some(candidate) = response.selected { - binding.texture = Some(candidate.reference); - } - accepted_drop -} - -fn material_texture_path_ui( - ui: &mut egui::Ui, - label: &str, - slot_id: &str, - value: &mut Option, - texture_assets: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> bool { - let response = texture_slot_ui( - ui, - label, - slot_id, - TextureSlotSelection::Path(value.as_deref()), - "(none)", - texture_assets, - drop_candidate, - ); - let accepted_drop = response.accepted_drop; - if response.clear { - *value = None; - } else if let Some(candidate) = response.selected { - *value = Some(candidate.path); - } - accepted_drop -} - -fn texture_slot_ui( - ui: &mut egui::Ui, - label: &str, - slot_id: impl std::hash::Hash, - current: TextureSlotSelection<'_>, - empty_label: &str, - candidates: &[MaterialTextureCandidate], - drop_candidate: Option<&MaterialTextureCandidate>, -) -> TextureSlotResponse { - let mut result = TextureSlotResponse::default(); - ui.push_id(slot_id, |ui| { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - let width = ui.available_width().max(1.0); - let (rect, hover) = - ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::hover()); - let valid_drag = drop_candidate.is_some(); - let drop_hovered = valid_drag && ui.rect_contains_pointer(rect); - let row_hovered = hover.hovered(); - let stroke = if drop_hovered { - egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255)) - } else if valid_drag { - egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122)) - } else if row_hovered { - egui::Stroke::new(1.0, egui::Color32::from_rgb(92, 102, 118)) - } else { - egui::Stroke::new(1.0, BORDER) - }; - let fill = if drop_hovered { - egui::Color32::from_rgb(29, 57, 86) - } else if row_hovered { - WIDGET_BG.linear_multiply(1.05) - } else { - WIDGET_BG.linear_multiply(0.75) - }; - ui.painter() - .rect(rect, 4.0, fill, stroke, egui::StrokeKind::Inside); - - let selected_candidate = candidates.iter().find(|candidate| match current { - TextureSlotSelection::Path(path) => path == Some(candidate.path.as_str()), - TextureSlotSelection::Reference(reference) => reference.is_some_and(|reference| { - reference.asset_id == candidate.reference.asset_id - && reference.sub_asset_id == candidate.reference.sub_asset_id - }), - }); - let current_present = match current { - TextureSlotSelection::Path(path) => path.is_some(), - TextureSlotSelection::Reference(reference) => reference.is_some(), - }; - let display_label = selected_candidate - .map(|candidate| candidate.label.as_str()) - .or_else(|| match current { - TextureSlotSelection::Path(path) => path, - TextureSlotSelection::Reference(reference) => { - reference.map(|reference| reference.label.as_str()) - } - }) - .unwrap_or(empty_label); - - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(rect.shrink2(egui::vec2(5.0, 3.0))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - child.set_clip_rect(rect); - child.add_sized( - [18.0, 20.0], - egui::Label::new(icon_text(icons::IMAGE, 13.0).color(TEXT_DIM)), - ); - let action_width = 56.0; - let text_width = - (child.available_width() - action_width - child.spacing().item_spacing.x).max(1.0); - child.allocate_ui_with_layout( - egui::vec2(text_width, 22.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - ui.add_sized( - [text_width, 20.0], - egui::Label::new(display_label).truncate(), - ); - }, - ); - child.allocate_ui_with_layout( - egui::vec2(action_width, 24.0), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - let clear = ui.add_enabled( - current_present, - egui::Button::new(icon_text(icons::X, 14.0)) - .frame(false) - .min_size(egui::vec2(20.0, 20.0)), - ); - if clear.on_hover_text("Clear texture").clicked() { - result.clear = true; - } - if candidates.is_empty() { - ui.add_enabled( - false, - egui::Button::new(icon_text(icons::FOLDER_OPEN, 14.0)) - .frame(false) - .min_size(egui::vec2(20.0, 20.0)), - ) - .on_hover_text("No project textures available"); - } else { - let menu = ui.menu_button(icon_text(icons::FOLDER_OPEN, 14.0), |ui| { - ui.set_min_width(240.0); - for candidate in candidates { - let selected = selected_candidate.is_some_and(|selected| { - selected.reference.asset_id == candidate.reference.asset_id - && selected.reference.sub_asset_id - == candidate.reference.sub_asset_id - }); - if ui - .selectable_label(selected, candidate.label.as_str()) - .on_hover_text(candidate.detail.as_str()) - .clicked() - { - result.selected = Some(candidate.clone()); - ui.close(); - } - } - }); - menu.response.on_hover_text("Select a project texture"); - } - }, - ); - - if drop_hovered && ui.input(|input| input.pointer.any_released()) { - if let Some(candidate) = drop_candidate { - result.selected = Some(candidate.clone()); - result.accepted_drop = true; - } - } - }); - }); - result -} - -fn default_value_for_shader_property(property_type: &ShaderPropertyType) -> MaterialParameterValue { - match property_type { - ShaderPropertyType::Bool => MaterialParameterValue::Bool(false), - ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0), - ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO), - ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO), - ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()), - ShaderPropertyType::Enum { options } => { - MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default()) - } - ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0), - } -} - -fn parameter_value_matches_property( - value: &MaterialParameterValue, - property_type: &ShaderPropertyType, -) -> bool { - matches!( - (value, property_type), - (MaterialParameterValue::Bool(_), ShaderPropertyType::Bool) - | ( - MaterialParameterValue::Float(_), - ShaderPropertyType::Float { .. } - ) - | (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2) - | (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3) - | (MaterialParameterValue::Color(_), ShaderPropertyType::Color) - | ( - MaterialParameterValue::Enum(_), - ShaderPropertyType::Enum { .. } - ) - ) -} - -fn property_type_for_value(value: &MaterialParameterValue) -> ShaderPropertyType { - match value { - MaterialParameterValue::Bool(_) => ShaderPropertyType::Bool, - MaterialParameterValue::Float(_) => ShaderPropertyType::Float { - min: None, - max: None, - }, - MaterialParameterValue::Vec2(_) => ShaderPropertyType::Vec2, - MaterialParameterValue::Vec3(_) => ShaderPropertyType::Vec3, - MaterialParameterValue::Color(_) => ShaderPropertyType::Color, - MaterialParameterValue::Enum(_) => ShaderPropertyType::Enum { - options: Vec::new(), - }, - } -} - -fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - ui.add_sized( - [fit_width(ui, 100.0, f32::INFINITY), 22.0], - egui::TextEdit::singleline(value), - ); - }); -} - -fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new("Shader")); - egui::ComboBox::from_id_salt("asset_material_shader_kind") - .selected_text(match kind { - MaterialShaderKind::StandardLit => "Standard Lit", - MaterialShaderKind::Unlit => "Unlit", - MaterialShaderKind::Custom => "Custom", - }) - .show_ui(ui, |ui| { - ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit"); - ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit"); - ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom"); - }); - }); -} - -fn color_desc_edit(ui: &mut egui::Ui, label: &str, color: &mut ColorDesc) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - let mut rgba = [color.r, color.g, color.b, color.a]; - if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { - *color = ColorDesc { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3], - }; - } - }); -} - -fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option) { - ui.horizontal(|ui| { - ui.add_sized([92.0, 20.0], egui::Label::new(label)); - let mut text = value.clone().unwrap_or_default(); - if ui - .add_sized( - [fit_width(ui, 80.0, f32::INFINITY), 22.0], - egui::TextEdit::singleline(&mut text), - ) - .changed() - { - *value = if text.trim().is_empty() { - None - } else { - Some(text) - }; - } - if ui.button("Clear").clicked() { - *value = None; - } - }); -} - -fn texture_asset_from_subasset_selection( - world: &World, - selection: &Option, -) -> Option { - let Some(AssetSelection::SubAsset { - label, - kind: AssetSubAssetKind::Texture, - source_path: Some(path), - .. - }) = selection - else { - return None; - }; - if let Some(asset) = world - .resource::() - .assets - .iter() - .find(|asset| asset.path.as_deref() == Some(path.as_str())) - { - return Some(asset.clone()); - } - Some(EditorAsset { - label: label.clone(), - path: Some(path.clone()), - folder_path: Path::new(path) - .parent() - .map(|parent| parent.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|| ASSETS_ROOT.to_string()), - kind: EditorAssetKind::Texture, - }) -} - -fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(label).color(TEXT_DIM)); - ui.add(egui::Label::new(value).wrap().selectable(false)); - }); -} - -fn empty_content(ui: &mut egui::Ui, search: &str) { - ui.add_space(24.0); - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new(icons::FOLDER_OPEN.as_str()).font(egui::FontId::new( - 28.0, - egui::FontFamily::Name("phosphor-regular".into()), - )), - ); - if search.trim().is_empty() { - ui.label(egui::RichText::new("Folder is empty").color(TEXT_DIM)); - } else { - ui.label(egui::RichText::new("No matching assets").color(TEXT_DIM)); - } - }); -} - -fn navigate_to_parent(world: &mut World) { - let current = world.resource::().current_folder.clone(); - if let Some(parent) = current.rfind('/').map(|index| current[..index].to_string()) { - world.resource_mut::().current_folder = parent; - } -} - -fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFilter) -> bool { - match filter { - AssetKindFilter::All => true, - AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model), - AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture), - AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material), - AssetKindFilter::Audio => matches!(asset.kind, EditorAssetKind::AudioClip), - AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level), - AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab), - AssetKindFilter::Builtin => asset.path.is_none(), - } -} - -fn kind_filter_label(filter: AssetKindFilter) -> &'static str { - match filter { - AssetKindFilter::All => "All", - AssetKindFilter::Model => "Models", - AssetKindFilter::Texture => "Textures", - AssetKindFilter::Material => "Materials", - AssetKindFilter::Audio => "Audio", - AssetKindFilter::Level => "Levels", - AssetKindFilter::Prefab => "Prefabs", - AssetKindFilter::Builtin => "Built-ins", - } -} - -fn sort_label(sort: AssetSort) -> &'static str { - match sort { - AssetSort::Name => "Name", - AssetSort::Kind => "Type", - AssetSort::Modified => "Modified", - AssetSort::Size => "Size", - } -} - -fn kind_label(kind: &EditorAssetKind) -> &'static str { - match kind { - EditorAssetKind::Primitive(_) => "Primitive", - EditorAssetKind::Light(_) => "Light", - EditorAssetKind::Model => "Model", - EditorAssetKind::Texture => "Texture", - EditorAssetKind::Material => "Material", - EditorAssetKind::AudioClip => "Audio Clip", - EditorAssetKind::Level => "Level", - EditorAssetKind::Prefab => "Prefab", - EditorAssetKind::PostProcessVolume => "Post Process Volume", - EditorAssetKind::PostProcessEffect => "Post FX", - EditorAssetKind::RenderingProfile => "Rendering Profile", - EditorAssetKind::ShaderSchema => "Shader Schema", - } -} - -fn file_size(path: &str) -> Option { - fs::metadata(path).ok().map(|metadata| metadata.len()) -} - -fn audio_format_label(path: &str) -> &'static str { - match Path::new(path) - .extension() - .and_then(|extension| extension.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("ogg" | "oga") => "Ogg audio", - Some("spx") => "Speex", - Some("wav") => "WAV", - Some("mp3") => "MP3", - Some("flac") => "FLAC", - _ => "Unknown", - } -} - -fn modified_time(path: &str) -> Option { - fs::metadata(path) - .ok() - .and_then(|metadata| metadata.modified().ok()) -} - -fn format_bytes(bytes: u64) -> String { - const KIB: f64 = 1024.0; - const MIB: f64 = KIB * 1024.0; - const GIB: f64 = MIB * 1024.0; - let bytes = bytes as f64; - if bytes >= GIB { - format!("{:.1} GB", bytes / GIB) - } else if bytes >= MIB { - format!("{:.1} MB", bytes / MIB) - } else if bytes >= KIB { - format!("{:.1} KB", bytes / KIB) - } else { - format!("{bytes:.0} B") - } -} - -fn format_modified(time: SystemTime) -> String { - let Ok(duration) = SystemTime::now().duration_since(time) else { - return "Just now".to_string(); - }; - let days = duration.as_secs() / 86_400; - if days == 0 { - "Today".to_string() - } else if days == 1 { - "Yesterday".to_string() - } else if days < 30 { - format!("{days} days ago") - } else if days < 365 { - format!("{} months ago", days / 30) - } else { - format!("{} years ago", days / 365) - } -} - -fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { - a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) -} - -fn asset_context_menu( - world: &mut World, - ui: &mut egui::Ui, - asset: &EditorAsset, - selected_entities: &SelectedEntities, -) { - if ui.button("Select").clicked() { - world - .resource_mut::() - .select(AssetSelection::from_asset(asset)); - ui.close(); - } - if matches!( - asset.kind, - EditorAssetKind::Primitive(_) - | EditorAssetKind::Light(_) - | EditorAssetKind::Model - | EditorAssetKind::AudioClip - | EditorAssetKind::Prefab - ) && ui.button("Place at Origin").clicked() - { - place_asset_operator(world, asset.clone(), Vec3::ZERO); - ui.close(); - } - if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { - open_level_asset(world, asset); - ui.close(); - } - if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) { - let expanded = asset.path.as_deref().is_some_and(|path| { - world - .resource::() - .expanded_assets - .contains(path) - }); - if ui - .button(if expanded { - "Collapse Contents" - } else { - "Expand Contents" - }) - .clicked() - { - if let Some(path) = asset.path.as_ref() { - toggle_asset_expanded(world, path); - } - ui.close(); - } - } - if matches!(asset.kind, EditorAssetKind::Texture) - && ui.button("Apply as Base Color Texture").clicked() - { - apply_texture_operator(world, asset.clone(), selected_entities); - ui.close(); - } - if matches!(asset.kind, EditorAssetKind::Material) - && ui.button("Apply Material To Selection").clicked() - { - apply_material_operator(world, asset.clone(), selected_entities); - ui.close(); - } - if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { - reimport_asset(world, asset); - ui.close(); - } - if matches!( - asset.kind, - EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material - ) && ui.button("Regenerate Thumbnail").clicked() - { - regenerate_asset_thumbnail(world, asset); - ui.close(); - } - if asset.path.is_some() { - ui.separator(); - if ui.button("Move To Trash").clicked() { - request_delete_for_asset(world, asset); - ui.close(); - } - } -} - -fn subasset_context_menu( - world: &mut World, - ui: &mut egui::Ui, - embedded: &EmbeddedAsset, - selected_entities: &SelectedEntities, -) { - if ui.button("Select").clicked() { - world - .resource_mut::() - .select(embedded.selection.clone()); - ui.close(); - } - match embedded.kind { - AssetSubAssetKind::Mesh => { - if embedded.requires_skinned_hierarchy { - if ui.button("Place Skinned Model at Origin").clicked() { - place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); - ui.close(); - } - } else if ui.button("Place at Origin").clicked() { - place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); - ui.close(); - } - } - AssetSubAssetKind::Texture => { - if ui.button("Apply as Base Color Texture").clicked() { - if let Some(asset) = - texture_asset_from_subasset_selection(world, &Some(embedded.selection.clone())) - { - apply_texture_operator(world, asset, selected_entities); - } - ui.close(); - } - } - AssetSubAssetKind::Material => { - ui.label(egui::RichText::new("Embedded source material").color(TEXT_DIM)); - } - AssetSubAssetKind::Skeleton => { - ui.label(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM)); - } - AssetSubAssetKind::AnimationClip => { - let animated_actor = selected_entities - .as_slice() - .iter() - .copied() - .find(|entity| world.get::(*entity).is_some()); - let action = if animated_actor.is_some() { - "Assign To Selected Actor" - } else { - "Create Animated Actor" - }; - if ui.button(action).clicked() { - if let Some(entity) = animated_actor { - assign_animation_clip_operator(world, embedded.selection.clone(), entity); - } else { - place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); - } - ui.close(); - } - } - } - if matches!( - embedded.kind, - AssetSubAssetKind::Mesh | AssetSubAssetKind::Material | AssetSubAssetKind::Texture - ) && ui.button("Regenerate Thumbnail").clicked() - { - regenerate_subasset_thumbnail(world, embedded); - ui.close(); - } - if let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection { - if ui.button("Select Parent Asset").clicked() { - world - .resource_mut::() - .select(AssetSelection::File(parent_path.clone())); - ui.close(); - } - } -} - -fn regenerate_asset_thumbnail(world: &mut World, asset: &EditorAsset) { - let Some(path) = asset.path.clone() else { - return; - }; - let key = asset_cache_key(asset); - let kind = asset.kind.clone(); - let asset_server = world.resource::().clone(); - world.resource_scope(|world, mut cache: Mut| { - cache.retry(&key); - match kind { - EditorAssetKind::Texture => { - cache.request_texture(key.clone(), path.clone(), &asset_server); - } - EditorAssetKind::Model => { - world.resource_scope(|_world, mut studio: Mut| { - cache.request_model(key.clone(), path.clone(), &asset_server, &mut studio); - }); - } - EditorAssetKind::Material => { - world.resource_scope(|_world, mut studio: Mut| { - cache.request_material_asset(key.clone(), path.clone(), &mut studio); - }); - } - _ => {} - } - }); - world.resource_mut::().status = format!("Regenerating thumbnail for {}", asset.label); -} - -fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) { - let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection else { - return; - }; - let key = embedded.thumbnail_key.clone(); - let asset_server = world.resource::().clone(); - world.resource_scope(|world, mut cache: Mut| { - cache.retry(&key); - match embedded.kind { - AssetSubAssetKind::Mesh => { - let Some(mesh_label) = embedded.mesh_label.clone() else { - return; - }; - world.resource_scope(|_world, mut studio: Mut| { - cache.request_mesh_subasset( - key.clone(), - parent_path.clone(), - mesh_label, - embedded.material_label.clone(), - embedded.requires_skinned_hierarchy, - &mut studio, - ); - }); - } - AssetSubAssetKind::Material => { - let Some(material_label) = embedded.material_label.clone() else { - return; - }; - world.resource_scope(|_world, mut studio: Mut| { - cache.request_source_material( - key.clone(), - parent_path.clone(), - material_label, - &mut studio, - ); - }); - } - AssetSubAssetKind::Texture => { - if let Some(texture_path) = embedded.texture_path.clone() { - cache.request_texture(key.clone(), texture_path, &asset_server); - } - } - AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {} - } - }); - world.resource_mut::().status = - format!("Regenerating thumbnail for {}", embedded.label); -} - -fn reimport_asset(world: &mut World, asset: &EditorAsset) { - let Some(path) = asset.path.as_deref() else { - return; - }; - let mut status = format!("Reimported {}", asset.label); - if let Some(mut registry) = world.get_resource_mut::() { - if let Some(record) = find_asset_mut_by_path(&mut registry, path) { - if let Err(error) = refresh_model_artifacts(record) { - status = format!("Reimport failed for {}: {error}", asset.label); - warn!("{status}"); - } - } - if let Err(error) = save_registry(®istry) { - status = format!("Asset registry save failed: {error}"); - warn!("{status}"); - } else { - registry.index_dirty = false; - } - } - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = status; -} - -fn request_delete_for_asset(world: &mut World, asset: &EditorAsset) { - if let Some(request) = build_delete_request(world, asset) { - world.resource_mut::().pending_delete = Some(request); - } -} - -fn build_delete_request(world: &World, asset: &EditorAsset) -> Option { - let path = asset.path.clone()?; - let mut files = vec![path.clone()]; - let mut warnings = Vec::new(); - if matches!(asset.kind, EditorAssetKind::Model) { - if let Some(record) = world - .get_resource::() - .and_then(|registry| find_asset_by_path(registry, &path)) - { - if let Some(manifest) = record.import_settings.static_mesh_manifest_path { - files.push(manifest); - } - if let Some(manifest) = record.import_settings.animation_manifest_path { - files.push(manifest); - } - if !record.dependencies.is_empty() { - warnings.push(format!( - "{} imported dependencies will be kept because they may be shared.", - record.dependencies.len() - )); - } - } - } - warnings.push("Scene and prefab references are not rewritten automatically.".to_string()); - dedup_strings(&mut files); - Some(AssetDeleteRequest { - selection: AssetSelection::from_asset(asset), - label: asset.label.clone(), - files, - warnings, - }) -} - -fn draw_delete_modal(world: &mut World, ctx: &egui::Context) { - let pending = world - .resource::() - .pending_delete - .clone(); - let Some(request) = pending else { - return; - }; - let mut action = None; - egui::Window::new("Move Asset To Trash") - .collapsible(false) - .resizable(false) - .default_width(360.0) - .show(ctx, |ui| { - ui.label(format!("Move {} to assets/.trash?", request.label)); - ui.separator(); - ui.label(panel_heading("Files")); - for file in &request.files { - ui.add(egui::Label::new(file).wrap()); - } - if !request.warnings.is_empty() { - ui.separator(); - ui.label(panel_heading("Warnings")); - for warning in &request.warnings { - ui.small(egui::RichText::new(warning).color(TEXT_DIM)); - } - } - ui.separator(); - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - action = Some(false); - } - if ui.button("Move To Trash").clicked() { - action = Some(true); - } - }); - }); - - match action { - Some(false) => { - world.resource_mut::().pending_delete = None; - } - Some(true) => { - execute_delete_request(world, &request); - world.resource_mut::().pending_delete = None; - } - None => {} - } -} - -fn execute_delete_request(world: &mut World, request: &AssetDeleteRequest) { - let trash_root = PathBuf::from("assets/.trash").join(trash_timestamp()); - let mut moved = 0usize; - let mut errors = Vec::new(); - for file in &request.files { - let source = Path::new(file); - if !source.exists() { - continue; - } - match move_file_to_trash(source, &trash_root) { - Ok(()) => moved += 1, - Err(error) => errors.push(format!("{file}: {error}")), - } - } - if !errors.is_empty() { - world.resource_mut::().status = format!( - "Could not move {} file(s) to trash: {}", - errors.len(), - errors.join("; ") - ); - return; - } - - if let Some(parent_path) = request.selection.parent_path().map(str::to_string) { - if let Some(mut registry) = world.get_resource_mut::() { - registry.records.retain(|record| record.path != parent_path); - registry.index_dirty = true; - if let Err(error) = save_registry(®istry) { - warn!("Asset registry save failed after delete: {error}"); - } else { - registry.index_dirty = false; - } - } - } - - { - let mut assets = world.resource_mut::(); - if let Some(parent_path) = request.selection.parent_path() { - if assets - .selected - .as_ref() - .and_then(AssetSelection::parent_path) - == Some(parent_path) - { - assets.selected = None; - } - } - assets.refresh(); - } - invalidate_on_catalog_refresh(world); - world.resource_mut::().status = format!( - "Moved {} file(s) for {} to {}", - moved, - request.label, - trash_root.to_string_lossy() - ); -} - -fn move_file_to_trash(source: &Path, trash_root: &Path) -> Result<(), String> { - let target = trash_root.join(source); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("could not create {}: {error}", parent.display()))?; - } - fs::rename(source, &target) - .or_else(|_| { - fs::copy(source, &target)?; - fs::remove_file(source) - }) - .map_err(|error| format!("could not move to {}: {error}", target.display()))?; - Ok(()) -} - -fn trash_timestamp() -> String { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs().to_string()) - .unwrap_or_else(|_| "now".to_string()) -} - -fn dedup_strings(values: &mut Vec) { - let mut seen = HashSet::new(); - values.retain(|value| seen.insert(value.clone())); -} - -pub(crate) fn validate_material_conflict_destination( - world: &World, - destination: &Path, -) -> Result { - let root = PathBuf::from(&world.resource::().root); - let root = root.canonicalize().unwrap_or(root); - let absolute = if destination.is_absolute() { - destination.to_path_buf() - } else { - root.join(destination) - }; - let absolute = absolute - .parent() - .and_then(|parent| parent.canonicalize().ok()) - .and_then(|parent| destination.file_name().map(|name| parent.join(name))) - .unwrap_or(absolute); - let relative = absolute.strip_prefix(&root).map_err(|_| { - format!( - "Material assets must stay inside {}", - root.join("assets").display() - ) - })?; - if !relative.starts_with("assets") { - return Err(format!( - "Material assets must stay inside {}", - root.join("assets").display() - )); - } - Ok(relative.to_string_lossy().replace('\\', "/")) -} - -pub(crate) fn reload_material_after_file_conflict( - world: &mut World, - path: &Path, - intent: &FileWriteIntent, -) -> Result { - let path = path.to_string_lossy(); - { - let mut state = world.resource_mut::(); - match intent { - FileWriteIntent::Material => { - if state - .material_draft - .as_ref() - .is_some_and(|draft| draft.path == path) - { - state.material_draft = None; - } - } - FileWriteIntent::MaterialInstance => { - if state - .material_instance_draft - .as_ref() - .is_some_and(|draft| draft.path == path) - { - state.material_instance_draft = None; - } - } - _ => return Err("the conflict is not an editable material document".into()), - } - } - world.resource_mut::().refresh(); - invalidate_on_catalog_refresh(world); - Ok(format!("Reloaded {} from disk", path)) -} - -pub(crate) fn adopt_material_conflict_save_as( - world: &mut World, - original_path: &Path, - catalog_path: String, - disk_snapshot: FileSnapshot, - intent: &FileWriteIntent, -) -> Result { - let original_path = original_path.to_string_lossy(); - { - let mut state = world.resource_mut::(); - match intent { - FileWriteIntent::Material => { - if let Some(draft) = state - .material_draft - .as_mut() - .filter(|draft| draft.path == original_path) - { - draft.path.clone_from(&catalog_path); - draft.disk_snapshot = disk_snapshot; - draft.error = None; - } else { - let asset = MaterialAsset::load_from_path(&catalog_path)?; - state.material_draft = Some(MaterialAssetDraft { - path: catalog_path.clone(), - asset, - disk_snapshot, - error: None, - }); - } - } - FileWriteIntent::MaterialInstance => { - if let Some(draft) = state - .material_instance_draft - .as_mut() - .filter(|draft| draft.path == original_path) - { - draft.path.clone_from(&catalog_path); - draft.disk_snapshot = disk_snapshot; - draft.error = None; - } else { - let asset = MaterialInstanceAsset::load_from_path(&catalog_path)?; - state.material_instance_draft = Some(MaterialInstanceAssetDraft { - path: catalog_path.clone(), - asset, - disk_snapshot, - error: None, - }); - } - } - _ => return Err("the conflict is not an editable material document".into()), - } - } - { - let mut assets = world.resource_mut::(); - assets.refresh(); - assets.select(AssetSelection::File(catalog_path.clone())); - } - invalidate_on_catalog_refresh(world); - Ok(format!("Saved material copy to {catalog_path}")) -} + draw_import_to_modal(world, ui.ctx()); + draw_path_edit_modal(world, ui.ctx()); + draw_content_transaction_modal(world, ui.ctx()); + draw_gltf_material_extraction_modal(world, ui.ctx()); + draw_pbr_grouping_modal(world, ui.ctx()); + draw_trash_modal(world, ui.ctx()); + draw_external_move_repair_modal(world, ui.ctx()); +} + +#[path = "panel/details.rs"] +mod details; +#[path = "panel/embedded.rs"] +mod embedded; +#[path = "panel/file_operations.rs"] +mod file_operations; +#[path = "panel/grid.rs"] +mod grid; +#[path = "panel/import_settings.rs"] +mod import_settings; +#[path = "panel/list.rs"] +mod list; +#[path = "panel/material_extraction.rs"] +mod material_extraction; +#[path = "panel/navigation.rs"] +mod navigation; +#[path = "panel/pbr_transactions.rs"] +mod pbr_transactions; +#[path = "panel/toolbar_import.rs"] +mod toolbar_import; +#[path = "panel/undo_trash.rs"] +mod undo_trash; +#[path = "panel/utilities.rs"] +mod utilities; +#[path = "panel/validation.rs"] +mod validation; + +use crate::ui::materials::{ + create_material_instance_from_base, material_asset_editor, material_instance_asset_editor, +}; +pub(crate) use details::top_level_asset_details_panel; +use details::*; +use embedded::*; +use file_operations::*; +use grid::*; +use import_settings::*; +use list::*; +pub(crate) use material_extraction::begin_actor_material_extraction; +use material_extraction::*; +use navigation::*; +use pbr_transactions::*; +pub(crate) use toolbar_import::draw_asset_import_review_modal; +use toolbar_import::*; +use undo_trash::*; +use utilities::*; +pub(crate) use validation::validate_material_conflict_destination; #[cfg(test)] mod tests { use super::*; - - #[test] - fn material_apply_refuses_an_external_revision() { - let root = std::env::temp_dir().join(format!( - "blacksite-material-external-write-{}", - uuid::Uuid::new_v4() - )); - let path = root.join("assets/materials/test.ron"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, b"loaded").unwrap(); - let disk_snapshot = FileSnapshot::capture(&path).unwrap(); - let draft = MaterialAssetDraft { - path: path.to_string_lossy().into_owned(), - asset: MaterialAsset { - schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, - label: "Test".into(), - shader: None, - shader_ref: None, - render_state: shared::MaterialRenderState::default(), - material: MaterialDesc::default(), - }, - disk_snapshot, - error: None, - }; - let state = AssetBrowserUiState { - material_draft: Some(draft), - ..Default::default() - }; - let mut world = World::new(); - world.insert_resource(state); - world.insert_resource(SceneIo::default()); - fs::write(&path, b"external").unwrap(); - - save_material_draft(&mut world); - - assert_eq!(fs::read(&path).unwrap(), b"external"); - assert!(world - .resource::() - .material_draft - .as_ref() - .and_then(|draft| draft.error.as_deref()) - .is_some_and(|error| error.contains("changed outside Blacksite"))); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn audio_filter_matches_only_audio_clips() { - let audio = EditorAsset { - label: "Impact".into(), - path: Some("assets/audio/impact.ogg".into()), - folder_path: "assets/audio".into(), - kind: EditorAssetKind::AudioClip, - }; - let texture = EditorAsset { - kind: EditorAssetKind::Texture, - ..audio.clone() - }; - - assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio)); - assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio)); - assert_eq!(kind_filter_label(AssetKindFilter::Audio), "Audio"); - } - - #[test] - fn audio_details_label_supported_formats() { - assert_eq!(audio_format_label("assets/audio/music.oga"), "Ogg audio"); - assert_eq!(audio_format_label("assets/audio/voice.spx"), "Speex"); - assert_eq!(audio_format_label("assets/audio/source.WAV"), "WAV"); - assert_eq!(audio_format_label("assets/audio/source.mp3"), "MP3"); - assert_eq!(audio_format_label("assets/audio/source.flac"), "FLAC"); - } - - #[test] - fn material_instance_texture_override_stays_sparse() { - let first = EditorAssetRef::new("texture-a", "texture:source", "First") - .with_source_path("assets/textures/first.png"); - let second = EditorAssetRef::new("texture-b", "texture:source", "Second") - .with_source_path("assets/textures/second.png"); - let mut textures = Vec::new(); - - set_material_instance_texture_override(&mut textures, "base_color_texture", Some(first)); - set_material_instance_texture_override( - &mut textures, - "base_color_texture", - Some(second.clone()), - ); - - assert_eq!(textures.len(), 1); - assert_eq!(textures[0].name, "base_color_texture"); - assert_eq!(textures[0].texture.as_ref(), Some(&second)); - - set_material_instance_texture_override(&mut textures, "base_color_texture", None); - assert!(textures.is_empty()); - } + include!("panel/tests/a.rs"); + include!("panel/tests/b.rs"); } diff --git a/crates/editor/src/ui/asset_browser/panel/details.rs b/crates/editor/src/ui/asset_browser/panel/details.rs new file mode 100644 index 0000000..c902798 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/details.rs @@ -0,0 +1,439 @@ +use super::*; + +pub(super) fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(label).color(TEXT_DIM)); + ui.add(egui::Label::new(value).wrap().selectable(false)); + }); +} + +pub(super) fn details_panel( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, +) { + ui.label(panel_heading("Details")); + ui.separator(); + let selections = world.resource::().selections.clone(); + if selections.len() > 1 { + let has_authored_content = !selected_content_paths(world).is_empty(); + ui.label(panel_heading(&format!( + "{} items selected", + selections.len() + ))); + ui.small( + egui::RichText::new("Batch file operations apply to every selected item.") + .color(TEXT_DIM), + ); + ui.add_space(6.0); + for selection in selections.iter().take(12) { + ui.add(egui::Label::new(selection.display_label()).truncate()); + } + if selections.len() > 12 { + ui.small(format!("…and {} more", selections.len() - 12)); + } + ui.separator(); + if ui + .add_enabled( + has_authored_content, + egui::Button::new("Duplicate Selection"), + ) + .clicked() + { + duplicate_selection(world); + } + if ui + .add_enabled( + has_authored_content, + egui::Button::new("Move Selection To Trash"), + ) + .clicked() + { + request_delete_for_selection(world); + } + return; + } + let selection = world.resource::().selected.clone(); + if let Some(AssetSelection::Folder(path)) = selection { + ui.label(panel_heading( + Path::new(&path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&path), + )); + detail_row(ui, "Path", &path); + ui.separator(); + if ui.button("Open Folder").clicked() { + navigate_content_folder(world, path); + } + } else if let Some(selection @ AssetSelection::SubAsset { .. }) = selection { + subasset_details_panel(world, ui, selected_entities, &selection); + } else if let Some(asset) = world.resource::().selected_asset().cloned() { + top_level_asset_details_panel(world, ui, selected_entities, &asset); + } else { + ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); + } +} + +pub(crate) fn top_level_asset_details_panel( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + asset: &EditorAsset, +) { + asset_details_header(world, ui, asset); + ui.add_space(8.0); + detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); + detail_row(ui, "Folder", asset.folder_path.as_str()); + + if asset.path.is_none() && asset.label == blacksite_surface::DEFAULT_GRID_LABEL { + ui.separator(); + ui.label(egui::RichText::new("Engine built-in · read-only").color(TEXT_DIM)); + ui.small("World-space tri-planar checker used when no valid project material resolves."); + return; + } + + if let Some(path) = asset.path.as_deref() { + if let Some(record) = world + .get_resource::() + .and_then(|registry| find_asset_by_path(registry, path)) + { + detail_row(ui, "Asset ID", &record.id.as_string()); + if matches!(asset.kind, EditorAssetKind::Model) { + let settings = record.model_import(); + ui.separator(); + ui.label(panel_heading("Import Settings")); + import_settings_editor(world, ui, asset, path, settings); + if let Some(manifest) = settings.static_mesh_manifest_path.as_deref() { + detail_row(ui, "Static mesh artifact", manifest); + } + } + if matches!(asset.kind, EditorAssetKind::Texture) { + if let Some(settings) = record.texture_import() { + ui.separator(); + ui.label(panel_heading("Texture Properties")); + texture_import_settings_editor(world, ui, path, &record, settings); + } + } + if !record.dependencies.is_empty() { + ui.separator(); + let missing_count = record + .dependencies + .iter() + .filter(|dependency| !dependency_path(dependency).is_file()) + .count(); + ui.horizontal_wrapped(|ui| { + ui.label(panel_heading("Dependencies")); + if missing_count > 0 { + let optional = !model_uses_source_materials(&record); + ui.small( + egui::RichText::new(if optional { + format!("{missing_count} missing | override") + } else { + format!("{missing_count} missing") + }) + .color(if optional { + WARNING + } else { + ERROR + }), + ); + } + }); + for dep in &record.dependencies { + let exists = dependency_path(dep).is_file(); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(if exists { + icons::CHECK_CIRCLE.as_str() + } else { + icons::WARNING_CIRCLE.as_str() + }) + .font(egui::FontId::new( + 12.0, + egui::FontFamily::Name(PHOSPHOR.into()), + )) + .color(if exists { + SUCCESS + } else { + WARNING + }), + ); + ui.add(egui::Label::new(dep).truncate()).on_hover_text(dep); + }); + } + } + } + detail_row( + ui, + "Size", + file_size(path).map(format_bytes).as_deref().unwrap_or("-"), + ); + detail_row( + ui, + "Modified", + modified_time(path) + .map(format_modified) + .as_deref() + .unwrap_or("-"), + ); + if matches!(asset.kind, EditorAssetKind::Material) { + ui.separator(); + ui.label(panel_heading("Material")); + crate::ui::design_system::scope(ui, |ui| { + if MaterialInstanceAsset::load_from_path(path).is_ok() { + material_instance_asset_editor(world, ui, asset, path, true); + } else { + material_asset_editor(world, ui, asset, path, true); + } + }); + } + if matches!(asset.kind, EditorAssetKind::AudioClip) { + detail_row(ui, "Format", audio_format_label(path)); + let previewing = world + .resource::() + .clip_path + .as_deref() + == Some(path); + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled( + !previewing, + egui::Button::new(format!("{} Audition", icons::PLAY.as_str())), + ) + .clicked() + { + if let Err(error) = + crate::play::audio_preview::audition_audio_asset(world, asset) + { + world.resource_mut::().status = + format!("Audio audition failed: {error}"); + } + } + if ui + .add_enabled( + previewing, + egui::Button::new(format!("{} Stop", icons::STOP.as_str())), + ) + .clicked() + { + crate::play::audio_preview::stop_audio_preview(world); + } + }); + } + } + + ui.separator(); + asset_action_buttons(world, ui, selected_entities, asset); +} + +pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) { + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(kind_icon(&asset.kind).as_str()) + .font(egui::FontId::new( + 30.0, + egui::FontFamily::Name(PHOSPHOR.into()), + )) + .color(ACCENT), + ); + ui.vertical(|ui| { + ui.strong(asset.label.as_str()); + ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); + if let (Some(state), Some(path)) = ( + world.get_resource::(), + asset.path.as_deref(), + ) { + let status = state.file_status(Path::new(path)); + let _ = file_status_indicator_ui(ui, &status, Path::new(path)); + } + if let Some(snapshot) = asset.path.as_deref().and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }) { + if snapshot.dirty { + ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong()); + } + } + }); + }); +} + +pub(super) fn subasset_details_panel( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selection: &AssetSelection, +) { + let Some(embedded) = embedded_asset_for_selection(world, selection) else { + ui.colored_label(egui::Color32::YELLOW, "Embedded asset is not available."); + return; + }; + let AssetSelection::SubAsset { + parent_path, + sub_asset_id, + .. + } = selection + else { + return; + }; + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(subasset_icon(embedded.kind).as_str()) + .font(egui::FontId::new( + 30.0, + egui::FontFamily::Name(PHOSPHOR.into()), + )) + .color(ACCENT), + ); + ui.vertical(|ui| { + ui.strong(embedded.label.as_str()); + ui.small(egui::RichText::new(subasset_kind_label(embedded.kind)).color(TEXT_DIM)); + }); + }); + ui.add_space(8.0); + detail_row(ui, "Parent", parent_path); + detail_row(ui, "ID", sub_asset_id); + detail_row(ui, "Source", &embedded.detail); + + ui.separator(); + match embedded.kind { + AssetSubAssetKind::Mesh => { + if embedded.requires_skinned_hierarchy { + if ui.button("Place Skinned Model At Origin").clicked() { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } else if ui.button("Place At Origin").clicked() { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } + AssetSubAssetKind::Texture => { + if ui.button("Apply Texture To Selection").clicked() { + if let Some(asset) = + texture_asset_from_subasset_selection(world, &Some(selection.clone())) + { + apply_texture_operator(world, asset, selected_entities); + } + } + } + AssetSubAssetKind::Material => { + let status = if embedded.detail.contains("Authoring Override") { + "Authoring Override is active; this source material is not assigned." + } else { + "Source material is assigned through static mesh renderer slots." + }; + ui.small(egui::RichText::new(status).color(TEXT_DIM)); + } + AssetSubAssetKind::Skeleton => { + ui.small( + egui::RichText::new("Skeleton metadata is generated from the model source.") + .color(TEXT_DIM), + ); + } + AssetSubAssetKind::AnimationClip => { + let animated_actor = selected_entities + .as_slice() + .iter() + .copied() + .find(|entity| world.get::(*entity).is_some()); + let action = if animated_actor.is_some() { + "Assign To Selected Actor" + } else { + "Create Animated Actor" + }; + if ui.button(action).clicked() { + if let Some(entity) = animated_actor { + assign_animation_clip_operator(world, selection.clone(), entity); + } else { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } + } + } + if ui.button("Select Parent Asset").clicked() { + world + .resource_mut::() + .select(AssetSelection::File(parent_path.clone())); + } +} + +pub(super) fn dependency_path(reference: &str) -> PathBuf { + let path = Path::new(reference); + if path.starts_with("assets") { + path.to_path_buf() + } else { + Path::new("assets").join(path) + } +} + +pub(super) fn asset_action_buttons( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + asset: &EditorAsset, +) { + ui.horizontal_wrapped(|ui| { + if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture").clicked() { + apply_texture_operator(world, asset.clone(), selected_entities); + } + if asset.path.is_some() + && matches!(asset.kind, EditorAssetKind::Material) + && ui.button("Apply Material").clicked() + { + apply_material_operator(world, asset.clone(), selected_entities); + } + if matches!( + asset.kind, + EditorAssetKind::Primitive(_) + | EditorAssetKind::Light(_) + | EditorAssetKind::Model + | EditorAssetKind::AudioClip + | EditorAssetKind::Prefab + ) && ui.button("Place At Origin").clicked() + { + place_asset_operator(world, asset.clone(), Vec3::ZERO); + } + if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { + open_level_asset(world, asset); + } + if matches!(asset.kind, EditorAssetKind::Model) + && has_embedded_assets(world, asset) + && ui.button("Toggle Contents").clicked() + { + if let Some(path) = asset.path.as_ref() { + toggle_asset_expanded(world, path); + } + } + if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { + reimport_asset(world, asset); + } + if asset.path.is_some() && ui.button("Move To Trash").clicked() { + request_delete_for_asset(world, asset); + } + }); +} + +pub(super) fn open_level_asset(world: &mut World, asset: &EditorAsset) { + let Some(path) = asset.path.as_deref() else { + return; + }; + world.resource_mut::().request = Some(SceneIoRequest::OpenPath(PathBuf::from(path))); +} + +pub(super) fn activate_content_asset(world: &mut World, asset: &EditorAsset) { + world + .resource_mut::() + .select(AssetSelection::from_asset(asset)); + match asset.kind { + EditorAssetKind::Level => open_level_asset(world, asset), + EditorAssetKind::Model if has_embedded_assets(world, asset) => { + if let Some(path) = asset.path.as_deref() { + toggle_asset_expanded(world, path); + } + } + _ => { + world.resource_mut::().show_details = true; + } + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/embedded.rs b/crates/editor/src/ui/asset_browser/panel/embedded.rs new file mode 100644 index 0000000..d139826 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/embedded.rs @@ -0,0 +1,374 @@ +use super::*; + +#[derive(Clone)] +pub(super) struct AssetBrowserStateSnapshot { + pub(super) search: String, + pub(super) view: AssetBrowserView, + pub(super) sort: AssetSort, + pub(super) kind_filter: AssetKindFilter, + pub(super) thumbnail_size: f32, + pub(super) recursive: bool, + pub(super) show_details: bool, + pub(super) details_width: f32, +} + +pub(super) fn state_snapshot(state: &AssetBrowserUiState) -> AssetBrowserStateSnapshot { + AssetBrowserStateSnapshot { + search: state.search.clone(), + view: state.view, + sort: state.sort, + kind_filter: state.kind_filter, + thumbnail_size: state.thumbnail_size, + recursive: state.recursive, + show_details: state.show_details, + details_width: state.details_width, + } +} + +pub(super) fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec { + if !matches!(asset.kind, EditorAssetKind::Model) { + return Vec::new(); + } + let Some(parent_path) = asset.path.as_ref() else { + return Vec::new(); + }; + let Some(record) = world + .get_resource::() + .and_then(|registry| find_asset_by_path(registry, parent_path)) + else { + return Vec::new(); + }; + let mut embedded = Vec::new(); + let settings = record.model_import(); + let static_manifest = settings + .static_mesh_manifest_path + .as_deref() + .and_then(|path| load_static_mesh_manifest(path).ok()); + let legacy_skinned_primitives = static_manifest + .as_ref() + .filter(|manifest| manifest.schema_version < 2) + .map(|_| crate::assets::gltf_skinned_primitive_labels(parent_path.as_str())) + .unwrap_or_default(); + if let Some(manifest) = static_manifest.as_ref() { + for part in &manifest.parts { + let mesh_id = part_effective_id(part); + let material_label = part.material_label.clone(); + let requires_skinned_hierarchy = manifest.metadata.animation_count > 0 + || part.skinned + || legacy_skinned_primitives.contains(&part.mesh_label); + embedded.push(EmbeddedAsset { + selection: AssetSelection::SubAsset { + parent_path: parent_path.clone(), + sub_asset_id: mesh_id, + label: part.name.clone(), + kind: AssetSubAssetKind::Mesh, + source_path: None, + }, + label: part.name.clone(), + kind: AssetSubAssetKind::Mesh, + detail: if requires_skinned_hierarchy { + "Skinned | dedicated renderer".to_string() + } else { + part.source_mesh + .clone() + .unwrap_or_else(|| part.mesh_label.clone()) + }, + texture_path: None, + thumbnail_key: subasset_thumbnail_key( + parent_path, + AssetSubAssetKind::Mesh, + &part_effective_id(part), + ), + mesh_label: Some(part.mesh_label.clone()), + material_label, + requires_skinned_hierarchy, + }); + } + + let mut material_ids = HashSet::new(); + for part in &manifest.parts { + let Some(material_id) = part_effective_material_id(part) else { + continue; + }; + if !material_ids.insert(material_id.clone()) { + continue; + } + let label = if part.material_slot_name.trim().is_empty() { + part.material_label + .clone() + .unwrap_or_else(|| "Source Material".to_string()) + } else { + part.material_slot_name.clone() + }; + let source_used = manifest.parts.iter().any(|candidate| { + part_effective_material_id(candidate).as_deref() == Some(material_id.as_str()) + && matches!( + crate::assets::static_mesh::material_selection( + settings, + &format!("slot:{}", part_effective_id(candidate)), + ), + ModelMaterialSelection::Source + ) + }); + embedded.push(EmbeddedAsset { + selection: AssetSelection::SubAsset { + parent_path: parent_path.clone(), + sub_asset_id: material_id, + label: label.clone(), + kind: AssetSubAssetKind::Material, + source_path: None, + }, + label, + kind: AssetSubAssetKind::Material, + detail: if source_used { + "Embedded source material · active model default".to_string() + } else { + "Embedded source material · available".to_string() + }, + texture_path: None, + thumbnail_key: subasset_thumbnail_key( + parent_path, + AssetSubAssetKind::Material, + &part_effective_material_id(part).unwrap_or_else(|| { + material_id_from_label( + part.material_label + .as_deref() + .unwrap_or(&part.material_slot_name), + ) + }), + ), + mesh_label: None, + material_label: part.material_label.clone(), + requires_skinned_hierarchy: false, + }); + } + + let mut texture_paths = HashSet::new(); + for dependency in &manifest.source.dependencies { + if !is_texture_path(dependency) || !texture_paths.insert(dependency.clone()) { + continue; + } + let label = Path::new(dependency) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(dependency) + .to_string(); + embedded.push(EmbeddedAsset { + selection: AssetSelection::SubAsset { + parent_path: parent_path.clone(), + sub_asset_id: format!("texture:{}", stable_subasset_slug(dependency)), + label: label.clone(), + kind: AssetSubAssetKind::Texture, + source_path: Some(dependency.clone()), + }, + label, + kind: AssetSubAssetKind::Texture, + detail: dependency.clone(), + texture_path: Some(dependency.clone()), + thumbnail_key: subasset_thumbnail_key( + parent_path, + AssetSubAssetKind::Texture, + &stable_subasset_slug(dependency), + ), + mesh_label: None, + material_label: None, + requires_skinned_hierarchy: false, + }); + } + } + + let animation_manifest = settings + .animation_manifest_path + .as_deref() + .and_then(|path| load_animation_manifest(path).ok()); + if let Some(manifest) = animation_manifest { + for skeleton in manifest.skeletons { + let signature = skeleton.signature.0; + let signature_short = signature.get(..8).unwrap_or(&signature); + embedded.push(EmbeddedAsset { + selection: AssetSelection::SubAsset { + parent_path: parent_path.clone(), + sub_asset_id: skeleton.id.clone(), + label: skeleton.label.clone(), + kind: AssetSubAssetKind::Skeleton, + source_path: Some(parent_path.clone()), + }, + label: skeleton.label, + kind: AssetSubAssetKind::Skeleton, + detail: format!( + "{} joints | rig {}", + skeleton.joint_paths.len(), + signature_short + ), + texture_path: None, + thumbnail_key: subasset_thumbnail_key( + parent_path, + AssetSubAssetKind::Skeleton, + &skeleton.id, + ), + mesh_label: None, + material_label: None, + requires_skinned_hierarchy: false, + }); + } + for clip in manifest.clips { + let event_suffix = if clip.events.is_empty() { + String::new() + } else { + format!(" | {} events", clip.events.len()) + }; + embedded.push(EmbeddedAsset { + selection: AssetSelection::SubAsset { + parent_path: parent_path.clone(), + sub_asset_id: clip.id.clone(), + label: clip.label.clone(), + kind: AssetSubAssetKind::AnimationClip, + source_path: Some(parent_path.clone()), + }, + label: clip.label, + kind: AssetSubAssetKind::AnimationClip, + detail: format!("{:.2}s{event_suffix}", clip.duration_seconds), + texture_path: None, + thumbnail_key: subasset_thumbnail_key( + parent_path, + AssetSubAssetKind::AnimationClip, + &clip.id, + ), + mesh_label: None, + material_label: None, + requires_skinned_hierarchy: false, + }); + } + } + + embedded +} + +pub(super) fn texture_asset_from_subasset_selection( + world: &World, + selection: &Option, +) -> Option { + let Some(AssetSelection::SubAsset { + label, + kind: AssetSubAssetKind::Texture, + source_path: Some(path), + .. + }) = selection + else { + return None; + }; + if let Some(asset) = world + .resource::() + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(path.as_str())) + { + return Some(asset.clone()); + } + Some(EditorAsset { + label: label.clone(), + path: Some(path.clone()), + folder_path: Path::new(path) + .parent() + .map(|parent| parent.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| ASSETS_ROOT.to_string()), + kind: EditorAssetKind::Texture, + }) +} + +pub(super) fn model_uses_source_materials(record: &crate::asset_db::AssetRecord) -> bool { + let settings = record.model_import(); + let Some(manifest_path) = settings.static_mesh_manifest_path.as_deref() else { + return true; + }; + let Ok(manifest) = load_static_mesh_manifest(manifest_path) else { + return true; + }; + manifest.parts.iter().any(|part| { + part_effective_material_id(part).is_some() + && matches!( + crate::assets::static_mesh::material_selection( + settings, + &format!("slot:{}", part_effective_id(part)), + ), + ModelMaterialSelection::Source + ) + }) +} + +pub(super) fn embedded_asset_for_selection( + world: &World, + selection: &AssetSelection, +) -> Option { + let AssetSelection::SubAsset { parent_path, .. } = selection else { + return None; + }; + let asset = world + .resource::() + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(parent_path.as_str()))? + .clone(); + embedded_assets_for_asset(world, &asset) + .into_iter() + .find(|embedded| &embedded.selection == selection) +} + +pub(super) fn has_embedded_assets(world: &World, asset: &EditorAsset) -> bool { + !embedded_assets_for_asset(world, asset).is_empty() +} + +pub(super) fn part_effective_id(part: &crate::assets::static_mesh::StaticMeshPart) -> String { + if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id.clone() + } +} + +pub(super) fn part_effective_material_id( + part: &crate::assets::static_mesh::StaticMeshPart, +) -> Option { + part.material_id.clone().or_else(|| { + part.material_label + .as_ref() + .map(|label| material_id_from_label(label)) + }) +} + +pub(super) fn is_texture_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "webp" | "ktx2" + ) + }) +} + +pub(super) fn stable_subasset_slug(label: &str) -> String { + let mut slug = String::new(); + for ch in label.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + } else if !slug.ends_with('_') { + slug.push('_'); + } + } + slug.trim_matches('_').to_string() +} + +pub(super) fn subasset_thumbnail_key( + parent_path: &str, + kind: AssetSubAssetKind, + sub_asset_id: &str, +) -> String { + format!( + "subasset:{}#{}:{}", + parent_path, + subasset_kind_label(kind).to_ascii_lowercase(), + sub_asset_id + ) +} diff --git a/crates/editor/src/ui/asset_browser/panel/file_operations.rs b/crates/editor/src/ui/asset_browser/panel/file_operations.rs new file mode 100644 index 0000000..aa5af2e --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/file_operations.rs @@ -0,0 +1,544 @@ +use super::*; + +pub(super) fn begin_new_folder(world: &mut World, parent: String) { + world + .resource_mut::() + .pending_path_edit = Some(ContentPathEdit { + kind: ContentPathEditKind::NewFolder { parent }, + name: "New Folder".into(), + }); +} + +pub(super) fn create_material_here(world: &mut World, folder: &str) { + if let Err(error) = + crate::ui::material_library::create_material_asset_in(world, Path::new(folder)) + { + world.resource_mut::().status = error; + } +} + +pub(super) fn begin_rename(world: &mut World) { + if world.resource::().selections.len() > 1 { + world.resource_mut::().status = "Rename requires one selected item".into(); + return; + } + let Some(source) = selected_content_path(world) else { + world.resource_mut::().status = "Select an asset or folder to rename".into(); + return; + }; + let name = Path::new(&source) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Asset") + .to_string(); + world + .resource_mut::() + .pending_path_edit = Some(ContentPathEdit { + kind: ContentPathEditKind::Rename { source }, + name, + }); +} + +pub(super) fn set_content_clipboard(world: &mut World, cut: bool) { + let sources = selected_content_paths(world); + if sources.is_empty() { + world.resource_mut::().status = "Select an asset or folder first".into(); + return; + } + world.resource_mut::().clipboard = Some(ContentClipboard { + sources: sources.clone(), + cut, + }); + world.resource_mut::().status = format!( + "{} {} item(s)", + if cut { "Cut" } else { "Copied" }, + sources.len() + ); +} + +pub(super) fn duplicate_selection(world: &mut World) { + let sources = selected_content_paths(world); + if sources.is_empty() { + world.resource_mut::().status = "Select an asset or folder to duplicate".into(); + return; + } + let operations = sources + .into_iter() + .map(|source| { + let source_path = Path::new(&source); + let parent = source_path + .parent() + .unwrap_or_else(|| Path::new(ASSETS_ROOT)); + content_pipeline::ContentOperation::Copy { + source: source.clone().into(), + destination: unique_copy_destination(parent, source_path), + } + }) + .collect(); + queue_content_operations(world, operations, false, false); +} + +pub(super) fn paste_content_clipboard(world: &mut World) { + let destination_folder = world.resource::().current_folder.clone(); + paste_content_clipboard_into(world, &destination_folder); +} + +pub(super) fn paste_content_clipboard_into(world: &mut World, destination_folder: &str) { + let Some(clipboard) = world.resource::().clipboard.clone() else { + return; + }; + let mut reserved = HashSet::new(); + let operations: Vec<_> = clipboard + .sources + .iter() + .filter_map(|source| { + let source_path = Path::new(source); + if clipboard.cut && source_path.parent() == Some(Path::new(destination_folder)) { + return None; + } + let destination = paste_destination( + Path::new(destination_folder), + source_path, + clipboard.cut, + &mut reserved, + ); + Some(if clipboard.cut { + content_pipeline::ContentOperation::Move { + source: source.clone().into(), + destination, + } + } else { + content_pipeline::ContentOperation::Copy { + source: source.clone().into(), + destination, + } + }) + }) + .collect(); + if operations.is_empty() { + world.resource_mut::().status = "Content is already in this folder".into(); + return; + } + queue_content_operations(world, operations, clipboard.cut, false); +} + +pub(super) fn paste_destination( + parent: &Path, + source: &Path, + cut: bool, + reserved: &mut HashSet, +) -> PathBuf { + let direct = parent.join(source.file_name().unwrap_or_default()); + if cut { + // Moves keep their intended filename. Existing or duplicate targets must remain visible as + // review collisions instead of silently turning a move into a renamed copy. + return direct; + } + if !direct.exists() && reserved.insert(direct.clone()) { + return direct; + } + let mut candidate = unique_copy_destination(parent, source); + while !reserved.insert(candidate.clone()) { + candidate = unique_copy_destination(parent, &candidate); + } + candidate +} + +pub(super) fn unique_copy_destination(parent: &Path, source: &Path) -> PathBuf { + let name = source + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Asset"); + let (stem, suffix) = name + .split_once('.') + .map_or((name, ""), |(stem, suffix)| (stem, suffix)); + for index in 1.. { + let marker = if index == 1 { + "_copy".into() + } else { + format!("_copy_{index}") + }; + let candidate_name = if suffix.is_empty() { + format!("{stem}{marker}") + } else { + format!("{stem}{marker}.{suffix}") + }; + let candidate = parent.join(candidate_name); + if !candidate.exists() { + return candidate; + } + } + unreachable!() +} + +pub(super) fn draw_path_edit_modal(world: &mut World, ctx: &egui::Context) { + let Some(mut edit) = world + .resource::() + .pending_path_edit + .clone() + else { + return; + }; + let title = match edit.kind { + ContentPathEditKind::Rename { .. } => "Rename Content", + ContentPathEditKind::NewFolder { .. } => "Create Folder", + }; + let mut apply = false; + let mut cancel = false; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label("Name"); + let response = ui.add_sized([300.0, 24.0], egui::TextEdit::singleline(&mut edit.name)); + let valid = valid_content_name(&edit.name); + if !valid { + ui.colored_label( + crate::ui::theme::ERROR, + "Use a normal file/folder name without slashes.", + ); + } + ui.horizontal(|ui| { + apply = ui.add_enabled(valid, egui::Button::new("Apply")).clicked() + || (valid + && response.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter))); + cancel = ui.button("Cancel").clicked(); + }); + }); + if cancel { + world + .resource_mut::() + .pending_path_edit = None; + } else if apply { + let operation = match &edit.kind { + ContentPathEditKind::Rename { source } => { + let source_path = Path::new(source); + let destination = source_path + .parent() + .unwrap_or_else(|| Path::new(ASSETS_ROOT)) + .join(&edit.name); + content_pipeline::ContentOperation::Move { + source: source.into(), + destination, + } + } + ContentPathEditKind::NewFolder { parent } => { + content_pipeline::ContentOperation::CreateFolder { + path: Path::new(parent).join(&edit.name), + } + } + }; + if queue_content_operations(world, vec![operation], false, false) { + world + .resource_mut::() + .pending_path_edit = None; + } + } else { + world + .resource_mut::() + .pending_path_edit = Some(edit); + } +} + +pub(super) fn valid_content_name(name: &str) -> bool { + let trimmed = name.trim(); + !trimmed.is_empty() + && !matches!(trimmed, "." | "..") + && !trimmed.contains(['/', '\\']) + && !trimmed.starts_with('.') +} + +pub(super) fn queue_content_operations( + world: &mut World, + operations: Vec, + clear_cut_clipboard: bool, + clear_drag: bool, +) -> bool { + if operations.is_empty() { + return false; + } + let document = world + .resource::() + .document(); + let preview = match content_pipeline::preview_transaction(Path::new("."), operations, &document) + { + Ok(preview) => preview, + Err(error) => { + world.resource_mut::().status = + format!("Content operation preview failed: {error}"); + return false; + } + }; + let operation_count = preview.operations.len(); + let affected_count = preview.affected_asset_ids.len(); + let rewrite_count = preview.reference_rewrites.len(); + let mut guarded_paths = HashSet::new(); + for operation in &preview.operations { + if let content_pipeline::ContentOperation::Move { source, .. } + | content_pipeline::ContentOperation::Copy { source, .. } = operation + { + guarded_paths.insert(source.clone()); + } + } + guarded_paths.extend( + preview + .reference_rewrites + .iter() + .map(|rewrite| rewrite.document.clone()), + ); + let mut guarded_paths = guarded_paths + .into_iter() + .map(|path| { + content_pipeline::tree_fingerprint(&path).map(|fingerprint| (path, fingerprint)) + }) + .collect::, _>>() + .map_err(|error| { + world.resource_mut::().status = + format!("Content operation guard failed: {error}"); + }); + let Ok(ref mut guarded_paths) = guarded_paths else { + return false; + }; + guarded_paths.sort_by(|left, right| left.0.cmp(&right.0)); + let browser_snapshot = capture_content_browser_snapshot(world); + world + .resource_mut::() + .pending_content_transaction = Some(PendingContentTransaction { + preview, + guarded_paths: std::mem::take(guarded_paths), + clear_cut_clipboard, + clear_drag, + browser_snapshot, + }); + world.resource_mut::().status = format!( + "Review {operation_count} content operation(s): {affected_count} asset ID(s), {rewrite_count} cached reference rewrite(s)" + ); + true +} + +pub(super) fn draw_content_transaction_modal(world: &mut World, ctx: &egui::Context) { + let Some(pending) = world + .resource::() + .pending_content_transaction + .clone() + else { + return; + }; + let mut cancel = false; + let mut commit = false; + let collaboration_statuses = + content_transaction_collaboration_statuses(world, &pending.preview); + let has_collaboration_blocker = collaboration_statuses + .iter() + .any(|(_, _, _, blocked)| *blocked); + let externally_changed = pending + .guarded_paths + .iter() + .filter(|(path, expected)| { + content_pipeline::tree_fingerprint(path).as_deref() != Ok(expected.as_str()) + }) + .map(|(path, _)| path.clone()) + .collect::>(); + egui::Window::new("Review Content Operation") + .collapsible(false) + .resizable(true) + .default_width(660.0) + .min_width(520.0) + .show(ctx, |ui| { + ui.small( + egui::RichText::new( + "Filesystem paths, registry identity, generated catalog entries, and cached project references commit together or roll back together.", + ) + .color(TEXT_DIM), + ); + ui.separator(); + egui::ScrollArea::vertical() + .max_height(260.0) + .show(ui, |ui| { + for operation in &pending.preview.operations { + ui.label(content_operation_summary(operation)); + } + }); + ui.separator(); + ui.label(format!( + "Affected registered assets: {}", + pending.preview.affected_asset_ids.len() + )); + ui.label(format!( + "Cached reference documents to rewrite: {}", + pending.preview.reference_rewrites.len() + )); + for rewrite in pending.preview.reference_rewrites.iter().take(8) { + ui.small(format!( + "{}: {} → {}", + rewrite.document.display(), + rewrite.from, + rewrite.to + )); + } + if pending.preview.reference_rewrites.len() > 8 { + ui.small(format!( + "…and {} more", + pending.preview.reference_rewrites.len() - 8 + )); + } + if !pending.preview.collisions.is_empty() { + ui.separator(); + ui.colored_label(ERROR, "Resolve these path collisions before committing:"); + for collision in &pending.preview.collisions { + ui.small(collision.display().to_string()); + } + } + if !collaboration_statuses.is_empty() { + ui.separator(); + ui.label("Source-control / ownership state"); + for (path, label, tooltip, blocked) in &collaboration_statuses { + ui.horizontal(|ui| { + ui.small(path.display().to_string()); + ui.colored_label(if *blocked { ERROR } else { WARNING }, label) + .on_hover_text(tooltip); + }); + } + } + if !externally_changed.is_empty() { + ui.separator(); + ui.colored_label( + ERROR, + "Content changed after this preview. Cancel and review the operation again:", + ); + for path in &externally_changed { + ui.small(path.display().to_string()); + } + } + ui.separator(); + ui.horizontal(|ui| { + cancel = ui.button("Cancel").clicked(); + commit = ui + .add_enabled( + pending.preview.is_committable() + && !has_collaboration_blocker + && externally_changed.is_empty(), + egui::Button::new("Commit Transaction"), + ) + .clicked(); + }); + }); + + if cancel { + restore_cancelled_content_transaction(world, &pending); + world + .resource_mut::() + .pending_content_transaction = None; + } else if commit { + let operations = pending.preview.operations.clone(); + if commit_content_operations_internal(world, operations, true) { + if pending.clear_cut_clipboard { + world.resource_mut::().clipboard = None; + } + if pending.clear_drag { + world.resource_mut::().clear_drag(); + } + world + .resource_mut::() + .pending_content_transaction = None; + } + } +} + +pub(super) fn capture_content_browser_snapshot(world: &World) -> ContentBrowserSnapshot { + let assets = world.resource::(); + ContentBrowserSnapshot { + current_folder: assets.current_folder.clone(), + selected: assets.selected.clone(), + selections: assets.selections.clone(), + selection_anchor: assets.selection_anchor.clone(), + dragging: assets.dragging.clone(), + asset_status: assets.status.clone(), + scene_status: world.resource::().status.clone(), + } +} + +pub(super) fn restore_cancelled_content_transaction( + world: &mut World, + pending: &PendingContentTransaction, +) { + let snapshot = &pending.browser_snapshot; + { + let mut assets = world.resource_mut::(); + assets.current_folder.clone_from(&snapshot.current_folder); + assets.selected.clone_from(&snapshot.selected); + assets.selections.clone_from(&snapshot.selections); + assets + .selection_anchor + .clone_from(&snapshot.selection_anchor); + assets.dragging = if pending.clear_drag { + None + } else { + snapshot.dragging.clone() + }; + assets.status.clone_from(&snapshot.asset_status); + } + world + .resource_mut::() + .status + .clone_from(&snapshot.scene_status); +} + +pub(super) fn content_transaction_collaboration_statuses( + world: &World, + preview: &content_pipeline::ContentTransactionPreview, +) -> Vec<(PathBuf, String, String, bool)> { + let Some(collaboration) = world.get_resource::() else { + return Vec::new(); + }; + let mut paths = HashSet::new(); + for operation in &preview.operations { + if let content_pipeline::ContentOperation::Move { source, .. } + | content_pipeline::ContentOperation::Copy { source, .. } = operation + { + paths.insert(source.clone()); + } + } + paths.extend( + preview + .reference_rewrites + .iter() + .map(|rewrite| rewrite.document.clone()), + ); + let mut statuses = paths + .into_iter() + .filter_map(|path| { + let status = collaboration.file_status(&path); + let indicator = status.indicator()?; + let label = indicator.label.to_string(); + let blocked = status.readonly || status.locked_by_other || label == "CONFLICT"; + let tooltip = status.tooltip(&path); + Some((path, label, tooltip, blocked)) + }) + .collect::>(); + statuses.sort_by(|left, right| left.0.cmp(&right.0)); + statuses +} + +pub(super) fn content_operation_summary(operation: &content_pipeline::ContentOperation) -> String { + match operation { + content_pipeline::ContentOperation::CreateFolder { path } => { + format!("Create folder {}", path.display()) + } + content_pipeline::ContentOperation::Move { + source, + destination, + } => format!("Move {} → {}", source.display(), destination.display()), + content_pipeline::ContentOperation::Copy { + source, + destination, + } => format!("Copy {} → {}", source.display(), destination.display()), + content_pipeline::ContentOperation::WriteFile { path, bytes } => { + format!("Create asset {} ({} bytes)", path.display(), bytes.len()) + } + content_pipeline::ContentOperation::ReplaceFile { path, bytes, .. } => { + format!("Replace asset {} ({} bytes)", path.display(), bytes.len()) + } + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/grid.rs b/crates/editor/src/ui/asset_browser/panel/grid.rs new file mode 100644 index 0000000..7dce05e --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/grid.rs @@ -0,0 +1,567 @@ +use super::*; + +pub(super) fn content_empty_space( + world: &mut World, + response: egui::Response, + current_folder: &str, + visible_order: &[AssetSelection], +) { + if response.clicked() || response.secondary_clicked() { + world.resource_mut::().clear_selection(); + } + response.context_menu(|ui| { + empty_space_context_menu(world, ui, current_folder, visible_order); + }); +} + +#[expect( + clippy::too_many_arguments, + reason = "asset grid rendering keeps immediate-mode UI inputs explicit" +)] +pub(super) fn asset_grid( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selected: &Option, + folders: &[FolderSnapshot], + assets: &[AssetRow], + cache_snapshot: &ThumbnailCacheSnapshot, + thumbnail_size: f32, + visible_order: &[AssetSelection], +) { + let thumb_size = thumbnail_size.clamp(48.0, 112.0); + let cell_width = thumb_size + 20.0; + let gap = 6.0; + let columns = ((ui.available_width() + gap) / (cell_width + gap)) + .floor() + .max(1.0) as usize; + + for folder_row in folders.chunks(columns) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(gap, gap); + for folder in folder_row { + let selection = AssetSelection::Folder(folder.path.clone()); + let is_selected = world.resource::().is_selected(&selection); + let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected); + handle_asset_drop_to_folder(world, ui, &response, &folder.path); + if response.secondary_clicked() { + prepare_context_menu_selection(world, selection.clone()); + } + if response.double_clicked() { + navigate_content_folder(world, folder.path.clone()); + } else if response.clicked() { + apply_content_click_selection(world, ui, selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(selection.clone()); + } + response.context_menu(|ui| folder_context_menu(world, ui, folder)); + } + }); + } + + for asset_row in assets.chunks(columns) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(gap, gap); + for row in asset_row { + draw_asset_grid_item( + world, + ui, + selected_entities, + selected, + row, + cache_snapshot, + thumbnail_size, + visible_order, + ); + } + }); + + for row in asset_row { + if row.asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }) { + embedded_asset_shelf( + world, + ui, + selected_entities, + selected, + &row.asset, + cache_snapshot, + thumbnail_size, + ); + } + } + } +} + +pub(super) fn handle_asset_drop_to_folder( + world: &mut World, + ui: &egui::Ui, + response: &egui::Response, + destination_folder: &str, +) { + if !response.hovered() || !ui.input(|input| input.pointer.any_released()) { + return; + } + let dragging = world + .resource::() + .dragging_selection() + .cloned(); + let Some(dragging) = dragging else { + return; + }; + let sources = if world.resource::().is_selected(&dragging) { + selected_content_paths(world) + } else { + dragging + .parent_path() + .map(|path| vec![path.to_string()]) + .unwrap_or_default() + }; + let operations: Vec<_> = sources + .into_iter() + .filter_map(|source| { + let file_name = Path::new(&source).file_name()?; + let destination = Path::new(destination_folder).join(file_name); + (destination.as_path() != Path::new(&source)).then_some( + content_pipeline::ContentOperation::Move { + source: PathBuf::from(source), + destination, + }, + ) + }) + .collect(); + if !operations.is_empty() { + queue_content_operations(world, operations, false, true); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "asset rows keep selection, thumbnail, and visible-order UI inputs explicit" +)] +pub(super) fn draw_asset_grid_item( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + _selected: &Option, + row: &AssetRow, + cache_snapshot: &ThumbnailCacheSnapshot, + thumbnail_size: f32, + visible_order: &[AssetSelection], +) { + let selection = &row.selection; + let asset = &row.asset; + let is_selected = world.resource::().is_selected(selection); + let failure = cache_snapshot.failure_reason(asset); + let has_children = has_embedded_assets(world, asset); + let document_status = asset.path.as_deref().and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }); + let response = draw_asset_card( + ui, + asset, + cache_snapshot.texture_for(asset), + cache_snapshot.is_pending(asset), + failure, + is_selected, + thumbnail_size, + AssetCardStatus::for_document( + document_status.as_ref(), + document_status.as_ref().is_some_and(|status| status.dirty), + failure.is_some(), + ), + ); + + if has_children { + let expanded = asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }); + let icon = if expanded { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }; + let button_rect = egui::Rect::from_min_size( + response.rect.min + egui::vec2(6.0, 6.0), + egui::vec2(18.0, 18.0), + ); + let expand_response = ui.interact( + button_rect, + egui::Id::new(( + "asset_expand", + asset.path.as_deref().unwrap_or(&asset.label), + )), + egui::Sense::click(), + ); + ui.painter().rect( + button_rect, + 3.0, + if expand_response.hovered() { + ELEVATED_BG + } else { + WIDGET_BG.linear_multiply(1.15) + }, + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + ui.painter().text( + button_rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(11.0, egui::FontFamily::Name(PHOSPHOR.into())), + TEXT, + ); + if expand_response.clicked() { + if let Some(path) = asset.path.as_ref() { + toggle_asset_expanded(world, path); + } + } + } + + if response.double_clicked() { + if failure.is_some() { + let key = crate::assets::asset_cache_key(asset); + crate::assets::retry_thumbnail(world, &key); + } else { + activate_content_asset(world, asset); + } + } else if response.clicked() { + apply_content_click_selection(world, ui, selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, selection.clone()); + } + response.context_menu(|ui| { + asset_context_menu(world, ui, asset, selected_entities); + }); +} + +pub(super) fn embedded_asset_shelf( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selected: &Option, + parent_asset: &EditorAsset, + cache_snapshot: &ThumbnailCacheSnapshot, + thumbnail_size: f32, +) { + let embedded = embedded_assets_for_asset(world, parent_asset); + if embedded.is_empty() { + return; + } + prefetch_embedded_thumbnails(world, parent_asset, &embedded); + let parent_texture = cache_snapshot.texture_for(parent_asset); + + ui.add_space(4.0); + egui::Frame::new() + .fill(WIDGET_BG.linear_multiply(0.72)) + .stroke(egui::Stroke::new(1.0_f32, BORDER.linear_multiply(0.85))) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(icon_text(icons::TREE_STRUCTURE, 13.0).color(TEXT_DIM)); + ui.small( + egui::RichText::new(format!("{} contents", parent_asset.label)).color(TEXT_DIM), + ); + }); + egui::ScrollArea::horizontal() + .id_salt(format!( + "embedded_shelf_{}", + parent_asset.path.as_deref().unwrap_or(&parent_asset.label) + )) + .max_height(thumbnail_size.clamp(48.0, 88.0) + 56.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(7.0, 5.0); + for embedded_asset in &embedded { + draw_embedded_asset_cell( + world, + ui, + selected_entities, + selected, + embedded_asset, + cache_snapshot, + parent_texture, + thumbnail_size, + ); + } + }); + }); + }); + ui.add_space(4.0); +} + +pub(super) fn prefetch_embedded_thumbnails( + world: &mut World, + parent_asset: &EditorAsset, + embedded: &[EmbeddedAsset], +) { + let Some(parent_path) = parent_asset.path.clone() else { + return; + }; + let asset_server = world.resource::().clone(); + let requests: Vec = embedded.to_vec(); + world.resource_scope(|world, mut cache: Mut| { + world.resource_scope(|_world, mut studio: Mut| { + for embedded_asset in &requests { + match embedded_asset.kind { + AssetSubAssetKind::Mesh => { + let Some(mesh_label) = embedded_asset.mesh_label.clone() else { + continue; + }; + cache.request_mesh_subasset( + embedded_asset.thumbnail_key.clone(), + parent_path.clone(), + mesh_label, + embedded_asset.material_label.clone(), + embedded_asset.requires_skinned_hierarchy, + &mut studio, + ); + } + AssetSubAssetKind::Material => { + let Some(material_label) = embedded_asset.material_label.clone() else { + continue; + }; + cache.request_source_material( + embedded_asset.thumbnail_key.clone(), + parent_path.clone(), + material_label, + &mut studio, + ); + } + AssetSubAssetKind::Texture => { + let Some(texture_path) = embedded_asset.texture_path.clone() else { + continue; + }; + cache.request_texture( + embedded_asset.thumbnail_key.clone(), + texture_path, + &asset_server, + ); + } + AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {} + } + } + }); + }); +} + +#[expect( + clippy::too_many_arguments, + reason = "embedded asset rendering keeps immediate-mode UI inputs explicit" +)] +pub(super) fn draw_embedded_asset_cell( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selected: &Option, + embedded: &EmbeddedAsset, + cache_snapshot: &ThumbnailCacheSnapshot, + _parent_texture: Option, + thumbnail_size: f32, +) { + let selected = selected.as_ref() == Some(&embedded.selection); + let texture_id = cache_snapshot + .texture_for_key(&embedded.thumbnail_key) + .or_else(|| { + embedded + .texture_path + .as_deref() + .and_then(|path| thumbnail_for_path(world, cache_snapshot, path)) + }); + let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key); + let failure = cache_snapshot.failure_reason_for_key(&embedded.thumbnail_key); + let failed = failure.is_some(); + let thumb_size = thumbnail_size.clamp(44.0, 64.0); + let cell_size = egui::vec2(104.0, thumb_size + 50.0); + let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); + let response = if embedded.requires_skinned_hierarchy { + response.on_hover_text( + "Skinned primitives place through the dedicated renderer so their joint hierarchy remains intact.", + ) + } else { + response + }; + let response = if let Some(reason) = failure { + response.on_hover_text(reason) + } else { + response + }; + let fill = if selected { + crate::ui::theme::SELECTION_BG_MUTED + } else if response.hovered() { + WIDGET_BG.linear_multiply(1.2) + } else { + WIDGET_BG + }; + ui.painter().rect( + rect, + 4.0, + fill, + egui::Stroke::new(1.0_f32, if selected { ACCENT } else { BORDER }), + egui::StrokeKind::Inside, + ); + let thumb_rect = egui::Rect::from_min_size( + egui::pos2(rect.center().x - thumb_size * 0.5, rect.min.y + 8.0), + egui::vec2(thumb_size, thumb_size), + ); + if let Some(texture_id) = texture_id { + ui.painter().image( + texture_id, + thumb_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + ui.painter().rect( + thumb_rect, + 4.0, + ELEVATED_BG.linear_multiply(0.6), + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + let icon = if pending { + icons::CIRCLE_NOTCH + } else if failed { + icons::WARNING_CIRCLE + } else { + subasset_icon(embedded.kind) + }; + ui.painter().text( + thumb_rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(24.0, egui::FontFamily::Name(PHOSPHOR.into())), + TEXT, + ); + } + + let label = compact_subasset_label(embedded); + let detail = compact_text(&embedded.detail, 18); + let label_pos = egui::pos2(rect.center().x, thumb_rect.max.y + 7.0); + ui.painter().text( + label_pos, + egui::Align2::CENTER_TOP, + label.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Proportional), + if selected { + crate::ui::theme::TEXT_SELECTED + } else { + TEXT + }, + ); + ui.painter().text( + label_pos + egui::vec2(0.0, 17.0), + egui::Align2::CENTER_TOP, + format!("{} | {}", subasset_kind_label(embedded.kind), detail), + egui::FontId::new(10.0, egui::FontFamily::Proportional), + TEXT_DIM, + ); + + if response.clicked() { + world + .resource_mut::() + .select(embedded.selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, embedded.selection.clone()); + } + if response.drag_started() && embedded.kind != AssetSubAssetKind::Skeleton { + world + .resource_mut::() + .start_drag(embedded.selection.clone()); + } + response.context_menu(|ui| { + subasset_context_menu(world, ui, embedded, selected_entities); + }); +} + +pub(super) fn compact_subasset_label(embedded: &EmbeddedAsset) -> String { + let base = match embedded.kind { + AssetSubAssetKind::Mesh => embedded + .label + .split_once(" / ") + .map(|(head, _)| head) + .unwrap_or(&embedded.label), + AssetSubAssetKind::Material + | AssetSubAssetKind::Texture + | AssetSubAssetKind::Skeleton + | AssetSubAssetKind::AnimationClip => embedded.label.as_str(), + }; + compact_text(base, 16) +} + +pub(super) fn compact_text(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let mut compact = String::new(); + for _ in 0..max_chars { + let Some(ch) = chars.next() else { + return text.to_string(); + }; + compact.push(ch); + } + if chars.next().is_some() { + compact.push_str("..."); + } + compact +} + +pub(super) fn thumbnail_for_path( + world: &World, + cache_snapshot: &ThumbnailCacheSnapshot, + path: &str, +) -> Option { + let assets = world.resource::(); + assets + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(path)) + .and_then(|asset| cache_snapshot.texture_for(asset)) +} + +pub(super) fn toggle_asset_expanded(world: &mut World, path: &str) { + let mut state = world.resource_mut::(); + if !state.expanded_assets.insert(path.to_string()) { + state.expanded_assets.remove(path); + } +} + +pub(super) fn subasset_icon(kind: AssetSubAssetKind) -> Icon { + match kind { + AssetSubAssetKind::Mesh => icons::CUBE, + AssetSubAssetKind::Material => icons::PALETTE, + AssetSubAssetKind::Texture => icons::IMAGE, + AssetSubAssetKind::Skeleton => icons::BONE, + AssetSubAssetKind::AnimationClip => icons::FILM_STRIP, + } +} + +pub(super) fn subasset_kind_label(kind: AssetSubAssetKind) -> &'static str { + match kind { + AssetSubAssetKind::Mesh => "Mesh", + AssetSubAssetKind::Material => "Material", + AssetSubAssetKind::Texture => "Texture", + AssetSubAssetKind::Skeleton => "Skeleton", + AssetSubAssetKind::AnimationClip => "Animation Clip", + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/import_settings.rs b/crates/editor/src/ui/asset_browser/panel/import_settings.rs new file mode 100644 index 0000000..8542e7d --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/import_settings.rs @@ -0,0 +1,566 @@ +use super::*; + +pub(super) fn import_settings_editor( + world: &mut World, + ui: &mut egui::Ui, + _asset: &EditorAsset, + path: &str, + current: &ImportSettings, +) { + let material_slots = current + .static_mesh_manifest_path + .as_deref() + .and_then(|manifest_path| load_static_mesh_manifest(manifest_path).ok()) + .map(|manifest| { + manifest + .parts + .into_iter() + .map(|part| { + let part_id = if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id + }; + ( + shared::ComponentInstanceId::new(format!("slot:{part_id}")), + part.material_slot_name, + part.material_id.is_some() || part.material_label.is_some(), + ) + }) + .collect::>() + }) + .unwrap_or_default(); + let project_materials = world + .resource::() + .records + .iter() + .filter(|record| { + matches!( + record.kind, + AssetKind::Material | AssetKind::MaterialInstance + ) + }) + .map(|record| { + ( + record.label.clone(), + MaterialRef::new( + EditorAssetRef::new(record.id.as_string(), "", record.label.clone()) + .with_source_path(record.path.clone()), + ), + ) + }) + .collect::>(); + if Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "gltf" | "glb" | "fbx" + ) + }) + && ui.button("Extract Editable Materials Here").clicked() + { + begin_gltf_material_extraction(world, path); + } + let animation_clip_options = current + .animation_manifest_path + .as_deref() + .and_then(|manifest_path| load_animation_manifest(manifest_path).ok()) + .map(|manifest| { + manifest + .clips + .into_iter() + .map(|clip| (clip.id, clip.label)) + .collect::>() + }) + .unwrap_or_default(); + let registry_record = world + .resource::() + .records + .iter() + .find(|record| record.path == path) + .cloned() + .expect("model details require a registry record"); + let document_key = + crate::asset_documents::ensure_model_import_document(world, ®istry_record); + let mut draft_settings = world + .resource::() + .model_import(&document_key) + .cloned() + .unwrap_or_else(|| current.clone()); + let original_settings = draft_settings.clone(); + if let Some(snapshot) = world + .resource::() + .snapshot(&document_key) + { + authored_document_status_ui(ui, &snapshot); + } + let mut locate_material_path = None; + ui.add(egui::Slider::new(&mut draft_settings.scale, 0.01..=10.0).text("Scale")); + ui.checkbox(&mut draft_settings.generate_collider, "Generate collider"); + ui.checkbox(&mut draft_settings.lod0_only, "LOD0 only"); + egui::ComboBox::from_id_salt("model_placement_mode") + .selected_text(match draft_settings.placement_mode { + ModelPlacementMode::StaticAsset => "Renderable Asset (Auto)", + ModelPlacementMode::SceneInstance => "Scene Instance", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.placement_mode, + ModelPlacementMode::StaticAsset, + "Renderable Asset (Auto)", + ); + ui.selectable_value( + &mut draft_settings.placement_mode, + ModelPlacementMode::SceneInstance, + "Scene Instance", + ); + }); + egui::ComboBox::from_id_salt("model_hierarchy_mode") + .selected_text(match draft_settings.hierarchy_mode { + ModelHierarchyMode::SingleActor => "One Actor", + ModelHierarchyMode::SourceHierarchy => "Source Hierarchy", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.hierarchy_mode, + ModelHierarchyMode::SingleActor, + "One Actor", + ); + ui.selectable_value( + &mut draft_settings.hierarchy_mode, + ModelHierarchyMode::SourceHierarchy, + "Source Hierarchy", + ); + }); + if !material_slots.is_empty() { + ui.separator(); + ui.label(egui::RichText::new("Model Material Slots").strong()); + ui.horizontal(|ui| { + if ui.small_button("All Source").clicked() { + draft_settings.material_slots = material_slots + .iter() + .map(|(slot_id, _, _)| ModelMaterialSlotSelection { + slot_id: slot_id.clone(), + selection: ModelMaterialSelection::Source, + }) + .collect(); + } + if ui.small_button("All Default").clicked() { + draft_settings.material_slots = material_slots + .iter() + .map(|(slot_id, _, _)| ModelMaterialSlotSelection { + slot_id: slot_id.clone(), + selection: ModelMaterialSelection::Default, + }) + .collect(); + } + }); + for (slot_id, name, source_valid) in &material_slots { + let selection = draft_settings + .material_slots + .iter() + .find(|entry| entry.slot_id == *slot_id) + .map(|entry| entry.selection.clone()) + .unwrap_or_default(); + let selected_text = match &selection { + ModelMaterialSelection::Source if *source_valid => "Source".to_string(), + ModelMaterialSelection::Source => "Source (missing → DefaultGrid)".to_string(), + ModelMaterialSelection::Project(reference) => reference.0.label.clone(), + ModelMaterialSelection::Default => { + "Default (project fallback → DefaultGrid)".to_string() + } + }; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(name); + ui.small(format!("{} · source {}", slot_id.0, if *source_valid { "valid" } else { "missing" })); + }); + egui::ComboBox::from_id_salt(("model_material_slot", &slot_id.0)) + .selected_text(selected_text) + .show_ui(ui, |ui| { + let mut chosen = None; + if ui.selectable_label(matches!(selection, ModelMaterialSelection::Source), "Source").clicked() { + chosen = Some(ModelMaterialSelection::Source); + } + for (label, reference) in &project_materials { + let selected = matches!(&selection, ModelMaterialSelection::Project(current) if current == reference); + if ui.selectable_label(selected, label).clicked() { + chosen = Some(ModelMaterialSelection::Project(reference.clone())); + } + } + if ui.selectable_label(matches!(selection, ModelMaterialSelection::Default), "Default (project fallback → DefaultGrid)").clicked() { + chosen = Some(ModelMaterialSelection::Default); + } + if let Some(selection) = chosen { + if let Some(entry) = draft_settings.material_slots.iter_mut().find(|entry| entry.slot_id == *slot_id) { + entry.selection = selection; + } else { + draft_settings.material_slots.push(ModelMaterialSlotSelection { slot_id: slot_id.clone(), selection }); + } + } + }); + if let ModelMaterialSelection::Project(reference) = &selection { + let source_path = reference.0.source_path.clone(); + if ui + .add_enabled(source_path.is_some(), egui::Button::new("Locate")) + .on_disabled_hover_text("The referenced project Material has no content path") + .clicked() + { + locate_material_path = source_path; + } + } + if !matches!(selection, ModelMaterialSelection::Source) + && ui.small_button("Clear").clicked() + { + set_model_material_slot_selection( + &mut draft_settings, + slot_id, + ModelMaterialSelection::Source, + ); + } + }); + } + let mut orphan_to_clear = None; + for orphan in &draft_settings.orphaned_material_slots { + ui.horizontal(|ui| { + ui.colored_label( + WARNING, + format!( + "Orphaned {} → {}", + orphan.slot_id.0, orphan.material.0.label + ), + ); + let source_path = orphan.material.0.source_path.clone(); + if ui + .add_enabled(source_path.is_some(), egui::Button::new("Locate")) + .on_disabled_hover_text("The referenced project Material has no content path") + .clicked() + { + locate_material_path = source_path; + } + if ui.small_button("Clear Orphan").clicked() { + orphan_to_clear = Some(orphan.slot_id.clone()); + } + }); + } + if let Some(slot_id) = orphan_to_clear { + clear_orphaned_model_material_selection(&mut draft_settings, &slot_id); + } + ui.small("Precedence: scene slot → model slot → imported source → project default → DefaultGrid."); + } + if !animation_clip_options.is_empty() { + let selected_default = draft_settings + .default_animation_clip_id + .as_deref() + .and_then(|id| { + animation_clip_options + .iter() + .find(|(clip_id, _)| clip_id == id) + .map(|(_, label)| label.as_str()) + }) + .unwrap_or_else(|| { + if draft_settings.default_animation_clip_id.is_some() { + "Missing clip" + } else { + "Imported rest pose" + } + }); + egui::ComboBox::from_id_salt("model_default_animation_clip") + .selected_text(selected_default) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.default_animation_clip_id, + None, + "Imported rest pose", + ); + for (clip_id, label) in &animation_clip_options { + ui.selectable_value( + &mut draft_settings.default_animation_clip_id, + Some(clip_id.clone()), + label, + ); + } + }); + ui.small("Default animation is sampled and paused in edit mode; no clip is guessed."); + } + if draft_settings != original_settings { + world + .resource_mut::() + .update_model_import(&document_key, draft_settings); + } + if let Some(path) = locate_material_path { + locate_content_asset(world, &path); + } +} + +pub(super) fn set_model_material_slot_selection( + settings: &mut ImportSettings, + slot_id: &shared::ComponentInstanceId, + selection: ModelMaterialSelection, +) { + if let Some(entry) = settings + .material_slots + .iter_mut() + .find(|entry| entry.slot_id == *slot_id) + { + entry.selection = selection; + } else { + settings.material_slots.push(ModelMaterialSlotSelection { + slot_id: slot_id.clone(), + selection, + }); + } +} + +pub(super) fn clear_orphaned_model_material_selection( + settings: &mut ImportSettings, + slot_id: &shared::ComponentInstanceId, +) -> bool { + let previous_len = settings.orphaned_material_slots.len(); + settings + .orphaned_material_slots + .retain(|orphan| orphan.slot_id != *slot_id); + settings.orphaned_material_slots.len() != previous_len +} + +pub(super) fn texture_import_settings_editor( + world: &mut World, + ui: &mut egui::Ui, + path: &str, + record: &crate::asset_db::AssetRecord, + current: &TextureImportSettings, +) { + let document_key = crate::asset_documents::ensure_texture_import_document(world, record); + let mut draft_settings = world + .resource::() + .texture_import(&document_key) + .cloned() + .unwrap_or_else(|| current.clone()); + let original_settings = draft_settings.clone(); + if let Some(snapshot) = world + .resource::() + .snapshot(&document_key) + { + authored_document_status_ui(ui, &snapshot); + } + egui::Grid::new(("texture_import_settings", path)) + .num_columns(2) + .spacing([12.0, 6.0]) + .show(ui, |ui| { + ui.label("Semantic"); + egui::ComboBox::from_id_salt(("texture_semantic", path)) + .selected_text(texture_semantic_label(draft_settings.semantic)) + .show_ui(ui, |ui| { + for (value, label) in [ + (TextureAssetSemantic::Auto, "Auto"), + (TextureAssetSemantic::Color, "Color"), + (TextureAssetSemantic::Normal, "Normal"), + (TextureAssetSemantic::MaskData, "Mask / Data"), + (TextureAssetSemantic::Ui, "UI"), + (TextureAssetSemantic::Hdr, "HDR"), + ] { + ui.selectable_value(&mut draft_settings.semantic, value, label); + } + }); + ui.end_row(); + + ui.label("Color space"); + egui::ComboBox::from_id_salt(("texture_color_space", path)) + .selected_text(match draft_settings.color_space { + TextureColorSpace::Auto => "Auto", + TextureColorSpace::Srgb => "sRGB", + TextureColorSpace::Linear => "Linear", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.color_space, + TextureColorSpace::Auto, + "Auto", + ); + ui.selectable_value( + &mut draft_settings.color_space, + TextureColorSpace::Srgb, + "sRGB", + ); + ui.selectable_value( + &mut draft_settings.color_space, + TextureColorSpace::Linear, + "Linear", + ); + }); + ui.end_row(); + + ui.label("Mipmaps"); + egui::ComboBox::from_id_salt(("texture_mipmaps", path)) + .selected_text(match draft_settings.mipmaps { + TextureMipmapMode::Generate => "Generate", + TextureMipmapMode::PreserveSource => "Preserve Source", + TextureMipmapMode::None => "None", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.mipmaps, + TextureMipmapMode::Generate, + "Generate", + ); + ui.selectable_value( + &mut draft_settings.mipmaps, + TextureMipmapMode::PreserveSource, + "Preserve Source", + ); + ui.selectable_value( + &mut draft_settings.mipmaps, + TextureMipmapMode::None, + "None", + ); + }); + ui.end_row(); + + ui.label("Compression"); + egui::ComboBox::from_id_salt(("texture_compression", path)) + .selected_text(match draft_settings.compression { + TextureCompression::Auto => "Auto", + TextureCompression::Uastc => "Basis UASTC", + TextureCompression::Uncompressed => "Uncompressed KTX2", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.compression, + TextureCompression::Auto, + "Auto", + ); + ui.selectable_value( + &mut draft_settings.compression, + TextureCompression::Uastc, + "Basis UASTC", + ); + ui.selectable_value( + &mut draft_settings.compression, + TextureCompression::Uncompressed, + "Uncompressed KTX2", + ); + }); + ui.end_row(); + + ui.label("Maximum size"); + ui.horizontal(|ui| { + let mut enabled = draft_settings.max_dimension.is_some(); + if ui.checkbox(&mut enabled, "").changed() { + draft_settings.max_dimension = enabled.then_some(2048); + } + if let Some(dimension) = draft_settings.max_dimension.as_mut() { + ui.add(egui::DragValue::new(dimension).range(1..=16_384)); + } else { + ui.weak("No limit"); + } + }); + ui.end_row(); + }); + + ui.collapsing("Sampling", |ui| { + egui::Grid::new(("texture_sampling", path)) + .num_columns(2) + .spacing([12.0, 6.0]) + .show(ui, |ui| { + ui.label("Filtering"); + egui::ComboBox::from_id_salt(("texture_filter", path)) + .selected_text(match draft_settings.filter { + TextureFilter::Nearest => "Nearest", + TextureFilter::Linear => "Linear", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.filter, + TextureFilter::Nearest, + "Nearest", + ); + ui.selectable_value( + &mut draft_settings.filter, + TextureFilter::Linear, + "Linear", + ); + }); + ui.end_row(); + + ui.label("Wrapping"); + egui::ComboBox::from_id_salt(("texture_wrap", path)) + .selected_text(match draft_settings.wrap { + TextureWrap::Repeat => "Repeat", + TextureWrap::Clamp => "Clamp", + TextureWrap::Mirror => "Mirror", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut draft_settings.wrap, + TextureWrap::Repeat, + "Repeat", + ); + ui.selectable_value(&mut draft_settings.wrap, TextureWrap::Clamp, "Clamp"); + ui.selectable_value( + &mut draft_settings.wrap, + TextureWrap::Mirror, + "Mirror", + ); + }); + ui.end_row(); + + ui.label("Anisotropy"); + ui.add(egui::Slider::new(&mut draft_settings.anisotropy, 1..=16)); + ui.end_row(); + }); + }); + + if matches!( + draft_settings.semantic, + TextureAssetSemantic::Auto | TextureAssetSemantic::Normal + ) { + ui.horizontal(|ui| { + ui.label("Normal convention"); + ui.selectable_value( + &mut draft_settings.normal_map_convention, + NormalMapConvention::OpenGl, + "OpenGL (+Y)", + ); + ui.selectable_value( + &mut draft_settings.normal_map_convention, + NormalMapConvention::DirectX, + "DirectX (-Y → +Y)", + ); + }); + } + + let mut normalized = draft_settings.clone(); + content_pipeline::normalize_texture_settings(path, &mut normalized); + ui.small(format!( + "Processed as {} · {} · {}", + texture_semantic_label(normalized.semantic), + if shared::resolves_srgb(&normalized) { + "sRGB" + } else { + "Linear" + }, + match normalized.compression { + TextureCompression::Uastc => "Basis UASTC", + TextureCompression::Uncompressed => "KTX2", + TextureCompression::Auto => "Auto", + } + )); + if draft_settings != original_settings { + world + .resource_mut::() + .update_texture_import(&document_key, draft_settings); + } +} + +pub(super) fn texture_semantic_label(semantic: TextureAssetSemantic) -> &'static str { + match semantic { + TextureAssetSemantic::Auto => "Auto", + TextureAssetSemantic::Color => "Color", + TextureAssetSemantic::Normal => "Normal", + TextureAssetSemantic::MaskData => "Mask / Data", + TextureAssetSemantic::Ui => "UI", + TextureAssetSemantic::Hdr => "HDR", + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/list.rs b/crates/editor/src/ui/asset_browser/panel/list.rs new file mode 100644 index 0000000..36040f8 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/list.rs @@ -0,0 +1,406 @@ +use super::*; + +#[expect( + clippy::too_many_arguments, + reason = "asset list rendering shares explicit grid/list selection and thumbnail inputs" +)] +pub(super) fn asset_list( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selected: &Option, + folders: &[FolderSnapshot], + assets: &[AssetRow], + cache_snapshot: &ThumbnailCacheSnapshot, + visible_order: &[AssetSelection], +) { + if ui.available_width() < COMPACT_LIST_WIDTH { + compact_asset_list( + world, + ui, + selected_entities, + selected, + folders, + assets, + cache_snapshot, + visible_order, + ); + return; + } + + ui.horizontal(|ui| { + ui.add_sized([236.0, 18.0], egui::Label::new("Name")); + ui.add_sized([80.0, 18.0], egui::Label::new("Type")); + ui.add_sized([72.0, 18.0], egui::Label::new("Size")); + ui.add_sized([88.0, 18.0], egui::Label::new("Modified")); + ui.label("Path"); + }); + + for folder in folders { + let selection = AssetSelection::Folder(folder.path.clone()); + let is_selected = world.resource::().is_selected(&selection); + let row_frame = egui::Frame::new() + .fill(if is_selected { + crate::ui::theme::SELECTION_BG_MUTED + } else { + WIDGET_BG.linear_multiply(0.9) + }) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add_sized([20.0, 20.0], egui::Label::new("")); + ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); + ui.add_sized( + [196.0, 20.0], + egui::Button::selectable(is_selected, folder.name.as_str()), + ) + }) + .inner + }); + let response = row_frame.response.interact(egui::Sense::click_and_drag()); + if response.double_clicked() { + navigate_content_folder(world, folder.path.clone()); + } else if response.clicked() { + apply_content_click_selection(world, ui, selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, selection); + } + response.context_menu(|ui| folder_context_menu(world, ui, folder)); + } + + for row in assets { + draw_asset_list_item(world, ui, selected_entities, selected, row, visible_order); + if row.asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }) { + embedded_asset_shelf( + world, + ui, + selected_entities, + selected, + &row.asset, + cache_snapshot, + 56.0, + ); + } + } +} + +pub(super) fn draw_asset_list_item( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + _selected: &Option, + row: &AssetRow, + visible_order: &[AssetSelection], +) { + let has_children = has_embedded_assets(world, &row.asset); + let expanded = row.asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }); + let is_selected = world.resource::().is_selected(&row.selection); + let document_status = asset_document_status(world, row.asset.path.as_deref()); + let status = AssetCardStatus::for_document( + document_status.as_ref(), + document_status + .as_ref() + .is_some_and(|snapshot| snapshot.dirty), + false, + ); + let row_frame = egui::Frame::new() + .fill(if is_selected { + crate::ui::theme::SELECTION_BG_MUTED + } else { + WIDGET_BG.linear_multiply(0.9) + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if is_selected { ACCENT } else { BORDER }, + )) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| { + ui.horizontal(|ui| { + if has_children { + let icon = if expanded { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }; + if ui + .add_sized( + [20.0, 20.0], + egui::Button::new(icon_text(icon, 12.0)).frame(false), + ) + .clicked() + { + if let Some(path) = row.asset.path.as_ref() { + toggle_asset_expanded(world, path); + } + } + } else { + ui.add_sized([20.0, 20.0], egui::Label::new("")); + } + ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); + let response = ui.add_sized( + [176.0, 20.0], + egui::Button::selectable(is_selected, row.asset.label.as_str()), + ); + draw_asset_status_marker(ui, status); + ui.add_sized([80.0, 20.0], egui::Label::new(kind_label(&row.asset.kind))); + ui.add_sized( + [72.0, 20.0], + egui::Label::new( + row.file_size + .map(format_bytes) + .unwrap_or_else(|| "-".to_string()), + ), + ); + ui.add_sized( + [88.0, 20.0], + egui::Label::new( + row.modified + .map(format_modified) + .unwrap_or_else(|| "-".to_string()), + ), + ); + ui.add( + egui::Label::new(row.asset.path.as_deref().unwrap_or("Built-in")).truncate(), + ); + ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { + asset_context_menu(world, ui, &row.asset, selected_entities); + }); + response + }) + .inner + }); + let response = row_frame.response.interact(egui::Sense::click_and_drag()); + + if response.double_clicked() { + activate_content_asset(world, &row.asset); + } else if response.clicked() { + apply_content_click_selection(world, ui, row.selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(row.selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, row.selection.clone()); + } + response.context_menu(|ui| { + asset_context_menu(world, ui, &row.asset, selected_entities); + }); +} + +pub(super) fn asset_document_status( + world: &World, + path: Option<&str>, +) -> Option { + path.and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }) +} + +#[expect( + clippy::too_many_arguments, + reason = "compact list rendering shares explicit grid/list selection and thumbnail inputs" +)] +pub(super) fn compact_asset_list( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, + selected: &Option, + folders: &[FolderSnapshot], + assets: &[AssetRow], + cache_snapshot: &ThumbnailCacheSnapshot, + visible_order: &[AssetSelection], +) { + for folder in folders { + let selection = AssetSelection::Folder(folder.path.clone()); + let is_selected = world.resource::().is_selected(&selection); + let response = ui + .horizontal(|ui| { + ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); + ui.add_sized( + [fit_width(ui, 120.0, f32::INFINITY), 22.0], + egui::Button::selectable(is_selected, folder.name.as_str()), + ) + }) + .inner; + if response.double_clicked() { + navigate_content_folder(world, folder.path.clone()); + } else if response.clicked() { + apply_content_click_selection(world, ui, selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, selection); + } + response.context_menu(|ui| folder_context_menu(world, ui, folder)); + ui.small(egui::RichText::new("Folder").color(TEXT_DIM)); + ui.add_space(4.0); + } + + for row in assets { + let is_selected = world.resource::().is_selected(&row.selection); + let document_status = asset_document_status(world, row.asset.path.as_deref()); + let status = AssetCardStatus::for_document( + document_status.as_ref(), + document_status + .as_ref() + .is_some_and(|snapshot| snapshot.dirty), + false, + ); + let has_children = has_embedded_assets(world, &row.asset); + let expanded = row.asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }); + let mut response = None; + ui.horizontal(|ui| { + if has_children { + let icon = if expanded { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }; + if ui + .add_sized( + [20.0, 20.0], + egui::Button::new(icon_text(icon, 12.0)).frame(false), + ) + .clicked() + { + if let Some(path) = row.asset.path.as_ref() { + toggle_asset_expanded(world, path); + } + } + } + ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); + draw_asset_status_marker(ui, status); + response = Some(ui.add_sized( + [fit_width(ui, 120.0, f32::INFINITY), 22.0], + egui::Button::selectable(is_selected, row.asset.label.as_str()), + )); + ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { + asset_context_menu(world, ui, &row.asset, selected_entities); + }); + }); + let response = response.expect("compact asset list response"); + let size = row + .file_size + .map(format_bytes) + .unwrap_or_else(|| "-".to_string()); + let modified = row + .modified + .map(format_modified) + .unwrap_or_else(|| "-".to_string()); + let path = row.asset.path.as_deref().unwrap_or("Built-in"); + ui.add( + egui::Label::new( + egui::RichText::new(format!( + "{} | {} | {} | {}", + kind_label(&row.asset.kind), + size, + modified, + path + )) + .color(TEXT_DIM), + ) + .truncate(), + ); + ui.add_space(4.0); + + if response.double_clicked() { + activate_content_asset(world, &row.asset); + } else if response.clicked() { + apply_content_click_selection(world, ui, row.selection.clone(), visible_order); + } + if response.drag_started() { + world + .resource_mut::() + .start_drag(row.selection.clone()); + } + if response.secondary_clicked() { + prepare_context_menu_selection(world, row.selection.clone()); + } + response.context_menu(|ui| { + asset_context_menu(world, ui, &row.asset, selected_entities); + }); + if expanded { + embedded_asset_shelf( + world, + ui, + selected_entities, + selected, + &row.asset, + cache_snapshot, + 56.0, + ); + } + } +} + +pub(super) fn draw_folder_cell( + ui: &mut egui::Ui, + folder: &FolderSnapshot, + thumbnail_size: f32, + is_selected: bool, +) -> egui::Response { + let thumb_size = thumbnail_size.clamp(48.0, 112.0); + let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0); + let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); + let fill = if is_selected { + crate::ui::theme::SELECTION_BG_MUTED + } else if response.hovered() { + ELEVATED_BG + } else { + WIDGET_BG + }; + ui.painter().rect( + rect, + 4.0, + fill, + egui::Stroke::new(1.0_f32, if is_selected { ACCENT } else { BORDER }), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center_top() + egui::vec2(0.0, 10.0 + thumb_size * 0.45), + egui::Align2::CENTER_CENTER, + icons::FOLDER_SIMPLE.as_str(), + egui::FontId::new( + (thumb_size * 0.48).max(24.0), + egui::FontFamily::Name("phosphor-regular".into()), + ), + TEXT, + ); + ui.painter().text( + egui::pos2(rect.center().x, rect.max.y - 6.0), + egui::Align2::CENTER_BOTTOM, + folder.name.as_str(), + egui::FontId::new(11.0, egui::FontFamily::Proportional), + TEXT, + ); + response +} diff --git a/crates/editor/src/ui/asset_browser/panel/material_extraction.rs b/crates/editor/src/ui/asset_browser/panel/material_extraction.rs new file mode 100644 index 0000000..f61bdb8 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/material_extraction.rs @@ -0,0 +1,640 @@ +use super::*; + +pub(crate) fn begin_gltf_material_extraction(world: &mut World, source: &str) { + begin_model_material_extraction(world, source, None); +} + +pub(crate) fn begin_actor_material_extraction( + world: &mut World, + source: &str, + entity: Entity, + slot_id: shared::ComponentInstanceId, + source_sub_asset_id: String, +) { + begin_model_material_extraction( + world, + source, + Some(GltfMaterialExtractionTarget { + entity, + slot_id, + source_sub_asset_id, + }), + ); +} + +pub(super) fn begin_model_material_extraction( + world: &mut World, + source: &str, + actor_target: Option, +) { + let destination = world.resource::().current_folder.clone(); + match crate::assets::plan_model_material_extraction(Path::new(source), Path::new(&destination)) + { + Ok(materials) if materials.is_empty() => { + world.resource_mut::().status = + format!("No convertible source materials were found in {source}"); + } + Ok(materials) => { + let materials = materials + .into_iter() + .map(|material| { + let included = actor_target.as_ref().is_none_or(|target| { + material + .asset + .provenance + .as_ref() + .is_some_and(|provenance| { + provenance.source_sub_asset_id == target.source_sub_asset_id + }) + }); + let path = material + .asset + .provenance + .as_ref() + .and_then(|provenance| { + crate::assets::find_matching_extracted_material( + Path::new(&destination), + Path::new(source), + &provenance.source_sub_asset_id, + ) + }) + .unwrap_or(material.path); + GltfMaterialExtractionEntry { + included, + source_index: material.source_index, + source_name: material.source_name, + write_mode: if path.exists() { + MaterialExtractionWriteMode::Undecided + } else { + MaterialExtractionWriteMode::CreateNew + }, + path: path.to_string_lossy().replace('\\', "/"), + asset: material.asset, + } + }) + .collect::>(); + if actor_target.is_some() && !materials.iter().any(|material| material.included) { + world.resource_mut::().status = + "The selected imported material could not be matched for extraction".into(); + return; + } + world + .resource_mut::() + .pending_gltf_extraction = Some(GltfMaterialExtractionDraft { + source: source.to_string(), + destination, + materials, + actor_target, + }); + } + Err(error) => { + world.resource_mut::().status = + format!("Material extraction review failed: {error}"); + } + } +} + +pub(super) fn draw_gltf_material_extraction_modal(world: &mut World, ctx: &egui::Context) { + let Some(mut draft) = world + .resource::() + .pending_gltf_extraction + .clone() + else { + return; + }; + let mut cancel = false; + let mut extract = false; + let validation_error = gltf_extraction_validation_error(&draft); + let included = draft + .materials + .iter() + .filter(|material| material.included) + .count(); + + egui::Window::new("Extract Editable Model Materials") + .collapsible(false) + .resizable(true) + .default_width(880.0) + .min_width(680.0) + .show(ctx, |ui| { + ui.label(format!("Source: {}", draft.source)); + ui.label(format!("Destination: {}", draft.destination)); + ui.small( + egui::RichText::new( + "Review converted PBR values, texture references, final project paths, and any provenance-matched diff. Existing files require an explicit guarded Apply or Create New decision.", + ) + .color(TEXT_DIM), + ); + ui.separator(); + egui::ScrollArea::vertical() + .max_height(420.0) + .show(ui, |ui| { + for material in &mut draft.materials { + ui.push_id(material.source_index, |ui| { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut material.included, ""); + ui.strong(format!( + "{} · source material {}", + material.source_name, material.source_index + )); + }); + ui.horizontal(|ui| { + ui.label("Project path"); + let changed = ui + .add( + egui::TextEdit::singleline(&mut material.path) + .desired_width(f32::INFINITY), + ) + .changed(); + if changed { + material.write_mode = if Path::new(material.path.trim()).exists() { + MaterialExtractionWriteMode::Undecided + } else { + MaterialExtractionWriteMode::CreateNew + }; + } + }); + let resolved = material_asset_desc(&material.asset); + ui.small(format!( + "Metallic {:.3} · Roughness {:.3} · {}", + resolved.metallic, + resolved.roughness, + gltf_material_texture_summary(&material.asset) + )); + ui.small(format!( + "Alpha {:?} · Double sided {} · provenance {}", + material.asset.render_state.alpha_mode, + material.asset.render_state.double_sided, + material + .asset + .provenance + .as_ref() + .map(|provenance| provenance.source_sub_asset_id.as_str()) + .unwrap_or("missing") + )); + match review_existing_extraction_target(material) { + Ok(Some(review)) if review.provenance_matches => { + ui.separator(); + ui.strong("Existing extracted Material found"); + if review.changes.is_empty() { + ui.small("Diff: converted values are unchanged; applying refreshes source provenance."); + } else { + ui.small(format!("Diff: {}", review.changes.join(" · "))); + } + ui.horizontal(|ui| { + let selected = matches!( + &material.write_mode, + MaterialExtractionWriteMode::ApplyExisting { + expected_fingerprint, + .. + } if expected_fingerprint == &review.fingerprint + ); + if ui + .selectable_label(selected, "Apply Reviewed Update") + .on_hover_text( + "Replace this Material transactionally only if its reviewed bytes have not changed.", + ) + .clicked() + { + material.write_mode = + MaterialExtractionWriteMode::ApplyExisting { + expected_fingerprint: review.fingerprint, + previous_bytes: review.bytes, + }; + } + if ui.button("Create New Copy").clicked() { + material.path = unique_extraction_copy_path( + Path::new(material.path.trim()), + ) + .to_string_lossy() + .replace('\\', "/"); + material.write_mode = + MaterialExtractionWriteMode::CreateNew; + } + }); + } + Ok(Some(_)) => { + ui.small( + egui::RichText::new( + "Existing Material provenance does not match this source slot; Apply is blocked.", + ) + .color(WARNING), + ); + if ui.button("Create New Copy").clicked() { + material.path = unique_extraction_copy_path( + Path::new(material.path.trim()), + ) + .to_string_lossy() + .replace('\\', "/"); + material.write_mode = + MaterialExtractionWriteMode::CreateNew; + } + } + Ok(None) => { + material.write_mode = MaterialExtractionWriteMode::CreateNew; + ui.small("Create new editable project Material"); + } + Err(error) => { + ui.small(egui::RichText::new(error).color(WARNING)); + } + } + }); + ui.add_space(4.0); + }); + } + }); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + if ui + .add_enabled( + validation_error.is_none(), + egui::Button::new(format!("Extract {included} Material(s)")), + ) + .clicked() + { + extract = true; + } + }); + if let Some(error) = validation_error.as_deref() { + ui.small(egui::RichText::new(error).color(WARNING)); + } + }); + + if cancel { + world + .resource_mut::() + .pending_gltf_extraction = None; + } else if extract { + match commit_gltf_material_extraction(world, &draft) { + Ok((count, mapped_slots, warning)) => { + world + .resource_mut::() + .pending_gltf_extraction = None; + let scope = if draft.actor_target.is_some() { + "actor slot" + } else { + "model slot" + }; + let mut status = format!( + "Extracted {count} editable model material(s) into {} and assigned {mapped_slots} {scope}(s)", + draft.destination + ); + if let Some(warning) = warning { + status.push_str(&format!("; {warning}")); + } + world.resource_mut::().status = status; + } + Err(error) => { + world.resource_mut::().status = error; + world + .resource_mut::() + .pending_gltf_extraction = Some(draft); + } + } + } else { + world + .resource_mut::() + .pending_gltf_extraction = Some(draft); + } +} + +pub(super) fn gltf_extraction_validation_error( + draft: &GltfMaterialExtractionDraft, +) -> Option { + let selected = draft + .materials + .iter() + .filter(|material| material.included) + .collect::>(); + if selected.is_empty() { + return Some("Select at least one source material to extract.".into()); + } + let mut paths = HashSet::new(); + for material in selected { + let path = Path::new(material.path.trim()); + if let Err(error) = content_pipeline::validate_asset_path(path) { + return Some(format!("Invalid project path {}: {error}", path.display())); + } + if path.extension().and_then(|extension| extension.to_str()) != Some("ron") { + return Some(format!( + "Material path must end in .ron: {}", + path.display() + )); + } + let normalized = material.path.trim().replace('\\', "/"); + if !paths.insert(normalized) { + return Some("Two selected materials have the same project path.".into()); + } + match &material.write_mode { + MaterialExtractionWriteMode::Undecided => { + return Some(format!( + "Review the existing Material diff and choose Apply or Create New: {}", + path.display() + )); + } + MaterialExtractionWriteMode::CreateNew if path.exists() => { + return Some(format!("Project path already exists: {}", path.display())); + } + MaterialExtractionWriteMode::ApplyExisting { + expected_fingerprint, + previous_bytes, + } => match review_existing_extraction_target(material) { + Ok(Some(review)) + if review.provenance_matches + && review.fingerprint == *expected_fingerprint + && review.bytes == *previous_bytes => {} + Ok(Some(_)) => { + return Some(format!( + "Existing Material changed or has different provenance: {}", + path.display() + )); + } + Ok(None) => { + return Some(format!("Apply target no longer exists: {}", path.display())); + } + Err(error) => return Some(error), + }, + MaterialExtractionWriteMode::CreateNew => {} + } + } + None +} + +pub(super) struct ExistingExtractionReview { + pub(super) fingerprint: String, + pub(super) bytes: Vec, + pub(super) provenance_matches: bool, + pub(super) changes: Vec<&'static str>, +} + +pub(super) fn review_existing_extraction_target( + material: &GltfMaterialExtractionEntry, +) -> Result, String> { + let path = Path::new(material.path.trim()); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(path).map_err(|error| { + format!( + "Could not read existing Material {}: {error}", + path.display() + ) + })?; + let existing: MaterialAsset = ron::de::from_bytes(&bytes) + .map_err(|error| format!("Existing target is not a valid Material: {error}"))?; + let fingerprint = content_pipeline::tree_fingerprint(path)?; + let provenance_matches = existing + .provenance + .as_ref() + .zip(material.asset.provenance.as_ref()) + .is_some_and(|(existing, incoming)| { + existing.source_path == incoming.source_path + && existing.source_sub_asset_id == incoming.source_sub_asset_id + }); + let mut changes = Vec::new(); + if existing.label != material.asset.label { + changes.push("label"); + } + if existing.shader != material.asset.shader || existing.shader_ref != material.asset.shader_ref + { + changes.push("shader selection"); + } + if existing.render_state != material.asset.render_state { + changes.push("render state"); + } + if existing.inputs != material.asset.inputs { + changes.push("PBR values or textures"); + } + if existing + .provenance + .as_ref() + .map(|value| &value.source_fingerprint) + != material + .asset + .provenance + .as_ref() + .map(|value| &value.source_fingerprint) + { + changes.push("source revision"); + } + Ok(Some(ExistingExtractionReview { + fingerprint, + bytes, + provenance_matches, + changes, + })) +} + +pub(super) fn unique_extraction_copy_path(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or_else(|| Path::new("assets")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("material.material.ron"); + let stem = file_name + .strip_suffix(".material.ron") + .or_else(|| file_name.strip_suffix(".ron")) + .unwrap_or(file_name); + (2..) + .map(|suffix| parent.join(format!("{stem}_{suffix}.material.ron"))) + .find(|candidate| !candidate.exists()) + .expect("numeric material suffix space is effectively unbounded") +} + +pub(super) fn gltf_material_texture_summary(asset: &MaterialAsset) -> String { + let mut textures = Vec::new(); + if asset + .inputs + .texture("base_color") + .is_some_and(|value| value.texture.is_some()) + { + textures.push("base color"); + } + if asset + .inputs + .texture("normal") + .is_some_and(|value| value.texture.is_some()) + { + textures.push("normal"); + } + if asset + .inputs + .texture("roughness") + .is_some_and(|value| value.texture.is_some()) + || asset + .inputs + .texture("metallic") + .is_some_and(|value| value.texture.is_some()) + { + textures.push("metal/rough"); + } + if asset + .inputs + .texture("emissive") + .is_some_and(|value| value.texture.is_some()) + { + textures.push("emissive"); + } + if textures.is_empty() { + "no converted textures".into() + } else { + format!("textures: {}", textures.join(", ")) + } +} + +pub(super) fn material_asset_desc(asset: &MaterialAsset) -> MaterialDesc { + let mut material = MaterialDesc { + shader: asset.shader.clone(), + ..Default::default() + }; + asset.inputs.apply_to_material_desc(&mut material); + material +} + +pub(super) fn commit_gltf_material_extraction( + world: &mut World, + draft: &GltfMaterialExtractionDraft, +) -> Result<(usize, usize, Option), String> { + if let Some(error) = gltf_extraction_validation_error(draft) { + return Err(error); + } + let included = draft + .materials + .iter() + .filter(|material| material.included) + .cloned() + .collect::>(); + let operations = included + .iter() + .map(|material| { + ron::ser::to_string_pretty(&material.asset, ron::ser::PrettyConfig::default()) + .map(|ron| { + let path = PathBuf::from(material.path.trim()); + let bytes = format!("{ron}\n").into_bytes(); + match &material.write_mode { + MaterialExtractionWriteMode::CreateNew => { + content_pipeline::ContentOperation::WriteFile { path, bytes } + } + MaterialExtractionWriteMode::ApplyExisting { + expected_fingerprint, + previous_bytes, + } => content_pipeline::ContentOperation::ReplaceFile { + path, + bytes, + expected_fingerprint: expected_fingerprint.clone(), + previous_bytes: previous_bytes.clone(), + }, + MaterialExtractionWriteMode::Undecided => { + unreachable!("extraction validation rejects undecided existing targets") + } + } + }) + .map_err(|error| format!("Material extraction serialization failed: {error}")) + }) + .collect::, _>>()?; + let count = operations.len(); + let manifest = world + .resource::() + .records + .iter() + .find(|record| record.path == draft.source) + .ok_or_else(|| format!("Model is not registered: {}", draft.source))? + .model_import() + .static_mesh_manifest_path + .as_deref() + .ok_or_else(|| format!("Model has no generated mesh manifest: {}", draft.source)) + .and_then(load_static_mesh_manifest)?; + let bindings = included + .iter() + .map(|material| ExtractedMaterialBinding { + source_index: material.source_index, + path: material.path.trim().replace('\\', "/"), + }) + .collect::>(); + if let Some(target) = draft.actor_target.as_ref() { + validate_actor_material_extraction_target(world, target)?; + } + let mapped_slots = std::cell::Cell::new(0); + let committed = commit_content_operations_internal_with_registry_update( + world, + operations, + true, + |document| { + if draft.actor_target.is_none() { + mapped_slots.set(bind_extracted_material_slots( + document, + &draft.source, + &manifest, + &bindings, + )?); + } + Ok(()) + }, + ); + if !committed { + return Err(world.resource::().status.clone()); + } + + if let Some(target) = draft.actor_target.as_ref() { + let binding = included + .iter() + .find(|material| { + material + .asset + .provenance + .as_ref() + .is_some_and(|provenance| { + provenance.source_sub_asset_id == target.source_sub_asset_id + }) + }) + .ok_or_else(|| "Selected source material was not included in extraction".to_string())?; + let path = binding.path.trim().replace('\\', "/"); + let record = world + .resource::() + .records + .iter() + .find(|record| record.path == path) + .ok_or_else(|| format!("Extracted Material was not registered: {path}"))?; + let reference = MaterialRef::new( + EditorAssetRef::new( + record.id.as_string(), + "material:source", + record.label.clone(), + ) + .with_source_path(path), + ); + assign_extracted_material_to_actor_slot(world, target, reference)?; + mapped_slots.set(1); + } + + let warning = if draft.actor_target.is_none() { + refresh_extracted_model_artifacts(world, &draft.source).err() + } else { + None + }; + Ok((count, mapped_slots.get(), warning)) +} + +pub(super) fn validate_actor_material_extraction_target( + world: &World, + target: &GltfMaterialExtractionTarget, +) -> Result<(), String> { + let exists = world + .get::(target.entity) + .is_some_and(|primitive| primitive.surface.id == target.slot_id) + || world + .get::(target.entity) + .is_some_and(|renderer| renderer.materials.slot(&target.slot_id).is_some()) + || world + .get::(target.entity) + .is_some_and(|renderer| renderer.materials.slot(&target.slot_id).is_some()); + if exists { + Ok(()) + } else { + Err("The actor or exact material slot changed while extraction was open".into()) + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/navigation.rs b/crates/editor/src/ui/asset_browser/panel/navigation.rs new file mode 100644 index 0000000..a95dacc --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/navigation.rs @@ -0,0 +1,506 @@ +use super::*; + +pub(super) fn content_header( + world: &mut World, + ui: &mut egui::Ui, + current_folder: &str, + folders: &[FolderSnapshot], + assets: &[AssetRow], +) { + ui.horizontal_wrapped(|ui| { + ui.label(panel_heading("Assets")); + ui.separator(); + breadcrumb(world, ui, current_folder); + }); + let direct_assets = assets + .iter() + .filter(|row| row.asset.folder_path == current_folder) + .count(); + let direct_folders = folders + .iter() + .filter(|folder| folder.parent.as_deref() == Some(current_folder)) + .count(); + ui.small( + egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets")) + .color(TEXT_DIM), + ); +} + +pub(super) fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]) { + let requests: Vec<(String, String, EditorAssetKind)> = rows + .iter() + .filter(|row| { + matches!( + row.asset.kind, + EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material + ) + }) + .filter_map(|row| { + Some(( + asset_cache_key(&row.asset), + row.asset.path.clone()?, + row.asset.kind.clone(), + )) + }) + .collect(); + if requests.is_empty() { + return; + } + let asset_server = world.resource::().clone(); + world.resource_scope(|world, mut cache: Mut| { + world.resource_scope(|_world, mut studio: Mut| { + for (key, path, kind) in &requests { + match kind { + EditorAssetKind::Texture => { + cache.request_texture(key.clone(), path.clone(), &asset_server); + } + EditorAssetKind::Model => { + cache.request_model(key.clone(), path.clone(), &mut studio); + } + EditorAssetKind::Material => { + cache.request_material_asset(key.clone(), path.clone(), &mut studio); + } + _ => {} + } + } + }); + }); +} + +pub(super) fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) { + let mut parts = Vec::new(); + let mut cursor = Some(current_folder.to_string()); + while let Some(path) = cursor { + let label = if path == BUILTINS_FOLDER { + "Built-ins".to_string() + } else { + Path::new(&path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&path) + .to_string() + }; + cursor = path.rfind('/').map(|index| path[..index].to_string()); + parts.push((path, label)); + } + parts.reverse(); + for (index, (path, label)) in parts.iter().enumerate() { + if index > 0 { + ui.label(egui::RichText::new("/").color(TEXT_DIM)); + } + if ui.link(label).clicked() { + navigate_content_folder(world, path.clone()); + } + } +} + +pub(super) fn selection_footer( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &SelectedEntities, +) { + let selection = world.resource::().selected.clone(); + let selection_count = world.resource::().selections.len(); + if selection_count > 1 { + let has_authored_content = !selected_content_paths(world).is_empty(); + ui.label(format!("{selection_count} items selected")); + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled(has_authored_content, egui::Button::new("Cut")) + .clicked() + { + set_content_clipboard(world, true); + } + if ui + .add_enabled(has_authored_content, egui::Button::new("Copy")) + .clicked() + { + set_content_clipboard(world, false); + } + if ui + .add_enabled(has_authored_content, egui::Button::new("Duplicate")) + .clicked() + { + duplicate_selection(world); + } + if ui + .add_enabled(has_authored_content, egui::Button::new("Move To Trash")) + .clicked() + { + request_delete_for_selection(world); + } + }); + return; + } + let selected_embedded = selection + .as_ref() + .and_then(|selection| embedded_asset_for_selection(world, selection)); + if let Some(AssetSelection::Folder(path)) = selection.clone() { + ui.add(egui::Label::new(format!("Selected folder: {path}")).truncate()); + ui.horizontal_wrapped(|ui| { + if ui.button("Open").clicked() { + navigate_content_folder(world, path.clone()); + } + if ui.button("Import Here").clicked() { + request_import_to_destination(world, path.clone()); + } + if ui.button("Import To...").clicked() { + begin_import_to(world); + } + }); + } else if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() { + ui.add( + egui::Label::new(format!( + "Selected: {}: {}", + subasset_kind_label(kind), + label + )) + .truncate(), + ); + ui.horizontal_wrapped(|ui| match kind { + AssetSubAssetKind::Mesh => { + if selected_embedded + .as_ref() + .is_some_and(|embedded| embedded.requires_skinned_hierarchy) + { + if ui.button("Place Skinned Model").clicked() { + if let Some(selection) = selection.as_ref() { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } + } else if ui.button("Place At Origin").clicked() { + if let Some(selection) = selection.as_ref() { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } + } + AssetSubAssetKind::Texture => { + if ui.button("Apply Texture To Selection").clicked() { + if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) { + apply_texture_operator(world, asset, selected_entities); + } + } + } + AssetSubAssetKind::Material => { + ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM)); + } + AssetSubAssetKind::Skeleton => { + ui.small(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM)); + } + AssetSubAssetKind::AnimationClip => { + let animated_actor = selected_entities + .as_slice() + .iter() + .copied() + .find(|entity| world.get::(*entity).is_some()); + let action = if animated_actor.is_some() { + "Assign To Selected Actor" + } else { + "Create Animated Actor" + }; + if ui.button(action).clicked() { + if let Some(selection) = selection.as_ref() { + if let Some(entity) = animated_actor { + assign_animation_clip_operator(world, selection.clone(), entity); + } else { + place_subasset_operator(world, selection.clone(), Vec3::ZERO); + } + } + } + } + }); + } else if let Some(asset) = world.resource::().selected_asset().cloned() { + ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate()); + ui.horizontal_wrapped(|ui| { + if matches!(asset.kind, EditorAssetKind::Texture) + && ui.button("Apply Texture To Selection").clicked() + { + apply_texture_operator(world, asset.clone(), selected_entities); + } + if matches!( + asset.kind, + EditorAssetKind::Primitive(_) + | EditorAssetKind::Light(_) + | EditorAssetKind::Model + | EditorAssetKind::AudioClip + | EditorAssetKind::Prefab + ) && ui.button("Place At Origin").clicked() + { + place_asset_operator(world, asset.clone(), Vec3::ZERO); + } + if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { + open_level_asset(world, &asset); + } + }); + } else { + ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); + } +} + +pub(super) fn folder_tree_branch( + world: &mut World, + ui: &mut egui::Ui, + folders: &[FolderSnapshot], + node_path: &str, + depth: usize, + current_folder: &str, +) { + let Some(folder) = folders.iter().find(|folder| folder.path == node_path) else { + return; + }; + let path = &folder.path; + let name = &folder.name; + let children: Vec<&FolderSnapshot> = folders + .iter() + .filter(|child| child.parent.as_deref() == Some(path.as_str())) + .collect(); + let expanded = children.is_empty() + || world + .resource::() + .expanded_folders + .contains(path); + + let selected = current_folder == *path; + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 12.0); + if children.is_empty() { + ui.add_sized( + [18.0, 20.0], + egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)), + ); + } else { + let icon = if expanded { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }; + if ui + .add_sized( + [18.0, 18.0], + egui::Button::new(icon_text(icon, 12.0)).frame(false), + ) + .clicked() + { + let mut state = world.resource_mut::(); + if expanded { + state.expanded_folders.remove(path); + } else { + state.expanded_folders.insert(path.clone()); + } + } + } + ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); + let folder_response = ui.add_sized( + [fit_width(ui, 72.0, f32::INFINITY), 20.0], + egui::Button::selectable(selected, name.as_str()), + ); + if folder_response.clicked() { + navigate_content_folder(world, path.clone()); + } + if folder_response.secondary_clicked() { + prepare_context_menu_selection(world, AssetSelection::Folder(path.clone())); + } + folder_response.context_menu(|ui| folder_context_menu(world, ui, folder)); + handle_asset_drop_to_folder(world, ui, &folder_response, path); + }); + + if expanded { + for child in children { + folder_tree_branch(world, ui, folders, &child.path, depth + 1, current_folder); + } + } +} + +pub(super) fn child_folders_for_content( + folders: &[FolderSnapshot], + current_folder: &str, + search: &str, +) -> Vec { + if !search.trim().is_empty() { + return Vec::new(); + } + let mut children: Vec = folders + .iter() + .filter(|folder| folder.parent.as_deref() == Some(current_folder)) + .cloned() + .collect(); + children.sort_by(|a, b| natural_cmp(&a.name, &b.name)); + children +} + +pub(super) fn visible_assets( + assets: &[AssetRow], + current_folder: &str, + state: &AssetBrowserStateSnapshot, +) -> Vec { + let search = state.search.trim().to_ascii_lowercase(); + let mut rows: Vec = assets + .iter() + .filter(|row| { + if state.recursive { + row.asset.folder_path == current_folder + || row + .asset + .folder_path + .strip_prefix(current_folder) + .is_some_and(|rest| rest.starts_with('/')) + } else { + row.asset.folder_path == current_folder + } + }) + .filter(|row| asset_matches_kind_filter(&row.asset, state.kind_filter)) + .filter(|row| { + search.is_empty() + || row.asset.label.to_ascii_lowercase().contains(&search) + || row + .asset + .path + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .contains(&search) + }) + .cloned() + .collect(); + + rows.sort_by(|a, b| match state.sort { + AssetSort::Name => natural_cmp(&a.asset.label, &b.asset.label), + AssetSort::Kind => kind_label(&a.asset.kind).cmp(kind_label(&b.asset.kind)), + AssetSort::Modified => b.modified.cmp(&a.modified), + AssetSort::Size => b.file_size.cmp(&a.file_size), + }); + rows +} + +pub(super) fn visible_selection_order( + folders: &[FolderSnapshot], + assets: &[AssetRow], +) -> Vec { + folders + .iter() + .map(|folder| AssetSelection::Folder(folder.path.clone())) + .chain(assets.iter().map(|row| row.selection.clone())) + .collect() +} + +pub(super) fn apply_content_click_selection( + world: &mut World, + ui: &egui::Ui, + selection: AssetSelection, + visible_order: &[AssetSelection], +) { + let modifiers = ui.input(|input| input.modifiers); + let mut assets = world.resource_mut::(); + if modifiers.shift { + assets.select_range(selection, visible_order, modifiers.command); + } else if modifiers.command { + assets.toggle_selection(selection); + } else { + assets.select(selection); + } +} + +pub(super) fn handle_content_shortcuts( + world: &mut World, + ctx: &egui::Context, + visible_order: &[AssetSelection], + content_contains_pointer: bool, +) { + if !content_contains_pointer + || ctx.egui_wants_keyboard_input() + || ctx.any_popup_open() + || content_browser_modal_active(world) + { + return; + } + let (select_all, copy, cut, paste, duplicate, new_folder, undo, rename, delete) = + ctx.input(|input| { + let modifiers = input.modifiers; + let command_only = modifiers.command && !modifiers.shift && !modifiers.alt; + let command_shift = modifiers.command && modifiers.shift && !modifiers.alt; + let no_modifiers = + !modifiers.command && !modifiers.ctrl && !modifiers.shift && !modifiers.alt; + ( + command_only && input.key_pressed(egui::Key::A), + command_only && input.key_pressed(egui::Key::C), + command_only && input.key_pressed(egui::Key::X), + command_only && input.key_pressed(egui::Key::V), + command_only && input.key_pressed(egui::Key::D), + command_shift && input.key_pressed(egui::Key::N), + command_only && input.key_pressed(egui::Key::Z), + no_modifiers && input.key_pressed(egui::Key::F2), + no_modifiers && input.key_pressed(egui::Key::Delete), + ) + }); + let consumed = if select_all { + Some((egui::Modifiers::COMMAND, egui::Key::A)) + } else if copy { + Some((egui::Modifiers::COMMAND, egui::Key::C)) + } else if cut { + Some((egui::Modifiers::COMMAND, egui::Key::X)) + } else if paste { + Some((egui::Modifiers::COMMAND, egui::Key::V)) + } else if duplicate { + Some((egui::Modifiers::COMMAND, egui::Key::D)) + } else if new_folder { + Some(( + egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, + egui::Key::N, + )) + } else if undo { + Some((egui::Modifiers::COMMAND, egui::Key::Z)) + } else if rename { + Some((egui::Modifiers::NONE, egui::Key::F2)) + } else if delete { + Some((egui::Modifiers::NONE, egui::Key::Delete)) + } else { + None + }; + if let Some((modifiers, key)) = consumed { + ctx.input_mut(|input| { + input.consume_key(modifiers, key); + }); + } + if select_all { + world + .resource_mut::() + .select_all(visible_order); + } else if copy { + set_content_clipboard(world, false); + } else if cut { + set_content_clipboard(world, true); + } else if paste { + paste_content_clipboard(world); + } else if duplicate { + duplicate_selection(world); + } else if new_folder { + let current_folder = world.resource::().current_folder.clone(); + if current_folder != BUILTINS_FOLDER { + begin_new_folder(world, current_folder); + } + } else if undo { + undo_last_content_operation(world); + } else if rename { + begin_rename(world); + } else if delete { + request_delete_for_selection(world); + } +} + +pub(super) fn content_browser_modal_active(world: &World) -> bool { + let state = world.resource::(); + state.pending_delete.is_some() + || state.pending_path_edit.is_some() + || state.pending_content_transaction.is_some() + || state.pending_import_destination.is_some() + || state.pending_gltf_extraction.is_some() + || state.pending_pbr_grouping.is_some() + || state.show_trash + || world + .get_resource::() + .is_some_and(|pending| pending.0.is_some()) + || world + .get_resource::() + .and_then(|pending| pending.review.as_ref()) + .is_some_and(|review| review.open) +} diff --git a/crates/editor/src/ui/asset_browser/panel/pbr_transactions.rs b/crates/editor/src/ui/asset_browser/panel/pbr_transactions.rs new file mode 100644 index 0000000..be5d4c5 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/pbr_transactions.rs @@ -0,0 +1,595 @@ +use super::*; + +pub(super) fn assign_extracted_material_to_actor_slot( + world: &mut World, + target: &GltfMaterialExtractionTarget, + reference: MaterialRef, +) -> Result<(), String> { + use crate::history::reflected_component_transaction; + + if let Some(mut primitive) = world.get::(target.entity).cloned() { + if primitive.surface.id == target.slot_id { + primitive.surface.material = Some(reference); + return reflected_component_transaction( + world, + target.entity, + "Extract and Assign Material", + shared::AUTHORING_COMPONENT_PRIMITIVE, + shared::COMPONENT_PRIMITIVE, + move |world, entity| { + world.entity_mut(entity).insert(primitive); + Ok(()) + }, + ); + } + } + if let Some(mut renderer) = world + .get::(target.entity) + .cloned() + { + if let Some(slot) = renderer.materials.slot_mut(&target.slot_id) { + slot.material = Some(reference); + return reflected_component_transaction( + world, + target.entity, + "Extract and Assign Material", + shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER, + shared::COMPONENT_STATIC_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(renderer); + Ok(()) + }, + ); + } + } + if let Some(mut renderer) = world + .get::(target.entity) + .cloned() + { + if let Some(slot) = renderer.materials.slot_mut(&target.slot_id) { + slot.material = Some(reference); + return reflected_component_transaction( + world, + target.entity, + "Extract and Assign Material", + shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, + shared::COMPONENT_SKINNED_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(renderer); + Ok(()) + }, + ); + } + } + Err("The actor or exact material slot changed before assignment".into()) +} + +pub(super) fn bind_extracted_material_slots( + document: &mut AssetRegistryDocument, + source: &str, + manifest: &crate::assets::static_mesh::StaticMeshManifest, + bindings: &[ExtractedMaterialBinding], +) -> Result { + let material_bindings = bindings + .iter() + .map(|binding| { + let record = document + .records + .iter() + .find(|record| record.path == binding.path) + .ok_or_else(|| { + format!("Extracted Material was not registered: {}", binding.path) + })?; + let expected_source_id = + material_id_from_label(&format!("Material{}", binding.source_index)); + let reference = MaterialRef::new( + EditorAssetRef::new(record.id.as_string(), "", record.label.clone()) + .with_source_path(record.path.clone()), + ); + Ok((expected_source_id, reference)) + }) + .collect::, String>>()?; + let model = document + .records + .iter_mut() + .find(|record| record.path == source) + .ok_or_else(|| format!("Model is not registered: {source}"))?; + let settings = model + .import_settings + .model_mut() + .ok_or_else(|| format!("Asset is not a model: {source}"))?; + let mut mapped_slots = 0; + for part in &manifest.parts { + let Some((_, reference)) = material_bindings.iter().find(|(source_id, _)| { + part_effective_material_id(part).as_deref() == Some(source_id.as_str()) + }) else { + continue; + }; + let slot_id = shared::ComponentInstanceId::new(format!("slot:{}", part_effective_id(part))); + let selection = ModelMaterialSelection::Project(reference.clone()); + if let Some(existing) = settings + .material_slots + .iter_mut() + .find(|entry| entry.slot_id == slot_id) + { + existing.selection = selection; + } else { + settings + .material_slots + .push(ModelMaterialSlotSelection { slot_id, selection }); + } + mapped_slots += 1; + } + settings + .material_slots + .sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0)); + Ok(mapped_slots) +} + +pub(super) fn refresh_extracted_model_artifacts( + world: &mut World, + source: &str, +) -> Result<(), String> { + let mut registry = world.resource_mut::(); + let record = find_asset_mut_by_path(&mut registry, source) + .ok_or_else(|| format!("Model is no longer registered: {source}"))?; + refresh_model_artifacts(record)?; + save_registry(®istry)?; + registry.index_dirty = false; + Ok(()) +} + +pub(super) fn begin_pbr_grouping(world: &mut World, folder: String) { + let paths = match fs::read_dir(&folder) { + Ok(entries) => entries + .filter_map(Result::ok) + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(|kind| kind.is_file()) + .map(|_| entry.path()) + }) + .collect::>(), + Err(error) => { + world.resource_mut::().status = + format!("Could not inspect {folder} for PBR textures: {error}"); + return; + } + }; + let groups = content_pipeline::detect_pbr_texture_groups(paths); + let textures = groups + .into_iter() + .flat_map(|group| { + group + .textures + .into_iter() + .map(move |texture| PbrTextureDraft { + path: texture.path.to_string_lossy().replace('\\', "/"), + target: group.key.clone(), + role: texture.role, + confidence: texture.confidence, + included: texture.role != content_pipeline::PbrTextureRole::Unknown, + }) + }) + .collect::>(); + if textures.is_empty() { + world.resource_mut::().status = + format!("No supported texture files found directly in {folder}"); + return; + } + world + .resource_mut::() + .pending_pbr_grouping = Some(PbrGroupingDraft { folder, textures }); +} + +pub(super) fn draw_pbr_grouping_modal(world: &mut World, ctx: &egui::Context) { + let Some(mut draft) = world + .resource::() + .pending_pbr_grouping + .clone() + else { + return; + }; + let mut cancel = false; + let mut create = false; + egui::Window::new("Create PBR Materials From Folder") + .collapsible(false) + .resizable(true) + .default_width(820.0) + .min_width(620.0) + .show(ctx, |ui| { + ui.label(format!("Source folder: {}", draft.folder)); + ui.small( + egui::RichText::new( + "Review detected maps. Edit Target to merge or split material groups; unresolved files remain visible but are excluded by default.", + ) + .color(TEXT_DIM), + ); + ui.separator(); + egui::Grid::new("pbr_grouping_review") + .num_columns(5) + .striped(true) + .spacing([10.0, 6.0]) + .show(ui, |ui| { + ui.strong("Use"); + ui.strong("Texture"); + ui.strong("Target"); + ui.strong("Role"); + ui.strong("Confidence"); + ui.end_row(); + for (index, texture) in draft.textures.iter_mut().enumerate() { + ui.checkbox(&mut texture.included, ""); + let name = Path::new(&texture.path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&texture.path); + ui.add(egui::Label::new(name).truncate()).on_hover_text(&texture.path); + ui.add_sized([170.0, 20.0], egui::TextEdit::singleline(&mut texture.target)); + egui::ComboBox::from_id_salt(("pbr_role", index)) + .selected_text(texture.role.label()) + .show_ui(ui, |ui| { + for role in content_pipeline::PbrTextureRole::ALL { + ui.selectable_value(&mut texture.role, role, role.label()); + } + }); + ui.label(pbr_confidence_label(texture.confidence)); + ui.end_row(); + } + }); + ui.separator(); + let group_count = pbr_draft_group_count(&draft); + let valid = group_count > 0 + && draft.textures.iter().filter(|texture| texture.included).all(|texture| { + valid_content_name(&texture.target) + && texture.role != content_pipeline::PbrTextureRole::Unknown + }); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + cancel = true; + } + if ui + .add_enabled( + valid, + egui::Button::new(format!("Create {group_count} Material(s)")), + ) + .clicked() + { + create = true; + } + }); + if !valid { + ui.small( + egui::RichText::new( + "Included rows need a valid Target and a resolved texture role.", + ) + .color(WARNING), + ); + } + }); + + if cancel { + world + .resource_mut::() + .pending_pbr_grouping = None; + } else if create { + match create_pbr_materials(world, &draft) { + Ok(()) => { + world + .resource_mut::() + .pending_pbr_grouping = None; + } + Err(error) => { + world.resource_mut::().status = error; + world + .resource_mut::() + .pending_pbr_grouping = Some(draft); + } + } + } else { + world + .resource_mut::() + .pending_pbr_grouping = Some(draft); + } +} + +pub(super) fn pbr_confidence_label( + confidence: content_pipeline::PbrMatchConfidence, +) -> &'static str { + match confidence { + content_pipeline::PbrMatchConfidence::High => "High", + content_pipeline::PbrMatchConfidence::Medium => "Medium", + content_pipeline::PbrMatchConfidence::Low => "Low", + content_pipeline::PbrMatchConfidence::Unresolved => "Unresolved", + } +} + +pub(super) fn pbr_draft_group_count(draft: &PbrGroupingDraft) -> usize { + draft + .textures + .iter() + .filter(|texture| texture.included) + .map(|texture| texture.target.trim()) + .collect::>() + .len() +} + +pub(super) fn create_pbr_materials( + world: &mut World, + draft: &PbrGroupingDraft, +) -> Result<(), String> { + let mut groups: BTreeMap> = BTreeMap::new(); + for texture in draft.textures.iter().filter(|texture| texture.included) { + if !valid_content_name(&texture.target) + || texture.role == content_pipeline::PbrTextureRole::Unknown + { + return Err("Every included texture needs a valid target and resolved role".into()); + } + groups + .entry(texture.target.trim().to_string()) + .or_default() + .push(texture); + } + if groups.is_empty() { + return Err("Select at least one texture before creating materials".into()); + } + + let records = world + .resource::() + .records + .clone(); + let mut reserved = HashSet::new(); + let mut operations = Vec::new(); + for (target, textures) in groups { + let destination = unique_material_destination(&draft.folder, &target, &mut reserved); + let asset = material_asset_from_pbr_group(&target, &textures, &records); + let ron = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("Could not serialize material {target}: {error}"))?; + operations.push(content_pipeline::ContentOperation::WriteFile { + path: PathBuf::from(destination), + bytes: ron.into_bytes(), + }); + } + if !commit_content_operations(world, operations) { + return Err(world.resource::().status.clone()); + } + Ok(()) +} + +pub(super) fn unique_material_destination( + folder: &str, + target: &str, + reserved: &mut HashSet, +) -> String { + let stem = target + .trim() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + let stem = stem.trim_matches('_'); + let stem = if stem.is_empty() { "Material" } else { stem }; + for suffix in 1.. { + let file = if suffix == 1 { + format!("{stem}.material.ron") + } else { + format!("{stem}_{suffix}.material.ron") + }; + let path = Path::new(folder) + .join(file) + .to_string_lossy() + .replace('\\', "/"); + if !Path::new(&path).exists() && reserved.insert(path.clone()) { + return path; + } + } + unreachable!() +} + +pub(super) fn material_asset_from_pbr_group( + target: &str, + textures: &[&PbrTextureDraft], + records: &[crate::asset_db::AssetRecord], +) -> MaterialAsset { + let mut material = MaterialDesc::default(); + for texture in textures { + match texture.role { + content_pipeline::PbrTextureRole::BaseColor => { + material.base_color_texture = Some(texture.path.clone()); + } + content_pipeline::PbrTextureRole::Normal => { + material.normal_map_texture = Some(texture.path.clone()); + } + content_pipeline::PbrTextureRole::PackedOrm => { + material.metallic_roughness_texture = Some(texture.path.clone()); + material.textures.push(MaterialTextureBinding { + name: "occlusion_texture".into(), + texture: Some(pbr_texture_reference(&texture.path, records)), + channel: TextureChannel::R, + }); + } + content_pipeline::PbrTextureRole::Roughness => { + material.textures.push(MaterialTextureBinding { + name: "roughness_texture".into(), + texture: Some(pbr_texture_reference(&texture.path, records)), + channel: TextureChannel::G, + }); + } + content_pipeline::PbrTextureRole::Metallic => { + material.textures.push(MaterialTextureBinding { + name: "metallic_texture".into(), + texture: Some(pbr_texture_reference(&texture.path, records)), + channel: TextureChannel::B, + }); + } + content_pipeline::PbrTextureRole::Occlusion => { + material.textures.push(MaterialTextureBinding { + name: "occlusion_texture".into(), + texture: Some(pbr_texture_reference(&texture.path, records)), + channel: TextureChannel::R, + }); + } + content_pipeline::PbrTextureRole::Height => { + material.textures.push(MaterialTextureBinding { + name: "height_texture".into(), + texture: Some(pbr_texture_reference(&texture.path, records)), + channel: TextureChannel::R, + }); + } + content_pipeline::PbrTextureRole::Unknown => {} + } + } + MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: target.replace(['_', '-'], " "), + shader: material.shader.clone(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + provenance: None, + inputs: MaterialInputSet::from_material_desc(&material), + } +} + +pub(super) fn pbr_texture_reference( + path: &str, + records: &[crate::asset_db::AssetRecord], +) -> EditorAssetRef { + let label = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("Texture"); + let id = records + .iter() + .find(|record| record.path == path) + .map(|record| record.id.as_string()) + .unwrap_or_default(); + EditorAssetRef::new(id, "texture:source", label).with_source_path(path) +} + +pub(super) fn commit_content_operations( + world: &mut World, + operations: Vec, +) -> bool { + commit_content_operations_internal(world, operations, true) +} + +pub(super) fn commit_content_operations_internal( + world: &mut World, + operations: Vec, + record_undo: bool, +) -> bool { + commit_content_operations_internal_with_registry_update(world, operations, record_undo, |_| { + Ok(()) + }) +} + +pub(super) fn commit_content_operations_internal_with_registry_update( + world: &mut World, + operations: Vec, + record_undo: bool, + registry_update: impl FnOnce(&mut AssetRegistryDocument) -> Result<(), String>, +) -> bool { + let previous_folder = world.resource::().current_folder.clone(); + let mut resulting_folder = previous_folder.clone(); + for operation in &operations { + if let content_pipeline::ContentOperation::Move { + source, + destination, + } = operation + { + let source = source.to_string_lossy().replace('\\', "/"); + let destination = destination.to_string_lossy().replace('\\', "/"); + if resulting_folder == source + || resulting_folder + .strip_prefix(&source) + .is_some_and(|suffix| suffix.starts_with('/')) + { + resulting_folder = format!("{destination}{}", &resulting_folder[source.len()..]); + } + } + } + let mut document = world + .resource::() + .document(); + let preview = match content_pipeline::preview_transaction(Path::new("."), operations, &document) + { + Ok(preview) => preview, + Err(error) => { + world.resource_mut::().status = format!("Content operation blocked: {error}"); + return false; + } + }; + if !preview.is_committable() { + world.resource_mut::().status = format!( + "Content operation has {} collision(s)", + preview.collisions.len() + ); + return false; + } + crate::assets::begin_content_watch_transaction(world); + let commit_result = content_pipeline::commit_transaction_with_registry_update( + Path::new("."), + &preview, + &mut document, + registry_update, + ); + crate::assets::end_content_watch_transaction(world); + if let Err(error) = commit_result { + world.resource_mut::().status = error; + return false; + } + let undo_entry = record_undo + .then(|| build_content_undo_entry(Path::new("."), &preview)) + .flatten(); + { + let mut registry = world.resource_mut::(); + registry.schema_version = document.schema_version; + registry.defaults = document.defaults; + registry.records = document.records; + registry.index_dirty = false; + registry.migration_required = false; + } + let resulting_selections: Vec = preview + .operations + .iter() + .map(|operation| match operation { + content_pipeline::ContentOperation::CreateFolder { path } => { + AssetSelection::Folder(path.to_string_lossy().replace('\\', "/")) + } + content_pipeline::ContentOperation::Move { destination, .. } + | content_pipeline::ContentOperation::Copy { destination, .. } => { + let normalized = destination.to_string_lossy().replace('\\', "/"); + if Path::new(&normalized).is_dir() { + AssetSelection::Folder(normalized) + } else { + AssetSelection::File(normalized) + } + } + content_pipeline::ContentOperation::WriteFile { path, .. } + | content_pipeline::ContentOperation::ReplaceFile { path, .. } => { + AssetSelection::File(path.to_string_lossy().replace('\\', "/")) + } + }) + .collect(); + { + let mut assets = world.resource_mut::(); + assets.current_folder = resulting_folder; + assets.refresh(); + assets.select_all(&resulting_selections); + } + invalidate_on_catalog_refresh(world); + if let Some(entry) = undo_entry { + push_content_undo(world, entry); + } + world.resource_mut::().status = format!( + "Content transaction committed ({} item(s), {} reference rewrite(s))", + preview.operations.len(), + preview.reference_rewrites.len() + ); + true +} diff --git a/crates/editor/src/ui/asset_browser/panel/tests/a.rs b/crates/editor/src/ui/asset_browser/panel/tests/a.rs new file mode 100644 index 0000000..6098b93 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/tests/a.rs @@ -0,0 +1,455 @@ + fn content_navigation_fixture() -> EditorAssets { + let props = AssetSelection::Folder("assets/Props".into()); + EditorAssets { + folders: vec![ + crate::assets::AssetFolder { + path: "assets".into(), + name: "assets".into(), + parent: None, + }, + crate::assets::AssetFolder { + path: "assets/Props".into(), + name: "Props".into(), + parent: Some("assets".into()), + }, + ], + assets: Vec::new(), + current_folder: "assets".into(), + selected: Some(props.clone()), + selections: vec![props.clone()], + selection_anchor: Some(props.clone()), + dragging: Some(props), + status: String::new(), + catalog_revision: 1, + } + } + + #[test] + fn folder_navigation_clears_hidden_selection_and_drag_state() { + let mut world = World::new(); + world.insert_resource(content_navigation_fixture()); + + navigate_content_folder(&mut world, "assets/Props".into()); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, "assets/Props"); + assert!(assets.selected.is_none()); + assert!(assets.selections.is_empty()); + assert!(assets.selection_anchor.is_none()); + assert!(assets.dragging.is_none()); + } + + #[test] + fn visibility_filter_changes_clear_hidden_selection_but_noops_preserve_it() { + let mut world = World::new(); + world.insert_resource(content_navigation_fixture()); + + clear_selection_when_visible_results_change(&mut world, false); + assert_eq!(world.resource::().selections.len(), 1); + + clear_selection_when_visible_results_change(&mut world, true); + let assets = world.resource::(); + assert!(assets.selected.is_none()); + assert!(assets.selections.is_empty()); + assert_eq!( + assets.status, + "Selection cleared because visible results changed" + ); + } + + #[test] + fn context_targeting_preserves_selected_batches_and_selects_unselected_subassets() { + let mut world = World::new(); + let mut assets = content_navigation_fixture(); + assets.assets.push(EditorAsset { + label: "Desk".into(), + path: Some("assets/desk.glb".into()), + folder_path: "assets".into(), + kind: EditorAssetKind::Model, + }); + let folder = AssetSelection::Folder("assets/Props".into()); + let model = AssetSelection::File("assets/desk.glb".into()); + assets.select_all(&[folder.clone(), model.clone()]); + world.insert_resource(assets); + + prepare_context_menu_selection(&mut world, model); + assert_eq!(world.resource::().selections.len(), 2); + + let subasset = AssetSelection::SubAsset { + parent_path: "assets/desk.glb".into(), + sub_asset_id: "mesh:0".into(), + label: "Desk Top".into(), + kind: AssetSubAssetKind::Mesh, + source_path: None, + }; + prepare_context_menu_selection(&mut world, subasset.clone()); + let assets = world.resource::(); + assert_eq!(assets.selected, Some(subasset.clone())); + assert_eq!(assets.selections, vec![subasset]); + } + + #[test] + fn batch_selection_counts_distinguish_nested_and_non_filesystem_items() { + let mut world = World::new(); + let mut assets = content_navigation_fixture(); + assets.assets.extend([ + EditorAsset { + label: "Desk".into(), + path: Some("assets/Props/desk.glb".into()), + folder_path: "assets/Props".into(), + kind: EditorAssetKind::Model, + }, + EditorAsset { + label: "Cube".into(), + path: None, + folder_path: BUILTINS_FOLDER.into(), + kind: EditorAssetKind::Primitive(shared::PrimitiveShape::Box), + }, + ]); + assets.selections = vec![ + AssetSelection::Folder("assets/Props".into()), + AssetSelection::File("assets/Props/desk.glb".into()), + AssetSelection::SubAsset { + parent_path: "assets/Props/desk.glb".into(), + sub_asset_id: "mesh:0".into(), + label: "Desk Top".into(), + kind: AssetSubAssetKind::Mesh, + source_path: None, + }, + AssetSelection::Builtin("Cube".into()), + ]; + assets.selected = assets.selections.last().cloned(); + world.insert_resource(assets); + + assert_eq!(selected_content_paths(&world), vec!["assets/Props"]); + assert_eq!(collapsed_and_unaffected_selection_counts(&world), (1, 2)); + } + + #[test] + fn cancelling_reviewed_content_transaction_restores_browser_and_scene_state() { + let mut world = World::new(); + let mut assets = content_navigation_fixture(); + assets.status = "Before review".into(); + world.insert_resource(assets); + let mut scene_io = SceneIo::default(); + scene_io.status = "Scene status before review".into(); + world.insert_resource(scene_io); + let snapshot = capture_content_browser_snapshot(&world); + let pending = PendingContentTransaction { + preview: content_pipeline::ContentTransactionPreview { + operations: Vec::new(), + affected_asset_ids: Vec::new(), + reference_rewrites: Vec::new(), + collisions: Vec::new(), + }, + guarded_paths: Vec::new(), + clear_cut_clipboard: false, + clear_drag: false, + browser_snapshot: snapshot.clone(), + }; + + { + let mut assets = world.resource_mut::(); + assets.current_folder = "assets/Changed".into(); + assets.clear_selection(); + assets.dragging = None; + assets.status = "Review open".into(); + } + world.resource_mut::().status = "Transaction review".into(); + restore_cancelled_content_transaction(&mut world, &pending); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, snapshot.current_folder); + assert_eq!(assets.selected, snapshot.selected); + assert_eq!(assets.selections, snapshot.selections); + assert_eq!(assets.selection_anchor, snapshot.selection_anchor); + assert_eq!(assets.dragging, snapshot.dragging); + assert_eq!(assets.status, snapshot.asset_status); + assert_eq!(world.resource::().status, snapshot.scene_status); + } + + #[test] + fn cancelling_drag_review_restores_selection_but_clears_stale_drag() { + let mut world = World::new(); + world.insert_resource(content_navigation_fixture()); + world.insert_resource(SceneIo::default()); + let pending = PendingContentTransaction { + preview: content_pipeline::ContentTransactionPreview { + operations: Vec::new(), + affected_asset_ids: Vec::new(), + reference_rewrites: Vec::new(), + collisions: Vec::new(), + }, + guarded_paths: Vec::new(), + clear_cut_clipboard: false, + clear_drag: true, + browser_snapshot: capture_content_browser_snapshot(&world), + }; + + restore_cancelled_content_transaction(&mut world, &pending); + + let assets = world.resource::(); + assert!(assets.selected.is_some()); + assert_eq!(assets.selections.len(), 1); + assert!(assets.dragging.is_none()); + } + + #[test] + fn reviewed_model_import_rolls_back_files_when_artifact_processing_fails() { + let root = std::env::temp_dir().join(format!( + "blacksite-reviewed-model-import-{}", + uuid::Uuid::new_v4() + )); + let project = root.join("project"); + let destination = PathBuf::from("assets/Target"); + fs::create_dir_all(project.join(&destination)).unwrap(); + let source = root.join("broken.gltf"); + fs::write( + &source, + r#"{"asset":{"version":"2.0"},"buffers":[{"uri":"mesh.bin","byteLength":12}]}"#, + ) + .unwrap(); + fs::write(root.join("mesh.bin"), [0_u8]).unwrap(); + let plan = crate::assets::plan_external_assets_import_at( + &project, + std::slice::from_ref(&source), + &destination, + ) + .unwrap(); + let mut world = World::new(); + world.insert_resource(AssetRegistry::default()); + + let error = commit_reviewed_asset_import(&mut world, &plan).unwrap_err(); + + assert!(error.contains("processing rolled back")); + assert!(!project.join("assets/Target/broken.gltf").exists()); + assert!(!project.join("assets/Target/mesh.bin").exists()); + assert!(!project.join(content_pipeline::REGISTRY_PATH).exists()); + assert!(!project + .join(content_pipeline::RUNTIME_CATALOG_PATH) + .exists()); + assert!(world.resource::().records.is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_to_keeps_browser_location_and_pins_explicit_destination() { + let mut world = World::new(); + let mut assets = content_navigation_fixture(); + assets.current_folder = "assets/Props".into(); + world.insert_resource(assets); + world.insert_resource(AssetBrowserUiState::default()); + world.init_resource::(); + + begin_import_to(&mut world); + + assert_eq!( + world + .resource::() + .pending_import_destination + .as_deref(), + Some("assets/Props") + ); + request_import_to_destination(&mut world, "assets/Props".into()); + assert_eq!( + world.resource::().request, + Some(SceneIoRequest::ImportAssets { + destination: "assets/Props".into() + }) + ); + assert_eq!( + world.resource::().current_folder, + "assets/Props" + ); + } + + #[test] + fn locating_content_asset_navigates_to_parent_and_selects_the_file() { + let mut world = World::new(); + let mut assets = content_navigation_fixture(); + assets.assets.push(EditorAsset { + label: "Desk".into(), + path: Some("assets/Props/desk.material.ron".into()), + folder_path: "assets/Props".into(), + kind: EditorAssetKind::Material, + }); + world.insert_resource(assets); + + locate_content_asset(&mut world, "assets/Props/desk.material.ron"); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, "assets/Props"); + assert_eq!( + assets.selected, + Some(AssetSelection::File( + "assets/Props/desk.material.ron".into() + )) + ); + assert_eq!(assets.selections, vec![assets.selected.clone().unwrap()]); + assert_eq!(assets.status, "Located assets/Props/desk.material.ron"); + } + + #[test] + fn locating_missing_content_asset_reports_the_broken_reference() { + let mut world = World::new(); + world.insert_resource(content_navigation_fixture()); + + locate_content_asset(&mut world, "assets/Props/missing.material.ron"); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, "assets"); + assert!(assets.selected.is_some()); + assert_eq!( + assets.status, + "Referenced project asset is missing: assets/Props/missing.material.ron" + ); + } + + #[test] + fn model_material_clear_restores_source_and_removes_only_target_orphan() { + let active_slot = shared::ComponentInstanceId::new("slot:active"); + let removed_orphan = shared::ComponentInstanceId::new("slot:removed"); + let retained_orphan = shared::ComponentInstanceId::new("slot:retained"); + let material = MaterialRef::new( + EditorAssetRef::new("material-id", "", "Desk") + .with_source_path("assets/Props/desk.material.ron"), + ); + let mut settings = ImportSettings { + material_slots: vec![ModelMaterialSlotSelection { + slot_id: active_slot.clone(), + selection: ModelMaterialSelection::Project(material.clone()), + }], + orphaned_material_slots: vec![ + shared::OrphanedModelMaterialSelection { + slot_id: removed_orphan.clone(), + last_known_name: "Removed".into(), + material: material.clone(), + }, + shared::OrphanedModelMaterialSelection { + slot_id: retained_orphan.clone(), + last_known_name: "Retained".into(), + material, + }, + ], + ..Default::default() + }; + + set_model_material_slot_selection( + &mut settings, + &active_slot, + ModelMaterialSelection::Source, + ); + assert!(clear_orphaned_model_material_selection( + &mut settings, + &removed_orphan + )); + + assert!(matches!( + settings.material_slots[0].selection, + ModelMaterialSelection::Source + )); + assert_eq!(settings.orphaned_material_slots.len(), 1); + assert_eq!(settings.orphaned_material_slots[0].slot_id, retained_orphan); + assert!(!clear_orphaned_model_material_selection( + &mut settings, + &removed_orphan + )); + } + + #[test] + fn rename_target_never_falls_back_to_an_unselected_current_folder() { + let mut assets = content_navigation_fixture(); + assets.current_folder = "assets/Props".into(); + assets.clear_selection(); + let mut world = World::new(); + world.insert_resource(assets); + + assert_eq!(selected_content_path(&world), None); + } + + #[test] + fn embedded_subassets_never_become_filesystem_operation_targets() { + let selection = AssetSelection::SubAsset { + parent_path: "assets/Props/desk.glb".into(), + sub_asset_id: "mesh:top".into(), + label: "Desk Top".into(), + kind: AssetSubAssetKind::Mesh, + source_path: None, + }; + let mut assets = content_navigation_fixture(); + assets.assets.push(EditorAsset { + label: "Desk".into(), + path: Some("assets/Props/desk.glb".into()), + folder_path: "assets/Props".into(), + kind: EditorAssetKind::Model, + }); + assets.selected = Some(selection.clone()); + assets.selections = vec![selection]; + let mut world = World::new(); + world.insert_resource(assets); + + assert_eq!(selected_content_path(&world), None); + assert!(selected_content_paths(&world).is_empty()); + assert!(build_delete_request(&world).is_none()); + } + + #[test] + fn details_width_respects_content_and_pane_minimums() { + assert_eq!(details_width_for_layout(1_000.0, 220.0, 100.0), 200.0); + assert_eq!(details_width_for_layout(1_000.0, 220.0, 900.0), 600.0); + assert_eq!(details_width_for_layout(1_000.0, 220.0, 420.0), 420.0); + } + + #[test] + fn details_width_uses_reclaimed_tree_space() { + assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 830.0); + } + + #[test] + fn content_shortcuts_are_suppressed_while_a_browser_modal_is_active() { + let mut world = World::new(); + world.insert_resource(AssetBrowserUiState::default()); + assert!(!content_browser_modal_active(&world)); + + world + .resource_mut::() + .pending_path_edit = Some(ContentPathEdit { + kind: ContentPathEditKind::NewFolder { + parent: "assets".into(), + }, + name: "New Folder".into(), + }); + + assert!(content_browser_modal_active(&world)); + } + + #[test] + fn external_move_review_requires_complete_non_reused_identity_choices() { + let fingerprint = shared::AssetSourceFingerprint::from_bytes(b"matching source"); + let conflict = |new_path: &str| content_pipeline::ExternalMoveConflict { + new_path: new_path.into(), + candidate_paths: vec!["assets/Old/A.png".into(), "assets/Old/B.png".into()], + kind: shared::AssetKind::Texture, + fingerprint: fingerprint.clone(), + }; + let mut review = crate::assets::ExternalMoveRepairReview { + root: PathBuf::from("/unused"), + previous: shared::AssetRegistryDocument::default(), + conflicts: vec![conflict("assets/New/A.png"), conflict("assets/New/B.png")], + choices: vec![None, None], + open: true, + }; + + assert!(!external_move_choices_valid(&review)); + review.choices = vec![ + Some(ExternalMoveRepairChoice::Preserve( + "assets/Old/A.png".into(), + )), + Some(ExternalMoveRepairChoice::Preserve( + "assets/Old/A.png".into(), + )), + ]; + assert!(!external_move_choices_valid(&review)); + review.choices[1] = Some(ExternalMoveRepairChoice::RegisterNew); + assert!(external_move_choices_valid(&review)); + } diff --git a/crates/editor/src/ui/asset_browser/panel/tests/b.rs b/crates/editor/src/ui/asset_browser/panel/tests/b.rs new file mode 100644 index 0000000..bd0875f --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/tests/b.rs @@ -0,0 +1,481 @@ +#[test] + fn cut_paste_keeps_exact_target_name_so_collisions_reach_review() { + let mut reserved = HashSet::new(); + let source = Path::new("assets/Props/desk.material.ron"); + let destination = + paste_destination(Path::new("assets/Archive"), source, true, &mut reserved); + + assert_eq!( + destination, + PathBuf::from("assets/Archive/desk.material.ron") + ); + assert!(reserved.is_empty()); + } + + #[test] + fn created_content_undo_is_trash_first_and_fingerprint_guarded() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-create-undo-{}", + uuid::Uuid::new_v4() + )); + let created = PathBuf::from("assets/Props/New Folder"); + fs::create_dir_all(root.join(&created)).unwrap(); + let preview = content_pipeline::ContentTransactionPreview { + operations: vec![content_pipeline::ContentOperation::CreateFolder { + path: created.clone(), + }], + affected_asset_ids: Vec::new(), + reference_rewrites: Vec::new(), + collisions: Vec::new(), + }; + + let undo = build_content_undo_entry(&root, &preview).unwrap(); + + assert!(matches!( + &undo.action, + ContentUndoAction::TrashCreated(paths) if paths == std::slice::from_ref(&created) + )); + assert_eq!(first_changed_content_undo_source(&root, &undo), None); + fs::write(root.join(&created).join("external.txt"), b"external").unwrap(); + assert_eq!( + first_changed_content_undo_source(&root, &undo), + Some(created) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn copied_content_undo_targets_only_the_created_destination() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-copy-undo-{}", + uuid::Uuid::new_v4() + )); + let source = PathBuf::from("assets/Props/source.txt"); + let destination = PathBuf::from("assets/Props/source_copy.txt"); + fs::create_dir_all(root.join("assets/Props")).unwrap(); + fs::write(root.join(&source), b"source").unwrap(); + fs::write(root.join(&destination), b"source").unwrap(); + let preview = content_pipeline::ContentTransactionPreview { + operations: vec![content_pipeline::ContentOperation::Copy { + source: source.clone(), + destination: destination.clone(), + }], + affected_asset_ids: Vec::new(), + reference_rewrites: Vec::new(), + collisions: Vec::new(), + }; + + let undo = build_content_undo_entry(&root, &preview).unwrap(); + + assert!(matches!( + &undo.action, + ContentUndoAction::TrashCreated(paths) if paths == &[destination] + )); + assert_eq!(fs::read(root.join(source)).unwrap(), b"source"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn trash_restore_undo_is_guarded_by_the_complete_batch() { + let root = std::env::temp_dir().join(format!( + "blacksite-content-trash-undo-{}", + uuid::Uuid::new_v4() + )); + let batch = PathBuf::from("assets/.trash/test-batch"); + fs::create_dir_all(root.join(&batch).join("assets/Props")).unwrap(); + fs::write(root.join(&batch).join("assets/Props/item.txt"), b"item").unwrap(); + let fingerprint = content_pipeline::tree_fingerprint(&root.join(&batch)).unwrap(); + let undo = ContentUndoEntry { + label: "Move Item to Trash".into(), + action: ContentUndoAction::RestoreTrash(batch.clone()), + guarded_sources: vec![(batch.clone(), fingerprint)], + }; + + assert_eq!(first_changed_content_undo_source(&root, &undo), None); + fs::write(root.join(&batch).join("assets/Props/item.txt"), b"external").unwrap(); + assert_eq!(first_changed_content_undo_source(&root, &undo), Some(batch)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn manual_trash_restore_forgets_only_its_matching_undo_entry() { + let matching = PathBuf::from("assets/.trash/matching"); + let other = PathBuf::from("assets/.trash/other"); + let mut state = AssetBrowserUiState { + content_undo: vec![ + ContentUndoEntry { + label: "Move A to Trash".into(), + action: ContentUndoAction::RestoreTrash(matching.clone()), + guarded_sources: Vec::new(), + }, + ContentUndoEntry { + label: "Move B to Trash".into(), + action: ContentUndoAction::RestoreTrash(other.clone()), + guarded_sources: Vec::new(), + }, + ], + ..Default::default() + }; + + forget_restored_trash_undo(&mut state, &matching); + + assert_eq!(state.content_undo.len(), 1); + assert!(matches!( + &state.content_undo[0].action, + ContentUndoAction::RestoreTrash(path) if path == &other + )); + } + + #[test] + fn audio_filter_matches_only_audio_clips() { + let audio = EditorAsset { + label: "Impact".into(), + path: Some("assets/audio/impact.ogg".into()), + folder_path: "assets/audio".into(), + kind: EditorAssetKind::AudioClip, + }; + let texture = EditorAsset { + kind: EditorAssetKind::Texture, + ..audio.clone() + }; + + assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio)); + assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio)); + assert_eq!(kind_filter_label(AssetKindFilter::Audio), "Audio"); + } + + #[test] + fn audio_details_label_supported_formats() { + assert_eq!(audio_format_label("assets/audio/music.oga"), "Ogg audio"); + assert_eq!(audio_format_label("assets/audio/voice.spx"), "Speex"); + assert_eq!(audio_format_label("assets/audio/source.WAV"), "WAV"); + assert_eq!(audio_format_label("assets/audio/source.mp3"), "MP3"); + assert_eq!(audio_format_label("assets/audio/source.flac"), "FLAC"); + } + + #[test] + fn pbr_grouping_builds_standard_and_surface_texture_bindings() { + let base = PbrTextureDraft { + path: "assets/Props/desk_diff_2k.jpg".into(), + target: "desk".into(), + role: content_pipeline::PbrTextureRole::BaseColor, + confidence: content_pipeline::PbrMatchConfidence::High, + included: true, + }; + let normal = PbrTextureDraft { + path: "assets/Props/desk_nor_gl_2k.jpg".into(), + role: content_pipeline::PbrTextureRole::Normal, + ..base.clone() + }; + let arm = PbrTextureDraft { + path: "assets/Props/desk_arm_2k.jpg".into(), + role: content_pipeline::PbrTextureRole::PackedOrm, + ..base.clone() + }; + let asset = material_asset_from_pbr_group("desk", &[&base, &normal, &arm], &[]); + let mut material = MaterialDesc::default(); + asset.inputs.apply_to_material_desc(&mut material); + + assert_eq!( + material.base_color_texture.as_deref(), + Some("assets/Props/desk_diff_2k.jpg") + ); + assert_eq!( + material.normal_map_texture.as_deref(), + Some("assets/Props/desk_nor_gl_2k.jpg") + ); + assert_eq!( + material.metallic_roughness_texture.as_deref(), + Some("assets/Props/desk_arm_2k.jpg") + ); + assert!(material + .textures + .iter() + .any(|binding| binding.name == "occlusion_texture")); + } + + #[test] + fn pbr_group_count_reflects_manual_merge_and_exclusions() { + let draft = PbrGroupingDraft { + folder: "assets/Props".into(), + textures: vec![ + PbrTextureDraft { + path: "assets/Props/a.png".into(), + target: "shared".into(), + role: content_pipeline::PbrTextureRole::BaseColor, + confidence: content_pipeline::PbrMatchConfidence::High, + included: true, + }, + PbrTextureDraft { + path: "assets/Props/b.png".into(), + target: "shared".into(), + role: content_pipeline::PbrTextureRole::Normal, + confidence: content_pipeline::PbrMatchConfidence::High, + included: true, + }, + PbrTextureDraft { + path: "assets/Props/c.png".into(), + target: "ignored".into(), + role: content_pipeline::PbrTextureRole::Unknown, + confidence: content_pipeline::PbrMatchConfidence::Unresolved, + included: false, + }, + ], + }; + + assert_eq!(pbr_draft_group_count(&draft), 1); + } + + #[test] + fn gltf_extraction_review_rejects_duplicate_and_external_paths() { + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Desk".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: Some(shared::MaterialProvenance { + source_path: "assets/desk.gltf".into(), + source_fingerprint: "abc".into(), + source_sub_asset_id: "material:0".into(), + source_label: "Desk".into(), + }), + inputs: Default::default(), + }; + let entry = GltfMaterialExtractionEntry { + included: true, + source_index: 0, + source_name: "Desk".into(), + path: format!( + "assets/extraction-review-{}/desk.material.ron", + uuid::Uuid::new_v4() + ), + asset: asset.clone(), + write_mode: MaterialExtractionWriteMode::CreateNew, + }; + let mut draft = GltfMaterialExtractionDraft { + source: "assets/desk.gltf".into(), + destination: "assets".into(), + materials: vec![entry.clone()], + actor_target: None, + }; + assert_eq!(gltf_extraction_validation_error(&draft), None); + + draft.materials.push(GltfMaterialExtractionEntry { + source_index: 1, + ..entry + }); + assert!(gltf_extraction_validation_error(&draft) + .is_some_and(|error| error.contains("same project path"))); + + draft.materials.truncate(1); + draft.materials[0].path = "../outside.material.ron".into(); + assert!(gltf_extraction_validation_error(&draft) + .is_some_and(|error| error.contains("Invalid project path"))); + } + + #[test] + fn gltf_extraction_existing_material_requires_reviewed_guarded_apply() { + let folder = PathBuf::from(format!( + "assets/.extraction-review-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&folder).unwrap(); + let path = folder.join("desk.material.ron"); + let provenance = shared::MaterialProvenance { + source_path: "assets/desk.gltf".into(), + source_fingerprint: "old".into(), + source_sub_asset_id: "material:0".into(), + source_label: "Desk".into(), + }; + let existing = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Locally Edited Desk".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: Default::default(), + provenance: Some(provenance.clone()), + inputs: Default::default(), + }; + fs::write(&path, ron::ser::to_string(&existing).unwrap()).unwrap(); + let mut incoming = existing.clone(); + incoming.label = "Desk".into(); + incoming.provenance.as_mut().unwrap().source_fingerprint = "new".into(); + let entry = GltfMaterialExtractionEntry { + included: true, + source_index: 0, + source_name: "Desk".into(), + path: path.to_string_lossy().replace('\\', "/"), + asset: incoming, + write_mode: MaterialExtractionWriteMode::Undecided, + }; + let mut draft = GltfMaterialExtractionDraft { + source: provenance.source_path, + destination: folder.to_string_lossy().replace('\\', "/"), + materials: vec![entry], + actor_target: None, + }; + + assert!(gltf_extraction_validation_error(&draft) + .is_some_and(|error| error.contains("choose Apply or Create New"))); + let review = review_existing_extraction_target(&draft.materials[0]) + .unwrap() + .unwrap(); + assert!(review.provenance_matches); + assert!(review.changes.contains(&"label")); + assert!(review.changes.contains(&"source revision")); + draft.materials[0].write_mode = MaterialExtractionWriteMode::ApplyExisting { + expected_fingerprint: review.fingerprint, + previous_bytes: review.bytes, + }; + assert_eq!(gltf_extraction_validation_error(&draft), None); + + fs::write(&path, "external edit").unwrap(); + assert!(gltf_extraction_validation_error(&draft).is_some()); + fs::remove_dir_all(folder).unwrap(); + } + + #[test] + fn actor_extraction_assignment_changes_only_the_exact_primitive_slot() { + let mut app = App::new(); + app.add_plugins(shared::SharedTypesPlugin); + app.init_resource::(); + let entity = app + .world_mut() + .spawn(( + shared::LevelObject, + shared::ActorKind::StaticMesh, + shared::Primitive::default(), + )) + .id(); + let target = GltfMaterialExtractionTarget { + entity, + slot_id: shared::ComponentInstanceId::new(shared::PRIMITIVE_SURFACE_SLOT_ID), + source_sub_asset_id: "material:0".into(), + }; + let reference = MaterialRef::new( + EditorAssetRef::new("material-id", "material:source", "Desk") + .with_source_path("assets/Props/desk.material.ron"), + ); + + validate_actor_material_extraction_target(app.world(), &target).unwrap(); + assign_extracted_material_to_actor_slot(app.world_mut(), &target, reference.clone()) + .unwrap(); + + assert_eq!( + app.world() + .get::(entity) + .unwrap() + .surface + .material + .as_ref(), + Some(&reference) + ); + assert!(app + .world() + .resource::() + .can_undo()); + } + + #[test] + fn extracted_material_binding_maps_every_draw_slot_using_the_source_material() { + use crate::assets::static_mesh::{ + StaticMeshImportSnapshot, StaticMeshManifest, StaticMeshMetadata, StaticMeshPart, + StaticMeshSource, + }; + + let model_path = "assets/Props/desk.gltf"; + let material_path = "assets/Props/desk.material.ron"; + let material_id = shared::AssetId::new(); + let mut document = AssetRegistryDocument { + records: vec![ + shared::AssetRecord { + id: shared::AssetId::new(), + path: model_path.into(), + label: "Desk".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + }, + shared::AssetRecord { + id: material_id.clone(), + path: material_path.into(), + label: "Desk Steel".into(), + kind: AssetKind::Material, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::None, + dependencies: Vec::new(), + }, + ], + ..Default::default() + }; + let part = |id: &str, material_index: usize| StaticMeshPart { + id: id.into(), + name: id.into(), + mesh_label: format!("Mesh{material_index}/Primitive0"), + material_id: Some(material_id_from_label(&format!("Material{material_index}"))), + material_slot_name: "Desk Steel".into(), + material_label: Some(format!("Material{material_index}")), + local_transform: Transform::default(), + source_node: None, + source_mesh: None, + source_material: Some("Desk Steel".into()), + skinned: false, + }; + let manifest = StaticMeshManifest { + schema_version: crate::assets::static_mesh::STATIC_MESH_MANIFEST_SCHEMA, + asset_id: document.records[0].id.as_string(), + label: "Desk".into(), + source: StaticMeshSource { + path: model_path.into(), + format: "gltf".into(), + fingerprint: shared::AssetSourceFingerprint::from_bytes(b"desk"), + dependencies: Vec::new(), + }, + import: StaticMeshImportSnapshot { + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: ModelPlacementMode::StaticAsset, + hierarchy_mode: ModelHierarchyMode::SingleActor, + material_policy: Default::default(), + material_slots: Vec::new(), + orphaned_material_slots: Vec::new(), + }, + metadata: StaticMeshMetadata { + mesh_count: 3, + material_count: 2, + node_count: 3, + animation_count: 0, + skin_count: 0, + light_count: 0, + camera_count: 0, + }, + parts: vec![part("draw:a", 0), part("draw:b", 0), part("draw:c", 1)], + warnings: Vec::new(), + }; + + let mapped = bind_extracted_material_slots( + &mut document, + model_path, + &manifest, + &[ExtractedMaterialBinding { + source_index: 0, + path: material_path.into(), + }], + ) + .unwrap(); + + assert_eq!(mapped, 2); + let slots = &document.records[0].model_import().material_slots; + assert_eq!(slots.len(), 2); + assert!(slots.iter().all(|slot| { + matches!( + &slot.selection, + ModelMaterialSelection::Project(reference) + if reference.0.asset_id == material_id.as_string() + && reference.0.source_path.as_deref() == Some(material_path) + ) + })); + } diff --git a/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs new file mode 100644 index 0000000..bb49b72 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs @@ -0,0 +1,557 @@ +use super::*; + +pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { + let current_folder = world.resource::().current_folder.clone(); + let selection_count = world.resource::().selections.len(); + let has_editable_selection = !selected_content_paths(world).is_empty(); + let can_rename = selection_count == 1 && has_editable_selection; + ui.horizontal(|ui| { + if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh").clicked() { + crate::assets::refresh_content_browser(world); + } + let pending_repairs = world + .get_resource::() + .and_then(|pending| pending.review.as_ref()) + .map_or(0, |review| review.conflicts.len()); + if ui + .add_enabled( + pending_repairs > 0, + egui::Button::new(format!("Resolve Moves ({pending_repairs})")), + ) + .on_hover_text("Choose which stable asset identities match ambiguous external moves") + .clicked() + { + if let Some(review) = world + .resource_mut::() + .review + .as_mut() + { + review.open = true; + } + } + if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() { + request_import_to_destination(world, current_folder.clone()); + } + if ui + .add_enabled( + current_folder != BUILTINS_FOLDER, + egui::Button::new("Import To..."), + ) + .clicked() + { + begin_import_to(world); + } + if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() { + navigate_to_parent(world); + } + if ui + .add_enabled( + current_folder != BUILTINS_FOLDER, + egui::Button::new("New Folder"), + ) + .clicked() + { + begin_new_folder(world, current_folder.clone()); + } + if ui + .add_enabled(can_rename, egui::Button::new("Rename")) + .clicked() + { + begin_rename(world); + } + if ui + .add_enabled(has_editable_selection, egui::Button::new("Duplicate")) + .clicked() + { + duplicate_selection(world); + } + let can_undo_content = !world + .resource::() + .content_undo + .is_empty(); + if ui + .add_enabled(can_undo_content, egui::Button::new("Undo Content")) + .clicked() + { + undo_last_content_operation(world); + } + if ui + .add_enabled(has_editable_selection, egui::Button::new("Cut")) + .clicked() + { + set_content_clipboard(world, true); + } + if ui + .add_enabled(has_editable_selection, egui::Button::new("Copy")) + .clicked() + { + set_content_clipboard(world, false); + } + let can_paste = world.resource::().clipboard.is_some() + && world.resource::().current_folder != BUILTINS_FOLDER; + if ui + .add_enabled(can_paste, egui::Button::new("Paste")) + .clicked() + { + paste_content_clipboard(world); + } + if ui.small_button("Open Trash").clicked() { + world.resource_mut::().show_trash = true; + } + + ui.separator(); + + let visible_results_changed = { + let mut state = world.resource_mut::(); + let mut visible_results_changed = ui + .add( + egui::TextEdit::singleline(&mut state.search) + .hint_text("Search assets...") + .desired_width(fit_width(ui, 120.0, 190.0)), + ) + .changed(); + visible_results_changed |= ui.checkbox(&mut state.recursive, "Subfolders").changed(); + + let previous_kind_filter = state.kind_filter; + egui::ComboBox::from_id_salt("asset_kind_filter") + .selected_text(kind_filter_label(state.kind_filter)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut state.kind_filter, AssetKindFilter::All, "All"); + ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Model, "Models"); + ui.selectable_value( + &mut state.kind_filter, + AssetKindFilter::Texture, + "Textures", + ); + ui.selectable_value( + &mut state.kind_filter, + AssetKindFilter::Material, + "Materials", + ); + ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Audio, "Audio"); + ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels"); + ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs"); + ui.selectable_value( + &mut state.kind_filter, + AssetKindFilter::Builtin, + "Built-ins", + ); + }); + visible_results_changed |= state.kind_filter != previous_kind_filter; + + egui::ComboBox::from_id_salt("asset_sort") + .selected_text(sort_label(state.sort)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut state.sort, AssetSort::Name, "Name"); + ui.selectable_value(&mut state.sort, AssetSort::Kind, "Type"); + ui.selectable_value(&mut state.sort, AssetSort::Modified, "Modified"); + ui.selectable_value(&mut state.sort, AssetSort::Size, "Size"); + }); + + ui.separator(); + if tool_button( + ui, + icons::GRID_FOUR, + state.view == AssetBrowserView::Grid, + "Grid view", + ) + .clicked() + { + state.view = AssetBrowserView::Grid; + } + if tool_button( + ui, + icons::LIST, + state.view == AssetBrowserView::List, + "List view", + ) + .clicked() + { + state.view = AssetBrowserView::List; + } + if ui.available_width() > 180.0 { + ui.label(icon_text(icons::IMAGE_SQUARE, 13.0).color(TEXT_DIM)); + ui.add_sized( + [96.0, 20.0], + egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false), + ); + } + ui.checkbox(&mut state.show_details, "Details"); + visible_results_changed + }; + clear_selection_when_visible_results_change(world, visible_results_changed); + }); +} + +pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) { + if changed && !world.resource::().selections.is_empty() { + let mut assets = world.resource_mut::(); + assets.clear_selection(); + assets.status = "Selection cleared because visible results changed".into(); + } +} + +pub(super) fn request_import_to_destination(world: &mut World, destination: String) { + let destination = if destination == BUILTINS_FOLDER { + ASSETS_ROOT.to_string() + } else { + destination + }; + world.resource_mut::().request = Some(SceneIoRequest::ImportAssets { destination }); +} + +pub(super) fn begin_import_to(world: &mut World) { + let current = world.resource::().current_folder.clone(); + let destination = if current == BUILTINS_FOLDER { + ASSETS_ROOT.to_string() + } else { + current + }; + world + .resource_mut::() + .pending_import_destination = Some(destination); +} + +pub(super) fn draw_import_to_modal(world: &mut World, context: &egui::Context) { + let Some(selected) = world + .resource::() + .pending_import_destination + .clone() + else { + return; + }; + let mut folders = world + .resource::() + .folders + .iter() + .filter(|folder| { + folder.path == ASSETS_ROOT || folder.path.starts_with(&format!("{ASSETS_ROOT}/")) + }) + .map(|folder| (folder.path.clone(), folder.name.clone())) + .collect::>(); + folders.sort_by(|left, right| left.0.cmp(&right.0)); + let mut action = context + .input(|input| input.key_pressed(egui::Key::Escape)) + .then_some(false); + let mut next_selection = None; + egui::Window::new("Import To Project Folder") + .id(egui::Id::new("asset_browser_import_to")) + .collapsible(false) + .resizable(true) + .default_width(460.0) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(context, |ui| { + ui.label("Choose the destination beneath the managed project content root."); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("asset_browser_import_to_folders") + .max_height(320.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for (path, name) in &folders { + let depth = path.matches('/').count(); + ui.horizontal(|ui| { + ui.add_space(depth.saturating_sub(1) as f32 * 14.0); + if ui + .selectable_label(path == &selected, format!("{name} — {path}")) + .clicked() + { + next_selection = Some(path.clone()); + } + }); + } + }); + ui.add_space(8.0); + ui.label(format!("Destination: {selected}")); + ui.small( + egui::RichText::new( + "Imported files keep their source names here; safe dependency-relative bundle paths are preserved.", + ) + .color(TEXT_DIM), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = Some(false); + } + if ui.button("Choose Files...").clicked() { + action = Some(true); + } + }); + }); + if let Some(next_selection) = next_selection { + world + .resource_mut::() + .pending_import_destination = Some(next_selection); + } + match action { + Some(true) => { + world + .resource_mut::() + .pending_import_destination = None; + request_import_to_destination(world, selected); + } + Some(false) => { + world + .resource_mut::() + .pending_import_destination = None; + } + None => {} + } +} + +pub(crate) fn draw_asset_import_review_modal(world: &mut World, context: &egui::Context) { + let Some(pending) = world + .resource::() + .0 + .clone() + else { + return; + }; + let plan = &pending.plan; + let mut action = context + .input(|input| input.key_pressed(egui::Key::Escape)) + .then_some(false); + egui::Window::new("Review Asset Import") + .id(egui::Id::new("asset_import_transaction_review")) + .collapsible(false) + .resizable(true) + .default_width(640.0) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(context, |ui| { + ui.label(format!("Destination: {}", plan.destination.display())); + ui.small( + egui::RichText::new( + "Every source dependency and final project path is fingerprinted before the batch publishes.", + ) + .color(TEXT_DIM), + ); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("asset_import_transaction_entries") + .max_height(360.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for entry in &plan.entries { + egui::Frame::new() + .fill(WIDGET_BG.linear_multiply(0.8)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.label(egui::RichText::new(entry.source.display().to_string()).strong()); + if entry.adopt_in_place { + ui.small("Adopt existing project file in place"); + } else { + for target in &entry.targets { + ui.small(format!("→ {}", target.display())); + } + } + if entry.source_files.len() > 1 { + ui.small( + egui::RichText::new(format!( + "{} source bundle files", + entry.source_files.len() + )) + .color(TEXT_DIM), + ); + } + }); + ui.add_space(4.0); + } + }); + if !plan.conflicts.is_empty() { + ui.add_space(8.0); + ui.colored_label(egui::Color32::LIGHT_RED, "Resolve these collisions before import:"); + for conflict in &plan.conflicts { + ui.colored_label(egui::Color32::LIGHT_RED, format!("• {conflict}")); + } + } + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = Some(false); + } + if ui + .add_enabled( + plan.can_commit(), + egui::Button::new(format!("Import {} Asset(s)", plan.imported_count())), + ) + .clicked() + { + action = Some(true); + } + }); + }); + match action { + Some(false) => { + world + .resource_mut::() + .0 = None; + world.resource_mut::().status = pending.previous_status; + } + Some(true) => match commit_reviewed_asset_import(world, plan) { + Ok(count) => { + world + .resource_mut::() + .0 = None; + world.resource_mut::().refresh(); + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = format!("Imported {count} asset(s)"); + } + Err(error) => { + world.resource_mut::().status = format!("Import failed: {error}"); + } + }, + None => {} + } +} + +pub(super) fn commit_reviewed_asset_import( + world: &mut World, + plan: &crate::assets::ExternalAssetImportPlan, +) -> Result { + let previous = world.resource::().document(); + let imported_paths = plan + .entries + .iter() + .flat_map(|entry| entry.targets.iter()) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .collect::>(); + let mut files_committed = false; + let mut publication_backups: Vec<(PathBuf, Option>)> = Vec::new(); + + crate::assets::begin_content_watch_transaction(world); + let result: Result<(usize, AssetRegistryDocument), String> = (|| { + let count = crate::assets::commit_external_assets_import(plan)?; + files_committed = true; + + let mut processed = content_pipeline::scan_project(&plan.project_root, &previous)?; + let mut model_plans = Vec::new(); + for record in processed.registry.records.iter_mut().filter(|record| { + record.kind == AssetKind::Model && imported_paths.contains(&record.path) + }) { + let model_plan = crate::assets::plan_model_artifacts_at(&plan.project_root, record) + .map_err(|error| { + format!("could not process imported model {}: {error}", record.path) + })?; + *record = model_plan.record.clone(); + model_plans.push(model_plan); + } + processed.runtime_catalog = shared::RuntimeContentCatalog::from(&processed.registry); + let mut texture_plans = Vec::new(); + for record in processed + .registry + .records + .iter() + .filter(|record| record.kind == AssetKind::Texture) + { + let texture_plan = content_pipeline::plan_texture_artifact(&plan.project_root, record) + .map_err(|error| format!("could not process Texture {}: {error}", record.path))?; + content_pipeline::apply_texture_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &texture_plan, + )?; + texture_plans.push(texture_plan); + } + let mut material_plans = Vec::new(); + for record in processed.registry.records.iter().filter(|record| { + matches!( + record.kind, + AssetKind::Material | AssetKind::MaterialInstance + ) + }) { + if let Some(material_plan) = content_pipeline::plan_material_artifact( + &plan.project_root, + record, + &processed.registry, + )? { + content_pipeline::apply_material_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &material_plan, + )?; + material_plans.push(material_plan); + } + } + + let mut publication_paths = model_plans + .iter() + .flat_map(|model_plan| { + [ + model_plan.static_path.clone(), + model_plan.animation_path.clone(), + ] + }) + .collect::>(); + publication_paths.extend( + texture_plans + .iter() + .map(|texture_plan| texture_plan.output_path.clone()), + ); + publication_paths.extend( + material_plans + .iter() + .map(|material_plan| material_plan.output_path.clone()), + ); + publication_paths.push(plan.project_root.join(content_pipeline::REGISTRY_PATH)); + publication_paths.push( + plan.project_root + .join(content_pipeline::RUNTIME_CATALOG_PATH), + ); + publication_paths.sort(); + publication_paths.dedup(); + publication_backups = publication_paths + .iter() + .map(|path| (path.clone(), fs::read(path).ok())) + .collect(); + + for model_plan in &model_plans { + crate::assets::publish_model_artifacts(model_plan)?; + } + for texture_plan in &texture_plans { + content_pipeline::publish_texture_artifact(texture_plan)?; + } + for material_plan in &material_plans { + content_pipeline::publish_material_artifact(material_plan)?; + } + content_pipeline::publish_content_documents_with_catalog( + &plan.project_root, + &processed.registry, + &processed.runtime_catalog, + )?; + Ok((count, processed.registry)) + })(); + + let result = match result { + Ok((count, document)) => { + apply_registry_document(world, document); + Ok(count) + } + Err(error) => { + for (path, bytes) in publication_backups.iter().rev() { + restore_import_publication_file(path, bytes.as_deref()); + } + let rollback_error = files_committed + .then(|| crate::assets::rollback_external_assets_import(plan)) + .and_then(Result::err); + Err(match rollback_error { + Some(rollback_error) => format!( + "import processing failed: {error}; file rollback was incomplete: {rollback_error}" + ), + None => format!("import processing rolled back: {error}"), + }) + } + }; + crate::assets::end_content_watch_transaction(world); + result +} + +pub(super) fn restore_import_publication_file(path: &Path, bytes: Option<&[u8]>) { + if let Some(bytes) = bytes { + let _ = fs::write(path, bytes); + } else { + let _ = fs::remove_file(path); + } +} diff --git a/crates/editor/src/ui/asset_browser/panel/undo_trash.rs b/crates/editor/src/ui/asset_browser/panel/undo_trash.rs new file mode 100644 index 0000000..60b418a --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/undo_trash.rs @@ -0,0 +1,725 @@ +use super::*; + +pub(super) fn build_content_undo_entry( + project_root: &Path, + preview: &content_pipeline::ContentTransactionPreview, +) -> Option { + let all_replacements = preview.operations.iter().all(|operation| { + matches!( + operation, + content_pipeline::ContentOperation::ReplaceFile { .. } + ) + }); + let all_moves = preview + .operations + .iter() + .all(|operation| matches!(operation, content_pipeline::ContentOperation::Move { .. })); + let (label, action, guarded_paths) = if all_replacements { + let inverse_operations = preview + .operations + .iter() + .rev() + .filter_map(|operation| match operation { + content_pipeline::ContentOperation::ReplaceFile { + path, + bytes, + previous_bytes, + .. + } => Some(content_pipeline::ContentOperation::ReplaceFile { + path: path.clone(), + bytes: previous_bytes.clone(), + expected_fingerprint: content_pipeline::tree_fingerprint( + &project_root.join(path), + ) + .ok()?, + previous_bytes: bytes.clone(), + }), + _ => None, + }) + .collect::>(); + if inverse_operations.len() != preview.operations.len() { + return None; + } + let guarded_paths = inverse_operations + .iter() + .filter_map(|operation| match operation { + content_pipeline::ContentOperation::ReplaceFile { path, .. } => Some(path.clone()), + _ => None, + }) + .collect::>(); + ( + format!("Apply {} Material update(s)", inverse_operations.len()), + ContentUndoAction::Apply(inverse_operations), + guarded_paths, + ) + } else if all_moves { + let inverse_operations: Vec<_> = preview + .operations + .iter() + .rev() + .filter_map(|operation| match operation { + content_pipeline::ContentOperation::Move { + source, + destination, + } => Some(content_pipeline::ContentOperation::Move { + source: destination.clone(), + destination: source.clone(), + }), + _ => None, + }) + .collect(); + if inverse_operations.is_empty() { + return None; + } + let guarded_paths = inverse_operations + .iter() + .filter_map(|operation| match operation { + content_pipeline::ContentOperation::Move { source, .. } => Some(source.clone()), + _ => None, + }) + .collect::>(); + ( + format!("Move {} content item(s)", inverse_operations.len()), + ContentUndoAction::Apply(inverse_operations), + guarded_paths, + ) + } else { + let mut created_paths = Vec::with_capacity(preview.operations.len()); + let mut copy_count = 0; + let mut folder_count = 0; + for operation in &preview.operations { + match operation { + content_pipeline::ContentOperation::CreateFolder { path } => { + folder_count += 1; + created_paths.push(path.clone()); + } + content_pipeline::ContentOperation::Copy { destination, .. } => { + copy_count += 1; + created_paths.push(destination.clone()); + } + content_pipeline::ContentOperation::WriteFile { path, .. } => { + created_paths.push(path.clone()); + } + content_pipeline::ContentOperation::ReplaceFile { .. } => return None, + content_pipeline::ContentOperation::Move { .. } => return None, + } + } + if created_paths.is_empty() { + return None; + } + let label = if copy_count == preview.operations.len() { + format!("Copy {copy_count} content item(s)") + } else if folder_count == preview.operations.len() { + format!("Create {folder_count} folder(s)") + } else { + format!("Create {} content item(s)", created_paths.len()) + }; + ( + label, + ContentUndoAction::TrashCreated(created_paths.clone()), + created_paths, + ) + }; + let guarded_sources = guarded_paths + .into_iter() + .filter_map(|path| { + content_pipeline::tree_fingerprint(&project_root.join(&path)) + .ok() + .map(|fingerprint| (path, fingerprint)) + }) + .collect::>(); + let expected_guard_count = match &action { + ContentUndoAction::Apply(operations) => operations.len(), + ContentUndoAction::TrashCreated(paths) => paths.len(), + ContentUndoAction::RestoreTrash(_) => 1, + }; + (guarded_sources.len() == expected_guard_count).then_some(ContentUndoEntry { + label, + action, + guarded_sources, + }) +} + +pub(super) fn undo_last_content_operation(world: &mut World) { + let Some(entry) = world + .resource::() + .content_undo + .last() + .cloned() + else { + return; + }; + if let Some(path) = first_changed_content_undo_source(Path::new("."), &entry) { + world.resource_mut::().status = format!( + "Cannot undo {}: {} changed outside the transaction", + entry.label, + path.display() + ); + return; + } + let undone = match &entry.action { + ContentUndoAction::Apply(operations) => { + commit_content_operations_internal(world, operations.clone(), false) + } + ContentUndoAction::TrashCreated(paths) => trash_created_content_for_undo(world, paths), + ContentUndoAction::RestoreTrash(batch_path) => { + restore_trashed_content_for_undo(world, batch_path) + } + }; + if undone { + world + .resource_mut::() + .content_undo + .pop(); + world.resource_mut::().status = format!("Undid {}", entry.label); + } +} + +pub(super) fn first_changed_content_undo_source( + project_root: &Path, + entry: &ContentUndoEntry, +) -> Option { + entry.guarded_sources.iter().find_map(|(path, expected)| { + let actual = content_pipeline::tree_fingerprint(&project_root.join(path)); + (actual.as_deref() != Ok(expected.as_str())).then(|| path.clone()) + }) +} + +pub(super) fn trash_created_content_for_undo(world: &mut World, paths: &[PathBuf]) -> bool { + let mut document = world + .resource::() + .document(); + crate::assets::begin_content_watch_transaction(world); + let trash_result = content_pipeline::trash_content(Path::new("."), paths, &mut document); + crate::assets::end_content_watch_transaction(world); + if let Err(error) = trash_result { + world.resource_mut::().status = error; + return false; + } + apply_registry_document(world, document); + let current_folder = world.resource::().current_folder.clone(); + { + let mut assets = world.resource_mut::(); + assets.clear_selection(); + assets.refresh(); + if !Path::new(¤t_folder).is_dir() { + assets.current_folder = nearest_existing_content_folder(¤t_folder); + } + } + invalidate_on_catalog_refresh(world); + true +} + +pub(super) fn restore_trashed_content_for_undo(world: &mut World, batch_path: &Path) -> bool { + let mut document = world + .resource::() + .document(); + crate::assets::begin_content_watch_transaction(world); + let restore_result = + content_pipeline::restore_trash_batch(Path::new("."), batch_path, &mut document); + crate::assets::end_content_watch_transaction(world); + let restored = match restore_result { + Ok(restored) => restored, + Err(error) => { + world.resource_mut::().status = error; + return false; + } + }; + apply_registry_document(world, document); + let selections = restored + .original_paths + .iter() + .map(|path| { + if Path::new(path).is_dir() { + AssetSelection::Folder(path.clone()) + } else { + AssetSelection::File(path.clone()) + } + }) + .collect::>(); + { + let mut assets = world.resource_mut::(); + assets.refresh(); + assets.select_all(&selections); + } + invalidate_on_catalog_refresh(world); + true +} + +pub(super) fn nearest_existing_content_folder(folder: &str) -> String { + let mut candidate = Path::new(folder); + loop { + if candidate.is_dir() && candidate.starts_with(ASSETS_ROOT) { + return candidate.to_string_lossy().replace('\\', "/"); + } + let Some(parent) = candidate.parent() else { + return ASSETS_ROOT.into(); + }; + candidate = parent; + } +} + +pub(super) fn request_delete_for_asset(world: &mut World, asset: &EditorAsset) { + let selection = AssetSelection::from_asset(asset); + if !world.resource::().is_selected(&selection) { + world.resource_mut::().select(selection); + } + request_delete_for_selection(world); +} + +pub(super) fn request_delete_for_selection(world: &mut World) { + if let Some(request) = build_delete_request(world) { + world.resource_mut::().pending_delete = Some(request); + } +} + +pub(super) fn external_move_choices_valid( + review: &crate::assets::ExternalMoveRepairReview, +) -> bool { + if review.choices.len() != review.conflicts.len() || review.choices.iter().any(Option::is_none) + { + return false; + } + let mut preserved = HashSet::new(); + review + .conflicts + .iter() + .zip(&review.choices) + .all(|(conflict, choice)| match choice { + Some(ExternalMoveRepairChoice::Preserve(path)) => { + conflict.candidate_paths.contains(path) && preserved.insert(path) + } + Some(ExternalMoveRepairChoice::RegisterNew) => true, + None => false, + }) +} + +pub(super) fn draw_external_move_repair_modal(world: &mut World, ctx: &egui::Context) { + let Some(mut review) = world + .get_resource::() + .and_then(|pending| pending.review.clone()) + .filter(|review| review.open) + else { + return; + }; + let mut action = None; + egui::Window::new("Resolve Ambiguous External Moves") + .collapsible(false) + .resizable(true) + .default_width(620.0) + .show(ctx, |ui| { + ui.label( + "The same imported bytes match multiple missing registry records. Choose which stable identity moved to each new path.", + ); + ui.small( + egui::RichText::new( + "Register as New intentionally assigns a fresh ID. No registry or manifest changes are published until every row is resolved.", + ) + .color(TEXT_DIM), + ); + ui.separator(); + egui::ScrollArea::vertical() + .id_salt("external_move_repair_rows") + .max_height(420.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + for (index, conflict) in review.conflicts.iter().enumerate() { + ui.group(|ui| { + ui.label(egui::RichText::new(&conflict.new_path).strong()); + ui.small(format!( + "{:?} · {} candidate identities", + conflict.kind, + conflict.candidate_paths.len() + )); + let selected_text = match review.choices.get(index).and_then(Option::as_ref) { + Some(ExternalMoveRepairChoice::Preserve(path)) => { + format!("Preserve ID from {path}") + } + Some(ExternalMoveRepairChoice::RegisterNew) => { + "Register as New Asset".into() + } + None => "Choose stable identity…".into(), + }; + egui::ComboBox::from_id_salt(("external_move_choice", index)) + .selected_text(selected_text) + .width(ui.available_width().max(240.0)) + .show_ui(ui, |ui| { + for candidate in &conflict.candidate_paths { + let used_elsewhere = review + .choices + .iter() + .enumerate() + .any(|(other_index, choice)| { + other_index != index + && choice.as_ref() + == Some(&ExternalMoveRepairChoice::Preserve( + candidate.clone(), + )) + }); + ui.add_enabled_ui(!used_elsewhere, |ui| { + ui.selectable_value( + &mut review.choices[index], + Some(ExternalMoveRepairChoice::Preserve( + candidate.clone(), + )), + format!("Preserve ID from {candidate}"), + ); + }); + } + ui.separator(); + ui.selectable_value( + &mut review.choices[index], + Some(ExternalMoveRepairChoice::RegisterNew), + "Register as New Asset", + ); + }); + }); + ui.add_space(6.0); + } + }); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Review Later").clicked() { + action = Some(false); + } + if ui + .add_enabled( + external_move_choices_valid(&review), + egui::Button::new("Apply Identity Repairs"), + ) + .clicked() + { + action = Some(true); + } + }); + }); + + match action { + Some(false) => review.open = false, + Some(true) => {} + None => {} + } + world.resource_mut::().review = Some(review); + if action == Some(true) { + if let Err(error) = crate::assets::commit_external_move_repairs(world) { + world.resource_mut::().status = + format!("External move repair failed: {error}"); + } + } +} + +pub(super) fn build_delete_request(world: &World) -> Option { + let assets = world.resource::(); + let selections: Vec = assets + .selections + .iter() + .filter(|selection| { + authored_content_path(selection) + .is_some_and(|path| !matches!(path, ASSETS_ROOT | BUILTINS_FOLDER)) + }) + .cloned() + .collect(); + if selections.is_empty() { + return None; + } + let selected_paths: Vec = selected_content_paths(world); + let mut files = selected_paths.clone(); + let mut warnings = Vec::new(); + for selection in &selections { + let Some(asset) = assets.asset_for_selection(selection) else { + continue; + }; + let Some(path) = asset.path.as_deref() else { + continue; + }; + if !matches!(asset.kind, EditorAssetKind::Model) { + continue; + } + if let Some(record) = world + .get_resource::() + .and_then(|registry| find_asset_by_path(registry, path)) + { + let settings = record.model_import(); + if let Some(manifest) = settings.static_mesh_manifest_path.clone() { + files.push(manifest); + } + if let Some(manifest) = settings.animation_manifest_path.clone() { + files.push(manifest); + } + if !record.dependencies.is_empty() { + warnings.push(format!( + "{} imported dependencies will be kept because they may be shared.", + record.dependencies.len() + )); + } + } + } + if let Some(registry) = world.get_resource::() { + let sources = selected_paths.iter().map(PathBuf::from).collect::>(); + match content_pipeline::find_reference_usages( + Path::new("."), + &sources, + ®istry.document(), + ) { + Ok(usages) if !usages.is_empty() => { + warnings.push(format!( + "{} external reference(s) will become unresolved:", + usages.len() + )); + warnings.extend( + usages + .into_iter() + .map(|usage| format!("{} — {}", usage.document.display(), usage.reference)), + ); + } + Ok(_) => {} + Err(error) => warnings.push(format!( + "Reference usage scan failed; review could be incomplete: {error}" + )), + } + } + dedup_strings(&mut files); + let label = if selections.len() == 1 { + selections[0].display_label().to_string() + } else { + format!("{} selected items", selections.len()) + }; + Some(AssetDeleteRequest { + label, + files, + warnings, + }) +} + +pub(super) fn draw_delete_modal(world: &mut World, ctx: &egui::Context) { + let pending = world + .resource::() + .pending_delete + .clone(); + let Some(request) = pending else { + return; + }; + let mut action = None; + egui::Window::new("Move Content To Trash") + .collapsible(false) + .resizable(false) + .default_width(360.0) + .show(ctx, |ui| { + ui.label(format!("Move {} to assets/.trash?", request.label)); + ui.separator(); + ui.label(panel_heading("Files")); + for file in &request.files { + ui.add(egui::Label::new(file).wrap()); + } + if !request.warnings.is_empty() { + ui.separator(); + ui.label(panel_heading("Warnings")); + for warning in &request.warnings { + ui.small(egui::RichText::new(warning).color(TEXT_DIM)); + } + } + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = Some(false); + } + if ui.button("Move To Trash").clicked() { + action = Some(true); + } + }); + }); + + match action { + Some(false) => { + world.resource_mut::().pending_delete = None; + } + Some(true) => { + execute_delete_request(world, &request); + world.resource_mut::().pending_delete = None; + } + None => {} + } +} + +pub(super) fn draw_trash_modal(world: &mut World, ctx: &egui::Context) { + if !world.resource::().show_trash { + return; + } + let batches = content_pipeline::list_trash_batches(Path::new(".")); + let mut close = false; + let mut restore = None; + egui::Window::new("Content Trash") + .collapsible(false) + .resizable(true) + .default_width(520.0) + .min_width(380.0) + .show(ctx, |ui| { + ui.label("Restore a complete transaction to its original project paths."); + ui.small( + egui::RichText::new( + "Restore is blocked when a destination or stable registry ID is already in use.", + ) + .color(TEXT_DIM), + ); + ui.separator(); + match &batches { + Err(error) => { + ui.label(egui::RichText::new(error).color(ERROR)); + } + Ok(batches) if batches.is_empty() => { + ui.label(egui::RichText::new("Trash is empty.").color(TEXT_DIM)); + } + Ok(batches) => { + egui::ScrollArea::vertical() + .max_height(420.0) + .show(ui, |ui| { + for batch in batches { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.strong(&batch.batch_id); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("Restore").clicked() { + restore = Some(batch.clone()); + } + }, + ); + }); + for path in &batch.original_paths { + ui.small(path); + } + }); + ui.add_space(4.0); + } + }); + } + } + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + }); + + if let Some(batch) = restore { + let mut document = world + .resource::() + .document(); + crate::assets::begin_content_watch_transaction(world); + let restore_result = + content_pipeline::restore_trash_batch(Path::new("."), &batch.path, &mut document); + crate::assets::end_content_watch_transaction(world); + match restore_result { + Ok(restored) => { + apply_registry_document(world, document); + forget_restored_trash_undo( + &mut world.resource_mut::(), + &batch.path, + ); + let selections = restored + .original_paths + .iter() + .map(|path| { + if Path::new(path).is_dir() { + AssetSelection::Folder(path.clone()) + } else { + AssetSelection::File(path.clone()) + } + }) + .collect::>(); + { + let mut assets = world.resource_mut::(); + assets.refresh(); + assets.select_all(&selections); + } + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = format!( + "Restored {} content item(s) from Trash", + restored.original_paths.len() + ); + } + Err(error) => world.resource_mut::().status = error, + } + } + if close { + world.resource_mut::().show_trash = false; + } +} + +pub(super) fn forget_restored_trash_undo(state: &mut AssetBrowserUiState, batch_path: &Path) { + state.content_undo.retain(|entry| { + !matches!( + &entry.action, + ContentUndoAction::RestoreTrash(path) if path == batch_path + ) + }); +} + +pub(super) fn execute_delete_request(world: &mut World, request: &AssetDeleteRequest) { + let paths = request + .files + .iter() + .map(PathBuf::from) + .filter(|path| path.exists()) + .collect::>(); + let mut document = world + .resource::() + .document(); + crate::assets::begin_content_watch_transaction(world); + let trash_result = content_pipeline::trash_content(Path::new("."), &paths, &mut document); + crate::assets::end_content_watch_transaction(world); + let batch = match trash_result { + Ok(batch) => batch, + Err(error) => { + world.resource_mut::().status = error; + return; + } + }; + apply_registry_document(world, document); + + if let Ok(fingerprint) = content_pipeline::tree_fingerprint(&batch.path) { + push_content_undo( + world, + ContentUndoEntry { + label: format!("Move {} to Trash", request.label), + action: ContentUndoAction::RestoreTrash(batch.path.clone()), + guarded_sources: vec![(batch.path.clone(), fingerprint)], + }, + ); + } + + { + let mut assets = world.resource_mut::(); + assets.clear_selection(); + assets.refresh(); + } + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = format!( + "Moved {} file(s) for {} to {} (restorable from Trash)", + paths.len(), + request.label, + batch.path.to_string_lossy() + ); +} + +pub(super) fn push_content_undo(world: &mut World, entry: ContentUndoEntry) { + let mut state = world.resource_mut::(); + state.content_undo.push(entry); + if state.content_undo.len() > 32 { + state.content_undo.remove(0); + } +} + +pub(super) fn apply_registry_document(world: &mut World, document: shared::AssetRegistryDocument) { + let mut registry = world.resource_mut::(); + registry.schema_version = document.schema_version; + registry.defaults = document.defaults; + registry.records = document.records; + registry.index_dirty = false; + registry.migration_required = false; +} + +pub(super) fn dedup_strings(values: &mut Vec) { + let mut seen = HashSet::new(); + values.retain(|value| seen.insert(value.clone())); +} diff --git a/crates/editor/src/ui/asset_browser/panel/utilities.rs b/crates/editor/src/ui/asset_browser/panel/utilities.rs new file mode 100644 index 0000000..ff719f5 --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/utilities.rs @@ -0,0 +1,761 @@ +use super::*; + +pub(super) fn empty_content(ui: &mut egui::Ui, search: &str) { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(icons::FOLDER_OPEN.as_str()).font(egui::FontId::new( + 28.0, + egui::FontFamily::Name("phosphor-regular".into()), + )), + ); + if search.trim().is_empty() { + ui.label(egui::RichText::new("Folder is empty").color(TEXT_DIM)); + } else { + ui.label(egui::RichText::new("No matching assets").color(TEXT_DIM)); + } + }); +} + +pub(super) fn navigate_to_parent(world: &mut World) { + let current = world.resource::().current_folder.clone(); + if let Some(parent) = current.rfind('/').map(|index| current[..index].to_string()) { + navigate_content_folder(world, parent); + } +} + +pub(super) fn navigate_content_folder(world: &mut World, folder: String) { + let mut assets = world.resource_mut::(); + assets.current_folder = folder.clone(); + assets.clear_selection(); + assets.clear_drag(); + assets.status = format!("Opened {folder}"); +} + +pub(super) fn locate_content_asset(world: &mut World, path: &str) { + let normalized = path.replace('\\', "/"); + let selection = AssetSelection::File(normalized.clone()); + if !world + .resource::() + .selection_exists(&selection) + { + let status = format!("Referenced project asset is missing: {normalized}"); + world.resource_mut::().status = status.clone(); + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = status; + } + return; + } + let parent = Path::new(&normalized) + .parent() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| ASSETS_ROOT.to_string()); + navigate_content_folder(world, parent); + let mut assets = world.resource_mut::(); + assets.select(selection); + assets.status = format!("Located {normalized}"); +} + +pub(super) fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFilter) -> bool { + match filter { + AssetKindFilter::All => true, + AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model), + AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture), + AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material), + AssetKindFilter::Audio => matches!(asset.kind, EditorAssetKind::AudioClip), + AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level), + AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab), + AssetKindFilter::Builtin => asset.path.is_none(), + } +} + +pub(super) fn kind_filter_label(filter: AssetKindFilter) -> &'static str { + match filter { + AssetKindFilter::All => "All", + AssetKindFilter::Model => "Models", + AssetKindFilter::Texture => "Textures", + AssetKindFilter::Material => "Materials", + AssetKindFilter::Audio => "Audio", + AssetKindFilter::Level => "Levels", + AssetKindFilter::Prefab => "Prefabs", + AssetKindFilter::Builtin => "Built-ins", + } +} + +pub(super) fn sort_label(sort: AssetSort) -> &'static str { + match sort { + AssetSort::Name => "Name", + AssetSort::Kind => "Type", + AssetSort::Modified => "Modified", + AssetSort::Size => "Size", + } +} + +pub(super) fn kind_label(kind: &EditorAssetKind) -> &'static str { + match kind { + EditorAssetKind::Primitive(_) => "Primitive", + EditorAssetKind::Light(_) => "Light", + EditorAssetKind::Model => "Model", + EditorAssetKind::Texture => "Texture", + EditorAssetKind::Material => "Material", + EditorAssetKind::AudioClip => "Audio Clip", + EditorAssetKind::Level => "Level", + EditorAssetKind::Prefab => "Prefab", + EditorAssetKind::PostProcessVolume => "Post Process Volume", + EditorAssetKind::PostProcessEffect => "Post FX", + EditorAssetKind::RenderingProfile => "Rendering Profile", + EditorAssetKind::ShaderSchema => "Shader Schema", + } +} + +pub(super) fn file_size(path: &str) -> Option { + fs::metadata(path).ok().map(|metadata| metadata.len()) +} + +pub(super) fn audio_format_label(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("ogg" | "oga") => "Ogg audio", + Some("spx") => "Speex", + Some("wav") => "WAV", + Some("mp3") => "MP3", + Some("flac") => "FLAC", + _ => "Unknown", + } +} + +pub(super) fn modified_time(path: &str) -> Option { + fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) +} + +pub(super) fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + let bytes = bytes as f64; + if bytes >= GIB { + format!("{:.1} GB", bytes / GIB) + } else if bytes >= MIB { + format!("{:.1} MB", bytes / MIB) + } else if bytes >= KIB { + format!("{:.1} KB", bytes / KIB) + } else { + format!("{bytes:.0} B") + } +} + +pub(super) fn format_modified(time: SystemTime) -> String { + let Ok(duration) = SystemTime::now().duration_since(time) else { + return "Just now".to_string(); + }; + let days = duration.as_secs() / 86_400; + if days == 0 { + "Today".to_string() + } else if days == 1 { + "Yesterday".to_string() + } else if days < 30 { + format!("{days} days ago") + } else if days < 365 { + format!("{} months ago", days / 30) + } else { + format!("{} years ago", days / 365) + } +} + +pub(super) fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) +} + +pub(super) fn asset_context_menu( + world: &mut World, + ui: &mut egui::Ui, + asset: &EditorAsset, + selected_entities: &SelectedEntities, +) { + let menu_selection = AssetSelection::from_asset(asset); + prepare_context_menu_selection(world, menu_selection); + let selected_count = world.resource::().selections.len(); + if selected_count > 1 { + ui.label(egui::RichText::new(format!("{selected_count} items selected")).color(TEXT_DIM)); + batch_content_menu(world, ui); + return; + } + if ui.button("Select").clicked() { + world + .resource_mut::() + .select(AssetSelection::from_asset(asset)); + ui.close(); + } + if matches!( + asset.kind, + EditorAssetKind::Primitive(_) + | EditorAssetKind::Light(_) + | EditorAssetKind::Model + | EditorAssetKind::AudioClip + | EditorAssetKind::Prefab + ) && ui.button("Place at Origin").clicked() + { + place_asset_operator(world, asset.clone(), Vec3::ZERO); + ui.close(); + } + if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() { + open_level_asset(world, asset); + ui.close(); + } + if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) { + let expanded = asset.path.as_deref().is_some_and(|path| { + world + .resource::() + .expanded_assets + .contains(path) + }); + if ui + .button(if expanded { + "Collapse Contents" + } else { + "Expand Contents" + }) + .clicked() + { + if let Some(path) = asset.path.as_ref() { + toggle_asset_expanded(world, path); + } + ui.close(); + } + } + if matches!(asset.kind, EditorAssetKind::Texture) + && ui.button("Apply as Base Color Texture").clicked() + { + apply_texture_operator(world, asset.clone(), selected_entities); + ui.close(); + } + if asset.path.is_some() + && matches!(asset.kind, EditorAssetKind::Material) + && ui.button("Apply Material To Selection").clicked() + { + apply_material_operator(world, asset.clone(), selected_entities); + ui.close(); + } + if matches!(asset.kind, EditorAssetKind::Material) { + if let Some(path) = asset.path.as_deref() { + if ui.button("Create Material Instance").clicked() { + create_material_instance_from_base(world, path, &asset.label); + ui.close(); + } + } + } + if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { + reimport_asset(world, asset); + ui.close(); + } + if matches!( + asset.kind, + EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material + ) && ui.button("Regenerate Thumbnail").clicked() + { + regenerate_asset_thumbnail(world, asset); + ui.close(); + } + if asset.path.is_some() { + ui.separator(); + if ui.button("Rename").clicked() { + begin_rename(world); + ui.close(); + } + if ui.button("Cut").clicked() { + set_content_clipboard(world, true); + ui.close(); + } + if ui.button("Copy").clicked() { + set_content_clipboard(world, false); + ui.close(); + } + if ui.button("Duplicate").clicked() { + duplicate_selection(world); + ui.close(); + } + ui.separator(); + if ui.button("Move To Trash").clicked() { + request_delete_for_asset(world, asset); + ui.close(); + } + } +} + +pub(super) fn prepare_context_menu_selection(world: &mut World, selection: AssetSelection) { + if !world.resource::().is_selected(&selection) { + world.resource_mut::().select(selection); + } +} + +pub(super) fn batch_content_menu(world: &mut World, ui: &mut egui::Ui) { + let authored_count = selected_content_paths(world).len(); + let (nested_count, unaffected_count) = collapsed_and_unaffected_selection_counts(world); + let has_authored_content = authored_count > 0; + if nested_count > 0 { + ui.small( + egui::RichText::new(format!( + "{nested_count} nested item(s) already included by a selected parent" + )) + .color(TEXT_DIM), + ); + } + if unaffected_count > 0 { + ui.small( + egui::RichText::new(format!( + "{unaffected_count} embedded, built-in, or protected item(s) unaffected" + )) + .color(TEXT_DIM), + ); + } + let can_undo = !world + .resource::() + .content_undo + .is_empty(); + if ui + .add_enabled(can_undo, egui::Button::new("Undo Last Content Operation")) + .clicked() + { + undo_last_content_operation(world); + ui.close(); + } + ui.separator(); + if ui + .add_enabled( + has_authored_content, + egui::Button::new(format!("Cut {authored_count} Item(s)")), + ) + .clicked() + { + set_content_clipboard(world, true); + ui.close(); + } + if ui + .add_enabled( + has_authored_content, + egui::Button::new(format!("Copy {authored_count} Item(s)")), + ) + .clicked() + { + set_content_clipboard(world, false); + ui.close(); + } + if ui + .add_enabled( + has_authored_content, + egui::Button::new(format!("Duplicate {authored_count} Item(s)")), + ) + .clicked() + { + duplicate_selection(world); + ui.close(); + } + ui.separator(); + if ui + .add_enabled( + has_authored_content, + egui::Button::new(format!("Move {authored_count} Item(s) To Trash")), + ) + .clicked() + { + request_delete_for_selection(world); + ui.close(); + } +} + +pub(super) fn folder_context_menu(world: &mut World, ui: &mut egui::Ui, folder: &FolderSnapshot) { + let selected_count = world.resource::().selections.len(); + if selected_count > 1 { + ui.label(egui::RichText::new(format!("{selected_count} items selected")).color(TEXT_DIM)); + batch_content_menu(world, ui); + return; + } + if ui.button("Open").clicked() { + navigate_content_folder(world, folder.path.clone()); + ui.close(); + } + if folder.path != BUILTINS_FOLDER { + if ui.button("Import Here...").clicked() { + request_import_to_destination(world, folder.path.clone()); + ui.close(); + } + if ui.button("Import To...").clicked() { + begin_import_to(world); + ui.close(); + } + if ui.button("Create Materials From Folder...").clicked() { + begin_pbr_grouping(world, folder.path.clone()); + ui.close(); + } + if ui.button("Create Material Here").clicked() { + create_material_here(world, &folder.path); + ui.close(); + } + if ui.button("New Folder").clicked() { + begin_new_folder(world, folder.path.clone()); + ui.close(); + } + if ui.button("Open Trash...").clicked() { + world.resource_mut::().show_trash = true; + ui.close(); + } + let can_paste = world.resource::().clipboard.is_some(); + if ui + .add_enabled(can_paste, egui::Button::new("Paste Into")) + .clicked() + { + paste_content_clipboard_into(world, &folder.path); + ui.close(); + } + } + if !matches!(folder.path.as_str(), ASSETS_ROOT | BUILTINS_FOLDER) { + ui.separator(); + if ui.button("Rename").clicked() { + world + .resource_mut::() + .select(AssetSelection::Folder(folder.path.clone())); + begin_rename(world); + ui.close(); + } + if ui.button("Cut").clicked() { + set_content_clipboard(world, true); + ui.close(); + } + if ui.button("Copy").clicked() { + set_content_clipboard(world, false); + ui.close(); + } + if ui.button("Duplicate").clicked() { + duplicate_selection(world); + ui.close(); + } + if ui.button("Move To Trash").clicked() { + request_delete_for_selection(world); + ui.close(); + } + } +} + +pub(super) fn empty_space_context_menu( + world: &mut World, + ui: &mut egui::Ui, + current_folder: &str, + visible_order: &[AssetSelection], +) { + if !world.resource::().selections.is_empty() { + world.resource_mut::().clear_selection(); + } + if current_folder != BUILTINS_FOLDER { + if ui.button("Import Here...").clicked() { + request_import_to_destination(world, current_folder.to_string()); + ui.close(); + } + if ui.button("Import To...").clicked() { + begin_import_to(world); + ui.close(); + } + if ui.button("Create Materials From Folder...").clicked() { + begin_pbr_grouping(world, current_folder.to_string()); + ui.close(); + } + if ui.button("Create Material Here").clicked() { + create_material_here(world, current_folder); + ui.close(); + } + if ui.button("New Folder").clicked() { + begin_new_folder(world, current_folder.to_string()); + ui.close(); + } + if ui.button("Open Trash...").clicked() { + world.resource_mut::().show_trash = true; + ui.close(); + } + let can_paste = world.resource::().clipboard.is_some(); + if ui + .add_enabled(can_paste, egui::Button::new("Paste")) + .clicked() + { + paste_content_clipboard(world); + ui.close(); + } + ui.separator(); + } + let can_undo = !world + .resource::() + .content_undo + .is_empty(); + if ui + .add_enabled(can_undo, egui::Button::new("Undo Last Content Operation")) + .clicked() + { + undo_last_content_operation(world); + ui.close(); + } + if ui + .add_enabled(!visible_order.is_empty(), egui::Button::new("Select All")) + .clicked() + { + world + .resource_mut::() + .select_all(visible_order); + ui.close(); + } + if ui.button("Refresh").clicked() { + crate::assets::refresh_content_browser(world); + ui.close(); + } + ui.separator(); + let view = world.resource::().view; + if ui + .selectable_label(view == AssetBrowserView::Grid, "Grid View") + .clicked() + { + world.resource_mut::().view = AssetBrowserView::Grid; + ui.close(); + } + if ui + .selectable_label(view == AssetBrowserView::List, "List View") + .clicked() + { + world.resource_mut::().view = AssetBrowserView::List; + ui.close(); + } +} + +pub(super) fn subasset_context_menu( + world: &mut World, + ui: &mut egui::Ui, + embedded: &EmbeddedAsset, + selected_entities: &SelectedEntities, +) { + if ui.button("Select").clicked() { + world + .resource_mut::() + .select(embedded.selection.clone()); + ui.close(); + } + match embedded.kind { + AssetSubAssetKind::Mesh => { + if embedded.requires_skinned_hierarchy { + if ui.button("Place Skinned Model at Origin").clicked() { + place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); + ui.close(); + } + } else if ui.button("Place at Origin").clicked() { + place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); + ui.close(); + } + } + AssetSubAssetKind::Texture => { + if ui.button("Apply as Base Color Texture").clicked() { + if let Some(asset) = + texture_asset_from_subasset_selection(world, &Some(embedded.selection.clone())) + { + apply_texture_operator(world, asset, selected_entities); + } + ui.close(); + } + } + AssetSubAssetKind::Material => { + ui.label(egui::RichText::new("Embedded source material").color(TEXT_DIM)); + } + AssetSubAssetKind::Skeleton => { + ui.label(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM)); + } + AssetSubAssetKind::AnimationClip => { + let animated_actor = selected_entities + .as_slice() + .iter() + .copied() + .find(|entity| world.get::(*entity).is_some()); + let action = if animated_actor.is_some() { + "Assign To Selected Actor" + } else { + "Create Animated Actor" + }; + if ui.button(action).clicked() { + if let Some(entity) = animated_actor { + assign_animation_clip_operator(world, embedded.selection.clone(), entity); + } else { + place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO); + } + ui.close(); + } + } + } + if matches!( + embedded.kind, + AssetSubAssetKind::Mesh | AssetSubAssetKind::Material | AssetSubAssetKind::Texture + ) && ui.button("Regenerate Thumbnail").clicked() + { + regenerate_subasset_thumbnail(world, embedded); + ui.close(); + } + if let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection { + if ui.button("Select Parent Asset").clicked() { + world + .resource_mut::() + .select(AssetSelection::File(parent_path.clone())); + ui.close(); + } + } +} + +pub(super) fn regenerate_asset_thumbnail(world: &mut World, asset: &EditorAsset) { + let Some(path) = asset.path.clone() else { + return; + }; + let key = asset_cache_key(asset); + let kind = asset.kind.clone(); + let asset_server = world.resource::().clone(); + crate::assets::retry_thumbnail(world, &key); + world.resource_scope(|world, mut cache: Mut| match kind { + EditorAssetKind::Texture => { + cache.request_texture(key.clone(), path.clone(), &asset_server); + } + EditorAssetKind::Model => { + world.resource_scope(|_world, mut studio: Mut| { + cache.request_model(key.clone(), path.clone(), &mut studio); + }); + } + EditorAssetKind::Material => { + world.resource_scope(|_world, mut studio: Mut| { + cache.request_material_asset(key.clone(), path.clone(), &mut studio); + }); + } + _ => {} + }); + world.resource_mut::().status = format!("Regenerating thumbnail for {}", asset.label); +} + +pub(super) fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) { + let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection else { + return; + }; + let key = embedded.thumbnail_key.clone(); + let asset_server = world.resource::().clone(); + crate::assets::retry_thumbnail(world, &key); + world.resource_scope( + |world, mut cache: Mut| match embedded.kind { + AssetSubAssetKind::Mesh => { + let Some(mesh_label) = embedded.mesh_label.clone() else { + return; + }; + world.resource_scope(|_world, mut studio: Mut| { + cache.request_mesh_subasset( + key.clone(), + parent_path.clone(), + mesh_label, + embedded.material_label.clone(), + embedded.requires_skinned_hierarchy, + &mut studio, + ); + }); + } + AssetSubAssetKind::Material => { + let Some(material_label) = embedded.material_label.clone() else { + return; + }; + world.resource_scope(|_world, mut studio: Mut| { + cache.request_source_material( + key.clone(), + parent_path.clone(), + material_label, + &mut studio, + ); + }); + } + AssetSubAssetKind::Texture => { + if let Some(texture_path) = embedded.texture_path.clone() { + cache.request_texture(key.clone(), texture_path, &asset_server); + } + } + AssetSubAssetKind::Skeleton | AssetSubAssetKind::AnimationClip => {} + }, + ); + world.resource_mut::().status = + format!("Regenerating thumbnail for {}", embedded.label); +} + +pub(super) fn reimport_asset(world: &mut World, asset: &EditorAsset) { + let Some(path) = asset.path.as_deref() else { + return; + }; + let mut status = format!("Reimported {}", asset.label); + if let Some(mut registry) = world.get_resource_mut::() { + if let Some(record) = find_asset_mut_by_path(&mut registry, path) { + if let Err(error) = refresh_model_artifacts(record) { + status = format!("Reimport failed for {}: {error}", asset.label); + warn!("{status}"); + } + } + if let Err(error) = save_registry(®istry) { + status = format!("Asset registry save failed: {error}"); + warn!("{status}"); + } else { + registry.index_dirty = false; + } + } + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = status; +} + +pub(super) fn selected_content_path(world: &World) -> Option { + let assets = world.resource::(); + assets + .selected + .as_ref() + .and_then(authored_content_path) + .map(str::to_string) +} + +pub(super) fn authored_content_path(selection: &AssetSelection) -> Option<&str> { + match selection { + AssetSelection::Folder(path) | AssetSelection::File(path) => Some(path), + AssetSelection::Builtin(_) | AssetSelection::SubAsset { .. } => None, + } +} + +pub(super) fn selected_content_paths(world: &World) -> Vec { + let assets = world.resource::(); + let mut paths: Vec = assets + .selections + .iter() + .filter_map(authored_content_path) + .filter(|path| !matches!(*path, ASSETS_ROOT | BUILTINS_FOLDER)) + .map(str::to_string) + .collect(); + paths.sort(); + paths.dedup(); + let all_paths = paths.clone(); + paths.retain(|candidate| { + !all_paths.iter().any(|parent| { + parent != candidate + && candidate + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('/')) + }) + }); + paths +} + +pub(super) fn collapsed_and_unaffected_selection_counts(world: &World) -> (usize, usize) { + let assets = world.resource::(); + let raw_authored_count = assets + .selections + .iter() + .filter_map(authored_content_path) + .filter(|path| !matches!(*path, ASSETS_ROOT | BUILTINS_FOLDER)) + .count(); + let effective_count = selected_content_paths(world).len(); + ( + raw_authored_count.saturating_sub(effective_count), + assets.selections.len().saturating_sub(raw_authored_count), + ) +} diff --git a/crates/editor/src/ui/asset_browser/panel/validation.rs b/crates/editor/src/ui/asset_browser/panel/validation.rs new file mode 100644 index 0000000..14e501b --- /dev/null +++ b/crates/editor/src/ui/asset_browser/panel/validation.rs @@ -0,0 +1,32 @@ +use super::*; + +pub(crate) fn validate_material_conflict_destination( + world: &World, + destination: &Path, +) -> Result { + let root = PathBuf::from(&world.resource::().root); + let root = root.canonicalize().unwrap_or(root); + let absolute = if destination.is_absolute() { + destination.to_path_buf() + } else { + root.join(destination) + }; + let absolute = absolute + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .and_then(|parent| destination.file_name().map(|name| parent.join(name))) + .unwrap_or(absolute); + let relative = absolute.strip_prefix(&root).map_err(|_| { + format!( + "Material assets must stay inside {}", + root.join("assets").display() + ) + })?; + if !relative.starts_with("assets") { + return Err(format!( + "Material assets must stay inside {}", + root.join("assets").display() + )); + } + Ok(relative.to_string_lossy().replace('\\', "/")) +} diff --git a/crates/editor/src/ui/asset_browser/state.rs b/crates/editor/src/ui/asset_browser/state.rs index a3c98d4..93a201e 100644 --- a/crates/editor/src/ui/asset_browser/state.rs +++ b/crates/editor/src/ui/asset_browser/state.rs @@ -1,13 +1,13 @@ //! Asset browser UI state and view enums. use std::collections::HashSet; +use std::path::PathBuf; -use bevy::prelude::*; -use shared::{MaterialAsset, MaterialInstanceAsset}; - -use crate::asset_db::ImportSettings; use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER}; -use crate::project::collaboration::FileSnapshot; +use bevy::prelude::*; +use shared::MaterialAsset; + +pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 260.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum AssetBrowserView { @@ -44,42 +44,133 @@ pub struct AssetBrowserUiState { pub(crate) thumbnail_size: f32, pub(crate) recursive: bool, pub(crate) show_details: bool, + /// Width of the details pane for the current editor session. + pub(crate) details_width: f32, pub(crate) expanded_folders: HashSet, pub(crate) expanded_assets: HashSet, pub(crate) pending_delete: Option, - pub(crate) import_draft: Option, - pub(crate) material_draft: Option, - pub(crate) material_instance_draft: Option, + pub(crate) clipboard: Option, + pub(crate) pending_path_edit: Option, + pub(crate) pending_content_transaction: Option, + /// Selected project folder while the explicit Import To review is open. + pub(crate) pending_import_destination: Option, + pub(crate) content_undo: Vec, + pub(crate) pending_gltf_extraction: Option, + pub(crate) pending_pbr_grouping: Option, + pub(crate) show_trash: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct ContentClipboard { + pub(crate) sources: Vec, + pub(crate) cut: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct ContentUndoEntry { + pub(crate) label: String, + pub(crate) action: ContentUndoAction, + pub(crate) guarded_sources: Vec<(PathBuf, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) enum ContentUndoAction { + Apply(Vec), + TrashCreated(Vec), + RestoreTrash(PathBuf), +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingContentTransaction { + pub(crate) preview: content_pipeline::ContentTransactionPreview, + pub(crate) guarded_paths: Vec<(PathBuf, String)>, + pub(crate) clear_cut_clipboard: bool, + pub(crate) clear_drag: bool, + pub(crate) browser_snapshot: ContentBrowserSnapshot, +} + +/// Browser-local state captured before a reviewed content operation begins. +/// Cancel restores this verbatim; commit deliberately replaces it with the result selection. +#[derive(Debug, Clone)] +pub(crate) struct ContentBrowserSnapshot { + pub(crate) current_folder: String, + pub(crate) selected: Option, + pub(crate) selections: Vec, + pub(crate) selection_anchor: Option, + pub(crate) dragging: Option, + pub(crate) asset_status: String, + pub(crate) scene_status: String, +} + +#[derive(Debug, Clone)] +pub(crate) enum ContentPathEditKind { + Rename { source: String }, + NewFolder { parent: String }, +} + +#[derive(Debug, Clone)] +pub(crate) struct ContentPathEdit { + pub(crate) kind: ContentPathEditKind, + pub(crate) name: String, } #[derive(Debug, Clone)] pub(crate) struct AssetDeleteRequest { - pub(crate) selection: AssetSelection, pub(crate) label: String, pub(crate) files: Vec, pub(crate) warnings: Vec, } #[derive(Debug, Clone)] -pub(crate) struct ImportSettingsDraft { - pub(crate) path: String, - pub(crate) settings: ImportSettings, +pub(crate) struct GltfMaterialExtractionDraft { + pub(crate) source: String, + pub(crate) destination: String, + pub(crate) materials: Vec, + /// When present, extraction publishes only this imported source and assigns only this actor slot. + pub(crate) actor_target: Option, } #[derive(Debug, Clone)] -pub(crate) struct MaterialAssetDraft { +pub(crate) struct GltfMaterialExtractionTarget { + pub(crate) entity: Entity, + pub(crate) slot_id: shared::ComponentInstanceId, + pub(crate) source_sub_asset_id: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct GltfMaterialExtractionEntry { + pub(crate) included: bool, + pub(crate) source_index: usize, + pub(crate) source_name: String, pub(crate) path: String, pub(crate) asset: MaterialAsset, - pub(crate) disk_snapshot: FileSnapshot, - pub(crate) error: Option, + pub(crate) write_mode: MaterialExtractionWriteMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MaterialExtractionWriteMode { + /// An existing provenance match must be explicitly reviewed before publishing. + Undecided, + CreateNew, + ApplyExisting { + expected_fingerprint: String, + previous_bytes: Vec, + }, } #[derive(Debug, Clone)] -pub(crate) struct MaterialInstanceAssetDraft { +pub(crate) struct PbrGroupingDraft { + pub(crate) folder: String, + pub(crate) textures: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct PbrTextureDraft { pub(crate) path: String, - pub(crate) asset: MaterialInstanceAsset, - pub(crate) disk_snapshot: FileSnapshot, - pub(crate) error: Option, + pub(crate) target: String, + pub(crate) role: content_pipeline::PbrTextureRole, + pub(crate) confidence: content_pipeline::PbrMatchConfidence, + pub(crate) included: bool, } impl Default for AssetBrowserUiState { @@ -95,12 +186,18 @@ impl Default for AssetBrowserUiState { thumbnail_size: 72.0, recursive: false, show_details: true, + details_width: ASSET_DETAILS_DEFAULT_WIDTH, expanded_folders, expanded_assets: HashSet::new(), pending_delete: None, - import_draft: None, - material_draft: None, - material_instance_draft: None, + clipboard: None, + pending_path_edit: None, + pending_content_transaction: None, + pending_import_destination: None, + content_undo: Vec::new(), + pending_gltf_extraction: None, + pending_pbr_grouping: None, + show_trash: false, } } } diff --git a/crates/editor/src/ui/asset_card.rs b/crates/editor/src/ui/asset_card.rs new file mode 100644 index 0000000..24d7ef5 --- /dev/null +++ b/crates/editor/src/ui/asset_card.rs @@ -0,0 +1,225 @@ +//! Reusable Content Browser and Material Library asset cards. + +use bevy_egui::egui; +use egui_phosphor_icons::icons; + +use crate::assets::{kind_icon, EditorAsset}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AssetCardStatus { + pub dirty: bool, + pub processing: bool, + pub error: bool, +} + +impl AssetCardStatus { + pub fn for_document( + snapshot: Option<&crate::asset_documents::AuthoredDocumentSnapshot>, + dirty: bool, + failed: bool, + ) -> Self { + use crate::asset_documents::{AuthoredDocumentState, DerivedProcessingState}; + + Self { + dirty, + processing: snapshot.is_some_and(|status| { + matches!( + status.processing, + DerivedProcessingState::Queued | DerivedProcessingState::Processing + ) + }), + error: failed + || snapshot.is_some_and(|status| { + matches!( + status.state, + AuthoredDocumentState::ExternalConflict | AuthoredDocumentState::SaveFailed + ) || status.processing == DerivedProcessingState::Failed + }), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn draw_asset_card( + ui: &mut egui::Ui, + asset: &EditorAsset, + texture_id: Option, + pending: bool, + failure: Option<&str>, + selected: bool, + thumbnail_size: f32, + status: AssetCardStatus, +) -> egui::Response { + let response = draw_asset_cell( + ui, + asset, + texture_id, + pending, + failure, + selected, + thumbnail_size, + ); + draw_asset_status_rail(ui, response.rect, status); + response +} + +pub fn draw_asset_status_rail(ui: &egui::Ui, rect: egui::Rect, status: AssetCardStatus) { + let mut y = rect.top() + 11.0; + let x = rect.right() - 11.0; + if status.error { + ui.painter().text( + egui::pos2(x, y), + egui::Align2::CENTER_CENTER, + icons::WARNING_CIRCLE.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + super::theme::ERROR, + ); + y += 12.0; + } + if status.processing { + ui.painter().text( + egui::pos2(x, y), + egui::Align2::CENTER_CENTER, + icons::CIRCLE_NOTCH.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + super::theme::ACCENT, + ); + y += 12.0; + } + if status.dirty { + ui.painter() + .circle_filled(egui::pos2(x, y), 4.0, super::theme::WARNING); + } +} + +/// Draws the same authored-document status model in a fixed-width list cell. +pub fn draw_asset_status_marker(ui: &mut egui::Ui, status: AssetCardStatus) { + let (rect, response) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::hover()); + let (glyph, color) = if status.error { + (Some(icons::WARNING_CIRCLE), super::theme::ERROR) + } else if status.processing { + (Some(icons::CIRCLE_NOTCH), super::theme::ACCENT) + } else if status.dirty { + (None, super::theme::WARNING) + } else { + return; + }; + if let Some(glyph) = glyph { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + glyph.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + color, + ); + } else { + ui.painter().circle_filled(rect.center(), 4.0, color); + } + response.on_hover_text(if status.error { + "Asset has a save, conflict, or processing error" + } else if status.processing { + "Derived asset processing is running" + } else { + "Asset has unsaved changes" + }); +} + +#[allow(clippy::too_many_arguments)] +fn draw_asset_cell( + ui: &mut egui::Ui, + asset: &EditorAsset, + texture_id: Option, + pending: bool, + failed: Option<&str>, + selected: bool, + thumbnail_size: f32, +) -> egui::Response { + let thumb_size = thumbnail_size.clamp(48.0, 112.0); + let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 42.0); + let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); + let fill = if selected { + super::theme::SELECTION_BG_MUTED + } else if response.hovered() { + super::theme::ELEVATED_BG + } else { + super::theme::WIDGET_BG + }; + ui.painter().rect( + rect, + 4.0, + fill, + egui::Stroke::new( + 1.0_f32, + if selected { + super::theme::ACCENT_HOVER + } else { + super::theme::BORDER + }, + ), + egui::StrokeKind::Inside, + ); + + let thumbnail_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(10.0, 8.0), + egui::vec2(thumb_size, thumb_size), + ); + let painter = ui.painter().with_clip_rect(thumbnail_rect); + if let Some(texture_id) = texture_id { + painter.image( + texture_id, + thumbnail_rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + let (icon, color) = if pending { + (icons::CIRCLE_NOTCH, super::theme::TEXT_DIM) + } else if failed.is_some() { + (icons::WARNING_CIRCLE, super::theme::TEXT_DIM) + } else { + (kind_icon(&asset.kind), super::theme::TEXT) + }; + painter.text( + thumbnail_rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(26.0, egui::FontFamily::Name("phosphor-regular".into())), + color, + ); + } + + let label_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x + 6.0, thumbnail_rect.max.y + 4.0), + egui::pos2(rect.max.x - 6.0, rect.max.y - 4.0), + ); + let label_color = if selected { + super::theme::TEXT_SELECTED + } else { + super::theme::TEXT + }; + let font = egui::FontId::proportional(11.0); + let mut job = + egui::text::LayoutJob::simple(asset.label.clone(), font, label_color, label_rect.width()); + job.halign = egui::Align::Center; + job.wrap.max_rows = 2; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let galley = ui.painter().layout_job(job); + let label_painter = ui.painter().with_clip_rect(label_rect); + label_painter.galley( + egui::pos2( + label_rect.center().x - galley.rect.width() * 0.5, + label_rect.center().y - galley.rect.height() * 0.5, + ), + galley.clone(), + label_color, + ); + + if let Some(reason) = failed { + response.on_hover_text(reason) + } else if galley.elided { + response.on_hover_text(&asset.label) + } else { + response + } +} diff --git a/crates/editor/src/ui/audio_inspector.rs b/crates/editor/src/ui/audio_inspector.rs index 30ac0cb..48afd17 100644 --- a/crates/editor/src/ui/audio_inspector.rs +++ b/crates/editor/src/ui/audio_inspector.rs @@ -99,7 +99,7 @@ pub fn audio_source_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: E ui.painter().rect_stroke( clip_response.rect.expand(3.0), 3.0, - egui::Stroke::new(1.5, SELECTION), + egui::Stroke::new(1.5_f32, SELECTION), egui::StrokeKind::Outside, ); if ui.input(|input| input.pointer.any_released()) { diff --git a/crates/editor/src/ui/build.rs b/crates/editor/src/ui/build.rs index 8b94dcd..72a9e9b 100644 --- a/crates/editor/src/ui/build.rs +++ b/crates/editor/src/ui/build.rs @@ -97,6 +97,24 @@ pub fn request_build(world: &mut World, run_after: bool) { .set_status("Save all modified scene tabs before building a package"); return; } + if world + .get_resource::() + .is_some_and(|store| store.has_dirty_documents()) + { + world + .resource_mut::() + .set_status("Save all modified asset documents before building a package"); + return; + } + if world + .get_resource::() + .is_some_and(|store| store.has_pending_or_failed_processing()) + { + world.resource_mut::().set_status( + "Asset processing is pending or failed; wait for completion or resolve diagnostics before packaging", + ); + return; + } let engine_root = engine_workspace_root(); let root = PathBuf::from(&world.resource::().root); let profile = world.resource::().selected_profile.clone(); diff --git a/crates/editor/src/ui/component_registry.rs b/crates/editor/src/ui/component_registry.rs index dbc3e31..3b79377 100644 --- a/crates/editor/src/ui/component_registry.rs +++ b/crates/editor/src/ui/component_registry.rs @@ -9,6 +9,10 @@ use bevy::reflect::GetTypeRegistration; use bevy_egui::egui; use egui_phosphor_icons::icons; +mod dispatch; +#[cfg(test)] +mod tests; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditorComponentCategory { Authoring, @@ -44,10 +48,9 @@ pub struct EditorComponentDescriptor { #[derive(Resource, Debug, Clone)] pub struct EditorComponentRegistry { pub descriptors: Vec, - inspectors: HashMap<&'static str, ComponentInspectorFn>, + pub(super) inspectors: HashMap<&'static str, ComponentInspectorFn>, } -/// Synchronous inspector callbacks run while [`crate::ui::UiState`] is scoped out of `World`. /// They must not request that resource. Keep extension-owned transient state in independently /// registered resources, use [`crate::ui::request_ui_selection`] and /// [`crate::ui::request_editor_tab`] for host UI changes, or defer other cross-panel work through @@ -333,8 +336,7 @@ impl Default for EditorComponentRegistry { "shared::components::BrushDesc", "shared::animation::SkinnedMeshRenderer", ], - hydration_effect: - "Hydrates into generated mesh children and material bindings.", + hydration_effect: "Hydrates into generated mesh children and material bindings.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, @@ -346,17 +348,17 @@ impl Default for EditorComponentRegistry { reorderable: true, hidden: false, icon: icons::PERSON_SIMPLE_RUN.as_str(), - description: - "Renders rigged geometry while preserving its imported joints and skin bindings.", - search_terms: &["mesh", "renderer", "skinned", "skeletal", "rigged", "joints"], + description: "Renders rigged geometry while preserving its imported joints and skin bindings.", + search_terms: &[ + "mesh", "renderer", "skinned", "skeletal", "rigged", "joints", + ], recommended: &["shared::animation::AnimationControllerDesc"], conflicts_with: &[ "shared::components::Primitive", "shared::components::BrushDesc", "shared::components::StaticMeshRenderer", ], - hydration_effect: - "Hydrates through a dedicated scene root containing Bevy skinned meshes and joints.", + hydration_effect: "Hydrates through a dedicated scene root containing Bevy skinned meshes and joints.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_BRUSH, @@ -370,7 +372,7 @@ impl Default for EditorComponentRegistry { icon: icons::CUBE.as_str(), description: "Creates authored convex brush geometry for blockout.", search_terms: &["brush", "blockout", "convex", "csg", "geometry"], - recommended: &["shared::components::MaterialDesc"], + recommended: &[], conflicts_with: &[ "shared::components::Primitive", "shared::components::StaticMeshRenderer", @@ -402,18 +404,18 @@ impl Default for EditorComponentRegistry { EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_MATERIAL, type_name: "shared::components::MaterialDesc", - display_name: "Authoring Material", + display_name: "Brush Fallback Material", category: EditorComponentCategory::Rendering, - addable: true, + addable: false, removable: true, reorderable: true, hidden: false, icon: icons::PALETTE.as_str(), - description: "Defines an authored material directly on this actor.", + description: "Legacy fallback used only by authored brush geometry.", search_terms: &["material", "shader", "texture"], recommended: &[], conflicts_with: &[], - hydration_effect: "Hydrates into Bevy material assets for renderable actors.", + hydration_effect: "Hydrates a legacy brush fallback; primitives and mesh renderers use material slots.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_RIGID_BODY, @@ -474,11 +476,17 @@ impl Default for EditorComponentRegistry { hidden: false, icon: icons::FILM_SLATE.as_str(), description: "Authors named animation states for an imported model rig.", - search_terms: &["animation", "controller", "state", "clip", "skeleton", "rig"], + search_terms: &[ + "animation", + "controller", + "state", + "clip", + "skeleton", + "rig", + ], recommended: &["shared::animation::SkinnedMeshRenderer"], conflicts_with: &[], - hydration_effect: - "Hydrates into a Bevy animation graph and player bound to the imported model.", + hydration_effect: "Hydrates into a Bevy animation graph and player bound to the imported model.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_AUDIO_SOURCE, @@ -494,8 +502,7 @@ impl Default for EditorComponentRegistry { search_terms: &["audio", "sound", "clip", "speaker", "spatial"], recommended: &[], conflicts_with: &["shared::components::AudioListenerDesc"], - hydration_effect: - "Hydrates into runtime Bevy audio voices with bus and attenuation control.", + hydration_effect: "Hydrates into runtime Bevy audio voices with bus and attenuation control.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_AUDIO_LISTENER, @@ -511,8 +518,7 @@ impl Default for EditorComponentRegistry { search_terms: &["audio", "sound", "listener", "ears", "spatial"], recommended: &[], conflicts_with: &["shared::components::AudioSourceDesc"], - hydration_effect: - "The highest-priority enabled listener becomes the runtime listener.", + hydration_effect: "The highest-priority enabled listener becomes the runtime listener.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_PRIMITIVE, @@ -532,8 +538,7 @@ impl Default for EditorComponentRegistry { "shared::components::BrushDesc", "shared::animation::SkinnedMeshRenderer", ], - hydration_effect: - "Hydrates into generated primitive render and collision data.", + hydration_effect: "Hydrates into generated primitive render and collision data.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_PLAYER_SPAWN, @@ -549,8 +554,7 @@ impl Default for EditorComponentRegistry { search_terms: &["player", "spawn", "start"], recommended: &[], conflicts_with: &[], - hydration_effect: - "Used by PIE/session startup; no saved runtime player is created.", + hydration_effect: "Used by PIE/session startup; no saved runtime player is created.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_WEAPON_SPAWN, @@ -630,8 +634,7 @@ impl Default for EditorComponentRegistry { search_terms: &["post", "process", "volume", "rendering", "fx"], recommended: &[], conflicts_with: &[], - hydration_effect: - "Affects active camera rendering when the camera is inside the volume.", + hydration_effect: "Affects active camera rendering when the camera is inside the volume.", }, EditorComponentDescriptor { id: shared::AUTHORING_COMPONENT_NAVIGATION_BOUNDS, @@ -881,32 +884,3 @@ where .resource_mut::() .register_with_inspector(descriptor, inspector) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn built_in_registry_is_unique_and_reflectable() { - let mut app = App::new(); - app.add_plugins(shared::SharedTypesPlugin) - .init_resource::(); - - app.world() - .resource::() - .validate(app.world()) - .unwrap(); - } - - #[test] - fn static_extensions_cannot_shadow_stable_ids_or_types() { - let mut registry = EditorComponentRegistry::default(); - let mut duplicate = registry.descriptors[0].clone(); - duplicate.type_name = "game::editor_ext::StaticFixture"; - assert!(registry.register(duplicate).is_err()); - - let mut duplicate = registry.descriptors[0].clone(); - duplicate.id = "game.static_fixture"; - assert!(registry.register(duplicate).is_err()); - } -} diff --git a/crates/editor/src/ui/component_registry/dispatch.rs b/crates/editor/src/ui/component_registry/dispatch.rs new file mode 100644 index 0000000..851e9e9 --- /dev/null +++ b/crates/editor/src/ui/component_registry/dispatch.rs @@ -0,0 +1,28 @@ +//! Inspector callback registration and completeness checks. + +use super::{ComponentInspectorFn, EditorComponentRegistry}; + +impl EditorComponentRegistry { + pub fn register_inspector_for_existing( + &mut self, + type_name: &'static str, + inspector: ComponentInspectorFn, + ) -> Result<(), String> { + if self.by_type_name(type_name).is_none() { + return Err(format!( + "cannot register inspector for unknown component `{type_name}`" + )); + } + if self.inspectors.insert(type_name, inspector).is_some() { + return Err(format!("component `{type_name}` already has an inspector")); + } + Ok(()) + } + + pub fn visible_descriptors_have_inspectors(&self) -> bool { + self.descriptors + .iter() + .filter(|descriptor| !descriptor.hidden) + .all(|descriptor| self.inspectors.contains_key(descriptor.type_name)) + } +} diff --git a/crates/editor/src/ui/component_registry/tests.rs b/crates/editor/src/ui/component_registry/tests.rs new file mode 100644 index 0000000..c95f2b6 --- /dev/null +++ b/crates/editor/src/ui/component_registry/tests.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn built_in_registry_is_unique_and_reflectable() { + let mut app = App::new(); + app.add_plugins(shared::SharedTypesPlugin) + .init_resource::(); + + app.world() + .resource::() + .validate(app.world()) + .unwrap(); +} + +#[test] +fn static_extensions_cannot_shadow_stable_ids_or_types() { + let mut registry = EditorComponentRegistry::default(); + let mut duplicate = registry.descriptors[0].clone(); + duplicate.type_name = "game::editor_ext::StaticFixture"; + assert!(registry.register(duplicate).is_err()); + + let mut duplicate = registry.descriptors[0].clone(); + duplicate.id = "game.static_fixture"; + assert!(registry.register(duplicate).is_err()); +} + +#[test] +fn every_visible_builtin_registers_one_inspector_callback() { + let mut world = World::new(); + world.init_resource::(); + + crate::ui::inspector::register_builtin_component_inspectors(&mut world); + + assert!(world + .resource::() + .visible_descriptors_have_inspectors()); +} diff --git a/crates/editor/src/ui/design_system/color_picker.rs b/crates/editor/src/ui/design_system/color_picker.rs new file mode 100644 index 0000000..2000533 --- /dev/null +++ b/crates/editor/src/ui/design_system/color_picker.rs @@ -0,0 +1,308 @@ +//! Penpot 420×350 material color popup with live preview and reversible dismissal. + +mod popup; + +use bevy_egui::egui; + +use super::typography::TypeRole; + +pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PickerMode { + Wheel, + Sliders, + Presets, +} + +#[derive(Debug, Clone)] +pub(super) struct PickerSession { + original: [f32; 4], + current: [f32; 4], + mode: PickerMode, + hex: String, + opened_at: f64, + owner_rect: egui::Rect, + original_intensity: Option, + current_intensity: Option, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ColorPickerResponse { + pub changed: bool, + pub applied: bool, + pub cancelled: bool, +} + +pub(crate) fn color_control( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], +) -> ColorPickerResponse { + color_control_impl(ui, salt, label, rgba, None) +} + +pub(crate) fn color_control_with_intensity( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], + intensity: &mut f32, +) -> ColorPickerResponse { + color_control_impl(ui, salt, label, rgba, Some(intensity)) +} + +fn color_control_impl( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], + intensity: Option<&mut f32>, +) -> ColorPickerResponse { + let palette = super::palette(ui); + let id = ui.make_persistent_id(("penpot_color_picker", salt)); + let (rect, response) = ui.allocate_exact_size(egui::vec2(130.0, 22.0), egui::Sense::click()); + let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(24.0, 22.0)); + let hex_rect = + egui::Rect::from_min_size(rect.min + egui::vec2(30.0, 0.0), egui::vec2(100.0, 22.0)); + popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated); + ui.painter() + .rect_filled(swatch_rect.shrink(1.0), 4.0, color32(*rgba)); + ui.painter().rect_stroke( + swatch_rect, + 4.0, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + ui.painter().rect( + hex_rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + hex_rect.left_center() + egui::vec2(9.0, 0.0), + egui::Align2::LEFT_CENTER, + hex(*rgba), + TypeRole::Body.font(), + palette.text_primary, + ); + + if response.clicked() { + let opened_at = ui.input(|input| input.time); + ui.ctx().data_mut(|data| { + data.insert_temp( + id, + PickerSession { + original: *rgba, + current: *rgba, + mode: PickerMode::Wheel, + hex: hex(*rgba), + opened_at, + owner_rect: ui.clip_rect(), + original_intensity: intensity.as_deref().copied(), + current_intensity: intensity.as_deref().copied(), + }, + ); + }); + } + + let before = *rgba; + let mut result = ColorPickerResponse::default(); + let Some(mut session) = ui.ctx().data_mut(|data| data.get_temp::(id)) else { + return result; + }; + let screen_rect = ui.ctx().content_rect(); + let popup_rect = centered_popup_rect(session.owner_rect, screen_rect); + + let scrim = egui::Area::new(id.with("modal_scrim")) + .order(egui::Order::Foreground) + .fixed_pos(session.owner_rect.min) + .show(ui.ctx(), |ui| { + let (rect, response) = + ui.allocate_exact_size(session.owner_rect.size(), egui::Sense::click()); + ui.painter() + .rect_filled(rect, 0.0, egui::Color32::from_black_alpha(132)); + response + }) + .inner; + + let popup_response = egui::Area::new(id.with("popup")) + .order(egui::Order::Foreground) + .fixed_pos(popup_rect.min) + .show(ui.ctx(), |ui| { + ui.set_min_size(POPUP_SIZE); + ui.set_max_size(POPUP_SIZE); + popup::show(ui, label, &mut session, &mut result) + }) + .response; + + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + let outside_click = scrim.clicked() + || ui.input(|input| { + input.pointer.any_pressed() + && input.time > session.opened_at + && input + .pointer + .interact_pos() + .is_some_and(|position| !popup_response.rect.contains(position)) + }); + if escape || outside_click { + result.cancelled = true; + } + + if result.cancelled { + *rgba = session.original; + if let (Some(target), Some(original)) = (intensity, session.original_intensity) { + *target = original; + } + ui.ctx().data_mut(|data| data.remove::(id)); + } else { + *rgba = session.current; + if let (Some(target), Some(current)) = (intensity, session.current_intensity) { + *target = current; + } + if result.applied { + ui.ctx().data_mut(|data| data.remove::(id)); + } else { + ui.ctx().data_mut(|data| data.insert_temp(id, session)); + } + } + result.changed = *rgba != before; + result +} + +fn centered_popup_rect(owner: egui::Rect, screen: egui::Rect) -> egui::Rect { + const SCREEN_MARGIN: f32 = 12.0; + let desired = owner.center() - POPUP_SIZE * 0.5; + let minimum = screen.min + egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN); + let maximum = screen.max - POPUP_SIZE - egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN); + let x = if maximum.x >= minimum.x { + desired.x.clamp(minimum.x, maximum.x) + } else { + screen.center().x - POPUP_SIZE.x * 0.5 + }; + let y = if maximum.y >= minimum.y { + desired.y.clamp(minimum.y, maximum.y) + } else { + screen.center().y - POPUP_SIZE.y * 0.5 + }; + egui::Rect::from_min_size(egui::pos2(x, y), POPUP_SIZE) +} + +pub(super) fn set_current(session: &mut PickerSession, value: [f32; 4]) { + session.current = value.map(|component| component.clamp(0.0, 1.0)); + session.hex = hex(session.current); +} + +pub(super) fn color32(value: [f32; 4]) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + byte(value[0]), + byte(value[1]), + byte(value[2]), + byte(value[3]), + ) +} + +pub(super) fn rgba(value: egui::Color32) -> [f32; 4] { + [ + value.r() as f32 / 255.0, + value.g() as f32 / 255.0, + value.b() as f32 / 255.0, + value.a() as f32 / 255.0, + ] +} + +pub(super) fn byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +pub(super) fn hex(value: [f32; 4]) -> String { + if byte(value[3]) == 255 { + format!( + "#{:02X}{:02X}{:02X}", + byte(value[0]), + byte(value[1]), + byte(value[2]) + ) + } else { + format!( + "#{:02X}{:02X}{:02X}{:02X}", + byte(value[0]), + byte(value[1]), + byte(value[2]), + byte(value[3]) + ) + } +} + +pub(super) fn parse_hex(value: &str) -> Option<[f32; 4]> { + let value = value.trim().trim_start_matches('#'); + if !matches!(value.len(), 6 | 8) { + return None; + } + let rgb = u32::from_str_radix(&value[..6], 16).ok()?; + let alpha = if value.len() == 8 { + u8::from_str_radix(&value[6..], 16).ok()? + } else { + 255 + }; + Some([ + ((rgb >> 16) & 0xFF) as f32 / 255.0, + ((rgb >> 8) & 0xFF) as f32 / 255.0, + (rgb & 0xFF) as f32 / 255.0, + alpha as f32 / 255.0, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_centers_on_inspector_owner_and_stays_on_screen() { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + let inspector = + egui::Rect::from_min_size(egui::pos2(800.0, 40.0), egui::vec2(640.0, 820.0)); + let popup = centered_popup_rect(inspector, screen); + assert_eq!(popup.size(), POPUP_SIZE); + assert_eq!(popup.center(), inspector.center()); + + let edge = centered_popup_rect( + egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 200.0)), + screen, + ); + assert!(edge.left() >= 12.0); + assert!(edge.top() >= 12.0); + } + + #[test] + fn penpot_hex_round_trip_preserves_rgba() { + let value = [0.2, 0.4, 0.6, 0.8]; + let parsed = parse_hex(&hex(value)).expect("valid generated hex"); + for (left, right) in value.into_iter().zip(parsed) { + assert!((left - right).abs() <= 1.0 / 255.0); + } + } + + #[test] + fn cancel_restores_opening_value_and_apply_keeps_live_preview() { + let original = [0.1, 0.2, 0.3, 1.0]; + let mut session = PickerSession { + original, + current: [0.8, 0.7, 0.6, 0.5], + mode: PickerMode::Wheel, + hex: String::new(), + opened_at: 0.0, + owner_rect: egui::Rect::ZERO, + original_intensity: None, + current_intensity: None, + }; + let applied = session.current; + set_current(&mut session, applied); + assert_eq!(session.original, original); + assert_eq!(session.current, applied); + } +} diff --git a/crates/editor/src/ui/design_system/color_picker/popup.rs b/crates/editor/src/ui/design_system/color_picker/popup.rs new file mode 100644 index 0000000..04da0f1 --- /dev/null +++ b/crates/editor/src/ui/design_system/color_picker/popup.rs @@ -0,0 +1,765 @@ +//! Fixed-coordinate renderer for the exported Penpot color overlay. + +mod sliders; + +use bevy_egui::egui::{self, ecolor::Hsva}; +use egui_phosphor_icons::{icons, Icon}; + +use super::{ + byte, color32, parse_hex, rgba, set_current, ColorPickerResponse, PickerMode, PickerSession, + POPUP_SIZE, +}; +use crate::ui::design_system::typography::TypeRole; + +const RECENTS: [egui::Color32; 8] = colors([ + 0xE7D4B4, 0x53D3E6, 0xCA6CE0, 0xF06B75, 0x55C58A, 0xF3C85B, 0x6F88EF, 0xF3F5F7, +]); +const PRESETS: [egui::Color32; 24] = colors([ + 0xE7D4B4, 0xF2EEE8, 0xD5B59C, 0xA8795B, 0x734C3C, 0x3B2D29, 0xE45D68, 0xF09A55, 0xE9D05B, + 0x79C267, 0x45B7A6, 0x4FA9D8, 0x5A78DE, 0x8E68D8, 0xC26BC7, 0xE68DB5, 0xF5F7FA, 0xC4CCD5, + 0x8995A3, 0x56616E, 0x303842, 0x1D242C, 0x101419, 0x07090C, +]); +const PLANNED_EYEDROPPER: &str = "Planned — screen sampling pending"; + +const fn colors(values: [u32; N]) -> [egui::Color32; N] { + let mut result = [egui::Color32::BLACK; N]; + let mut index = 0; + while index < N { + let value = values[index]; + result[index] = egui::Color32::from_rgb( + ((value >> 16) & 0xFF) as u8, + ((value >> 8) & 0xFF) as u8, + (value & 0xFF) as u8, + ); + index += 1; + } + result +} + +struct Geometry; + +impl Geometry { + const HEADER_HEIGHT: f32 = 38.0; + const CONTENT: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(408.0, 276.0)); + const WHEEL_EDITOR: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(190.0, 276.0)); + const VALUES: egui::Rect = + egui::Rect::from_min_max(egui::pos2(198.0, 82.0), egui::pos2(396.0, 268.0)); + const RECENTS: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 282.0), egui::pos2(408.0, 310.0)); + const FOOTER: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 316.0), egui::pos2(408.0, 342.0)); + + fn rect(root: egui::Rect, local: egui::Rect) -> egui::Rect { + local.translate(root.min.to_vec2()) + } + + fn at(root: egui::Rect, x: f32, y: f32, width: f32, height: f32) -> egui::Rect { + egui::Rect::from_min_size(root.min + egui::vec2(x, y), egui::vec2(width, height)) + } +} + +pub(super) fn show( + ui: &mut egui::Ui, + label: &str, + session: &mut PickerSession, + result: &mut ColorPickerResponse, +) -> egui::Response { + let palette = crate::ui::design_system::palette(ui); + let (root, response) = ui.allocate_exact_size(POPUP_SIZE, egui::Sense::hover()); + ui.painter().rect( + root, + 7.0, + palette.panel, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + ui.painter().line_segment( + [ + root.min + egui::vec2(0.0, Geometry::HEADER_HEIGHT), + root.min + egui::vec2(root.width(), Geometry::HEADER_HEIGHT), + ], + egui::Stroke::new(1.0_f32, palette.border), + ); + + header(ui, root, label, session, result); + mode_tabs(ui, root, session); + intensity_control(ui, root, session); + match session.mode { + PickerMode::Wheel => wheel_mode(ui, root, session), + PickerMode::Sliders => sliders::show(ui, root, session), + PickerMode::Presets => presets_mode(ui, root, session), + } + recent_row(ui, root, session); + footer(ui, root, result); + response +} + +fn intensity_control(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let Some(intensity) = session.current_intensity.as_mut() else { + return; + }; + let palette = crate::ui::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(272.0, 55.0), + egui::Align2::LEFT_CENTER, + "Intensity", + TypeRole::Small.font(), + palette.text_muted, + ); + ui.put( + Geometry::at(root, 326.0, 43.0, 82.0, 24.0), + egui::DragValue::new(intensity) + .range(0.0..=50_000.0) + .speed(10.0), + ) + .on_hover_text("Emissive luminance multiplier in nits"); +} + +fn header( + ui: &mut egui::Ui, + root: egui::Rect, + label: &str, + session: &PickerSession, + result: &mut ColorPickerResponse, +) { + let palette = crate::ui::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(12.0, 19.0), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Section.font(), + palette.text_primary, + ); + text( + ui, + root.min + egui::vec2(84.0, 19.0), + egui::Align2::LEFT_CENTER, + "sRGB · RGBA", + TypeRole::Small.font(), + palette.text_muted, + ); + paint_swatch( + ui, + Geometry::at(root, 348.0, 7.0, 26.0, 24.0), + color32(session.current), + egui::Sense::hover(), + "Current color", + ); + if icon_button( + ui, + Geometry::at(root, 384.0, 7.0, 24.0, 24.0), + icons::X, + "Cancel color changes", + true, + ) + .clicked() + { + result.cancelled = true; + } +} + +fn mode_tabs(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + for (index, (mode, icon, label, width)) in [ + (PickerMode::Wheel, icons::CIRCLE, "Wheel", 76.0), + (PickerMode::Sliders, icons::SLIDERS, "Sliders", 76.0), + (PickerMode::Presets, icons::SQUARES_FOUR, "Presets", 78.0), + ] + .into_iter() + .enumerate() + { + let x = [12.0, 92.0, 172.0][index]; + let rect = Geometry::at(root, x, 42.0, width, 26.0); + let selected = session.mode == mode; + let response = ui.put( + rect, + egui::Button::new(crate::ui::design_system::controls::icon_label( + icon, + label, + TypeRole::Body, + 12.0, + if selected { + palette.text_primary + } else { + palette.text_secondary + }, + )) + .fill(if selected { + palette.accent_dark + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if selected { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(4.0), + ); + if selected { + ui.painter().rect_filled( + egui::Rect::from_center_size( + egui::pos2(rect.center().x, rect.bottom() - 1.0), + egui::vec2(60.0, 2.0), + ), + 1.0, + palette.accent, + ); + } + if response.clicked() { + session.mode = mode; + } + } +} + +fn wheel_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + let ring = Geometry::at(root, 29.0, 88.0, 144.0, 144.0); + let sv = Geometry::at(root, 65.0, 124.0, 72.0, 72.0); + let editor = Geometry::rect(root, Geometry::WHEEL_EDITOR); + let response = ui.interact( + editor, + ui.make_persistent_id("penpot_color_wheel"), + egui::Sense::click_and_drag(), + ); + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + paint_hue_wheel(ui.painter(), ring, hsva.h, palette.text_primary); + paint_sv( + ui.painter(), + sv, + hsva.h, + hsva.s, + hsva.v, + palette.text_primary, + ); + if response.clicked() || response.dragged() { + if let Some(position) = response.interact_pointer_pos() { + let delta = position - ring.center(); + if sv.contains(position) { + hsva.s = ((position.x - sv.left()) / sv.width()).clamp(0.0, 1.0); + hsva.v = (1.0 - (position.y - sv.top()) / sv.height()).clamp(0.0, 1.0); + } else if (52.0..=76.0).contains(&delta.length()) { + hsva.h = (delta.angle() / std::f32::consts::TAU + 0.25).rem_euclid(1.0); + } + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } + } + text( + ui, + root.min + egui::vec2(101.0, 252.0), + egui::Align2::CENTER_CENTER, + "Drag ring or field", + TypeRole::Small.font(), + palette.text_muted, + ); + values(ui, root, session); +} + +fn values(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + let _values_bounds = Geometry::rect(root, Geometry::VALUES); + caption(ui, root, 198.0, 82.0, "CURRENT"); + caption(ui, root, 302.0, 82.0, "PREVIOUS"); + paint_swatch( + ui, + Geometry::at(root, 198.0, 96.0, 94.0, 30.0), + color32(session.current), + egui::Sense::hover(), + "Current live value", + ); + paint_swatch( + ui, + Geometry::at(root, 302.0, 96.0, 94.0, 30.0), + color32(session.original), + egui::Sense::hover(), + "Value when the picker opened", + ); + + caption(ui, root, 198.0, 132.0, "HEX"); + let hex_field = Geometry::at(root, 198.0, 144.0, 166.0, 24.0); + field_background(ui, hex_field); + let edit_rect = egui::Rect::from_min_max( + hex_field.min, + egui::pos2(hex_field.right() - 24.0, hex_field.bottom()), + ); + let edit = ui.put( + edit_rect.shrink2(egui::vec2(5.0, 2.0)), + egui::TextEdit::singleline(&mut session.hex) + .font(TypeRole::Body.font()) + .frame(egui::Frame::NONE), + ); + if edit.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + if let Some(value) = parse_hex(&session.hex) { + set_current(session, value); + } + } + if bare_icon( + ui, + egui::Rect::from_min_size( + egui::pos2(hex_field.right() - 24.0, hex_field.top()), + egui::vec2(24.0, 24.0), + ), + icons::COPY, + "Copy HEX", + palette.text_secondary, + ) + .clicked() + { + ui.ctx().copy_text(session.hex.clone()); + } + let eyedropper = Geometry::at(root, 370.0, 144.0, 26.0, 24.0); + field_background(ui, eyedropper); + bare_icon( + ui, + eyedropper, + icons::EYEDROPPER, + PLANNED_EYEDROPPER, + palette.text_muted, + ); + + caption(ui, root, 198.0, 174.0, "RGBA"); + let mut bytes = session.current.map(byte); + let before_bytes = bytes; + for (index, prefix) in ["R ", "G ", "B ", "A "].into_iter().enumerate() { + let rect = Geometry::at(root, 198.0 + index as f32 * 49.0, 186.0, 45.0, 24.0); + ui.put( + rect, + egui::DragValue::new(&mut bytes[index]) + .range(0..=255) + .prefix(prefix), + ); + } + if bytes != before_bytes { + set_current(session, bytes.map(|component| component as f32 / 255.0)); + } + + caption(ui, root, 198.0, 216.0, "HSV"); + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + let mut hsv = [hsva.h * 360.0, hsva.s * 100.0, hsva.v * 100.0]; + let before_hsv = hsv; + for (index, suffix) in ["°", "%", "%"].into_iter().enumerate() { + let rect = Geometry::at(root, 198.0 + index as f32 * 66.0, 228.0, 61.0, 24.0); + ui.put( + rect, + egui::DragValue::new(&mut hsv[index]) + .range(if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 }) + .suffix(suffix) + .max_decimals(0), + ); + } + if hsv != before_hsv { + hsva.h = hsv[0] / 360.0; + hsva.s = hsv[1] / 100.0; + hsva.v = hsv[2] / 100.0; + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } + + caption(ui, root, 198.0, 258.0, "ALPHA"); + alpha_control(ui, Geometry::at(root, 240.0, 256.0, 156.0, 14.0), session); +} + +fn presets_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + let content = Geometry::rect(root, Geometry::CONTENT); + ui.painter().rect_filled(content, 4.0, palette.recessed); + caption(ui, root, 24.0, 86.0, "MATERIAL PALETTE"); + for (index, color) in PRESETS.into_iter().enumerate() { + let column = index % 8; + let row = index / 8; + let rect = Geometry::at( + root, + 24.0 + column as f32 * 47.0, + 104.0 + row as f32 * 52.0, + 34.0, + 34.0, + ); + if paint_swatch(ui, rect, color, egui::Sense::click(), "Use preset color").clicked() { + set_current(session, rgba(color)); + } + } +} + +fn recent_row(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + let row = Geometry::rect(root, Geometry::RECENTS); + ui.painter().rect_filled(row, 4.0, palette.section); + text( + ui, + root.min + egui::vec2(20.0, 296.0), + egui::Align2::LEFT_CENTER, + "RECENT", + TypeRole::Caption.font(), + palette.text_muted, + ); + for (index, color) in RECENTS.into_iter().enumerate() { + let rect = Geometry::at(root, 74.0 + index as f32 * 36.0, 286.0, 28.0, 20.0); + if paint_swatch(ui, rect, color, egui::Sense::click(), "Use recent color").clicked() { + set_current(session, rgba(color)); + } + } +} + +fn footer(ui: &mut egui::Ui, root: egui::Rect, result: &mut ColorPickerResponse) { + let palette = crate::ui::design_system::palette(ui); + let footer = Geometry::rect(root, Geometry::FOOTER); + text( + ui, + footer.left_center(), + egui::Align2::LEFT_CENTER, + "Enter apply · Esc cancel", + TypeRole::Small.font(), + palette.text_muted, + ); + if labeled_button( + ui, + Geometry::at(root, 254.0, 316.0, 72.0, 26.0), + icons::X, + "Cancel", + false, + ) + .clicked() + { + result.cancelled = true; + } + if labeled_button( + ui, + Geometry::at(root, 334.0, 316.0, 74.0, 26.0), + icons::CHECK, + "Apply", + true, + ) + .clicked() + { + result.applied = true; + } +} + +fn alpha_control(ui: &mut egui::Ui, rect: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + checker(ui.painter(), rect, palette.control, palette.elevated); + let response = ui.interact( + rect.expand2(egui::vec2(0.0, 4.0)), + ui.make_persistent_id("penpot_color_alpha"), + egui::Sense::click_and_drag(), + ); + if response.clicked() || response.dragged() { + if let Some(position) = response.interact_pointer_pos() { + let mut value = session.current; + value[3] = ((position.x - rect.left()) / rect.width()).clamp(0.0, 1.0); + set_current(session, value); + } + } + let x = egui::lerp(rect.left()..=rect.right(), session.current[3]); + ui.painter().rect_filled( + egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.bottom())), + 2.0, + palette.accent.gamma_multiply(0.65), + ); + ui.painter().rect_filled( + egui::Rect::from_center_size(egui::pos2(x, rect.center().y), egui::vec2(10.0, 18.0)), + 3.0, + palette.text_primary, + ); +} + +fn paint_hue_wheel( + painter: &egui::Painter, + rect: egui::Rect, + selected_hue: f32, + marker_color: egui::Color32, +) { + let center = rect.center(); + let outer = 72.0; + let inner = 54.0; + let mut mesh = egui::Mesh::default(); + const SEGMENTS: usize = 72; + for segment in 0..SEGMENTS { + let h0 = segment as f32 / SEGMENTS as f32; + let h1 = (segment + 1) as f32 / SEGMENTS as f32; + let a0 = (h0 - 0.25) * std::f32::consts::TAU; + let a1 = (h1 - 0.25) * std::f32::consts::TAU; + let start = mesh.vertices.len() as u32; + let c0 = egui::Color32::from(Hsva::new(h0, 1.0, 1.0, 1.0)); + let c1 = egui::Color32::from(Hsva::new(h1, 1.0, 1.0, 1.0)); + for (radius, angle, color) in [ + (inner, a0, c0), + (outer, a0, c0), + (outer, a1, c1), + (inner, a1, c1), + ] { + mesh.colored_vertex( + center + egui::vec2(angle.cos(), angle.sin()) * radius, + color, + ); + } + mesh.add_triangle(start, start + 1, start + 2); + mesh.add_triangle(start, start + 2, start + 3); + } + painter.add(mesh); + let angle = (selected_hue - 0.25) * std::f32::consts::TAU; + let marker = center + egui::vec2(angle.cos(), angle.sin()) * 63.0; + painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color)); +} + +fn paint_sv( + painter: &egui::Painter, + rect: egui::Rect, + hue: f32, + saturation: f32, + value: f32, + marker_color: egui::Color32, +) { + let mut mesh = egui::Mesh::default(); + for (position, color) in [ + (rect.left_top(), egui::Color32::WHITE), + ( + rect.right_top(), + egui::Color32::from(Hsva::new(hue, 1.0, 1.0, 1.0)), + ), + (rect.right_bottom(), egui::Color32::BLACK), + (rect.left_bottom(), egui::Color32::BLACK), + ] { + mesh.colored_vertex(position, color); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(mesh); + let marker = egui::pos2( + egui::lerp(rect.x_range(), saturation), + egui::lerp(rect.y_range(), 1.0 - value), + ); + painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color)); +} + +pub(super) fn checker( + painter: &egui::Painter, + rect: egui::Rect, + dark: egui::Color32, + light: egui::Color32, +) { + let size = 6.0; + let columns = (rect.width() / size).ceil() as usize; + let rows = (rect.height() / size).ceil() as usize; + for row in 0..rows { + for column in 0..columns { + let cell = egui::Rect::from_min_size( + rect.min + egui::vec2(column as f32 * size, row as f32 * size), + egui::vec2(size, size), + ) + .intersect(rect); + painter.rect_filled( + cell, + 0.0, + if (row + column) % 2 == 0 { dark } else { light }, + ); + } + } +} + +fn paint_swatch( + ui: &mut egui::Ui, + rect: egui::Rect, + color: egui::Color32, + sense: egui::Sense, + tooltip: &str, +) -> egui::Response { + let palette = crate::ui::design_system::palette(ui); + checker(ui.painter(), rect, palette.control, palette.elevated); + ui.painter().rect_filled(rect.shrink(1.0), 3.0, color); + ui.painter().rect_stroke( + rect, + 3.0, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.interact( + rect, + ui.make_persistent_id(("color_swatch", rect.min.x.to_bits(), rect.min.y.to_bits())), + sense, + ) + .on_hover_text(tooltip) +} + +fn icon_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + enabled: bool, +) -> egui::Response { + let palette = crate::ui::design_system::palette(ui); + ui.put( + rect, + egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new( + 13.0, + egui::FontFamily::Name("phosphor-regular".into()), + ))) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(4.0) + .sense(if enabled { + egui::Sense::click() + } else { + egui::Sense::hover() + }), + ) + .on_hover_text(tooltip) +} + +fn bare_icon( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + color: egui::Color32, +) -> egui::Response { + let response = ui.interact( + rect, + ui.make_persistent_id(("color_icon", tooltip)), + if tooltip == PLANNED_EYEDROPPER { + egui::Sense::hover() + } else { + egui::Sense::click() + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + color, + ); + response.on_hover_text(tooltip) +} + +fn labeled_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + label: &str, + accent: bool, +) -> egui::Response { + let palette = crate::ui::design_system::palette(ui); + ui.push_id(("color_picker_footer_action", label), |ui| { + ui.put( + rect, + egui::Button::new(crate::ui::design_system::controls::icon_label( + icon, + label, + TypeRole::Control, + 12.0, + palette.text_primary, + )) + .fill(if accent { + palette.accent + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if accent { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(4.0), + ) + }) + .inner +} + +fn field_background(ui: &egui::Ui, rect: egui::Rect) { + let palette = crate::ui::design_system::palette(ui); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); +} + +fn caption(ui: &egui::Ui, root: egui::Rect, x: f32, y: f32, value: &str) { + let palette = crate::ui::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(x, y), + egui::Align2::LEFT_TOP, + value, + TypeRole::Caption.font(), + palette.text_muted, + ); +} + +fn text( + ui: &egui::Ui, + position: egui::Pos2, + align: egui::Align2, + value: &str, + font: egui::FontId, + color: egui::Color32, +) { + ui.painter().text(position, align, value, font, color); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exported_popup_regions_match_penpot_geometry() { + assert_eq!(POPUP_SIZE, egui::vec2(420.0, 350.0)); + assert_eq!(Geometry::HEADER_HEIGHT, 38.0); + assert_eq!(Geometry::CONTENT.size(), egui::vec2(396.0, 202.0)); + assert_eq!(Geometry::WHEEL_EDITOR.size(), egui::vec2(178.0, 202.0)); + assert_eq!(Geometry::VALUES.size(), egui::vec2(198.0, 186.0)); + assert_eq!(Geometry::RECENTS.size(), egui::vec2(396.0, 28.0)); + assert_eq!(Geometry::FOOTER.size(), egui::vec2(396.0, 26.0)); + } + + #[test] + fn exported_value_and_action_fields_retain_exact_sizes() { + let root = egui::Rect::from_min_size(egui::Pos2::ZERO, POPUP_SIZE); + assert_eq!( + Geometry::at(root, 198.0, 144.0, 166.0, 24.0).size(), + egui::vec2(166.0, 24.0) + ); + assert_eq!( + Geometry::at(root, 370.0, 144.0, 26.0, 24.0).size(), + egui::vec2(26.0, 24.0) + ); + assert_eq!( + Geometry::at(root, 240.0, 256.0, 156.0, 14.0).size(), + egui::vec2(156.0, 14.0) + ); + assert_eq!( + Geometry::at(root, 334.0, 316.0, 74.0, 26.0).size(), + egui::vec2(74.0, 26.0) + ); + } + + #[test] + fn disabled_eyedropper_has_no_state_or_action_contract() { + assert_eq!(PLANNED_EYEDROPPER, "Planned — screen sampling pending"); + } +} diff --git a/crates/editor/src/ui/design_system/color_picker/popup/sliders.rs b/crates/editor/src/ui/design_system/color_picker/popup/sliders.rs new file mode 100644 index 0000000..c3f781b --- /dev/null +++ b/crates/editor/src/ui/design_system/color_picker/popup/sliders.rs @@ -0,0 +1,167 @@ +//! Fixed Penpot geometry for the color picker's RGBA and HSV channel modes. + +use bevy_egui::egui::{self, ecolor::Hsva}; + +use super::super::{byte, set_current, PickerSession}; +use super::{caption, text, Geometry}; +use crate::ui::design_system::typography::TypeRole; + +/// Each 186 px channel group owns a 178 px row: a single-letter channel label, +/// a 104 px track, and a 44 px value field. +struct SliderGeometry; + +impl SliderGeometry { + const RGBA_X: f32 = 12.0; + const HSV_X: f32 = 206.0; + const TITLE_Y: f32 = 74.0; + const FIRST_ROW_Y: f32 = 90.0; + const ROW_STRIDE: f32 = 30.0; + const LABEL_WIDTH: f32 = 18.0; + const TRACK_X: f32 = 24.0; + const TRACK_WIDTH: f32 = 104.0; + const VALUE_X: f32 = 134.0; + const VALUE_WIDTH: f32 = 44.0; + const ROW_HEIGHT: f32 = 24.0; +} + +pub(super) fn show(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::ui::design_system::palette(ui); + let content = Geometry::rect(root, Geometry::CONTENT); + ui.painter().rect_filled(content, 4.0, palette.recessed); + caption( + ui, + root, + SliderGeometry::RGBA_X, + SliderGeometry::TITLE_Y, + "RGBA CHANNELS", + ); + caption( + ui, + root, + SliderGeometry::HSV_X, + SliderGeometry::TITLE_Y, + "HSV CHANNELS", + ); + + let mut bytes = session.current.map(|component| f32::from(byte(component))); + let before_bytes = bytes; + for (index, label) in ["R", "G", "B", "A"].into_iter().enumerate() { + slider_channel( + ui, + root, + SliderGeometry::RGBA_X, + index, + label, + &mut bytes[index], + 0.0..=255.0, + "", + ); + } + if bytes != before_bytes { + set_current(session, bytes.map(|component| component / 255.0)); + } + + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + let mut values = [ + hsva.h * 360.0, + hsva.s * 100.0, + hsva.v * 100.0, + hsva.a * 100.0, + ]; + let before = values; + for (index, label) in ["H", "S", "V", "A"].into_iter().enumerate() { + let range = if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 }; + slider_channel( + ui, + root, + SliderGeometry::HSV_X, + index, + label, + &mut values[index], + range, + if index == 0 { "°" } else { "%" }, + ); + } + if values != before { + hsva.h = values[0] / 360.0; + hsva.s = values[1] / 100.0; + hsva.v = values[2] / 100.0; + hsva.a = values[3] / 100.0; + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } +} + +#[allow(clippy::too_many_arguments)] +fn slider_channel( + ui: &mut egui::Ui, + root: egui::Rect, + group_x: f32, + index: usize, + label: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, +) { + let palette = crate::ui::design_system::palette(ui); + let y = SliderGeometry::FIRST_ROW_Y + index as f32 * SliderGeometry::ROW_STRIDE; + let label_rect = Geometry::at( + root, + group_x, + y, + SliderGeometry::LABEL_WIDTH, + SliderGeometry::ROW_HEIGHT, + ); + text( + ui, + label_rect.left_center(), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_secondary, + ); + + ui.put( + Geometry::at( + root, + group_x + SliderGeometry::TRACK_X, + y, + SliderGeometry::TRACK_WIDTH, + SliderGeometry::ROW_HEIGHT, + ), + egui::Slider::new(value, range.clone()).show_value(false), + ); + ui.put( + Geometry::at( + root, + group_x + SliderGeometry::VALUE_X, + y, + SliderGeometry::VALUE_WIDTH, + SliderGeometry::ROW_HEIGHT, + ), + egui::DragValue::new(value) + .range(range) + .suffix(suffix) + .max_decimals(0), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sliders_mode_matches_the_two_exported_channel_groups() { + assert_eq!(SliderGeometry::HSV_X - SliderGeometry::RGBA_X, 194.0); + assert_eq!(SliderGeometry::TRACK_WIDTH, 104.0); + assert_eq!(SliderGeometry::VALUE_WIDTH, 44.0); + assert_eq!(SliderGeometry::VALUE_X + SliderGeometry::VALUE_WIDTH, 178.0); + assert_eq!(SliderGeometry::ROW_STRIDE, 30.0); + assert_eq!(SliderGeometry::ROW_HEIGHT, 24.0); + } +} diff --git a/crates/editor/src/ui/design_system/controls.rs b/crates/editor/src/ui/design_system/controls.rs new file mode 100644 index 0000000..88a2235 --- /dev/null +++ b/crates/editor/src/ui/design_system/controls.rs @@ -0,0 +1,356 @@ +//! Reusable Penpot-styled editor controls with no world or asset-store access. + +use bevy_egui::egui; +use egui_phosphor_icons::{icons, Icon}; +use std::ops::RangeInclusive; + +use super::palette; +use super::typography::TypeRole; + +const SCALAR_CONTROL_WIDTH: f32 = 132.0; +const SCALAR_TRACK_X: f32 = 0.0; +const SCALAR_TRACK_WIDTH: f32 = 78.0; +const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0; +const SCALAR_NUMBER_X: f32 = 84.0; +const SCALAR_NUMBER_WIDTH: f32 = 48.0; +const SCALAR_THUMB_RADIUS: f32 = 4.0; + +pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame { + let palette = palette(ui); + egui::Frame::new() + .fill(palette.section) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(egui::CornerRadius::same(5)) +} + +pub(crate) fn section_header(ui: &mut egui::Ui, title: &str, open: &mut bool) -> egui::Response { + section_header_with_summary(ui, title, None, open) +} + +pub(crate) fn section_header_with_summary( + ui: &mut egui::Ui, + title: &str, + summary: Option<&str>, + open: &mut bool, +) -> egui::Response { + let palette = palette(ui); + let (rect, response) = ui.allocate_exact_size( + egui::vec2( + ui.available_width().max(1.0), + super::INPUT_SECTION_HEADER_HEIGHT, + ), + egui::Sense::click(), + ); + ui.painter().rect_filled(rect, 4.0, palette.elevated); + ui.painter().text( + rect.left_center() + egui::vec2(12.0, 0.0), + egui::Align2::CENTER_CENTER, + if *open { + icons::CARET_DOWN.as_str() + } else { + icons::CARET_RIGHT.as_str() + }, + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(28.0, 0.0), + egui::Align2::LEFT_CENTER, + title, + TypeRole::Section.font(), + palette.text_primary, + ); + if let Some(summary) = summary { + ui.painter().text( + rect.right_center() - egui::vec2(12.0, 0.0), + egui::Align2::RIGHT_CENTER, + summary, + TypeRole::Small.font(), + palette.text_muted, + ); + } + if response.clicked() { + *open = !*open; + } + response +} + +/// Compose a Phosphor glyph and Source Sans label without asking either font to render the +/// other's codepoints. This is the canonical path for icon-plus-text controls in a design-system +/// scope. +pub(crate) fn icon_label( + icon: Icon, + label: &str, + role: TypeRole, + icon_size: f32, + color: egui::Color32, +) -> egui::WidgetText { + let mut job = egui::text::LayoutJob::default(); + job.append( + icon.as_str(), + 0.0, + egui::TextFormat { + font_id: egui::FontId::new( + icon_size, + egui::FontFamily::Name("phosphor-regular".into()), + ), + color, + ..Default::default() + }, + ); + if !label.is_empty() { + job.append( + label, + 6.0, + egui::TextFormat { + font_id: role.font(), + color, + ..Default::default() + }, + ); + } + job.into() +} + +pub(crate) fn icon_button( + ui: &mut egui::Ui, + icon: Icon, + tooltip: &str, + enabled: bool, +) -> egui::Response { + let palette = palette(ui); + let response = ui.add_enabled( + enabled, + egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new( + 14.0, + egui::FontFamily::Name("phosphor-regular".into()), + ))) + .min_size(egui::vec2(24.0, 24.0)) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(egui::CornerRadius::same(4)), + ); + response.on_hover_text(tooltip) +} + +pub(crate) fn status(ui: &mut egui::Ui, color: egui::Color32, label: &str, tooltip: &str) { + let palette = palette(ui); + let (dot, response) = ui.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover()); + ui.painter().circle_filled(dot.center(), 4.0, color); + response.on_hover_text(tooltip); + ui.label(TypeRole::Body.text(label).color(palette.text_secondary)); +} + +pub(crate) fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response { + let palette = palette(ui); + let (rect, mut response) = ui.allocate_exact_size(egui::vec2(31.0, 16.0), egui::Sense::click()); + if response.clicked() { + *value = !*value; + response.mark_changed(); + } + let fill = if *value { + palette.accent_dark + } else { + palette.control + }; + let stroke = if *value { + palette.accent + } else { + palette.border_strong + }; + ui.painter().rect( + rect, + 8.0, + fill, + egui::Stroke::new(1.0_f32, stroke), + egui::StrokeKind::Inside, + ); + let x = if *value { + rect.right() - 8.0 + } else { + rect.left() + 8.0 + }; + ui.painter() + .circle_filled(egui::pos2(x, rect.center().y), 5.0, palette.text_primary); + response +} + +/// Fixed 128×24 two-option control used by the Penpot material Surface section. +/// +/// Returning the selected side keeps the control independent from material state and prevents +/// stock `selectable_label` padding from changing its geometry. +pub(crate) fn two_option_segmented( + ui: &mut egui::Ui, + left_label: &str, + right_label: &str, + right_selected: bool, +) -> Option { + const WIDTH: f32 = 128.0; + const HEIGHT: f32 = 24.0; + const OPTION_WIDTH: f32 = WIDTH * 0.5; + + let palette = palette(ui); + let (rect, _) = ui.allocate_exact_size(egui::vec2(WIDTH, HEIGHT), egui::Sense::hover()); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let left = egui::Rect::from_min_size(rect.min, egui::vec2(OPTION_WIDTH, HEIGHT)); + let right = egui::Rect::from_min_size( + rect.min + egui::vec2(OPTION_WIDTH, 0.0), + egui::vec2(OPTION_WIDTH, HEIGHT), + ); + let mut selection = None; + for (option_rect, label, selected, value) in [ + (left, left_label, !right_selected, false), + (right, right_label, right_selected, true), + ] { + let response = ui.interact( + option_rect, + ui.make_persistent_id(("two_option_segment", label)), + egui::Sense::click(), + ); + let fill = if selected { + palette.accent_dark + } else if response.hovered() { + palette.elevated + } else { + palette.control + }; + ui.painter().rect_filled(option_rect.shrink(1.0), 3.0, fill); + if selected { + ui.painter().rect_stroke( + option_rect, + 3.0, + egui::Stroke::new(1.0_f32, palette.accent), + egui::StrokeKind::Inside, + ); + } + ui.painter().text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + label, + TypeRole::Control.font(), + if selected { + palette.text_primary + } else { + palette.text_secondary + }, + ); + if response.clicked() && right_selected != value { + selection = Some(value); + } + } + selection +} + +/// Exact Penpot scalar editor used by material inputs. +/// +/// The track and numeric field are painted as independent controls so egui's stock slider +/// padding cannot move the thumb into the value field at the reference width. +pub(crate) fn scalar_control( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + value: &mut f32, + range: RangeInclusive, + max_decimals: usize, +) -> egui::Response { + let palette = palette(ui); + let before = *value; + let width = SCALAR_CONTROL_WIDTH.min(ui.available_width().max(1.0)); + let (rect, base_response) = + ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + let number_width = SCALAR_NUMBER_WIDTH.min(width); + let number_x = SCALAR_NUMBER_X.min((width - number_width).max(0.0)); + let track_width = SCALAR_TRACK_WIDTH.min((number_x - 6.0).max(1.0)); + let slider_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(SCALAR_TRACK_X, 6.0), + egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT), + ); + let track_rect = + egui::Rect::from_center_size(slider_rect.center(), egui::vec2(track_width, 3.0)); + let slider_response = ui.interact( + slider_rect, + ui.make_persistent_id(("penpot_scalar", salt)), + egui::Sense::click_and_drag(), + ); + if slider_response.clicked() || slider_response.dragged() { + if let Some(pointer) = slider_response.interact_pointer_pos() { + let start = *range.start(); + let end = *range.end(); + let normalized = ((pointer.x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0); + *value = egui::lerp(start..=end, normalized); + } + } + + let start = *range.start(); + let end = *range.end(); + let normalized = if end > start { + ((*value - start) / (end - start)).clamp(0.0, 1.0) + } else { + 0.0 + }; + ui.painter().rect_filled(track_rect, 2.0, palette.border); + let fill = egui::Rect::from_min_max( + track_rect.left_top(), + egui::pos2( + egui::lerp(track_rect.x_range(), normalized), + track_rect.bottom(), + ), + ); + ui.painter().rect_filled(fill, 2.0, palette.accent); + let thumb_center = egui::pos2( + egui::lerp(track_rect.x_range(), normalized), + track_rect.center().y, + ); + ui.painter().circle( + thumb_center, + SCALAR_THUMB_RADIUS, + palette.text_primary, + egui::Stroke::new(1.0_f32, palette.accent), + ); + + let number_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(number_x, 0.0), + egui::vec2(number_width, 22.0), + ); + let number_response = ui.put( + number_rect, + egui::DragValue::new(value) + .speed(0.01) + .range(range) + .max_decimals(max_decimals), + ); + let mut response = base_response.union(slider_response).union(number_response); + if *value != before { + response.mark_changed(); + } + response +} + +#[cfg(test)] +mod scalar_tests { + use super::*; + + #[test] + fn penpot_scalar_geometry_matches_the_current_export() { + assert!((SCALAR_NUMBER_X - (SCALAR_TRACK_X + SCALAR_TRACK_WIDTH) - 6.0).abs() < 0.001); + assert_eq!(SCALAR_CONTROL_WIDTH, SCALAR_NUMBER_X + SCALAR_NUMBER_WIDTH); + assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0); + assert_eq!(SCALAR_THUMB_RADIUS, 4.0); + } +} + +#[cfg(test)] +mod segmented_tests { + #[test] + fn penpot_segmented_control_has_two_fixed_halves() { + const WIDTH: f32 = 128.0; + const OPTION_WIDTH: f32 = WIDTH * 0.5; + assert_eq!(OPTION_WIDTH, 64.0); + assert_eq!(OPTION_WIDTH * 2.0, WIDTH); + } +} diff --git a/crates/editor/src/ui/design_system/mod.rs b/crates/editor/src/ui/design_system/mod.rs new file mode 100644 index 0000000..36f3267 --- /dev/null +++ b/crates/editor/src/ui/design_system/mod.rs @@ -0,0 +1,154 @@ +//! Penpot-led editor design-system primitives. +//! +//! The first consumer is the Material Slot panel. These semantic tokens and controls are kept +//! independent from material/world state so later editor panels can migrate without copying style. + +pub(crate) mod color_picker; +pub(crate) mod controls; +pub(crate) mod property_grid; +pub(crate) mod typography; + +use bevy_egui::egui::{self, Color32, CornerRadius, Stroke}; + +use crate::ui::theme::EditorVisualPalette; + +#[cfg(test)] +pub(crate) const REFERENCE_WIDTH: f32 = 620.0; +pub(crate) const INSPECTOR_MIN_WIDTH: f32 = 420.0; +pub(crate) const OUTER_PADDING: f32 = 12.0; +pub(crate) const SECTION_GAP: f32 = 8.0; +pub(crate) const MATERIALS_HEADING_HEIGHT: f32 = 32.0; +pub(crate) const HEADER_HEIGHT: f32 = 82.0; +pub(crate) const COLLAPSED_HEADER_HEIGHT: f32 = 52.0; +pub(crate) const MATERIAL_PREVIEW_SIZE: f32 = 64.0; +pub(crate) const MATERIAL_IDENTITY_WIDTH: f32 = 229.5; +pub(crate) const SURFACE_SECTION_HEIGHT: f32 = 46.0; +pub(crate) const COMPACT_SURFACE_SECTION_HEIGHT: f32 = 46.0; +pub(crate) const INPUT_SECTION_HEADER_HEIGHT: f32 = 24.0; +pub(crate) const PARAMETER_ROW_HEIGHT: f32 = 32.0; +pub(crate) const COMPACT_PARAMETER_ROW_HEIGHT: f32 = 58.0; +pub(crate) const UV_SECTION_HEIGHT: f32 = 54.0; +pub(crate) const COMPACT_UV_SECTION_HEIGHT: f32 = 94.0; +pub(crate) const ADVANCED_COLLAPSED_HEIGHT: f32 = 30.0; +pub(crate) const ADVANCED_EXPANDED_HEIGHT: f32 = 178.0; +pub(crate) const CONTROL_HEIGHT: f32 = 24.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DesignPalette { + pub accent: Color32, + pub accent_dark: Color32, + pub panel: Color32, + pub section: Color32, + pub recessed: Color32, + pub control: Color32, + pub elevated: Color32, + pub row_even: Color32, + pub row_odd: Color32, + pub checker: Color32, + pub border: Color32, + pub border_strong: Color32, + pub text_primary: Color32, + pub text_secondary: Color32, + pub text_muted: Color32, + pub healthy: Color32, + pub warning: Color32, + pub error: Color32, +} + +impl From for DesignPalette { + fn from(value: EditorVisualPalette) -> Self { + Self { + accent: value.selection, + accent_dark: value.selection_bg, + panel: value.panel, + section: value.widget, + recessed: value.panel_dark, + control: value.widget, + elevated: value.elevated, + row_even: value.panel, + row_odd: value.widget, + checker: value.elevated, + border: value.border, + border_strong: value.border_strong, + text_primary: value.text, + text_secondary: value.text_dim, + text_muted: value.text_muted, + healthy: value.success, + warning: value.warning, + error: value.error, + } + } +} + +pub(crate) fn palette(ui: &egui::Ui) -> DesignPalette { + crate::ui::theme::editor_palette(ui.ctx()).into() +} + +/// Applies the Penpot material-panel visual language locally without restyling the whole editor. +pub(crate) fn scope(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R { + let palette = palette(ui); + ui.scope(|ui| { + let style = ui.style_mut(); + style.spacing.item_spacing = egui::vec2(8.0, 4.0); + style.spacing.button_padding = egui::vec2(8.0, 3.0); + style.spacing.interact_size = egui::vec2(31.0, CONTROL_HEIGHT); + style.visuals.widgets.noninteractive.bg_fill = palette.section; + style.visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border); + style.visuals.widgets.noninteractive.fg_stroke = + Stroke::new(1.0_f32, palette.text_secondary); + style.visuals.widgets.inactive.bg_fill = palette.control; + style.visuals.widgets.inactive.weak_bg_fill = palette.control; + style.visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border); + style.visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.widgets.hovered.bg_fill = palette.elevated; + style.visuals.widgets.hovered.weak_bg_fill = palette.elevated; + style.visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong); + style.visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.widgets.active.bg_fill = palette.accent_dark; + style.visuals.widgets.active.weak_bg_fill = palette.accent_dark; + style.visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent); + style.visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.selection.bg_fill = palette.accent_dark; + style.visuals.selection.stroke = Stroke::new(1.0_f32, palette.accent); + style.visuals.window_fill = palette.panel; + style.visuals.window_stroke = Stroke::new(1.0_f32, palette.border_strong); + style.visuals.window_corner_radius = CornerRadius::same(7); + style.visuals.menu_corner_radius = CornerRadius::same(5); + style.visuals.override_text_color = Some(palette.text_primary); + typography::install_local_text_styles(style); + add_contents(ui) + }) + .inner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_geometry_tokens_are_exact() { + assert_eq!(REFERENCE_WIDTH, 620.0); + assert_eq!(MATERIALS_HEADING_HEIGHT, 32.0); + assert_eq!(HEADER_HEIGHT, 82.0); + assert_eq!(COLLAPSED_HEADER_HEIGHT, 52.0); + assert_eq!(MATERIAL_PREVIEW_SIZE, 64.0); + assert_eq!(MATERIAL_IDENTITY_WIDTH, 229.5); + assert_eq!(SURFACE_SECTION_HEIGHT, 46.0); + assert_eq!(COMPACT_SURFACE_SECTION_HEIGHT, 46.0); + assert_eq!(INPUT_SECTION_HEADER_HEIGHT, 24.0); + assert_eq!(PARAMETER_ROW_HEIGHT, 32.0); + assert_eq!(COMPACT_PARAMETER_ROW_HEIGHT, 58.0); + assert_eq!(UV_SECTION_HEIGHT, 54.0); + assert_eq!(COMPACT_UV_SECTION_HEIGHT, 94.0); + assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0); + assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0); + } + + #[test] + fn material_design_palette_uses_blacksite_semantics() { + let palette = DesignPalette::from(crate::ui::theme::BLACKSITE_PALETTE); + assert_eq!(palette.accent, crate::ui::theme::SELECTION); + assert_eq!(palette.panel, crate::ui::theme::PANEL_BG); + assert_eq!(palette.text_primary, crate::ui::theme::TEXT); + } +} diff --git a/crates/editor/src/ui/design_system/property_grid.rs b/crates/editor/src/ui/design_system/property_grid.rs new file mode 100644 index 0000000..5d8dbc4 --- /dev/null +++ b/crates/editor/src/ui/design_system/property_grid.rs @@ -0,0 +1,260 @@ +//! Testable geometry for dense Penpot material property rows. + +use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT}; +use bevy_egui::egui; + +pub(crate) const WIDE_SECTION_WIDTH: f32 = 569.0; +pub(crate) const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH; +/// The actor component chrome can consume up to twelve pixels of the exported 372 px compact +/// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset. +const COMPACT_INLINE_ACTION_FIT_WIDTH: f32 = 357.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct PropertyGridGeometry { + pub stacked: bool, + pub row_height: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct PropertyRowRects { + pub row: egui::Rect, + pub label: egui::Rect, + pub value: egui::Rect, + pub channel: egui::Rect, + pub texture_group: egui::Rect, + pub texture_field: egui::Rect, + pub locate: egui::Rect, + pub clear: egui::Rect, + pub inline_texture_actions: bool, +} + +impl PropertyGridGeometry { + pub(crate) fn for_width(section_width: f32) -> Self { + if section_width + f32::EPSILON >= RESPONSIVE_BREAKPOINT { + Self { + stacked: false, + row_height: PARAMETER_ROW_HEIGHT, + } + } else { + Self { + stacked: true, + row_height: COMPACT_PARAMETER_ROW_HEIGHT, + } + } + } + + pub(crate) fn row_rects(self, row: egui::Rect) -> PropertyRowRects { + let inset = 12.0_f32.min(row.width() * 0.5); + let inner_width = (row.width() - inset * 2.0).max(1.0); + if !self.stacked { + let texture_group_width = (inner_width - 286.5).max(60.0); + let texture_field_width = (texture_group_width - 60.0).max(1.0); + let texture_group = egui::Rect::from_min_size( + row.min + egui::vec2(298.5, 5.0), + egui::vec2(texture_group_width, 22.0), + ); + return PropertyRowRects { + row, + label: egui::Rect::from_min_size( + row.min + egui::vec2(12.0, 5.0), + egui::vec2(82.0, 22.0), + ), + value: egui::Rect::from_min_size( + row.min + egui::vec2(96.0, 5.0), + egui::vec2(132.0, 22.0), + ), + channel: egui::Rect::from_min_size( + row.min + egui::vec2(234.0, 5.0), + egui::vec2(48.0, 22.0), + ), + texture_group, + texture_field: egui::Rect::from_min_size( + texture_group.min, + egui::vec2(texture_field_width, 22.0), + ), + locate: egui::Rect::from_min_size( + texture_group.min + egui::vec2(texture_field_width + 6.0, 0.0), + egui::vec2(24.0, 22.0), + ), + clear: egui::Rect::from_min_size( + texture_group.min + egui::vec2(texture_field_width + 36.0, 0.0), + egui::vec2(24.0, 22.0), + ), + inline_texture_actions: true, + }; + } + + let channel_width = 48.0_f32.min(inner_width); + let channel_x = inset + inner_width - channel_width; + let value_x = (inset + 84.0).min(channel_x); + let value_width = (channel_x - value_x - 6.35).max(1.0); + let texture_group = egui::Rect::from_min_size( + row.min + egui::vec2(inset, 31.0), + egui::vec2(inner_width, 22.0), + ); + let inline_texture_actions = row.width() + f32::EPSILON >= COMPACT_INLINE_ACTION_FIT_WIDTH; + let texture_field_width = if inline_texture_actions { + (inner_width - 60.0).max(1.0) + } else { + (inner_width - 30.0).max(1.0) + }; + let overflow_or_locate_x = texture_field_width + 6.0; + PropertyRowRects { + row, + label: egui::Rect::from_min_size( + row.min + egui::vec2(inset, 5.0), + egui::vec2(78.0_f32.min(inner_width), 22.0), + ), + value: egui::Rect::from_min_size( + row.min + egui::vec2(value_x, 5.0), + egui::vec2(value_width, 22.0), + ), + channel: egui::Rect::from_min_size( + row.min + egui::vec2(channel_x, 5.0), + egui::vec2(channel_width, 22.0), + ), + texture_group, + texture_field: egui::Rect::from_min_size( + texture_group.min, + egui::vec2(texture_field_width, 22.0), + ), + locate: egui::Rect::from_min_size( + texture_group.min + egui::vec2(overflow_or_locate_x, 0.0), + egui::vec2(24.0, 22.0), + ), + clear: egui::Rect::from_min_size( + texture_group.min + + egui::vec2( + if inline_texture_actions { + texture_field_width + 36.0 + } else { + inner_width + }, + 0.0, + ), + egui::vec2(if inline_texture_actions { 24.0 } else { 0.0 }, 22.0), + ), + inline_texture_actions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_exact_reference_geometry_matches_columns() { + let geometry = PropertyGridGeometry::for_width(569.0); + assert!(!geometry.stacked); + assert_eq!(geometry.row_height, 32.0); + } + + #[test] + fn penpot_below_reference_width_reflows_without_compression() { + let geometry = PropertyGridGeometry::for_width(568.0); + assert!(geometry.stacked); + assert_eq!(geometry.row_height, 58.0); + } + + #[test] + fn row_rects_match_exported_wide_geometry() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); + let rects = PropertyGridGeometry::for_width(569.0).row_rects(row); + assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); + assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); + assert!((rects.value.width() - 132.0).abs() < 0.001); + assert_eq!(rects.value.height(), 22.0); + assert_eq!( + rects.channel, + egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0)) + ); + assert_eq!( + rects.texture_group, + egui::Rect::from_min_size(egui::pos2(298.5, 5.0), egui::vec2(258.5, 22.0)) + ); + assert_eq!(rects.texture_field.size(), egui::vec2(198.5, 22.0)); + assert_eq!(rects.locate.min, egui::pos2(503.0, 5.0)); + assert_eq!(rects.clear.min, egui::pos2(533.0, 5.0)); + assert!(rects.inline_texture_actions); + } + + #[test] + fn row_rects_match_exported_compact_geometry() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0)); + let rects = PropertyGridGeometry::for_width(369.0).row_rects(row); + assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); + assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); + assert_eq!( + rects.channel, + egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0)) + ); + assert_eq!( + rects.texture_group, + egui::Rect::from_min_size(egui::pos2(12.0, 31.0), egui::vec2(345.0, 22.0)) + ); + assert_eq!(rects.texture_field.size(), egui::vec2(285.0, 22.0)); + assert_eq!(rects.locate.min, egui::pos2(303.0, 31.0)); + assert_eq!(rects.clear.min, egui::pos2(333.0, 31.0)); + assert!(rects.inline_texture_actions); + } + + #[test] + fn transient_row_rects_are_finite_and_contained() { + for width in [356.0, 300.0, 240.0] { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 58.0)); + let rects = PropertyGridGeometry::for_width(width).row_rects(row); + for rect in [ + rects.label, + rects.value, + rects.channel, + rects.texture_group, + rects.texture_field, + rects.locate, + rects.clear, + ] { + assert!(rect.is_finite()); + assert!(row.contains_rect(rect)); + } + assert!(!rects.inline_texture_actions); + } + } + + #[test] + fn inspector_chrome_reduced_floor_keeps_compact_texture_actions() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 58.0)); + let rects = PropertyGridGeometry::for_width(363.0).row_rects(row); + assert!(rects.inline_texture_actions); + assert_eq!(rects.texture_group.min, egui::pos2(12.0, 31.0)); + assert_eq!(rects.texture_group.max, egui::pos2(351.0, 53.0)); + assert!(row.contains_rect(rects.locate)); + assert!(row.contains_rect(rects.clear)); + } + + #[test] + fn penpot_wide_growth_only_expands_texture_field() { + let reference_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); + let wide_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(669.0, 32.0)); + let reference = PropertyGridGeometry::for_width(569.0).row_rects(reference_row); + let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row); + assert_eq!(wide.label, reference.label); + assert_eq!(wide.value, reference.value); + assert_eq!(wide.channel, reference.channel); + assert_eq!( + wide.texture_field.width(), + reference.texture_field.width() + 100.0 + ); + } + + #[test] + fn penpot_very_narrow_first_line_never_exceeds_inner_width() { + let geometry = PropertyGridGeometry::for_width(300.0); + assert!(geometry.stacked); + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 58.0)); + let rects = geometry.row_rects(row); + assert!(row.contains_rect(rects.label)); + assert!(row.contains_rect(rects.value)); + assert!(row.contains_rect(rects.channel)); + assert_eq!(rects.texture_group.width(), 276.0); + } +} diff --git a/crates/editor/src/ui/design_system/typography.rs b/crates/editor/src/ui/design_system/typography.rs new file mode 100644 index 0000000..8696f1c --- /dev/null +++ b/crates/editor/src/ui/design_system/typography.rs @@ -0,0 +1,80 @@ +//! Source Sans typography roles used by the Penpot-led editor visual system. + +use bevy_egui::egui::{self, FontFamily, FontId, RichText, Style, TextStyle}; + +use crate::ui::fonts::{SOURCE_SANS_BOLD, SOURCE_SANS_REGULAR}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum TypeRole { + Caption, + Small, + Body, + Control, + Section, + Title, +} + +impl TypeRole { + pub(crate) const fn size(self) -> f32 { + match self { + Self::Caption => 9.0, + Self::Small => 10.0, + Self::Body => 11.0, + Self::Control => 12.0, + Self::Section => 12.0, + Self::Title => 16.0, + } + } + + pub(crate) const fn bold(self) -> bool { + matches!(self, Self::Section | Self::Title) + } + + pub(crate) fn font(self) -> FontId { + let family = if self.bold() { + SOURCE_SANS_BOLD + } else { + SOURCE_SANS_REGULAR + }; + FontId::new(self.size(), FontFamily::Name(family.into())) + } + + pub(crate) fn text(self, value: impl Into) -> RichText { + RichText::new(value).font(self.font()) + } +} + +pub(crate) fn install_local_text_styles(style: &mut Style) { + style + .text_styles + .insert(TextStyle::Body, TypeRole::Body.font()); + style + .text_styles + .insert(TextStyle::Button, TypeRole::Control.font()); + style + .text_styles + .insert(TextStyle::Small, TypeRole::Small.font()); + style + .text_styles + .insert(TextStyle::Heading, TypeRole::Title.font()); + style.text_styles.insert( + TextStyle::Monospace, + FontId::new(11.0, egui::FontFamily::Monospace), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_type_scale_and_weights_are_stable() { + assert_eq!(TypeRole::Caption.size(), 9.0); + assert_eq!(TypeRole::Small.size(), 10.0); + assert_eq!(TypeRole::Body.size(), 11.0); + assert_eq!(TypeRole::Section.size(), 12.0); + assert_eq!(TypeRole::Title.size(), 16.0); + assert!(!TypeRole::Body.bold()); + assert!(TypeRole::Section.bold()); + } +} diff --git a/crates/editor/src/ui/document_status.rs b/crates/editor/src/ui/document_status.rs new file mode 100644 index 0000000..5be259e --- /dev/null +++ b/crates/editor/src/ui/document_status.rs @@ -0,0 +1,38 @@ +//! Shared authored-document state presentation for editor authoring surfaces. + +use bevy_egui::egui; + +use crate::asset_documents::{ + AuthoredDocumentSnapshot, AuthoredDocumentState, DerivedProcessingState, +}; + +use super::theme::{ACCENT, ERROR, WARNING}; + +pub(crate) fn authored_document_status_ui(ui: &mut egui::Ui, snapshot: &AuthoredDocumentSnapshot) { + ui.horizontal_wrapped(|ui| { + if snapshot.dirty { + ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong()); + } + match snapshot.state { + AuthoredDocumentState::ExternalConflict => { + ui.small(egui::RichText::new("CONFLICT").color(ERROR).strong()); + } + AuthoredDocumentState::SaveFailed => { + ui.small(egui::RichText::new("SAVE FAILED").color(ERROR).strong()); + } + AuthoredDocumentState::Saving => { + ui.small(egui::RichText::new("SAVING").color(ACCENT)); + } + AuthoredDocumentState::Clean | AuthoredDocumentState::Dirty => {} + } + match snapshot.processing { + DerivedProcessingState::Queued | DerivedProcessingState::Processing => { + ui.small(egui::RichText::new("PROCESSING").color(ACCENT)); + } + DerivedProcessingState::Failed => { + ui.small(egui::RichText::new("PROCESS FAILED").color(ERROR)); + } + DerivedProcessingState::Idle => {} + } + }); +} diff --git a/crates/editor/src/ui/fonts.rs b/crates/editor/src/ui/fonts.rs index 647f326..5e1925f 100644 --- a/crates/editor/src/ui/fonts.rs +++ b/crates/editor/src/ui/fonts.rs @@ -1,7 +1,11 @@ -//! Phosphor icon font registration for the primary egui context. +//! Editor text and icon font registration for the primary egui context. use bevy::prelude::*; use bevy_egui::{EguiContext, PrimaryEguiContext}; +use std::sync::Arc; + +pub(crate) const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular"; +pub(crate) const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold"; pub struct EditorFontsPlugin; @@ -15,9 +19,40 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added bevy_egui::egui::FontDefinitions { let mut fonts = bevy_egui::egui::FontDefinitions::default(); egui_phosphor_icons::add_fonts(&mut fonts); + fonts.font_data.insert( + SOURCE_SANS_REGULAR.to_owned(), + Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!( + "../../assets/fonts/source-sans/SourceSansPro-Regular.ttf" + ))), + ); + fonts.font_data.insert( + SOURCE_SANS_BOLD.to_owned(), + Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!( + "../../assets/fonts/source-sans/SourceSansPro-Bold.ttf" + ))), + ); + + let proportional = fonts + .families + .entry(bevy_egui::egui::FontFamily::Proportional) + .or_default(); + proportional.insert(0, SOURCE_SANS_REGULAR.to_owned()); + fonts.families.insert( + bevy_egui::egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()), + vec![SOURCE_SANS_REGULAR.to_owned()], + ); + fonts.families.insert( + bevy_egui::egui::FontFamily::Name(SOURCE_SANS_BOLD.into()), + vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()], + ); + // Phosphor families contain icon glyphs only. Text fallbacks give egui a // replacement glyph for malformed/missing icons without changing icon lookup. let text_fallbacks = fonts @@ -38,5 +73,5 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added 150.0 { @@ -696,7 +696,7 @@ fn draw_runtime_node( ui.painter().line_segment( [response.rect.left_top(), response.rect.left_bottom()], egui::Stroke::new( - if primary { 3.0 } else { 2.0 }, + if primary { 3.0_f32 } else { 2.0_f32 }, if primary { SELECTION } else { diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 5d6342d..7c2a8ce 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -1,7 +1,8 @@ //! Inspector extensions for authoring components. use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use bevy::prelude::*; use bevy_egui::egui; @@ -9,24 +10,25 @@ use egui_phosphor_icons::{icons, Icon}; use shared::{ authoring_component_active, brush_math::{validate_brush, BrushDiagnosticSeverity}, - infer_actor_kind, ActorId, ActorKind, AnimationControllerDesc, AudioListenerDesc, - AudioSourceDesc, AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc, - BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, - InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter, - MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds, - NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, - PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc, - SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TerrainDesc, - TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, - COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC, - COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, - COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_NAVIGATION_AREA, - COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE, - COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, - COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, - COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER, - COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TERRAIN_DESC, - COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN, + infer_actor_kind, standard_lit_input_schema, ActorId, ActorKind, AnimationControllerDesc, + AudioListenerDesc, AudioSourceDesc, AuthoringComponentStates, AuthoringLightKind, + AuthoringRigidBody, BrushDesc, BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, + ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject, LightDesc, MaterialAsset, + MaterialDesc, MaterialInstanceAsset, MaterialParameter, MaterialParameterValue, + MaterialPropertyBlock, MaterialPropertyBlocks, MaterialRef, MaterialShaderKind, NavigationArea, + NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, + PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, + RigidBodyDesc, SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, + TerrainDesc, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, + AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_ANIMATION_CONTROLLER_DESC, + COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, + COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, + COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, + COMPONENT_NAVIGATION_OBSTACLE, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, + COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, + COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, + COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, + COMPONENT_TERRAIN_DESC, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN, }; use crate::history::{ @@ -37,9 +39,9 @@ use crate::history::{ set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot, }; +use crate::ui::materials::material_input_schema_editor; use crate::ui::theme::{ - panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED, - WIDGET_BG, + panel_heading, BORDER, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED, WIDGET_BG, }; use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text}; use crate::viewport::brush_edit::{BrushElementKey, BrushElementSelection}; @@ -52,15 +54,20 @@ use super::component_registry::{ }; use super::helpers::create_scene_sun_override_from_project_settings; use super::EditorTab; -use crate::assets::asset_db::{find_asset_by_path, AssetRegistry}; +use crate::assets::asset_db::{ + ensure_asset_record_at, find_asset_by_path, AssetRegistry, ModelMaterialSelection, +}; use crate::assets::static_mesh::{ load_static_mesh_manifest, material_id_from_label, part_id_from_label, }; use crate::assets::thumbnails::ThumbnailStudio; use crate::assets::{ - asset_cache_key, AssetSelection, AssetSubAssetKind, AssetThumbnailCache, EditorAsset, - EditorAssetKind, EditorAssets, + asset_cache_key, invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection, + AssetSubAssetKind, AssetThumbnailCache, EditorAssetKind, EditorAssets, ASSETS_ROOT, + BUILTINS_FOLDER, }; +use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; +use crate::project_io::ProjectWorkspace; const COMPACT_INSPECTOR_WIDTH: f32 = 360.0; const ASSET_SELECTOR_LABEL_WIDTH: f32 = 72.0; @@ -111,6 +118,11 @@ struct AssetSelectorResponse { accepted_drop: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AssetSelectorActions { + Full, +} + #[derive(Debug, Clone)] struct TextureAssetCandidate { label: String, @@ -136,6 +148,35 @@ pub(crate) struct InspectorPanelState { collapsed_components: HashSet, } +#[derive(Resource, Default, Debug, Clone)] +pub(crate) struct PropertyBlockPromotionReview { + pending: Option, + error: Option, +} + +#[derive(Debug, Clone)] +struct PromotionSourceSnapshot { + path: PathBuf, + snapshot: FileSnapshot, +} + +#[derive(Debug, Clone)] +struct PendingPropertyBlockPromotion { + entity: Entity, + slot_id: ComponentInstanceId, + slot_name: String, + base: MaterialRef, + block: MaterialPropertyBlock, + project_root: PathBuf, + destination_path: PathBuf, + destination_absolute: PathBuf, + target_snapshot: FileSnapshot, + source_snapshots: Vec, + instance: MaterialInstanceAsset, + bytes: Vec, + clear_property_block: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AddComponentShelfDirection { Up, @@ -207,6 +248,8 @@ pub(crate) struct ComponentCardOptions { pub reorderable: bool, pub resettable: bool, pub copyable: bool, + pub summary: &'static str, + pub body_margin: i8, } impl ComponentCardOptions { @@ -220,6 +263,8 @@ impl ComponentCardOptions { reorderable: true, resettable: true, copyable: true, + summary: "Authoring component", + body_margin: 12, } } @@ -233,6 +278,8 @@ impl ComponentCardOptions { reorderable: false, resettable: true, copyable: false, + summary: "Authoring component", + body_margin: 12, } } } @@ -301,4699 +348,53 @@ pub(crate) fn component_card_context( } } -pub(crate) fn apply_component_card_response( - world: &mut World, - entity: Entity, - response: ComponentCardResponse, -) { - let type_name = response_type_name(&response).unwrap_or_default(); - if type_name.is_empty() { - return; - } - apply_component_card_response_for_type(world, entity, type_name, response); -} - -fn apply_component_card_response_for_type( - world: &mut World, - entity: Entity, - type_name: &'static str, - response: ComponentCardResponse, -) { - if let Some(collapsed) = response.collapsed { - let key = component_card_key(world, entity, type_name); - let mut panel_state = world.resource_mut::(); - if collapsed { - panel_state.collapsed_components.insert(key); - } else { - panel_state.collapsed_components.remove(&key); - } - } - - if let Some(active) = response.active { - let mut states = world - .get::(entity) - .cloned() - .unwrap_or_else(|| AuthoringComponentStates { - states: world - .get::(entity) - .map(|order| order.component_states.clone()) - .unwrap_or_default(), - }); - let component_id = world - .resource::() - .stable_id_for_type(type_name); - states.set_component_active(component_id, active); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Set Component Active", - "editor.component_states", - "shared::components::AuthoringComponentStates", - move |world, entity| { - world.entity_mut(entity).insert(states); - Ok(()) - }, - ); - } - - if response.move_up || response.move_down { - let mut order = world - .get::(entity) - .cloned() - .unwrap_or_default(); - let present = present_component_type_names(world, entity); - let offset = if response.move_up { -1 } else { 1 }; - let moved = world - .resource::() - .move_component(&mut order, type_name, offset, &present); - if moved { - set_inspector_order_with_history(world, entity, order); - } - } - - if response.copy { - copy_component(world, entity, type_name); - } - if response.paste { - paste_component(world, entity, type_name); - } - if response.reset { - reset_component(world, entity, type_name); - } - if response.remove { - remove_registered_component(world, entity, type_name); - } -} - -fn response_type_name(_response: &ComponentCardResponse) -> Option<&'static str> { - Some(_response.type_name) -} - -fn component_card_key(world: &World, entity: Entity, type_name: &str) -> String { - let actor_key = world - .get::(entity) - .map(|id| id.0.as_str()) - .filter(|id| !id.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("{entity:?}")); - format!("{actor_key}::{type_name}") -} - -fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static str> { - if let Some(registry) = world.get_resource::() { - return registry - .descriptors - .iter() - .filter(|descriptor| { - !descriptor.hidden - && registry.component_present(world, entity, descriptor.type_name) - }) - .map(|descriptor| descriptor.type_name) - .collect(); - } - let mut present = Vec::new(); - if world.get::(entity).is_some() { - present.push(COMPONENT_ANIMATION_CONTROLLER_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_STATIC_MESH_RENDERER); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_SKINNED_MESH_RENDERER); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_PRIMITIVE); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_BRUSH_DESC); - } - if world.get::(entity).is_some() || world.get::(entity).is_some() { - present.push(COMPONENT_MATERIAL_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_LIGHT_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_AUDIO_SOURCE_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_AUDIO_LISTENER_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_RIGID_BODY_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_COLLIDER_DESC); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_PHYSICS_BODY); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_PLAYER_SPAWN); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_WEAPON_SPAWN); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_TRIGGER_VOLUME); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_TEAM_SPAWN); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_OBJECTIVE_MARKER); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_PREFAB_INSTANCE); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_POST_PROCESS_VOLUME); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_PROJECT_SUN); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_NAVIGATION_BOUNDS); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_NAVIGATION_OBSTACLE); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_NAVIGATION_AREA); - } - if world.get::(entity).is_some() { - present.push(COMPONENT_NAVIGATION_LINK); - } - present -} - -fn copy_component(world: &mut World, entity: Entity, type_name: &str) { - let descriptor = world - .resource::() - .by_type_name(type_name) - .cloned(); - if let Some(descriptor) = descriptor { - if let Ok(Some(value)) = crate::history::capture_reflected_component( - world, - entity, - descriptor.id, - descriptor.type_name, - ) { - world.resource_mut::().component = - Some(CopiedComponent::Reflected(value)); - return; - } - } - let component = match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::AnimationControllerDesc), - COMPONENT_PRIMITIVE => world - .get::(entity) - .cloned() - .map(CopiedComponent::Primitive), - COMPONENT_BRUSH_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::BrushDesc), - COMPONENT_STATIC_MESH_RENDERER => world - .get::(entity) - .cloned() - .map(CopiedComponent::StaticMeshRenderer), - COMPONENT_MATERIAL_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::MaterialDesc), - COMPONENT_LIGHT_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::LightDesc), - COMPONENT_AUDIO_SOURCE_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::AudioSourceDesc), - COMPONENT_AUDIO_LISTENER_DESC => world - .get::(entity) - .copied() - .map(CopiedComponent::AudioListenerDesc), - COMPONENT_RIGID_BODY_DESC => world - .get::(entity) - .copied() - .map(CopiedComponent::RigidBodyDesc), - COMPONENT_COLLIDER_DESC => world - .get::(entity) - .cloned() - .map(CopiedComponent::ColliderDesc), - COMPONENT_PHYSICS_BODY => world - .get::(entity) - .cloned() - .map(CopiedComponent::PhysicsBody), - COMPONENT_WEAPON_SPAWN => world - .get::(entity) - .cloned() - .map(CopiedComponent::WeaponSpawn), - COMPONENT_TRIGGER_VOLUME => world - .get::(entity) - .cloned() - .map(CopiedComponent::TriggerVolume), - COMPONENT_TEAM_SPAWN => world - .get::(entity) - .cloned() - .map(CopiedComponent::TeamSpawn), - COMPONENT_OBJECTIVE_MARKER => world - .get::(entity) - .cloned() - .map(CopiedComponent::ObjectiveMarker), - COMPONENT_POST_PROCESS_VOLUME => world - .get::(entity) - .cloned() - .map(CopiedComponent::PostProcessVolume), - COMPONENT_PREFAB_INSTANCE => world - .get::(entity) - .cloned() - .map(CopiedComponent::PrefabInstance), - COMPONENT_NAVIGATION_BOUNDS => world - .get::(entity) - .cloned() - .map(CopiedComponent::NavigationBounds), - COMPONENT_NAVIGATION_OBSTACLE => world - .get::(entity) - .cloned() - .map(CopiedComponent::NavigationObstacle), - COMPONENT_NAVIGATION_AREA => world - .get::(entity) - .cloned() - .map(CopiedComponent::NavigationArea), - COMPONENT_NAVIGATION_LINK => world - .get::(entity) - .cloned() - .map(CopiedComponent::NavigationLink), - _ => None, - }; - if let Some(component) = component { - world.resource_mut::().component = Some(component); - } -} - -fn paste_component(world: &mut World, entity: Entity, type_name: &str) { - let component = world - .get_resource::() - .and_then(|clipboard| clipboard.component.clone()); - if let Some(CopiedComponent::Reflected(value)) = component.as_ref() { - if value.type_path == type_name { - let value = value.clone(); - let component_id = value.component_id.clone(); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Paste Component", - &component_id, - type_name, - move |world, entity| { - crate::history::apply_reflected_component( - world, - entity, - type_name, - Some(&value), - ) - }, - ); - } - return; - } - match component { - Some(CopiedComponent::Reflected(_)) => {} - Some(CopiedComponent::AnimationControllerDesc(value)) - if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC => - { - set_animation_controller_with_history(world, entity, value); - } - Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => { - set_primitive_with_history(world, entity, value); - } - Some(CopiedComponent::BrushDesc(value)) if type_name == COMPONENT_BRUSH_DESC => { - set_brush_with_history(world, entity, value); - } - Some(CopiedComponent::StaticMeshRenderer(value)) - if type_name == COMPONENT_STATIC_MESH_RENDERER => - { - set_static_mesh_renderer_with_history(world, entity, value); - } - Some(CopiedComponent::MaterialDesc(value)) if type_name == COMPONENT_MATERIAL_DESC => { - set_material_with_history(world, entity, value); - } - Some(CopiedComponent::LightDesc(value)) if type_name == COMPONENT_LIGHT_DESC => { - set_light_with_history(world, entity, value); - } - Some(CopiedComponent::AudioSourceDesc(value)) - if type_name == COMPONENT_AUDIO_SOURCE_DESC => - { - set_audio_source_with_history(world, entity, value); - } - Some(CopiedComponent::AudioListenerDesc(value)) - if type_name == COMPONENT_AUDIO_LISTENER_DESC => - { - set_audio_listener_with_history(world, entity, value); - } - Some(CopiedComponent::RigidBodyDesc(value)) if type_name == COMPONENT_RIGID_BODY_DESC => { - set_rigid_body_with_history(world, entity, value); - } - Some(CopiedComponent::ColliderDesc(value)) if type_name == COMPONENT_COLLIDER_DESC => { - set_collider_with_history(world, entity, value); - } - Some(CopiedComponent::PhysicsBody(value)) if type_name == COMPONENT_PHYSICS_BODY => { - set_physics_with_history(world, entity, value); - } - Some(CopiedComponent::PostProcessVolume(value)) - if type_name == COMPONENT_POST_PROCESS_VOLUME => - { - set_post_process_volume_with_history(world, entity, value); - } - Some(CopiedComponent::WeaponSpawn(value)) if type_name == COMPONENT_WEAPON_SPAWN => { - insert_direct_component(world, entity, value); - } - Some(CopiedComponent::TriggerVolume(value)) if type_name == COMPONENT_TRIGGER_VOLUME => { - insert_direct_component(world, entity, value); - } - Some(CopiedComponent::TeamSpawn(value)) if type_name == COMPONENT_TEAM_SPAWN => { - insert_direct_component(world, entity, value); - } - Some(CopiedComponent::ObjectiveMarker(value)) - if type_name == COMPONENT_OBJECTIVE_MARKER => - { - insert_direct_component(world, entity, value); - } - Some(CopiedComponent::PrefabInstance(value)) if type_name == COMPONENT_PREFAB_INSTANCE => { - insert_direct_component(world, entity, value); - } - Some(CopiedComponent::NavigationBounds(mut value)) - if type_name == COMPONENT_NAVIGATION_BOUNDS => - { - value.artifact_path = world - .get::(entity) - .map(|bounds| bounds.artifact_path.clone()) - .unwrap_or_else(|| navigation_bounds_for_entity(world, entity).artifact_path); - crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - bounds: Some(value), - ..Default::default() - }, - ); - } - Some(CopiedComponent::NavigationObstacle(value)) - if type_name == COMPONENT_NAVIGATION_OBSTACLE => - { - crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - obstacle: Some(value), - ..Default::default() - }, - ); - } - Some(CopiedComponent::NavigationArea(value)) if type_name == COMPONENT_NAVIGATION_AREA => { - crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - area: Some(value), - ..Default::default() - }, - ); - } - Some(CopiedComponent::NavigationLink(value)) if type_name == COMPONENT_NAVIGATION_LINK => { - crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - link: Some(value), - ..Default::default() - }, - ); - } - _ => {} - } -} - -fn reset_component(world: &mut World, entity: Entity, type_name: &str) { - let descriptor = world - .resource::() - .by_type_name(type_name) - .cloned(); - if let Some(descriptor) = descriptor { - let component_id = descriptor.id; - let type_path = descriptor.type_name; - if type_path == COMPONENT_ANIMATION_CONTROLLER_DESC { - let controller = - crate::ui::animation_inspector::default_controller_for_actor(world, entity); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Reset Component", - component_id, - type_path, - move |world, entity| { - world.entity_mut(entity).insert(controller); - Ok(()) - }, - ); - } else if type_path == COMPONENT_NAVIGATION_BOUNDS { - let bounds = reset_navigation_bounds_for_entity(world, entity); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Reset Component", - component_id, - type_path, - move |world, entity| { - world.entity_mut(entity).insert(bounds); - Ok(()) - }, - ); - } else { - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Reset Component", - component_id, - type_path, - move |world, entity| { - crate::history::apply_reflected_default(world, entity, type_path) - }, - ); - } - return; - } - match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC => { - let controller = - crate::ui::animation_inspector::default_controller_for_actor(world, entity); - set_animation_controller_with_history(world, entity, controller); - } - COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()), - COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()), - COMPONENT_STATIC_MESH_RENDERER => { - set_static_mesh_renderer_with_history(world, entity, StaticMeshRenderer::default()); - } - COMPONENT_MATERIAL_DESC => { - set_material_with_history(world, entity, MaterialDesc::default()) - } - COMPONENT_LIGHT_DESC => set_light_with_history(world, entity, LightDesc::default()), - COMPONENT_AUDIO_SOURCE_DESC => { - set_audio_source_with_history(world, entity, AudioSourceDesc::default()) - } - COMPONENT_AUDIO_LISTENER_DESC => { - set_audio_listener_with_history(world, entity, AudioListenerDesc::default()) - } - COMPONENT_RIGID_BODY_DESC => { - set_rigid_body_with_history(world, entity, RigidBodyDesc::default()); - } - COMPONENT_COLLIDER_DESC => { - set_collider_with_history(world, entity, ColliderDesc::default()) - } - COMPONENT_PHYSICS_BODY => set_physics_with_history(world, entity, PhysicsBody::default()), - COMPONENT_POST_PROCESS_VOLUME => { - set_post_process_volume_with_history(world, entity, PostProcessVolumeDesc::default()); - } - COMPONENT_NAVIGATION_BOUNDS => crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - bounds: Some(reset_navigation_bounds_for_entity(world, entity)), - ..Default::default() - }, - ), - COMPONENT_NAVIGATION_OBSTACLE => crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - obstacle: Some(NavigationObstacle::default()), - ..Default::default() - }, - ), - COMPONENT_NAVIGATION_AREA => crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - area: Some(NavigationArea::default()), - ..Default::default() - }, - ), - COMPONENT_NAVIGATION_LINK => crate::history::set_navigation_with_history( - world, - entity, - crate::history::NavigationComponentState { - link: Some(NavigationLink::default()), - ..Default::default() - }, - ), - COMPONENT_WEAPON_SPAWN => insert_direct_component( - world, - entity, - WeaponSpawn { - weapon_id: "rifle".into(), - }, - ), - COMPONENT_TRIGGER_VOLUME => { - insert_direct_component(world, entity, TriggerVolume::default()) - } - COMPONENT_TEAM_SPAWN => insert_direct_component(world, entity, TeamSpawn { team_id: 0 }), - COMPONENT_OBJECTIVE_MARKER => insert_direct_component( - world, - entity, - ObjectiveMarker { - objective_id: "objective".into(), - }, - ), - _ => {} - } -} - -fn insert_direct_component(world: &mut World, entity: Entity, component: T) { - if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - entity_mut.insert(component); - } - if let Some(mut scene_io) = world.get_resource_mut::() { - scene_io.mark_dirty(); - } -} - -fn remove_registered_component(world: &mut World, entity: Entity, type_name: &str) { - let descriptor = world - .resource::() - .by_type_name(type_name) - .cloned(); - if let Some(descriptor) = descriptor { - if !descriptor.removable { - return; - } - let dependents = world - .resource::() - .present_dependents(world, entity, descriptor.id) - .iter() - .map(|dependent| dependent.display_name) - .collect::>(); - if !dependents.is_empty() { - world - .resource_mut::() - .set_status(format!( - "Remove blocked: required by {}. Remove dependent components first.", - dependents.join(", ") - )); - return; - } - let result = crate::history::reflected_component_transaction( - world, - entity, - "Remove Component", - descriptor.id, - descriptor.type_name, - |world, entity| { - crate::history::apply_reflected_component(world, entity, descriptor.type_name, None) - }, - ); - if result.is_ok() && type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { - crate::ui::animation_inspector::stop_preview_if_actor(world, entity); - } - return; - } - let Some(before) = crate::history::snapshot_entity(world, entity) else { - return; - }; - let removed_dedicated_audio_kind = matches!( - (type_name, before.actor_kind), - (COMPONENT_AUDIO_SOURCE_DESC, ActorKind::AudioSource) - | (COMPONENT_AUDIO_LISTENER_DESC, ActorKind::AudioListener) - ); - let removed_dedicated_navigation_kind = before.actor_kind == ActorKind::Navigation - && matches!( - type_name, - COMPONENT_NAVIGATION_BOUNDS - | COMPONENT_NAVIGATION_OBSTACLE - | COMPONENT_NAVIGATION_AREA - | COMPONENT_NAVIGATION_LINK - ); - let removed_dedicated_skinned_kind = - type_name == COMPONENT_SKINNED_MESH_RENDERER && before.actor_kind == ActorKind::SkinnedMesh; - let removed = if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC if before.animation_controller.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_PRIMITIVE if before.primitive.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_BRUSH_DESC if before.brush.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_SKINNED_MESH_RENDERER if before.skinned_mesh_renderer.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_MATERIAL_DESC if before.material.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_LIGHT_DESC if before.light.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_AUDIO_SOURCE_DESC if before.audio_source.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_AUDIO_LISTENER_DESC if before.audio_listener.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_COLLIDER_DESC if before.collider.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_PHYSICS_BODY if before.physics.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_PLAYER_SPAWN if before.player_spawn => { - entity_mut.remove::(); - true - } - COMPONENT_WEAPON_SPAWN if before.weapon_spawn.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_TRIGGER_VOLUME if before.trigger_volume.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_TEAM_SPAWN if before.team_spawn.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_OBJECTIVE_MARKER if before.objective.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_PREFAB_INSTANCE if before.prefab_instance.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_POST_PROCESS_VOLUME if before.post_process_volume.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_NAVIGATION_BOUNDS if before.navigation_bounds.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_NAVIGATION_OBSTACLE if before.navigation_obstacle.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_NAVIGATION_AREA if before.navigation_area.is_some() => { - entity_mut.remove::(); - true - } - COMPONENT_NAVIGATION_LINK if before.navigation_link.is_some() => { - entity_mut.remove::(); - true - } - _ => false, - } - } else { - false - }; - if !removed { - return; - } - if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { - crate::ui::animation_inspector::stop_preview_if_actor(world, entity); - } - if removed_dedicated_audio_kind - || removed_dedicated_navigation_kind - || removed_dedicated_skinned_kind - { - let fallback = world - .get_entity(entity) - .ok() - .and_then(infer_actor_kind) - .unwrap_or(ActorKind::Empty); - if let Ok(mut entity_mut) = world.get_entity_mut(entity) { - entity_mut.insert(fallback); - } - } - if let Some(after) = crate::history::snapshot_entity(world, entity) { - let snapshot = diff_added(&after, &before); - crate::history::push_command( - world, - crate::history::EditorCommand::RemoveComponent { entity, snapshot }, - ); - } -} - -pub fn authoring_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - if !world - .get_entity(entity) - .is_ok_and(|entity_ref| entity_ref.contains::()) - { - return; - } - - let present = present_component_type_names(world, entity); - let ordered = if let Some(order) = world.get::(entity) { - world - .resource::() - .ordered_components(order, &present) - } else { - present - }; - for type_name in ordered { - draw_authoring_component_by_type(world, ui, entity, type_name); - } -} - -fn draw_authoring_component_by_type( - world: &mut World, - ui: &mut egui::Ui, - entity: Entity, - type_name: &'static str, -) { - if let Some(inspector) = world - .resource::() - .inspector(type_name) - { - inspector(world, ui, entity); - return; - } - match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC => { - crate::ui::animation_inspector::animation_controller_inspector_ui(world, ui, entity); - } - COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity), - COMPONENT_SKINNED_MESH_RENDERER => skinned_mesh_renderer_ui(world, ui, entity), - COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity), - COMPONENT_TERRAIN_DESC => terrain_editor_ui(world, ui, entity), - COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity), - COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity), - COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity), - COMPONENT_AUDIO_SOURCE_DESC => { - crate::ui::audio_inspector::audio_source_inspector_ui(world, ui, entity); - } - COMPONENT_AUDIO_LISTENER_DESC => { - crate::ui::audio_inspector::audio_listener_inspector_ui(world, ui, entity); - } - COMPONENT_RIGID_BODY_DESC => rigid_body_editor_ui(world, ui, entity), - COMPONENT_COLLIDER_DESC => collider_editor_ui(world, ui, entity), - COMPONENT_PHYSICS_BODY => physics_editor_ui(world, ui, entity), - COMPONENT_PLAYER_SPAWN => player_spawn_ui(world, ui, entity), - COMPONENT_WEAPON_SPAWN => weapon_spawn_ui(world, ui, entity), - COMPONENT_TRIGGER_VOLUME => trigger_volume_ui(world, ui, entity), - COMPONENT_TEAM_SPAWN => team_spawn_ui(world, ui, entity), - COMPONENT_OBJECTIVE_MARKER => objective_ui(world, ui, entity), - COMPONENT_PREFAB_INSTANCE => prefab_instance_ui(world, ui, entity), - COMPONENT_POST_PROCESS_VOLUME => { - crate::ui::post_process_volume_ui::post_process_volume_inspector_ui(world, ui, entity); - } - COMPONENT_PROJECT_SUN => project_sun_ui(world, ui, entity), - COMPONENT_NAVIGATION_BOUNDS => { - super::navigation_inspector::navigation_bounds_inspector_ui(world, ui, entity) - } - COMPONENT_NAVIGATION_OBSTACLE => { - super::navigation_inspector::navigation_obstacle_inspector_ui(world, ui, entity) - } - COMPONENT_NAVIGATION_AREA => { - super::navigation_inspector::navigation_area_inspector_ui(world, ui, entity) - } - COMPONENT_NAVIGATION_LINK => { - super::navigation_inspector::navigation_link_inspector_ui(world, ui, entity) - } - _ => {} - } -} - -pub(crate) fn add_component_footer(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - ui.add_space(4.0); - let button_response = egui::Frame::new() - .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 6)) - .show(ui, |ui| { - let open_for_entity = add_component_picker_open_for(world, entity); - let label = if open_for_entity { - "Close Add Component" - } else { - "+ Add Component" - }; - if ui.button(label).clicked() { - if let Some(mut state) = world.get_resource_mut::() { - if open_for_entity { - state.add_component_open = false; - state.add_component_target = None; - } else { - state.add_component_open = true; - state.add_component_focus_search = true; - state.add_component_scroll_selected = true; - state.add_component_selected_index = 0; - state.add_component_target = Some(entity); - state.add_component_search.clear(); - } - } - } - }); - - if add_component_picker_open_for(world, entity) { - add_component_picker_shelf(world, ui, entity, button_response.response.rect); - } -} - -fn add_component_picker_open_for(world: &World, entity: Entity) -> bool { - world - .get_resource::() - .is_some_and(|state| state.add_component_open && state.add_component_target == Some(entity)) -} - -fn shelf_list_max_height(visible_space: f32) -> f32 { - (visible_space - 108.0).clamp(56.0, 320.0) -} - -fn add_component_picker_shelf( - world: &mut World, - ui: &mut egui::Ui, - target: Entity, - anchor: egui::Rect, -) { - if world.get_entity(target).is_err() { - if let Some(mut state) = world.get_resource_mut::() { - state.add_component_open = false; - state.add_component_target = None; - } - return; - } - - let descriptors = world - .resource::() - .descriptors - .clone(); - - let visible = ui.clip_rect().intersect(ui.ctx().content_rect()); - let space_above = (anchor.min.y - visible.top()).max(0.0); - let space_below = (visible.bottom() - anchor.max.y).max(0.0); - let direction = if space_below >= space_above { - AddComponentShelfDirection::Down - } else { - AddComponentShelfDirection::Up - }; - let visible_space = match direction { - AddComponentShelfDirection::Up => space_above, - AddComponentShelfDirection::Down => space_below, - }; - let list_max_height = shelf_list_max_height(visible_space); - let shelf_height = (list_max_height + 108.0).min((visible_space - 4.0).max(96.0)); - let x = anchor - .min - .x - .clamp(visible.left(), visible.right() - anchor.width()); - let y = match direction { - AddComponentShelfDirection::Up => anchor.min.y - shelf_height - 4.0, - AddComponentShelfDirection::Down => anchor.max.y + 4.0, - } - .clamp( - visible.top(), - (visible.bottom() - shelf_height).max(visible.top()), - ); - let shelf_width = anchor.width().max(260.0).min(visible.width()); - - egui::Area::new(egui::Id::new(("add_component_shelf", target))) - .order(egui::Order::Foreground) - .fixed_pos(egui::pos2(x, y)) - .show(ui.ctx(), |ui| { - ui.set_width(shelf_width); - add_component_picker_shelf_contents(world, ui, target, &descriptors, list_max_height); - }); -} - -fn add_component_picker_shelf_contents( - world: &mut World, - ui: &mut egui::Ui, - target: Entity, - descriptors: &[EditorComponentDescriptor], - list_max_height: f32, -) { - egui::Frame::new() - .fill(PANEL_BG_DARK) - .stroke(egui::Stroke::new(1.0, BORDER)) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(panel_heading("Add Component")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icon_button_small(ui, icons::X, "Close Add Component").clicked() { - let mut state = world.resource_mut::(); - state.add_component_open = false; - state.add_component_target = None; - } - }); - }); - ui.add_space(4.0); - - let mut search_input = world - .resource::() - .add_component_search - .clone(); - let search_response = ui.add( - egui::TextEdit::singleline(&mut search_input) - .hint_text("Search components...") - .desired_width(f32::INFINITY), - ); - if search_response.changed() { - let mut state = world.resource_mut::(); - state.add_component_search = search_input.clone(); - state.add_component_scroll_selected = true; - state.add_component_selected_index = 0; - } - if world - .resource::() - .add_component_focus_search - { - search_response.request_focus(); - world - .resource_mut::() - .add_component_focus_search = false; - } - - let search = search_input.to_lowercase(); - let filtered = filtered_component_descriptors(descriptors, &search); - { - let mut state = world.resource_mut::(); - if !filtered.is_empty() { - state.add_component_selected_index = - state.add_component_selected_index.min(filtered.len() - 1); - } else { - state.add_component_selected_index = 0; - } - } - - if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) - && !filtered.is_empty() - { - let mut state = world.resource_mut::(); - state.add_component_selected_index = - (state.add_component_selected_index + 1) % filtered.len(); - state.add_component_scroll_selected = true; - } - if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) - && !filtered.is_empty() - { - let mut state = world.resource_mut::(); - state.add_component_selected_index = if state.add_component_selected_index == 0 { - filtered.len() - 1 - } else { - state.add_component_selected_index - 1 - }; - state.add_component_scroll_selected = true; - } - if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - let mut state = world.resource_mut::(); - state.add_component_open = false; - state.add_component_target = None; - } - if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { - let selected_index = world - .resource::() - .add_component_selected_index; - if let Some(descriptor) = filtered.get(selected_index) { - let add_state = component_add_state(world, target, descriptor, descriptors); - if add_state.addable { - insert_registered_component(world, target, descriptor.type_name); - let mut state = world.resource_mut::(); - state.add_component_open = false; - state.add_component_target = None; - } - } - } - - ui.separator(); - if filtered.is_empty() { - ui.label(egui::RichText::new("No matching components").color(TEXT_DIM)); - return; - } - - egui::ScrollArea::vertical() - .max_height(list_max_height) - .show(ui, |ui| { - let (selected_index, scroll_selected) = { - let state = world.resource::(); - ( - state.add_component_selected_index, - state.add_component_scroll_selected, - ) - }; - let mut last_category = None; - for (index, descriptor) in filtered.iter().enumerate() { - if last_category != Some(descriptor.category) { - if last_category.is_some() { - ui.separator(); - } - ui.label(panel_heading(component_category_label(descriptor.category))); - last_category = Some(descriptor.category); - } - - let add_state = component_add_state(world, target, descriptor, descriptors); - let selected = index == selected_index; - let row = ui - .horizontal(|ui| { - ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM)); - ui.add_enabled( - add_state.addable, - egui::Button::selectable(selected, descriptor.display_name), - ) - }) - .inner - .on_hover_text(component_hover_text(descriptor, &add_state)); - if selected && scroll_selected { - row.scroll_to_me(Some(egui::Align::Center)); - } - if row.clicked() { - world - .resource_mut::() - .add_component_selected_index = index; - if add_state.addable { - insert_registered_component(world, target, descriptor.type_name); - let mut state = world.resource_mut::(); - state.add_component_open = false; - state.add_component_target = None; - } - } - } - }); - world - .resource_mut::() - .add_component_scroll_selected = false; - }); -} - -fn filtered_component_descriptors<'a>( - descriptors: &'a [EditorComponentDescriptor], - search: &str, -) -> Vec<&'a EditorComponentDescriptor> { - [ - EditorComponentCategory::Authoring, - EditorComponentCategory::Rendering, - EditorComponentCategory::Animation, - EditorComponentCategory::Audio, - EditorComponentCategory::Navigation, - EditorComponentCategory::Physics, - EditorComponentCategory::Gameplay, - EditorComponentCategory::Volumes, - ] - .into_iter() - .flat_map(|category| { - descriptors.iter().filter(move |descriptor| { - descriptor.addable - && !descriptor.hidden - && descriptor.category == category - && descriptor_matches_search(descriptor, search) - }) - }) - .collect() -} - -fn component_category_label(category: EditorComponentCategory) -> &'static str { - match category { - EditorComponentCategory::Authoring => "Authoring", - EditorComponentCategory::Rendering => "Rendering", - EditorComponentCategory::Animation => "Animation", - EditorComponentCategory::Audio => "Audio", - EditorComponentCategory::Navigation => "Navigation", - EditorComponentCategory::Physics => "Physics", - EditorComponentCategory::Gameplay => "Gameplay", - EditorComponentCategory::Volumes => "Volumes", - EditorComponentCategory::Editor => "Editor", - } -} - -struct ComponentAddState { - addable: bool, - reason: Option, - required: Vec<&'static str>, - recommended: Vec<&'static str>, - conflicts: Vec<&'static str>, -} - -fn descriptor_matches_search(descriptor: &EditorComponentDescriptor, search: &str) -> bool { - search.trim().is_empty() - || descriptor.display_name.to_lowercase().contains(search) - || descriptor.type_name.to_lowercase().contains(search) - || descriptor.description.to_lowercase().contains(search) - || descriptor - .search_terms - .iter() - .any(|term| term.to_lowercase().contains(search)) -} - -fn component_add_state( - world: &World, - entity: Entity, - descriptor: &EditorComponentDescriptor, - descriptors: &[EditorComponentDescriptor], -) -> ComponentAddState { - let duplicate = component_present(world, entity, descriptor.type_name); - let conflicts = descriptor - .conflicts_with - .iter() - .copied() - .filter(|type_name| component_present(world, entity, type_name)) - .collect::>(); - let recommended = descriptor - .recommended - .iter() - .copied() - .filter(|type_name| !component_present(world, entity, type_name)) - .collect::>(); - let registry = world.resource::(); - let required = registry - .required_component_ids(descriptor) - .iter() - .filter_map(|id| registry.by_id(id)) - .filter(|required| !registry.component_present(world, entity, required.type_name)) - .map(|required| required.display_name) - .collect::>(); - let reason = if duplicate { - Some("Already present on this actor.".to_string()) - } else if !required.is_empty() { - Some(format!("Requires {}.", required.join(", "))) - } else if !conflicts.is_empty() { - Some(format!( - "Conflicts with {}.", - conflicts - .iter() - .map(|type_name| component_display_name(descriptors, type_name)) - .collect::>() - .join(", ") - )) - } else { - None - }; - ComponentAddState { - addable: reason.is_none(), - reason, - required, - recommended, - conflicts, - } -} - -fn component_hover_text( - descriptor: &EditorComponentDescriptor, - state: &ComponentAddState, -) -> String { - let mut lines = vec![ - descriptor.description.to_string(), - format!("Type: {}", descriptor.type_name), - format!("Hydration: {}", descriptor.hydration_effect), - format!( - "Inspector: removable={} reorderable={}", - descriptor.removable, descriptor.reorderable - ), - ]; - if let Some(reason) = &state.reason { - lines.push(format!("Unavailable: {reason}")); - } - if !state.required.is_empty() { - lines.push(format!("Requires: {}", state.required.join(", "))); - } - if !state.recommended.is_empty() { - lines.push(format!( - "Recommended with: {}", - state.recommended.join(", ") - )); - } - if !state.conflicts.is_empty() { - lines.push(format!("Conflicts: {}", state.conflicts.join(", "))); - } - lines.push(format!("Search: {}", descriptor.search_terms.join(", "))); - lines.join("\n") -} - -fn component_display_name( - descriptors: &[EditorComponentDescriptor], - type_name: &str, -) -> &'static str { - descriptors - .iter() - .find(|descriptor| descriptor.type_name == type_name) - .map(|descriptor| descriptor.display_name) - .unwrap_or("component") -} - -fn component_present(world: &World, entity: Entity, type_name: &str) -> bool { - if let Some(registry) = world.get_resource::() { - if registry.by_type_name(type_name).is_some() { - return registry.component_present(world, entity, type_name); - } - } - match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC => { - world.get::(entity).is_some() - } - "shared::components::Primitive" => world.get::(entity).is_some(), - "shared::components::BrushDesc" => world.get::(entity).is_some(), - "shared::components::StaticMeshRenderer" => { - world.get::(entity).is_some() - } - COMPONENT_SKINNED_MESH_RENDERER => world.get::(entity).is_some(), - "shared::components::MaterialDesc" => world.get::(entity).is_some(), - "shared::components::LightDesc" => world.get::(entity).is_some(), - "shared::components::AudioSourceDesc" => world.get::(entity).is_some(), - "shared::components::AudioListenerDesc" => world.get::(entity).is_some(), - "shared::components::RigidBodyDesc" => world.get::(entity).is_some(), - "shared::components::ColliderDesc" => world.get::(entity).is_some(), - "shared::components::PlayerSpawn" => world.get::(entity).is_some(), - "shared::components::WeaponSpawn" => world.get::(entity).is_some(), - "shared::components::TriggerVolume" => world.get::(entity).is_some(), - "shared::components::TeamSpawn" => world.get::(entity).is_some(), - "shared::components::ObjectiveMarker" => world.get::(entity).is_some(), - "shared::components::PostProcessVolumeDesc" => { - world.get::(entity).is_some() - } - COMPONENT_NAVIGATION_BOUNDS => world.get::(entity).is_some(), - COMPONENT_NAVIGATION_OBSTACLE => world.get::(entity).is_some(), - COMPONENT_NAVIGATION_AREA => world.get::(entity).is_some(), - COMPONENT_NAVIGATION_LINK => world.get::(entity).is_some(), - _ => false, - } -} - -fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) { - let descriptor = world - .resource::() - .by_type_name(type_name) - .cloned(); - if let Some(descriptor) = descriptor { - if !descriptor.addable || component_present(world, entity, descriptor.type_name) { - return; - } - if descriptor.type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { - let controller = - crate::ui::animation_inspector::default_controller_for_actor(world, entity); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Add Component", - descriptor.id, - descriptor.type_name, - move |world, entity| { - world.entity_mut(entity).insert(controller); - Ok(()) - }, - ); - } else if descriptor.type_name == COMPONENT_NAVIGATION_BOUNDS { - let bounds = navigation_bounds_for_entity(world, entity); - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Add Component", - descriptor.id, - descriptor.type_name, - move |world, entity| { - world.entity_mut(entity).insert(bounds); - Ok(()) - }, - ); - } else { - let _ = crate::history::reflected_component_transaction( - world, - entity, - "Add Component", - descriptor.id, - descriptor.type_name, - move |world, entity| { - crate::history::apply_reflected_default(world, entity, descriptor.type_name) - }, - ); - } - return; - } - match type_name { - COMPONENT_ANIMATION_CONTROLLER_DESC => { - let controller = - crate::ui::animation_inspector::default_controller_for_actor(world, entity); - insert_component(world, entity, move |world, e| { - world.entity_mut(e).insert(controller); - }); - } - "shared::components::Primitive" => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((shared::ActorKind::StaticMesh, Primitive::cuboid(Vec3::ONE))); - }), - "shared::components::BrushDesc" => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((shared::ActorKind::Brush, BrushDesc::default())); - }), - "shared::components::StaticMeshRenderer" => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((shared::ActorKind::StaticMesh, StaticMeshRenderer::default())); - }), - "shared::components::MaterialDesc" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(MaterialDesc::default()); - }), - "shared::components::LightDesc" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(LightDesc::default()); - }), - "shared::components::AudioSourceDesc" => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((shared::ActorKind::AudioSource, AudioSourceDesc::default())); - }), - "shared::components::AudioListenerDesc" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(( - shared::ActorKind::AudioListener, - AudioListenerDesc::default(), - )); - }), - "shared::components::RigidBodyDesc" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(RigidBodyDesc::default()); - }), - "shared::components::ColliderDesc" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(ColliderDesc::default()); - }), - "shared::components::PlayerSpawn" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(PlayerSpawn); - }), - "shared::components::WeaponSpawn" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(WeaponSpawn { - weapon_id: "rifle".into(), - }); - }), - "shared::components::TriggerVolume" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(TriggerVolume::default()); - }), - "shared::components::TeamSpawn" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(TeamSpawn { team_id: 0 }); - }), - "shared::components::ObjectiveMarker" => insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(ObjectiveMarker { - objective_id: "objective".into(), - }); - }), - COMPONENT_NAVIGATION_BOUNDS => insert_component(world, entity, |world, e| { - let bounds = navigation_bounds_for_entity(world, e); - world.entity_mut(e).insert((ActorKind::Navigation, bounds)); - }), - COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((ActorKind::Navigation, NavigationObstacle::default())); - }), - COMPONENT_NAVIGATION_AREA => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((ActorKind::Navigation, NavigationArea::default())); - }), - COMPONENT_NAVIGATION_LINK => insert_component(world, entity, |world, e| { - world - .entity_mut(e) - .insert((ActorKind::Navigation, NavigationLink::default())); - }), - "shared::components::PostProcessVolumeDesc" => { - insert_component(world, entity, |world, e| { - world.entity_mut(e).insert(PostProcessVolumeDesc::default()); - }) - } - _ => {} - } -} - -fn navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds { - world - .get::(entity) - .map(|actor_id| NavigationBounds::for_actor(&actor_id.0)) - .unwrap_or_else(|| NavigationBounds::for_actor(&uuid::Uuid::new_v4().to_string())) -} - -fn reset_navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds { - let mut bounds = navigation_bounds_for_entity(world, entity); - if let Some(current) = world.get::(entity) { - bounds.artifact_path.clone_from(¤t.artifact_path); - } - bounds -} - -fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) { - let before = crate::history::snapshot_entity(world, entity); - let old_kind = before.as_ref().map(|s| s.actor_kind); - insert(world, entity); - let after = crate::history::snapshot_entity(world, entity); - if let (Some(before), Some(after)) = (before, after) { - if let Some(old) = old_kind { - if old != after.actor_kind { - crate::history::set_actor_kind_with_history(world, entity, old, after.actor_kind); - } - } - crate::history::push_command( - world, - crate::history::EditorCommand::AddComponent { - entity, - snapshot: diff_added(&before, &after), - }, - ); - } -} - -fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> EditorEntitySnapshot { - EditorEntitySnapshot { - actor_id: None, - actor_kind: after.actor_kind, - actor_name: None, - name: None, - transform: after.transform, - primitive: after - .primitive - .clone() - .filter(|_| before.primitive.is_none()), - brush: after.brush.clone().filter(|_| before.brush.is_none()), - static_mesh_renderer: after - .static_mesh_renderer - .clone() - .filter(|_| before.static_mesh_renderer.is_none()), - skinned_mesh_renderer: after - .skinned_mesh_renderer - .clone() - .filter(|_| before.skinned_mesh_renderer.is_none()), - material: after.material.clone().filter(|_| before.material.is_none()), - material_override: after - .material_override - .clone() - .filter(|_| before.material_override.is_none()), - rigid_body: after.rigid_body.filter(|_| before.rigid_body.is_none()), - collider: after.collider.clone().filter(|_| before.collider.is_none()), - physics: after.physics.clone().filter(|_| before.physics.is_none()), - light: after.light.clone().filter(|_| before.light.is_none()), - animation_controller: after - .animation_controller - .clone() - .filter(|_| before.animation_controller.is_none()), - audio_source: after - .audio_source - .clone() - .filter(|_| before.audio_source.is_none()), - audio_listener: after - .audio_listener - .filter(|_| before.audio_listener.is_none()), - player_spawn: after.player_spawn && !before.player_spawn, - model: after.model.clone().filter(|_| before.model.is_none()), - prefab: after.prefab.clone().filter(|_| before.prefab.is_none()), - prefab_instance: after - .prefab_instance - .clone() - .filter(|_| before.prefab_instance.is_none()), - weapon_spawn: after - .weapon_spawn - .clone() - .filter(|_| before.weapon_spawn.is_none()), - trigger_volume: after - .trigger_volume - .clone() - .filter(|_| before.trigger_volume.is_none()), - post_process_volume: after - .post_process_volume - .clone() - .filter(|_| before.post_process_volume.is_none()), - team_spawn: after - .team_spawn - .clone() - .filter(|_| before.team_spawn.is_none()), - objective: after - .objective - .clone() - .filter(|_| before.objective.is_none()), - navigation_bounds: after - .navigation_bounds - .clone() - .filter(|_| before.navigation_bounds.is_none()), - navigation_obstacle: after - .navigation_obstacle - .clone() - .filter(|_| before.navigation_obstacle.is_none()), - navigation_area: after - .navigation_area - .clone() - .filter(|_| before.navigation_area.is_none()), - navigation_link: after - .navigation_link - .clone() - .filter(|_| before.navigation_link.is_none()), - hierarchy_sibling_index: after.hierarchy_sibling_index, - editor_visibility: after.editor_visibility, - inspector_order: None, - component_states: None, - children: Vec::new(), - } -} - -fn static_mesh_asset_ref_candidates( - world: &mut World, - kind: AssetRefCandidateKind, -) -> Vec { - let thumbnail_by_path = model_thumbnail_texture_by_path(world); - let Some(registry) = world.get_resource::() else { - return Vec::new(); - }; - let catalog = world.get_resource::(); - let mut seen = HashSet::new(); - let mut candidates = Vec::new(); - - for record in ®istry.records { - let Some(manifest_path) = record.import_settings.static_mesh_manifest_path.as_deref() - else { - continue; - }; - let Ok(manifest) = load_static_mesh_manifest(manifest_path) else { - continue; - }; - let selection = AssetSelection::File(manifest.source.path.clone()); - let texture_id = thumbnail_by_path.get(&manifest.source.path).copied(); - let folder_path = catalog - .and_then(|assets| { - assets - .assets - .iter() - .find(|asset| asset.path.as_deref() == Some(manifest.source.path.as_str())) - .map(|asset| asset.folder_path.clone()) - }) - .unwrap_or_else(|| fallback_asset_folder(&manifest.source.path)); - - for part in &manifest.parts { - let source_material_ref = source_material_ref_for_part(&manifest.asset_id, part); - let candidate = match kind { - AssetRefCandidateKind::Mesh => { - let sub_asset_id = if part.id.trim().is_empty() { - part_id_from_label(&part.mesh_label) - } else { - part.id.clone() - }; - AssetRefCandidate { - reference: EditorAssetRef::new( - manifest.asset_id.clone(), - sub_asset_id, - part.name.clone(), - ), - label: part.name.clone(), - detail: format!("{} | {}", manifest.label, manifest.source.path), - selection: selection.clone(), - folder_path: folder_path.clone(), - texture_id, - } - } - AssetRefCandidateKind::Material => { - let Some(material_ref) = source_material_ref else { - continue; - }; - AssetRefCandidate { - reference: material_ref, - label: part.material_slot_name.clone(), - detail: format!("{} | {}", manifest.label, manifest.source.path), - selection: selection.clone(), - folder_path: folder_path.clone(), - texture_id: None, - } - } - AssetRefCandidateKind::Texture => continue, - }; - - let key = ( - candidate.reference.asset_id.clone(), - candidate.reference.sub_asset_id.clone(), - ); - if seen.insert(key) { - candidates.push(candidate); - } - } - } - - candidates.sort_by(|a, b| a.detail.cmp(&b.detail).then(a.label.cmp(&b.label))); - candidates -} - -fn texture_asset_candidates(world: &World) -> Vec { - let Some(catalog) = world.get_resource::() else { - return Vec::new(); - }; - let snapshot = world - .get_resource::() - .map(AssetThumbnailCache::snapshot); - let mut candidates: Vec<_> = catalog - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) - .filter_map(|asset| { - let path = asset.path.clone()?; - let texture_id = snapshot - .as_ref() - .and_then(|snapshot| snapshot.texture_for(asset)); - Some(TextureAssetCandidate { - label: asset.label.clone(), - path: path.clone(), - folder_path: asset.folder_path.clone(), - selection: AssetSelection::File(path), - texture_id, - }) - }) - .collect(); - candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path))); - candidates -} - -fn texture_asset_ref_candidates(world: &World) -> Vec { - let Some(catalog) = world.get_resource::() else { - return Vec::new(); - }; - let Some(registry) = world.get_resource::() else { - return Vec::new(); - }; - let snapshot = world - .get_resource::() - .map(AssetThumbnailCache::snapshot); - let mut candidates: Vec<_> = catalog - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) - .filter_map(|asset| { - let path = asset.path.as_deref()?; - let record = find_asset_by_path(registry, path)?; - Some(AssetRefCandidate { - reference: EditorAssetRef::new( - record.id.as_string(), - "texture:source", - asset.label.clone(), - ) - .with_source_path(path), - label: asset.label.clone(), - detail: path.to_string(), - selection: AssetSelection::File(path.to_string()), - folder_path: asset.folder_path.clone(), - texture_id: snapshot - .as_ref() - .and_then(|snapshot| snapshot.texture_for(asset)), - }) - }) - .collect(); - candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail))); - candidates -} - -fn brush_face_material_ref_candidates(world: &mut World) -> Vec { - let mut candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Material); - let Some(catalog) = world.get_resource::() else { - return candidates; - }; - let Some(registry) = world.get_resource::() else { - return candidates; - }; - let mut seen: HashSet<_> = candidates - .iter() - .map(|candidate| { - ( - candidate.reference.asset_id.clone(), - candidate.reference.sub_asset_id.clone(), - ) - }) - .collect(); - for asset in catalog - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Material)) - { - let Some(path) = asset.path.as_deref() else { - continue; - }; - let Some(record) = find_asset_by_path(registry, path) else { - continue; - }; - let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() { - "material:instance" - } else { - "material:source" - }; - let reference = EditorAssetRef::new(record.id.as_string(), sub_asset_id, &asset.label) - .with_source_path(path); - let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); - if !seen.insert(key) { - continue; - } - candidates.push(AssetRefCandidate { - reference, - label: asset.label.clone(), - detail: path.to_string(), - selection: AssetSelection::File(path.to_string()), - folder_path: asset.folder_path.clone(), - texture_id: None, - }); - } - candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail))); - candidates -} - -fn asset_ref_candidate_from_selection( - world: &World, - selection: &AssetSelection, - kind: AssetRefCandidateKind, -) -> Option { - let registry = world.get_resource::()?; - match (kind, selection) { - (AssetRefCandidateKind::Material, AssetSelection::File(path)) => { - let asset = world - .get_resource::()? - .assets - .iter() - .find(|asset| { - matches!(asset.kind, EditorAssetKind::Material) - && asset.path.as_deref() == Some(path.as_str()) - })?; - let record = find_asset_by_path(registry, path)?; - let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() { - "material:instance" - } else { - "material:source" - }; - Some(AssetRefCandidate { - reference: EditorAssetRef::new( - record.id.as_string(), - sub_asset_id, - asset.label.clone(), - ) - .with_source_path(path), - label: asset.label.clone(), - detail: path.clone(), - selection: selection.clone(), - folder_path: asset.folder_path.clone(), - texture_id: None, - }) - } - (AssetRefCandidateKind::Texture, AssetSelection::File(path)) => { - let asset = world - .get_resource::()? - .assets - .iter() - .find(|asset| { - matches!(asset.kind, EditorAssetKind::Texture) - && asset.path.as_deref() == Some(path.as_str()) - })?; - let record = find_asset_by_path(registry, path)?; - Some(AssetRefCandidate { - reference: EditorAssetRef::new( - record.id.as_string(), - "texture:source", - asset.label.clone(), - ) - .with_source_path(path), - label: asset.label.clone(), - detail: path.clone(), - selection: selection.clone(), - folder_path: asset.folder_path.clone(), - texture_id: None, - }) - } - ( - AssetRefCandidateKind::Material, - AssetSelection::SubAsset { - parent_path, - sub_asset_id, - label, - kind: AssetSubAssetKind::Material, - .. - }, - ) => { - let record = find_asset_by_path(registry, parent_path)?; - Some(AssetRefCandidate { - reference: EditorAssetRef::new( - record.id.as_string(), - sub_asset_id.clone(), - label.clone(), - ), - label: label.clone(), - detail: parent_path.clone(), - selection: selection.clone(), - folder_path: fallback_asset_folder(parent_path), - texture_id: None, - }) - } - ( - AssetRefCandidateKind::Texture, - AssetSelection::SubAsset { - parent_path, - sub_asset_id, - label, - kind: AssetSubAssetKind::Texture, - source_path, - }, - ) => { - let record = find_asset_by_path(registry, parent_path)?; - Some(AssetRefCandidate { - reference: EditorAssetRef::new( - record.id.as_string(), - sub_asset_id.clone(), - label.clone(), - ) - .with_source_path(source_path.clone().unwrap_or_else(|| parent_path.clone())), - label: label.clone(), - detail: source_path.clone().unwrap_or_else(|| parent_path.clone()), - selection: selection.clone(), - folder_path: fallback_asset_folder(parent_path), - texture_id: None, - }) - } - _ => None, - } -} - -fn request_texture_asset_thumbnails(world: &mut World) { - let requests: Vec<_> = world - .get_resource::() - .map(|assets| { - assets - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) - .filter_map(|asset| Some((asset_cache_key(asset), asset.path.clone()?))) - .collect() - }) - .unwrap_or_default(); - if requests.is_empty() || world.get_resource::().is_none() { - return; - } - let asset_server = world.resource::().clone(); - world.resource_scope(|_world, mut cache: Mut| { - for (key, path) in requests { - cache.request_texture(key, path, &asset_server); - } - }); -} - -fn texture_path_from_selection( - world: &World, - selection: &AssetSelection, -) -> Option { - match selection { - AssetSelection::File(path) => { - let asset = world - .get_resource::()? - .assets - .iter() - .find(|asset| { - matches!(asset.kind, EditorAssetKind::Texture) - && asset.path.as_deref() == Some(path.as_str()) - })?; - Some(TextureAssetCandidate { - label: asset.label.clone(), - path: path.clone(), - folder_path: asset.folder_path.clone(), - selection: selection.clone(), - texture_id: None, - }) - } - AssetSelection::SubAsset { - label, - kind: AssetSubAssetKind::Texture, - source_path: Some(path), - .. - } => { - if let Some(asset) = world.get_resource::().and_then(|assets| { - assets - .assets - .iter() - .find(|asset| asset.path.as_deref() == Some(path.as_str())) - }) { - return Some(TextureAssetCandidate { - label: asset.label.clone(), - path: path.clone(), - folder_path: asset.folder_path.clone(), - selection: selection.clone(), - texture_id: None, - }); - } - Some(TextureAssetCandidate { - label: label.clone(), - path: path.clone(), - folder_path: fallback_asset_folder(path), - selection: selection.clone(), - texture_id: None, - }) - } - _ => None, - } -} - -fn source_material_ref_for_part( - asset_id: &str, - part: &crate::assets::static_mesh::StaticMeshPart, -) -> Option { - let material_label = part.material_label.as_deref()?; - let sub_asset_id = part - .material_id - .clone() - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| material_id_from_label(material_label)); - Some(EditorAssetRef::new( - asset_id.to_string(), - sub_asset_id, - part.material_slot_name.clone(), - )) -} - -fn model_thumbnail_texture_by_path(world: &mut World) -> HashMap { - let model_assets: Vec = world - .get_resource::() - .map(|assets| { - assets - .assets - .iter() - .filter(|asset| matches!(asset.kind, EditorAssetKind::Model)) - .cloned() - .collect() - }) - .unwrap_or_default(); - if model_assets.is_empty() { - return HashMap::new(); - } - if world.get_resource::().is_none() { - return HashMap::new(); - } - - let asset_server = world.resource::().clone(); - world.resource_scope(|world, mut cache: Mut| { - if let Some(mut studio) = world.get_resource_mut::() { - for asset in &model_assets { - let Some(path) = asset.path.as_ref() else { - continue; - }; - cache.request_model( - asset_cache_key(asset), - path.clone(), - &asset_server, - &mut studio, - ); - } - } - }); - - let snapshot = world.resource::().snapshot(); - model_assets - .iter() - .filter_map(|asset| { - Some(( - asset.path.clone()?, - snapshot.texture_ids.get(&asset_cache_key(asset)).copied()?, - )) - }) - .collect() -} - -fn fallback_asset_folder(path: &str) -> String { - Path::new(path) - .parent() - .and_then(|parent| parent.to_str()) - .filter(|folder| !folder.trim().is_empty()) - .unwrap_or(crate::assets::ASSETS_ROOT) - .replace('\\', "/") -} - -fn locate_asset_ref( - world: &mut World, - asset: Option<&EditorAssetRef>, - candidates: &[AssetRefCandidate], -) { - let Some(asset) = asset.filter(|asset| asset.is_resolved()) else { - return; - }; - let Some(candidate) = candidates.iter().find(|candidate| { - candidate.reference.asset_id == asset.asset_id - && candidate.reference.sub_asset_id == asset.sub_asset_id - }) else { - if let Some(mut scene_io) = world.get_resource_mut::() { - scene_io.status = format!("Could not locate imported asset {}", asset.label); - } - return; - }; - - reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); -} - -fn reveal_asset_in_browser(world: &mut World, folder_path: &str, selection: &AssetSelection) { - if let Some(mut assets) = world.get_resource_mut::() { - assets.current_folder = folder_path.to_string(); - assets.select(selection.clone()); - } - super::request_editor_tab(world, EditorTab::AssetBrowser); -} - -fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let material_candidates = brush_face_material_ref_candidates(world); - let Some(mut renderer) = world.get::(entity).cloned() else { - return; - }; - let original = renderer.clone(); - let mut changed = false; - let mut options = ComponentCardOptions::removable( - COMPONENT_SKINNED_MESH_RENDERER, - "Skinned Mesh Renderer", - icons::PERSON_SIMPLE_RUN, - ); - options.active_toggle = false; - options.removable = world.get::(entity).is_none(); - options.resettable = false; - options.copyable = false; - let context = component_card_context(world, entity, options); - let response = component_card(ui, &context, |ui| { - property_row(ui, "Source", |ui| { - ui.add( - egui::Label::new(if renderer.path.trim().is_empty() { - "Unassigned" - } else { - renderer.path.as_str() - }) - .truncate(), - ); - }); - property_row(ui, "Scene", |ui| { - ui.label(renderer.scene_index.to_string()); - }); - property_row(ui, "Asset ID", |ui| { - ui.add( - egui::Label::new(if renderer.asset_id.trim().is_empty() { - "Legacy/path-only" - } else { - renderer.asset_id.as_str() - }) - .truncate(), - ); - }); - ui.label( - egui::RichText::new( - "Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.", - ) - .small() - .color(TEXT_DIM), - ); - ui.add_space(8.0); - ui.label(egui::RichText::new("Material slots").strong()); - if renderer.materials.slots.is_empty() { - ui.label( - egui::RichText::new("No imported slots; reimport the source model") - .small() - .color(TEXT_DIM), - ); - } - for slot in &mut renderer.materials.slots { - slot_card(ui, |ui| { - ui.label(egui::RichText::new(&slot.name).strong()); - let response = asset_selector_row( - ui, - "Material", - icons::PALETTE, - slot.material.as_ref().map(|reference| &reference.0), - slot.source_material.as_ref().map(|reference| &reference.0), - true, - &material_candidates, - None, - ); - if let Some(selected) = response.selected { - slot.material = Some(MaterialRef::new(selected)); - changed = true; - } - if response.clear { - slot.material = None; - changed = true; - } - if response.locate { - let reference = slot - .material - .as_ref() - .or(slot.source_material.as_ref()) - .map(|reference| &reference.0); - locate_asset_ref(world, reference, &material_candidates); - } - ui.label( - egui::RichText::new(format!("ID: {}", slot.id.0)) - .small() - .color(TEXT_DIM), - ); - }); - } - for orphan in &renderer.materials.orphaned_assignments { - ui.label( - egui::RichText::new(format!( - "Orphaned: {} ({})", - orphan.last_known_name, orphan.slot_id.0 - )) - .small() - .color(egui::Color32::YELLOW), - ); - } - if world.get::(entity).is_some() { - ui.label( - egui::RichText::new( - "Remove the Animation Controller before removing its renderer.", - ) - .small() - .color(TEXT_MUTED), - ); - } - }); - apply_component_card_response(world, entity, response); - if changed && renderer != original { - let result = reflected_component_transaction( - world, - entity, - "Assign Skinned Material Slot", - shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, - COMPONENT_SKINNED_MESH_RENDERER, - move |world, entity| { - world.entity_mut(entity).insert(renderer); - Ok(()) - }, - ); - if let Err(error) = result { - world.resource_mut::().status = error; - } - } -} - -fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh); - let material_candidates = brush_face_material_ref_candidates(world); - let Some(mut renderer) = world.get::(entity).cloned() else { - return; - }; - let original = renderer.clone(); - let mut changed = false; - let mut remove_entry = None; - for index in 0..renderer.slots.len() { - ensure_slot_id(&mut renderer.slots[index], index); - let part = &renderer.slots[index]; - if renderer.materials.slot(&part.material_slot_id).is_none() { - renderer.materials.slots.push(shared::RendererMaterialSlot { - id: part.material_slot_id.clone(), - name: part.name.clone(), - source_material: part.material.clone().map(MaterialRef::new), - material: None, - }); - } - } - - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable( - COMPONENT_STATIC_MESH_RENDERER, - "Static Mesh Renderer", - icons::CUBE, - ), - ); - let card_response = component_card(ui, &card, |ui| { - if renderer.slots.is_empty() { - ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM)); - } - - for (index, entry) in renderer.slots.iter_mut().enumerate() { - ensure_slot_id(entry, index); - let material_slot_name = renderer - .materials - .slot(&entry.material_slot_id) - .map(|slot| slot.name.clone()) - .unwrap_or_else(|| "Missing slot".into()); - let thumbnail = thumbnail_for_mesh(&entry.mesh, &mesh_candidates); - - slot_card(ui, |ui| { - slot_header(ui, index, &entry.name, |ui| { - status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active"); - if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() { - remove_entry = Some(index); - } - ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM)); - }); - ui.add_space(6.0); - - let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| { - property_row(ui, "Name", |ui| { - changed |= ui - .add_sized( - [text_field_width(ui), 20.0], - egui::TextEdit::singleline(&mut entry.name), - ) - .changed(); - }); - let mesh_response = asset_selector_row( - ui, - "Mesh", - icons::CUBE, - Some(&entry.mesh), - None, - false, - &mesh_candidates, - None, - ); - if let Some(selected) = mesh_response.selected { - entry.mesh = selected; - changed = true; - } - if mesh_response.locate { - locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates); - } - - property_row(ui, "Material slot", |ui| { - ui.label(&material_slot_name); - }); - ui.horizontal_wrapped(|ui| { - changed |= ui.checkbox(&mut entry.visible, "Visible").changed(); - changed |= ui - .checkbox(&mut entry.cast_shadows, "Cast shadows") - .changed(); - changed |= ui - .checkbox(&mut entry.receive_shadows, "Receive shadows") - .changed(); - }); - }; - - if ui.available_width() < 430.0 { - slot_thumbnail(ui, thumbnail); - ui.add_space(6.0); - draw_fields(ui, entry); - } else { - ui.horizontal(|ui| { - slot_thumbnail(ui, thumbnail); - ui.add_space(10.0); - ui.vertical(|ui| { - ui.set_max_width(ui.available_width()); - draw_fields(ui, entry); - }); - }); - } - }); - } - - ui.add_space(8.0); - ui.label(egui::RichText::new("Material slots").strong()); - for slot in &mut renderer.materials.slots { - slot_card(ui, |ui| { - ui.label(egui::RichText::new(&slot.name).strong()); - let response = asset_selector_row( - ui, - "Material", - icons::PALETTE, - slot.material.as_ref().map(|reference| &reference.0), - slot.source_material.as_ref().map(|reference| &reference.0), - true, - &material_candidates, - None, - ); - if let Some(selected) = response.selected { - slot.material = Some(MaterialRef::new(selected)); - changed = true; - } - if response.clear { - slot.material = None; - changed = true; - } - if response.locate { - let reference = slot - .material - .as_ref() - .or(slot.source_material.as_ref()) - .map(|reference| &reference.0); - locate_asset_ref(world, reference, &material_candidates); - } - ui.label( - egui::RichText::new(format!("ID: {}", slot.id.0)) - .small() - .color(TEXT_DIM), - ); - }); - } - - let add_slot = ui - .add_sized( - [ui.available_width().max(1.0), 28.0], - egui::Button::new("+ Add Slot").fill(ELEVATED_BG.linear_multiply(0.45)), - ) - .clicked(); - if add_slot { - let index = renderer.slots.len(); - let entry = StaticMeshRendererEntry { - id: ComponentInstanceId::new(format!("slot:{index}")), - material_slot_id: ComponentInstanceId::new(format!("slot:manual:{index}")), - ..Default::default() - }; - renderer.materials.slots.push(shared::RendererMaterialSlot { - id: entry.material_slot_id.clone(), - name: format!("Material {index}"), - source_material: None, - material: None, - }); - renderer.slots.push(entry); - changed = true; - } - }); - apply_component_card_response(world, entity, card_response); - - if let Some(index) = remove_entry { - let removed = renderer.slots.remove(index); - if let Some(slot_index) = renderer - .materials - .slots - .iter() - .position(|slot| slot.id == removed.material_slot_id) - { - let slot = renderer.materials.slots.remove(slot_index); - if let Some(material) = slot.material { - renderer - .materials - .orphaned_assignments - .push(shared::OrphanedMaterialAssignment { - slot_id: slot.id, - last_known_name: slot.name, - material, - }); - } - } - changed = true; - } - if changed && renderer != original { - set_static_mesh_renderer_with_history(world, entity, renderer); - } -} - -fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize) { - if entry.id.is_empty() { - entry.id = ComponentInstanceId::new(format!("slot:{index}")); - } - if entry.material_slot_id.is_empty() { - entry.material_slot_id = ComponentInstanceId::new(format!("slot:{}", entry.id.0)); - } -} - -fn slot_card(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { - egui::Frame::new() - .fill(WIDGET_BG.linear_multiply(0.82)) - .stroke(egui::Stroke::new(1.0, BORDER)) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 8)) - .show(ui, |ui| { - ui.set_max_width(ui.available_width()); - add_contents(ui); - }); - ui.add_space(4.0); -} - -fn slot_header( - ui: &mut egui::Ui, - index: usize, - slot_name: &str, - add_actions: impl FnOnce(&mut egui::Ui), -) { - ui.horizontal(|ui| { - ui.label(phosphor_icon(icons::CARET_DOWN, 13.0).color(TEXT_DIM)); - ui.label(format!("Slot {index}")); - let badge = if slot_name.trim().is_empty() { - "Mesh" - } else { - slot_name.trim() - }; - ui.add( - egui::Button::new( - egui::RichText::new(badge).color(egui::Color32::from_rgb(153, 202, 255)), - ) - .fill(SELECTION_BG_MUTED) - .stroke(egui::Stroke::new(1.0, SELECTION_BG_MUTED)) - .corner_radius(4.0), - ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - add_actions, - ); - }); -} - -fn status_dot(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) { - let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::hover()); - ui.painter().circle_filled(rect.center(), 4.0, color); - response.on_hover_text(tooltip); -} - -fn status_dot_button(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) -> egui::Response { - let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::click()); - let color = if response.hovered() { - color.linear_multiply(1.2) - } else { - color - }; - ui.painter().circle_filled(rect.center(), 4.0, color); - response.on_hover_text(tooltip) -} - -fn slot_thumbnail(ui: &mut egui::Ui, texture_id: Option) { - let size = 86.0_f32.min(ui.available_width().max(1.0)); - let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); - ui.painter().rect( - rect, - 4.0, - PANEL_BG_DARK, - egui::Stroke::new(1.0, BORDER), - egui::StrokeKind::Inside, - ); - if let Some(texture_id) = texture_id { - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - ui.painter() - .image(texture_id, rect.shrink(6.0), uv, egui::Color32::WHITE); - } else { - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - icons::CUBE.as_str(), - egui::FontId::new(28.0, egui::FontFamily::Name("phosphor-regular".into())), - TEXT_MUTED, - ); - } -} - -fn thumbnail_for_mesh( - mesh: &EditorAssetRef, - candidates: &[AssetRefCandidate], -) -> Option { - candidates - .iter() - .find(|candidate| { - candidate.reference.asset_id == mesh.asset_id - && candidate.reference.sub_asset_id == mesh.sub_asset_id - }) - .and_then(|candidate| candidate.texture_id) -} - -#[expect( - clippy::too_many_arguments, - reason = "asset selector rows keep immediate-mode UI inputs explicit" -)] -fn asset_selector_row( - ui: &mut egui::Ui, - label: &str, - icon: Icon, - asset: Option<&EditorAssetRef>, - inherited_asset: Option<&EditorAssetRef>, - clearable: bool, - candidates: &[AssetRefCandidate], - drop_candidate: Option<&AssetRefCandidate>, -) -> AssetSelectorResponse { - let mut response = AssetSelectorResponse::default(); - if ui.available_width() < COMPACT_INSPECTOR_WIDTH { - ui.vertical(|ui| { - ui.label(label); - let control_width = ui.available_width().max(1.0); - asset_selector_control( - ui, - icon, - asset, - inherited_asset, - clearable, - control_width, - candidates, - drop_candidate, - &mut response, - ); - }); - } else { - ui.horizontal(|ui| { - let row_width = ui.available_width().max(1.0); - let label_width = ASSET_SELECTOR_LABEL_WIDTH.min(row_width); - let control_width = (row_width - label_width - ui.spacing().item_spacing.x).max(1.0); - ui.add_sized([label_width, 20.0], egui::Label::new(label)); - asset_selector_control( - ui, - icon, - asset, - inherited_asset, - clearable, - control_width, - candidates, - drop_candidate, - &mut response, - ); - }); - } - response -} - -#[expect( - clippy::too_many_arguments, - reason = "asset selector controls keep immediate-mode UI inputs explicit" -)] -fn asset_selector_control( - ui: &mut egui::Ui, - icon: Icon, - asset: Option<&EditorAssetRef>, - inherited_asset: Option<&EditorAssetRef>, - clearable: bool, - control_width: f32, - candidates: &[AssetRefCandidate], - drop_candidate: Option<&AssetRefCandidate>, - response: &mut AssetSelectorResponse, -) { - let display_asset = asset.or(inherited_asset); - let inherited = asset.is_none() && inherited_asset.is_some(); - let selector_width = control_width.min(ui.available_width()).max(1.0); - exact_region( - ui, - egui::vec2(selector_width, ASSET_SELECTOR_HEIGHT), - egui::Layout::top_down(egui::Align::Min), - |ui| { - ui.set_clip_rect(ui.max_rect()); - let rect = ui.max_rect(); - let valid_drag = drop_candidate.is_some(); - let drop_hovered = valid_drag && ui.rect_contains_pointer(rect); - let stroke = if drop_hovered { - egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255)) - } else if valid_drag { - egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122)) - } else { - egui::Stroke::new(1.0, BORDER) - }; - let fill = if drop_hovered { - egui::Color32::from_rgb(29, 57, 86) - } else { - WIDGET_BG.linear_multiply(0.75) - }; - let inner_width = (selector_width - 18.0).max(1.0); - egui::Frame::new() - .fill(fill) - .stroke(stroke) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 6)) - .show(ui, |ui| { - ui.set_width(inner_width); - ui.horizontal(|ui| { - ui.add_sized( - [18.0, 20.0], - egui::Label::new(phosphor_icon(icon, 14.0).color(TEXT_DIM)), - ); - let action_width = if clearable { 78.0 } else { 52.0 }; - let text_width = (ui.available_width() - action_width) - .max(1.0) - .min(ui.available_width().max(1.0)); - ui.vertical(|ui| { - ui.set_max_width(text_width); - let name = display_asset - .map(|asset| asset.label.as_str()) - .filter(|label| !label.trim().is_empty()) - .unwrap_or("(none)"); - ui.add(egui::Label::new(name).truncate()); - let id = display_asset - .map(|asset| { - if inherited { - format!("Source default | {}", asset.sub_asset_id) - } else { - asset.sub_asset_id.clone() - } - }) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| "No imported asset selected".to_string()); - ui.add( - egui::Label::new(egui::RichText::new(id).color(TEXT_DIM)) - .truncate(), - ); - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if clearable { - let clear = ui.add_enabled( - asset.is_some(), - egui::Button::new(phosphor_icon(icons::X, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ); - if clear.on_hover_text("Clear actor override").clicked() { - response.clear = true; - } - } - let locate = ui.add_enabled( - display_asset.is_some_and(EditorAssetRef::is_resolved), - egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ); - if locate.on_hover_text("Locate in content browser").clicked() { - response.locate = true; - } - if candidates.is_empty() { - ui.add_enabled( - false, - egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ) - .on_hover_text("No imported assets available"); - } else { - let menu = - ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| { - ui.set_min_width(220.0); - for candidate in candidates { - let selected = display_asset - .is_some_and(|asset| asset == &candidate.reference); - let clicked = ui - .selectable_label( - selected, - candidate.label.as_str(), - ) - .on_hover_text(candidate.detail.as_str()) - .clicked(); - if clicked { - response.selected = - Some(candidate.reference.clone()); - ui.close(); - } - } - }); - menu.response.on_hover_text("Browse assets"); - } - }); - }); - }); - if drop_hovered && ui.input(|input| input.pointer.any_released()) { - if let Some(candidate) = drop_candidate { - response.selected = Some(candidate.reference.clone()); - response.accepted_drop = true; - } - } - }, - ); -} - -fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool { - let mut changed = false; - property_row(ui, "Shader", |ui| { - egui::ComboBox::from_id_salt("material_shader_kind") - .selected_text(match material.shader.kind { - MaterialShaderKind::StandardLit => "Standard Lit", - MaterialShaderKind::Unlit => "Unlit", - MaterialShaderKind::Custom => "Custom", - }) - .show_ui(ui, |ui| { - changed |= ui - .selectable_value( - &mut material.shader.kind, - MaterialShaderKind::StandardLit, - "Standard Lit", - ) - .changed(); - changed |= ui - .selectable_value( - &mut material.shader.kind, - MaterialShaderKind::Unlit, - "Unlit", - ) - .changed(); - changed |= ui - .selectable_value( - &mut material.shader.kind, - MaterialShaderKind::Custom, - "Custom", - ) - .changed(); - }); - }); - if matches!(material.shader.kind, MaterialShaderKind::Custom) { - changed |= option_string_ui(ui, "Shader schema", &mut material.shader.schema_path); - changed |= option_string_ui(ui, "WGSL shader", &mut material.shader.shader_path); - } - if !material.parameters.is_empty() { - ui.label(panel_heading("Shader Parameters")); - for parameter in &mut material.parameters { - changed |= material_parameter_ui(ui, parameter); - } - } - changed -} - -fn material_parameter_ui(ui: &mut egui::Ui, parameter: &mut MaterialParameter) -> bool { - ui.horizontal_wrapped(|ui| { - ui.label(¶meter.name); - match &mut parameter.value { - MaterialParameterValue::Bool(value) => ui.checkbox(value, "").changed(), - MaterialParameterValue::Float(value) => ui - .add_sized( - [fit_width(ui, 64.0, 120.0), 20.0], - egui::DragValue::new(value) - .speed(0.01) - .min_decimals(2) - .max_decimals(4), - ) - .changed(), - MaterialParameterValue::Vec2(value) => { - let mut changed = false; - changed |= ui - .add_sized( - [fit_width(ui, 52.0, 88.0), 20.0], - egui::DragValue::new(&mut value.x).speed(0.01), - ) - .changed(); - changed |= ui - .add_sized( - [fit_width(ui, 52.0, 88.0), 20.0], - egui::DragValue::new(&mut value.y).speed(0.01), - ) - .changed(); - changed - } - MaterialParameterValue::Vec3(value) => { - let mut changed = false; - changed |= ui - .add_sized( - [fit_width(ui, 52.0, 88.0), 20.0], - egui::DragValue::new(&mut value.x).speed(0.01), - ) - .changed(); - changed |= ui - .add_sized( - [fit_width(ui, 52.0, 88.0), 20.0], - egui::DragValue::new(&mut value.y).speed(0.01), - ) - .changed(); - changed |= ui - .add_sized( - [fit_width(ui, 52.0, 88.0), 20.0], - egui::DragValue::new(&mut value.z).speed(0.01), - ) - .changed(); - changed - } - MaterialParameterValue::Color(value) => { - let mut rgba = [value.r, value.g, value.b, value.a]; - let changed = ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed(); - if changed { - *value = ColorDesc { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3], - }; - } - changed - } - MaterialParameterValue::Enum(value) => ui - .add_sized( - [ - fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH), - 20.0, - ], - egui::TextEdit::singleline(value), - ) - .changed(), - } - }) - .inner -} - -pub(crate) fn component_card( - ui: &mut egui::Ui, - context: &ComponentCardContext, - add_contents: impl FnOnce(&mut egui::Ui), -) -> ComponentCardResponse { - let mut response = ComponentCardResponse { - type_name: context.options.type_name, - ..Default::default() - }; - egui::Frame::new() - .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0, BORDER)) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - let caret = if context.collapsed { - icons::CARET_RIGHT - } else { - icons::CARET_DOWN - }; - if ui - .add( - egui::Button::new(phosphor_icon(caret, 13.0).color(TEXT_DIM)) - .frame(false) - .min_size(egui::vec2(20.0, 20.0)), - ) - .on_hover_text(if context.collapsed { - "Expand component" - } else { - "Collapse component" - }) - .clicked() - { - response.collapsed = Some(!context.collapsed); - } - ui.label(phosphor_icon(context.options.icon, 16.0).color(TEXT_DIM)); - ui.label(panel_heading(context.options.title)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let menu = ui.menu_button( - phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM), - |ui| { - ui.set_min_width(160.0); - if ui - .add_enabled(context.options.resettable, egui::Button::new("Reset")) - .clicked() - { - response.reset = true; - ui.close(); - } - if ui - .add_enabled( - context.options.copyable, - egui::Button::new("Copy Values"), - ) - .clicked() - { - response.copy = true; - ui.close(); - } - if ui - .add_enabled(context.pasteable, egui::Button::new("Paste Values")) - .clicked() - { - response.paste = true; - ui.close(); - } - ui.separator(); - if ui - .add_enabled(context.can_move_up, egui::Button::new("Move Up")) - .clicked() - { - response.move_up = true; - ui.close(); - } - if ui - .add_enabled(context.can_move_down, egui::Button::new("Move Down")) - .clicked() - { - response.move_down = true; - ui.close(); - } - ui.separator(); - ui.add_enabled(false, egui::Button::new("Open Documentation")); - if ui - .add_enabled( - context.options.removable, - egui::Button::new( - egui::RichText::new("Remove") - .color(egui::Color32::from_rgb(255, 137, 129)), - ), - ) - .clicked() - { - response.remove = true; - ui.close(); - } - }, - ); - menu.response.on_hover_text("Component actions"); - if context.options.active_toggle { - if status_dot_button( - ui, - if context.active { - egui::Color32::from_rgb(74, 181, 104) - } else { - TEXT_MUTED - }, - if context.active { - "Component active. Click to disable." - } else { - "Component disabled. Click to enable." - }, - ) - .clicked() - { - response.active = Some(!context.active); - } - } else { - status_dot( - ui, - egui::Color32::from_rgb(74, 181, 104), - "Component active", - ); - } - }); - }); - if !context.collapsed { - ui.add_space(4.0); - egui::Frame::new() - .fill(ELEVATED_BG.linear_multiply(0.35)) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::symmetric(8, 8)) - .show(ui, |ui| { - ui.set_clip_rect(ui.max_rect()); - ui.set_max_width(ui.available_width()); - add_contents(ui); - }); - } - }); - ui.add_space(6.0); - response -} - -pub(crate) fn property_row( - ui: &mut egui::Ui, - label: &str, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - if ui.available_width() < COMPACT_INSPECTOR_WIDTH { - ui.vertical(|ui| { - ui.label(label); - add_contents(ui) - }) - .inner - } else { - ui.horizontal(|ui| { - let label_width = PROPERTY_LABEL_WIDTH.min(ui.available_width().max(1.0)); - ui.add_sized([label_width, 20.0], egui::Label::new(label)); - add_contents(ui) - }) - .inner - } -} - -pub(crate) fn text_field_width(ui: &egui::Ui) -> f32 { - fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH).min(ui.available_width().max(1.0)) -} - -pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let mut material = world - .get::(entity) - .cloned() - .unwrap_or_default(); - request_texture_asset_thumbnails(world); - let texture_candidates = texture_asset_candidates(world); - let original = material.clone(); - let mut changed = false; - let mut options = ComponentCardOptions::removable( - COMPONENT_MATERIAL_DESC, - "Authoring Material", - icons::PALETTE, - ); - options.removable = world.get::(entity).is_some(); - options.copyable = options.removable; - let card = component_card_context(world, entity, options); - let card_response = component_card(ui, &card, |ui| { - changed |= material_shader_ui(ui, &mut material); - let mut color = [ - material.base_color.r, - material.base_color.g, - material.base_color.b, - material.base_color.a, - ]; - property_row(ui, "Base color", |ui| { - if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() { - material.base_color = ColorDesc { - r: color[0], - g: color[1], - b: color[2], - a: color[3], - }; - changed = true; - } - }); - property_row(ui, "Metallic", |ui| { - changed |= ui - .add(egui::Slider::new(&mut material.metallic, 0.0..=1.0)) - .changed(); - }); - property_row(ui, "Roughness", |ui| { - changed |= ui - .add(egui::Slider::new(&mut material.roughness, 0.0..=1.0)) - .changed(); - }); - - let mut emissive_color = [ - material.emissive_color.r, - material.emissive_color.g, - material.emissive_color.b, - material.emissive_color.a, - ]; - property_row(ui, "Emissive", |ui| { - if ui - .color_edit_button_rgba_unmultiplied(&mut emissive_color) - .changed() - { - material.emissive_color = ColorDesc { - r: emissive_color[0], - g: emissive_color[1], - b: emissive_color[2], - a: emissive_color[3], - }; - changed = true; - } - }); - property_row(ui, "Emissive nits", |ui| { - changed |= ui - .add(egui::Slider::new( - &mut material.emissive_intensity, - 0.0..=20_000.0, - )) - .changed(); - }); - - changed |= texture_asset_picker_ui( - world, - ui, - "Base color texture", - &mut material.base_color_texture, - &texture_candidates, - ); - changed |= texture_asset_picker_ui( - world, - ui, - "Emissive texture", - &mut material.emissive_texture, - &texture_candidates, - ); - changed |= texture_asset_picker_ui( - world, - ui, - "Normal map", - &mut material.normal_map_texture, - &texture_candidates, - ); - changed |= texture_asset_picker_ui( - world, - ui, - "Metallic/roughness texture", - &mut material.metallic_roughness_texture, - &texture_candidates, - ); - - if crate::assets::materials::material_asset_picker_ui( - world, - ui, - entity, - &mut material, - &original, - ) { - changed = true; - } - }); - apply_component_card_response(world, entity, card_response); - - if changed && !material_eq(&original, &material) { - set_material_with_history(world, entity, material); - } -} - -fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let material_candidates = brush_face_material_ref_candidates(world); - request_texture_asset_thumbnails(world); - let texture_candidates = texture_asset_ref_candidates(world); - let Some(mut brush) = world.get::(entity).cloned() else { - return; - }; - let original = brush.clone(); - let mut changed = false; - let mut reset_cube = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_BRUSH_DESC, "Brush", icons::CUBE), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Kind", |ui| { - egui::ComboBox::from_id_salt("brush_kind") - .selected_text(match brush.kind { - BrushKind::Additive => "Additive", - BrushKind::SubtractiveMarker => "Subtractive Marker", - }) - .show_ui(ui, |ui| { - changed |= ui - .selectable_value(&mut brush.kind, BrushKind::Additive, "Additive") - .changed(); - changed |= ui - .selectable_value( - &mut brush.kind, - BrushKind::SubtractiveMarker, - "Subtractive Marker", - ) - .changed(); - }); - }); - property_row(ui, "Faces", |ui| { - ui.label(format!("{}", brush.faces.len())); - }); - property_row(ui, "Shadows", |ui| { - ui.horizontal_wrapped(|ui| { - changed |= ui.checkbox(&mut brush.cast_shadows, "Cast").changed(); - changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed(); - }); - }); - brush_validation_ui(ui, &brush); - changed |= selected_brush_face_controls( - world, - ui, - entity, - &mut brush, - &material_candidates, - &texture_candidates, - ); - if ui.button("Reset Cube Brush").clicked() { - reset_cube = true; - } - }); - apply_component_card_response(world, entity, card_response); - - if reset_cube { - brush = BrushDesc::default(); - changed = true; - } - if changed && brush != original { - set_brush_with_history(world, entity, brush); - } -} - -fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let material_candidates = brush_face_material_ref_candidates(world); - let dragging_selection = world - .get_resource::() - .and_then(|assets| assets.dragging_selection().cloned()); - let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| { - asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material) - }); - let Some(mut terrain) = world.get::(entity).cloned() else { - return; - }; - let original = terrain.clone(); - let mut changed = false; - let mut requested_resolution = terrain.resolution; - let mut remove_layer = None; - let mut swap_layers = None; - let mut locate_layer = None; - let mut accepted_drop = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_TERRAIN_DESC, "Terrain", icons::MOUNTAINS), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Grid", |ui| { - ui.horizontal(|ui| { - ui.add( - egui::DragValue::new(&mut requested_resolution) - .range(2..=1025) - .suffix(" samples"), - ); - if ui - .add_enabled( - requested_resolution != terrain.resolution, - egui::Button::new("Resize Flat"), - ) - .on_hover_text("Replaces the current height grid with a flat grid") - .clicked() - { - let replacement = TerrainDesc::flat(requested_resolution); - terrain.resolution = replacement.resolution; - terrain.heights = replacement.heights; - terrain.material_weights.clear(); - terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1); - changed = true; - } - }); - }); - property_row(ui, "Sample Spacing", |ui| { - changed |= ui - .add( - egui::DragValue::new(&mut terrain.sample_spacing) - .range(0.01..=1000.0) - .speed(0.1) - .suffix(" m"), - ) - .changed(); - }); - property_row(ui, "Height Scale", |ui| { - changed |= ui - .add( - egui::DragValue::new(&mut terrain.height_scale) - .range(0.01..=10000.0) - .speed(0.1) - .suffix(" m"), - ) - .changed(); - }); - property_row(ui, "Chunk Size", |ui| { - changed |= ui - .add( - egui::DragValue::new(&mut terrain.chunk_quads) - .range(1..=terrain.resolution.saturating_sub(1)) - .suffix(" quads"), - ) - .changed(); - }); - property_row(ui, "Collision", |ui| { - changed |= ui - .checkbox(&mut terrain.generate_colliders, "Generate") - .changed(); - }); - property_row(ui, "Shadows", |ui| { - ui.horizontal_wrapped(|ui| { - changed |= ui.checkbox(&mut terrain.cast_shadows, "Cast").changed(); - changed |= ui - .checkbox(&mut terrain.receive_shadows, "Receive") - .changed(); - }); - }); - ui.add_space(6.0); - ui.separator(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Material Layers").strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add_enabled( - terrain.material_layers.len() < shared::TERRAIN_MATERIAL_LAYER_LIMIT, - egui::Button::new(phosphor_icon(icons::PLUS, 16.0)), - ) - .on_hover_text("Add a terrain blend channel") - .clicked() - { - let material = if terrain.material_layers.is_empty() { - terrain.base_material.take() - } else { - None - }; - terrain.material_layers.push(shared::TerrainMaterialLayer { - material, - ..Default::default() - }); - changed = true; - } - }); - }); - if terrain.material_layers.is_empty() { - ui.label( - egui::RichText::new( - terrain - .base_material - .as_ref() - .map(|material| format!("Legacy base: {}", material.label)) - .unwrap_or_else(|| "No layers assigned; visible terrain fallback".into()), - ) - .color(TEXT_DIM) - .small(), - ); - } - for index in 0..terrain.material_layers.len() { - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!("Layer {}", index + 1)) - .strong() - .color(if index == 0 { - SELECTION_BG_MUTED - } else { - TEXT_DIM - }), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icon_button_small(ui, icons::TRASH, "Remove layer").clicked() { - remove_layer = Some(index); - } - if ui - .add_enabled( - index + 1 < terrain.material_layers.len(), - egui::Button::new(phosphor_icon(icons::ARROW_DOWN, 15.0)).frame(false), - ) - .on_hover_text("Move layer down") - .clicked() - { - swap_layers = Some((index, index + 1)); - } - if ui - .add_enabled( - index > 0, - egui::Button::new(phosphor_icon(icons::ARROW_UP, 15.0)).frame(false), - ) - .on_hover_text("Move layer up") - .clicked() - { - swap_layers = Some((index, index - 1)); - } - }); - }); - let response = asset_selector_row( - ui, - "Material", - icons::PALETTE, - terrain.material_layers[index].material.as_ref(), - None, - true, - &material_candidates, - material_drop_candidate.as_ref(), - ); - if let Some(selected) = response.selected { - terrain.material_layers[index].material = Some(selected); - changed = true; - } - if response.clear { - terrain.material_layers[index].material = None; - changed = true; - } - if response.locate { - locate_layer = Some(index); - } - accepted_drop |= response.accepted_drop; - property_row(ui, "UV Tiling", |ui| { - changed |= ui - .add( - egui::DragValue::new(&mut terrain.material_layers[index].uv_scale) - .range(0.01..=1024.0) - .speed(0.1) - .suffix("×"), - ) - .changed(); - }); - if !terrain.material_weights.is_empty() { - let covered = terrain - .material_weights - .iter() - .filter(|weights| weights[index] > 0) - .count(); - ui.label( - egui::RichText::new(format!( - "{} / {} samples carry this layer", - covered, - terrain.material_weights.len() - )) - .color(TEXT_DIM) - .small(), - ); - } - } - match terrain.validate() { - Ok(()) => { - ui.label( - egui::RichText::new(format!( - "{} heights • {}×{} chunks", - terrain.heights.len(), - (terrain.resolution - 1).div_ceil(terrain.chunk_quads), - (terrain.resolution - 1).div_ceil(terrain.chunk_quads) - )) - .color(super::theme::SUCCESS), - ); - } - Err(error) => { - ui.label(egui::RichText::new(error).color(super::theme::ERROR)); - } - } - }); - apply_component_card_response(world, entity, card_response); - - if let Some(index) = remove_layer { - remove_terrain_material_layer(&mut terrain, index); - changed = true; - } - if let Some((a, b)) = swap_layers { - terrain.material_layers.swap(a, b); - for weights in &mut terrain.material_weights { - weights.swap(a, b); - } - changed = true; - } - if let Some(index) = locate_layer { - locate_asset_ref( - world, - terrain - .material_layers - .get(index) - .and_then(|layer| layer.material.as_ref()), - &material_candidates, - ); - } - if accepted_drop { - clear_asset_drag(world); - } - - if changed && terrain != original { - let _ = reflected_component_transaction( - world, - entity, - "Edit Terrain", - shared::AUTHORING_COMPONENT_TERRAIN, - COMPONENT_TERRAIN_DESC, - move |world, entity| { - world.entity_mut(entity).insert(terrain); - Ok(()) - }, - ); - } -} - -fn remove_terrain_material_layer(terrain: &mut TerrainDesc, index: usize) { - if index >= terrain.material_layers.len() { - return; - } - terrain.material_layers.remove(index); - if terrain.material_layers.is_empty() { - terrain.material_weights.clear(); - return; - } - for weights in &mut terrain.material_weights { - for channel in index..3 { - weights[channel] = weights[channel + 1]; - } - weights[3] = 0; - normalize_terrain_weights(weights, terrain.material_layers.len()); - } -} - -fn normalize_terrain_weights(weights: &mut [u8; 4], layer_count: usize) { - for weight in weights.iter_mut().skip(layer_count) { - *weight = 0; - } - let sum: u16 = weights - .iter() - .take(layer_count) - .copied() - .map(u16::from) - .sum(); - if sum == 0 { - *weights = [255, 0, 0, 0]; - return; - } - let source = *weights; - let mut assigned = 0_u16; - for index in 0..layer_count { - weights[index] = ((u16::from(source[index]) * 255) / sum) as u8; - assigned += u16::from(weights[index]); - } - let largest = (0..layer_count) - .max_by_key(|index| source[*index]) - .unwrap_or(0); - weights[largest] = weights[largest].saturating_add((255 - assigned) as u8); -} - -fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { - let report = validate_brush(brush); - if report.diagnostics.is_empty() { - return; - } - - ui.add_space(6.0); - egui::Frame::new() - .fill(egui::Color32::from_rgb(31, 32, 36)) - .stroke(egui::Stroke::new(1.0, BORDER)) - .inner_margin(egui::Margin::symmetric(8, 6)) - .show(ui, |ui| { - let has_errors = !report.is_valid(); - let title_color = if has_errors { - egui::Color32::from_rgb(255, 137, 129) - } else { - egui::Color32::from_rgb(255, 190, 110) - }; - ui.horizontal(|ui| { - let icon = if has_errors { - icons::WARNING - } else { - icons::INFO - }; - ui.label(phosphor_icon(icon, 14.0).color(title_color)); - ui.label( - egui::RichText::new(if has_errors { - "Brush geometry needs repair" - } else { - "Brush diagnostics" - }) - .color(title_color), - ); - }); - for diagnostic in report.diagnostics.iter().take(4) { - let color = match diagnostic.severity { - BrushDiagnosticSeverity::Error => egui::Color32::from_rgb(255, 137, 129), - BrushDiagnosticSeverity::Warning => egui::Color32::from_rgb(255, 190, 110), - }; - let face = diagnostic - .face - .as_ref() - .filter(|face| !face.is_empty()) - .map(|face| format!("{}: ", face.0)) - .unwrap_or_default(); - ui.label(egui::RichText::new(format!("{face}{}", diagnostic.message)).color(color)); - } - let hidden_count = report.diagnostics.len().saturating_sub(4); - if hidden_count > 0 { - ui.label(egui::RichText::new(format!("+{hidden_count} more")).color(TEXT_MUTED)); - } - }); -} - -fn selected_brush_face_controls( - world: &mut World, - ui: &mut egui::Ui, - entity: Entity, - brush: &mut BrushDesc, - material_candidates: &[AssetRefCandidate], - texture_candidates: &[AssetRefCandidate], -) -> bool { - let Some(selection) = world.get_resource::() else { - return false; - }; - if selection.brush != Some(entity) { - return false; - } - let selected_faces: Vec<_> = selection - .elements - .iter() - .filter_map(|element| match element { - BrushElementKey::Face { face } => Some(face.clone()), - _ => None, - }) - .collect(); - if selected_faces.is_empty() { - return false; - } - - let dragging_selection = world - .get_resource::() - .and_then(|assets| assets.dragging_selection().cloned()); - let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| { - asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material) - }); - let texture_drop_candidate = dragging_selection.as_ref().and_then(|selection| { - asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Texture) - }); - let mut changed = false; - ui.add_space(6.0); - ui.separator(); - ui.label( - egui::RichText::new(format!("{} selected face(s)", selected_faces.len())).color(TEXT_DIM), - ); - - for face_id in selected_faces { - let Some(face) = brush.faces.iter_mut().find(|face| face.id == face_id) else { - continue; - }; - let label = if face.id.0.trim().is_empty() { - "Face".to_string() - } else { - face.id.0.clone() - }; - egui::CollapsingHeader::new(label) - .default_open(true) - .show(ui, |ui| { - property_row(ui, "UV offset", |ui| { - changed |= ui - .add(egui::DragValue::new(&mut face.uv_offset.x).speed(0.05)) - .changed(); - changed |= ui - .add(egui::DragValue::new(&mut face.uv_offset.y).speed(0.05)) - .changed(); - }); - property_row(ui, "UV scale", |ui| { - changed |= ui - .add(egui::DragValue::new(&mut face.uv_scale.x).speed(0.05)) - .changed(); - changed |= ui - .add(egui::DragValue::new(&mut face.uv_scale.y).speed(0.05)) - .changed(); - }); - property_row(ui, "UV rotation", |ui| { - changed |= ui - .add(egui::DragValue::new(&mut face.uv_rotation).speed(1.0)) - .changed(); - }); - let material_response = asset_selector_row( - ui, - "Material", - icons::PALETTE, - face.material.as_ref(), - None, - true, - material_candidates, - material_drop_candidate.as_ref(), - ); - if let Some(selected) = material_response.selected { - face.material = Some(selected); - changed = true; - } - if material_response.clear { - face.material = None; - changed = true; - } - if material_response.locate { - locate_asset_ref(world, face.material.as_ref(), material_candidates); - } - if material_response.accepted_drop { - clear_asset_drag(world); - } - - let texture_response = asset_selector_row( - ui, - "Texture", - icons::IMAGE, - face.texture.as_ref(), - None, - true, - texture_candidates, - texture_drop_candidate.as_ref(), - ); - if let Some(selected) = texture_response.selected { - face.texture = Some(selected); - changed = true; - } - if texture_response.clear { - face.texture = None; - changed = true; - } - if texture_response.locate { - locate_asset_ref(world, face.texture.as_ref(), texture_candidates); - } - if texture_response.accepted_drop { - clear_asset_drag(world); - } - }); - } - - changed -} - -fn clear_asset_drag(world: &mut World) { - if let Some(mut assets) = world.get_resource_mut::() { - assets.clear_drag(); - } -} - -fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut primitive) = world.get::(entity).cloned() else { - return; - }; - let _original = primitive.clone(); - let mut changed = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_PRIMITIVE, "Primitive", icons::CUBE), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Shape", |ui| { - ui.horizontal_wrapped(|ui| { - for (label, shape) in [ - ("Box", PrimitiveShape::Box), - ("Sphere", PrimitiveShape::Sphere), - ("Ramp", PrimitiveShape::Ramp), - ] { - changed |= ui - .selectable_value(&mut primitive.shape, shape, label) - .changed(); - } - }); - }); - property_row(ui, "Size X", |ui| { - changed |= ui - .add(egui::Slider::new(&mut primitive.size.x, 0.1..=20.0)) - .changed(); - }); - property_row(ui, "Size Y", |ui| { - changed |= ui - .add(egui::Slider::new(&mut primitive.size.y, 0.1..=20.0)) - .changed(); - }); - property_row(ui, "Size Z", |ui| { - changed |= ui - .add(egui::Slider::new(&mut primitive.size.z, 0.1..=20.0)) - .changed(); - }); - }); - apply_component_card_response(world, entity, card_response); - if changed { - set_primitive_with_history(world, entity, primitive); - } -} - -fn light_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut light) = world.get::(entity).cloned() else { - return; - }; - let _original = light.clone(); - let mut changed = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_LIGHT_DESC, "Light", icons::LIGHTBULB), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Kind", |ui| { - ui.horizontal_wrapped(|ui| { - for (label, kind) in [ - ("Point", AuthoringLightKind::Point), - ("Spot", AuthoringLightKind::Spot), - ("Directional", AuthoringLightKind::Directional), - ] { - if ui.selectable_label(light.kind == kind, label).clicked() { - let color = light.color; - light = LightDesc { - color, - ..LightDesc::for_kind(kind) - }; - changed = true; - } - } - }); - }); - let solari_disabled = solari_disables_local_light(world, &light); - if solari_disabled { - ui.colored_label( - egui::Color32::from_rgb(255, 180, 100), - "Point and spot lights are disabled while Solari is active. Use a directional light or emissive material, or switch GI to Forward.", - ); - } - ui.add_enabled_ui(!solari_disabled, |ui| { - let mut color = [light.color.r, light.color.g, light.color.b, light.color.a]; - property_row(ui, "Color", |ui| { - if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() { - light.color = ColorDesc { - r: color[0], - g: color[1], - b: color[2], - a: color[3], - }; - changed = true; - } - }); - let intensity_label = match light.kind { - AuthoringLightKind::Directional => "Intensity (lux)", - _ => "Intensity (lumens)", - }; - let intensity_max = match light.kind { - AuthoringLightKind::Directional => AUTHORING_DIRECTIONAL_LUX_MAX, - _ => AUTHORING_POINT_SPOT_LUMENS_MAX, - }; - property_row(ui, intensity_label, |ui| { - ui.horizontal_wrapped(|ui| { - if ui - .add( - egui::Slider::new(&mut light.intensity, 0.0..=intensity_max) - .show_value(false), - ) - .changed() - { - changed = true; - } - if ui - .add_sized( - [fit_width(ui, 72.0, 120.0), 20.0], - egui::DragValue::new(&mut light.intensity) - .range(0.0..=intensity_max) - .speed(intensity_max * 0.001), - ) - .changed() - { - changed = true; - } - }); - }); - if matches!( - light.kind, - AuthoringLightKind::Point | AuthoringLightKind::Spot - ) { - property_row(ui, "Range (m)", |ui| { - changed |= ui - .add(egui::Slider::new(&mut light.range, 0.0..=100.0)) - .changed(); - }); - } - if matches!(light.kind, AuthoringLightKind::Spot) { - property_row(ui, "Inner angle", |ui| { - changed |= ui - .add(egui::Slider::new(&mut light.inner_angle_deg, 1.0..=80.0)) - .changed(); - }); - property_row(ui, "Outer angle", |ui| { - changed |= ui - .add(egui::Slider::new(&mut light.outer_angle_deg, 1.0..=90.0)) - .changed(); - }); - } - property_row(ui, "Shadows", |ui| { - changed |= ui.checkbox(&mut light.shadows, "Cast shadows").changed(); - }); - if matches!(light.kind, AuthoringLightKind::Directional) { - ui.small("Controls project sun while this directional exists."); - } - }); - }); - apply_component_card_response(world, entity, card_response); - if changed { - set_light_with_history(world, entity, light); - } -} - -fn solari_disables_local_light(world: &World, light: &LightDesc) -> bool { - matches!( - light.kind, - AuthoringLightKind::Point | AuthoringLightKind::Spot - ) && world - .get_resource::() - .is_some_and(|profile| profile.gi_path == settings::GiPath::SolariDeferred) - && world - .get_resource::() - .is_some_and(|caps| caps.rt_supported) -} - -fn rigid_body_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut body) = world.get::(entity).copied() else { - return; - }; - let original = body; - let mut changed = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_RIGID_BODY_DESC, "Rigid Body", icons::SPHERE), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Body", |ui| { - ui.horizontal_wrapped(|ui| { - for (label, kind) in [ - ("Static", AuthoringRigidBody::Static), - ("Kinematic", AuthoringRigidBody::Kinematic), - ("Dynamic", AuthoringRigidBody::Dynamic), - ] { - changed |= ui.selectable_value(&mut body.body, kind, label).changed(); - } - }); - }); - }); - apply_component_card_response(world, entity, card_response); - if changed && body != original { - set_rigid_body_with_history(world, entity, body); - } -} - -fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh); - let diagnostic_entry = diagnose_collider(world, entity); - let Some(mut collider) = world.get::(entity).cloned() else { - return; - }; - let original = collider.clone(); - let mut changed = false; - let renderer_meshes: Vec = world - .get::(entity) - .map(|renderer| { - renderer - .slots - .iter() - .map(|slot| slot.mesh.clone()) - .collect() - }) - .unwrap_or_default(); - - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_COLLIDER_DESC, "Collider", icons::SELECTION), - ); - let card_response = component_card(ui, &card, |ui| { - if let Some(entry) = diagnostic_entry.as_ref() { - let (label, color) = match entry.overlay_status { - ColliderOverlayStatus::Valid => ("Ready", egui::Color32::from_rgb(112, 210, 144)), - ColliderOverlayStatus::Trigger => { - ("Trigger", egui::Color32::from_rgb(86, 195, 235)) - } - ColliderOverlayStatus::Disabled => { - ("Disabled", egui::Color32::from_rgb(143, 151, 163)) - } - ColliderOverlayStatus::Warning => { - ("Warning", egui::Color32::from_rgb(242, 173, 72)) - } - ColliderOverlayStatus::Error => ("Invalid", egui::Color32::from_rgb(244, 91, 99)), - }; - ui.horizontal_wrapped(|ui| { - ui.colored_label(color, egui::RichText::new(label).strong()); - ui.label(egui::RichText::new(entry.shape_label).color(TEXT_DIM)); - if entry.runtime_ready { - ui.label(egui::RichText::new("Hydrated").color(TEXT_MUTED).small()); - } - }); - for diagnostic in &entry.diagnostics { - let color = match diagnostic.severity { - ColliderDiagnosticSeverity::Info => TEXT_MUTED, - ColliderDiagnosticSeverity::Warning => egui::Color32::from_rgb(242, 173, 72), - ColliderDiagnosticSeverity::Error => egui::Color32::from_rgb(244, 91, 99), - }; - ui.label( - egui::RichText::new(&diagnostic.message) - .color(color) - .small(), - ); - ui.label( - egui::RichText::new(&diagnostic.repair) - .color(TEXT_MUTED) - .small(), - ); - } - if entry.highest_severity() == Some(ColliderDiagnosticSeverity::Error) - && ui.small_button("Reset shape").clicked() - { - collider.shape = ColliderShapeDesc::default(); - collider.enabled = true; - changed = true; - } - ui.add_space(2.0); - } - property_row(ui, "Mode", |ui| { - changed |= ui.checkbox(&mut collider.enabled, "Enabled").changed(); - changed |= ui.checkbox(&mut collider.is_trigger, "Trigger").changed(); - }); - property_row(ui, "Shape", |ui| { - let mut shape_kind = collider_shape_kind(&collider.shape); - egui::ComboBox::from_id_salt(("collider_shape_kind", entity)) - .selected_text(shape_kind) - .show_ui(ui, |ui| { - for label in ["Box", "Sphere", "Capsule", "Static Mesh"] { - if ui.selectable_label(shape_kind == label, label).clicked() { - shape_kind = label; - } - } - }); - if shape_kind != collider_shape_kind(&collider.shape) { - collider.shape = - convert_collider_shape(&collider.shape, shape_kind, renderer_meshes.clone()); - changed = true; - } - }); - - match &mut collider.shape { - ColliderShapeDesc::Cuboid { - x_length, - y_length, - z_length, - } => { - changed |= dimension_drag(ui, "X", x_length); - changed |= dimension_drag(ui, "Y", y_length); - changed |= dimension_drag(ui, "Z", z_length); - } - ColliderShapeDesc::Sphere { radius } => { - changed |= dimension_drag(ui, "Radius", radius); - } - ColliderShapeDesc::Capsule { radius, height } => { - changed |= dimension_drag(ui, "Radius", radius); - changed |= dimension_drag(ui, "Height", height); - } - ColliderShapeDesc::StaticMesh { meshes, .. } => { - if meshes.is_empty() { - ui.label(egui::RichText::new("No mesh collider sources").color(TEXT_DIM)); - } - for mesh in meshes.iter_mut() { - let mesh_response = asset_selector_row( - ui, - "Mesh", - icons::CUBE, - Some(mesh), - None, - false, - &mesh_candidates, - None, - ); - if let Some(selected) = mesh_response.selected { - *mesh = selected; - changed = true; - } - if mesh_response.locate { - locate_asset_ref(world, Some(mesh), &mesh_candidates); - } - } - if !renderer_meshes.is_empty() && ui.button("Use renderer meshes").clicked() { - *meshes = renderer_meshes.clone(); - changed = true; - } - } - } - }); - apply_component_card_response(world, entity, card_response); - - if changed && collider != original { - set_collider_with_history(world, entity, collider); - } -} - -fn collider_shape_kind(shape: &ColliderShapeDesc) -> &'static str { - match shape { - ColliderShapeDesc::Cuboid { .. } => "Box", - ColliderShapeDesc::Sphere { .. } => "Sphere", - ColliderShapeDesc::Capsule { .. } => "Capsule", - ColliderShapeDesc::StaticMesh { .. } => "Static Mesh", - } -} - -fn convert_collider_shape( - previous: &ColliderShapeDesc, - shape_kind: &str, - renderer_meshes: Vec, -) -> ColliderShapeDesc { - let dimensions = match previous { - ColliderShapeDesc::Cuboid { - x_length, - y_length, - z_length, - } => Vec3::new(*x_length, *y_length, *z_length), - ColliderShapeDesc::Sphere { radius } => Vec3::splat(*radius * 2.0), - ColliderShapeDesc::Capsule { radius, height } => { - Vec3::new(*radius * 2.0, *height, *radius * 2.0) - } - ColliderShapeDesc::StaticMesh { .. } => Vec3::ONE, - } - .max(Vec3::splat(0.001)); - - match shape_kind { - "Sphere" => ColliderShapeDesc::Sphere { - radius: dimensions.max_element() * 0.5, - }, - "Capsule" => ColliderShapeDesc::Capsule { - radius: dimensions.x.max(dimensions.z) * 0.5, - height: dimensions.y, - }, - "Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes), - _ => ColliderShapeDesc::Cuboid { - x_length: dimensions.x, - y_length: dimensions.y, - z_length: dimensions.z, - }, - } -} - -fn dimension_drag(ui: &mut egui::Ui, label: &str, value: &mut f32) -> bool { - property_row(ui, label, |ui| { - ui.add_sized( - [fit_width(ui, 72.0, 120.0), 20.0], - egui::DragValue::new(value) - .range(0.001..=10_000.0) - .speed(0.05) - .min_decimals(2) - .max_decimals(3), - ) - .changed() - }) -} - -fn physics_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut body) = world.get::(entity).cloned() else { - return; - }; - let _original = body.clone(); - let mut changed = false; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_PHYSICS_BODY, "Physics", icons::SPHERE), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Body", |ui| { - ui.horizontal_wrapped(|ui| { - for (label, kind) in [ - ("Static", AuthoringRigidBody::Static), - ("Kinematic", AuthoringRigidBody::Kinematic), - ("Dynamic", AuthoringRigidBody::Dynamic), - ] { - if ui.selectable_label(body.body == kind, label).clicked() { - body.body = kind; - changed = true; - } - } - }); - }); - }); - apply_component_card_response(world, entity, card_response); - if changed { - set_physics_with_history(world, entity, body); - } -} - -fn player_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - if world.get::(entity).is_none() { - return; - } - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable( - COMPONENT_PLAYER_SPAWN, - "Player Spawn", - icons::PERSON_SIMPLE_RUN, - ), - ); - let card_response = component_card(ui, &card, |ui| { - ui.label( - egui::RichText::new("Uses this actor transform as a player start.").color(TEXT_DIM), - ); - }); - apply_component_card_response(world, entity, card_response); -} - -fn weapon_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut spawn) = world.get::(entity).cloned() else { - return; - }; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_WEAPON_SPAWN, "Weapon Spawn", icons::CROSSHAIR), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Weapon ID", |ui| { - ui.add_sized( - [text_field_width(ui), 20.0], - egui::TextEdit::singleline(&mut spawn.weapon_id), - ); - }); - }); - apply_component_card_response(world, entity, card_response); - if let Ok(mut e) = world.get_entity_mut(entity) { - e.insert(spawn); - } -} - -fn trigger_volume_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut trigger) = world.get::(entity).cloned() else { - return; - }; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable( - COMPONENT_TRIGGER_VOLUME, - "Trigger Volume", - icons::SELECTION, - ), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Event", |ui| { - ui.add_sized( - [text_field_width(ui), 20.0], - egui::TextEdit::singleline(&mut trigger.event_name), - ); - }); - property_row(ui, "Half X", |ui| { - ui.add(egui::Slider::new(&mut trigger.half_extents.x, 0.1..=50.0)); - }); - property_row(ui, "Half Y", |ui| { - ui.add(egui::Slider::new(&mut trigger.half_extents.y, 0.1..=50.0)); - }); - property_row(ui, "Half Z", |ui| { - ui.add(egui::Slider::new(&mut trigger.half_extents.z, 0.1..=50.0)); - }); - }); - apply_component_card_response(world, entity, card_response); - if let Ok(mut e) = world.get_entity_mut(entity) { - e.insert(trigger); - } -} - -fn team_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut spawn) = world.get::(entity).cloned() else { - return; - }; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_TEAM_SPAWN, "Team Spawn", icons::FLAG), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Team", |ui| { - ui.add(egui::Slider::new(&mut spawn.team_id, 0..=8)); - }); - }); - apply_component_card_response(world, entity, card_response); - if let Ok(mut e) = world.get_entity_mut(entity) { - e.insert(spawn); - } -} - -fn objective_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let Some(mut marker) = world.get::(entity).cloned() else { - return; - }; - let card = component_card_context( - world, - entity, - ComponentCardOptions::removable(COMPONENT_OBJECTIVE_MARKER, "Objective", icons::TARGET), - ); - let card_response = component_card(ui, &card, |ui| { - property_row(ui, "Objective ID", |ui| { - ui.add_sized( - [text_field_width(ui), 20.0], - egui::TextEdit::singleline(&mut marker.objective_id), - ); - }); - }); - apply_component_card_response(world, entity, card_response); - if let Ok(mut e) = world.get_entity_mut(entity) { - e.insert(marker); - } -} - -fn prefab_instance_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - crate::assets::prefab_overrides::prefab_instance_inspector_ui(world, ui, entity); -} - -pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - if world.get::(entity).is_none() { - return; - } - - let rendering = world - .resource::() - .rendering - .clone(); - let scene_override_active = has_scene_sun_override(world); - let mut create_override = false; - let mut options = ComponentCardOptions::fixed(COMPONENT_PROJECT_SUN, "Project Sun", icons::SUN); - options.resettable = false; - let card = component_card_context(world, entity, options); - let card_response = component_card(ui, &card, |ui| { - ui.label("Runtime default world lighting from assets/project.ron."); - property_row(ui, "Illuminance", |ui| { - ui.small(format!("{:.0} lux", rendering.sun_illuminance)); - }); - property_row(ui, "Ambient", |ui| { - ui.small(format!( - "{:.2}, {:.2}, {:.2} @ {:.1}", - rendering.ambient_color[0], - rendering.ambient_color[1], - rendering.ambient_color[2], - rendering.ambient_brightness - )); - }); - property_row(ui, "Shadows", |ui| { - ui.small(format!( - "{} cascades, max {:.0}m", - rendering.shadow_cascades, rendering.shadow_max_distance - )); - }); - if scene_override_active { - ui.label(egui::RichText::new("Scene sun override is active.").weak()); - } else if ui.button("Create Scene Sun Override").clicked() { - create_override = true; - } - }); - apply_component_card_response(world, entity, card_response); - - if create_override { - let sun = create_scene_sun_override_from_project_settings(world); - crate::ui::request_ui_selection(world, &[sun]); - } -} - -fn has_scene_sun_override(world: &mut World) -> bool { - world - .query_filtered::<( - &shared::LightDesc, - Option<&AuthoringComponentStates>, - Option<&InspectorOrder>, - ), With>() - .iter(world) - .any(|(light, states, legacy_order)| { - authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC) - && matches!(light.kind, shared::AuthoringLightKind::Directional) - }) -} - -fn texture_asset_picker_ui( - world: &mut World, - ui: &mut egui::Ui, - label: &str, - value: &mut Option, - candidates: &[TextureAssetCandidate], -) -> bool { - let current_path = value.clone(); - let dragging_selection = world - .get_resource::() - .and_then(|assets| assets.dragging_selection().cloned()); - let drop_candidate = dragging_selection - .as_ref() - .and_then(|selection| texture_path_from_selection(world, selection)); - let mut selected_path: Option = None; - let mut clear = false; - let mut locate = false; - let mut accepted_drop = false; - let current_candidate = current_path.as_ref().and_then(|path| { - candidates - .iter() - .find(|candidate| candidate.path == *path) - .cloned() - }); - - property_row(ui, label, |ui| { - let control_width = ui - .available_width() - .max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0))); - let row_height = 30.0; - let (rect, _response) = - ui.allocate_exact_size(egui::vec2(control_width, row_height), egui::Sense::hover()); - let valid_drag = drop_candidate.is_some(); - let row_hovered = ui.rect_contains_pointer(rect); - let drop_hovered = valid_drag && row_hovered; - let stroke = if drop_hovered { - egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255)) - } else if valid_drag { - egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122)) - } else if row_hovered { - egui::Stroke::new(1.0, egui::Color32::from_rgb(92, 102, 118)) - } else { - egui::Stroke::new(1.0, BORDER) - }; - let fill = if drop_hovered { - egui::Color32::from_rgb(29, 57, 86) - } else if valid_drag { - WIDGET_BG.linear_multiply(0.88) - } else if row_hovered { - WIDGET_BG.linear_multiply(1.05) - } else { - WIDGET_BG.linear_multiply(0.75) - }; - ui.painter() - .rect(rect, 4.0, fill, stroke, egui::StrokeKind::Inside); - if drop_hovered { - let badge_rect = egui::Rect::from_min_size( - rect.right_top() + egui::vec2(-82.0, 4.0), - egui::vec2(74.0, 16.0), - ); - ui.painter().rect( - badge_rect, - 3.0, - egui::Color32::from_rgb(35, 95, 155), - egui::Stroke::NONE, - egui::StrokeKind::Inside, - ); - ui.painter().text( - badge_rect.center(), - egui::Align2::CENTER_CENTER, - "Drop texture", - egui::FontId::new(10.0, egui::FontFamily::Proportional), - egui::Color32::from_rgb(225, 241, 255), - ); - } - - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(rect.shrink2(egui::vec2(6.0, 4.0))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - child.set_clip_rect(rect); - let preview_size = 22.0; - let (preview_rect, _preview_response) = - child.allocate_exact_size(egui::vec2(preview_size, preview_size), egui::Sense::hover()); - child.painter().rect( - preview_rect, - 3.0, - PANEL_BG_DARK, - egui::Stroke::new(1.0, BORDER), - egui::StrokeKind::Inside, - ); - if let Some(texture_id) = current_candidate - .as_ref() - .and_then(|candidate| candidate.texture_id) - { - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - child.painter().image( - texture_id, - preview_rect.shrink(1.0), - uv, - egui::Color32::WHITE, - ); - } else { - child.painter().text( - preview_rect.center(), - egui::Align2::CENTER_CENTER, - icons::IMAGE.as_str(), - egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), - TEXT_DIM, - ); - } - - let action_width = 92.0; - let text_width = (child.available_width() - action_width).max(1.0); - child.allocate_ui_with_layout( - egui::vec2(text_width, 22.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let display_label = current_candidate - .as_ref() - .map(|candidate| candidate.label.as_str()) - .or(current_path.as_deref()) - .unwrap_or("(none)"); - ui.add_sized( - [text_width, 20.0], - egui::Label::new(egui::RichText::new(display_label).color( - if current_path.is_some() { - TEXT_DIM.linear_multiply(1.45) - } else { - TEXT_DIM - }, - )) - .truncate(), - ); - }, - ); - - child.allocate_ui_with_layout( - egui::vec2(action_width, 28.0), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - let clear_response = ui.add_enabled( - current_path.is_some(), - egui::Button::new(phosphor_icon(icons::X, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ); - if clear_response.on_hover_text("Clear texture").clicked() { - clear = true; - } - let locate_response = ui.add_enabled( - current_path.is_some(), - egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ); - if locate_response - .on_hover_text("Locate in content browser") - .clicked() - { - locate = true; - } - if candidates.is_empty() { - ui.add_enabled( - false, - egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0)) - .frame(false) - .min_size(egui::vec2(22.0, 22.0)), - ) - .on_hover_text("No texture assets available"); - } else { - let menu = ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| { - ui.set_min_width(240.0); - for candidate in candidates { - let selected = current_path.as_deref() == Some(candidate.path.as_str()); - if ui - .selectable_label(selected, candidate.label.as_str()) - .on_hover_text(candidate.path.as_str()) - .clicked() - { - selected_path = Some(candidate.path.clone()); - ui.close(); - } - } - }); - menu.response.on_hover_text("Browse textures"); - } - }, - ); - - if drop_hovered && ui.input(|input| input.pointer.any_released()) { - if let Some(candidate) = drop_candidate.as_ref() { - selected_path = Some(candidate.path.clone()); - accepted_drop = true; - } - } - }); - - if locate { - locate_texture_asset(world, current_path.as_deref(), candidates); - } - if accepted_drop { - if let Some(mut assets) = world.get_resource_mut::() { - assets.clear_drag(); - } - } - if clear { - *value = None; - return current_path.is_some(); - } - if let Some(path) = selected_path { - let changed = current_path.as_deref() != Some(path.as_str()); - *value = Some(path); - return changed; - } - - false -} - -fn locate_texture_asset( - world: &mut World, - path: Option<&str>, - candidates: &[TextureAssetCandidate], -) { - let Some(path) = path else { - return; - }; - let Some(candidate) = candidates.iter().find(|candidate| candidate.path == path) else { - if let Some(mut scene_io) = world.get_resource_mut::() { - scene_io.status = format!("Could not locate texture {path}"); - } - return; - }; - - reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); -} - -fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option) -> bool { - let mut text = value.clone().unwrap_or_default(); - let before = text.clone(); - property_row(ui, label, |ui| { - ui.horizontal_wrapped(|ui| { - let clear_width = 52.0; - let field_width = (ui.available_width() - clear_width - ui.spacing().item_spacing.x) - .max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0))) - .min(TEXT_FIELD_MAX_WIDTH) - .min(ui.available_width().max(1.0)); - ui.add_sized([field_width, 20.0], egui::TextEdit::singleline(&mut text)); - if ui.button("Clear").clicked() { - text.clear(); - } - }); - }); - let changed = before != text; - *value = if text.trim().is_empty() { - None - } else { - Some(text) - }; - changed -} +#[path = "inspector/asset_selectors.rs"] +mod asset_selectors; +#[path = "inspector/brush_terrain.rs"] +mod brush_terrain; +#[path = "inspector/component_add.rs"] +mod component_add; +#[path = "inspector/component_chrome.rs"] +mod component_chrome; +#[path = "inspector/component_lifecycle.rs"] +mod component_lifecycle; +#[path = "inspector/dispatch.rs"] +mod dispatch; +#[path = "inspector/imported_material_preview.rs"] +mod imported_material_preview; +#[path = "inspector/material_slots.rs"] +mod material_slots; +#[path = "inspector/mesh_renderers.rs"] +mod mesh_renderers; +#[path = "inspector/primitive_domains.rs"] +mod primitive_domains; +#[path = "inspector/property_blocks.rs"] +mod property_blocks; +#[path = "inspector/renderer_panel.rs"] +mod renderer_panel; +#[path = "inspector/sun_textures.rs"] +mod sun_textures; + +use asset_selectors::*; +use brush_terrain::*; +use component_add::*; +use component_chrome::*; +pub(crate) use component_chrome::{component_card, property_row, text_field_width}; +pub(crate) use component_lifecycle::apply_component_card_response; +use component_lifecycle::*; +pub use dispatch::authoring_inspector_ui; +pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors}; +use material_slots::*; +use mesh_renderers::*; +use primitive_domains::*; +pub(crate) use property_blocks::property_block_promotion_review_ui; +use property_blocks::*; +use renderer_panel::*; +use sun_textures::*; #[cfg(test)] -mod collider_inspector_tests { +mod tests { use super::*; - use crate::history::{apply_command_undo, EditorHistory}; - use crate::ui::{DockTabRequest, UiState}; - - #[test] - fn component_collapse_persists_while_ui_state_is_scoped_out() { - let mut world = World::new(); - world.init_resource::(); - world.init_resource::(); - let entity = world - .spawn((ActorId::new("collapsed-component"), Transform::IDENTITY)) - .id(); - world.insert_resource(UiState::default_layout()); - - world.resource_scope::(|world, _ui_state| { - assert!(!world.contains_resource::()); - apply_component_card_response( - world, - entity, - ComponentCardResponse { - type_name: COMPONENT_TRANSFORM, - collapsed: Some(true), - ..Default::default() - }, - ); - let context = component_card_context( - world, - entity, - ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::CUBE), - ); - assert!(context.collapsed); - }); - } - - #[test] - fn locate_texture_requests_asset_browser_while_ui_state_is_scoped_out() { - let path = "assets/textures/scoped-locate.png"; - let folder = "assets/textures"; - let selection = AssetSelection::File(path.to_string()); - let mut world = World::new(); - world.insert_resource(EditorAssets { - folders: Vec::new(), - assets: vec![EditorAsset { - label: "Scoped Locate".into(), - path: Some(path.into()), - folder_path: folder.into(), - kind: EditorAssetKind::Texture, - }], - current_folder: crate::assets::ASSETS_ROOT.into(), - selected: None, - dragging: None, - status: String::new(), - }); - world.init_resource::(); - world.insert_resource(UiState::default_layout()); - let candidate = TextureAssetCandidate { - label: "Scoped Locate".into(), - path: path.into(), - folder_path: folder.into(), - selection: selection.clone(), - texture_id: None, - }; - - world.resource_scope::(|world, _ui_state| { - assert!(!world.contains_resource::()); - locate_texture_asset(world, Some(path), &[candidate]); - }); - - let assets = world.resource::(); - assert_eq!(assets.current_folder, folder); - assert_eq!(assets.selected.as_ref(), Some(&selection)); - assert_eq!( - world.resource::().0, - Some(EditorTab::AssetBrowser) - ); - } - - #[test] - fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() { - let mut world = World::new(); - world.init_resource::(); - let original = ColliderDesc::static_cuboid(Vec3::new(2.0, 4.0, 6.0)); - let entity = world.spawn((LevelObject, original.clone())).id(); - - let sphere = ColliderDesc { - shape: convert_collider_shape(&original.shape, "Sphere", Vec::new()), - ..original.clone() - }; - assert_eq!(sphere.shape, ColliderShapeDesc::Sphere { radius: 3.0 }); - - set_collider_with_history(&mut world, entity, sphere.clone()); - assert_eq!(world.resource::().undo_depth(), 1); - assert_eq!(world.get::(entity), Some(&sphere)); - - apply_command_undo(&mut world); - assert_eq!(world.get::(entity), Some(&original)); - } + include!("inspector/tests/a.rs"); + include!("inspector/tests/b.rs"); } diff --git a/crates/editor/src/ui/inspector/asset_selectors.rs b/crates/editor/src/ui/inspector/asset_selectors.rs new file mode 100644 index 0000000..5c2f61b --- /dev/null +++ b/crates/editor/src/ui/inspector/asset_selectors.rs @@ -0,0 +1,544 @@ +use super::*; + +pub(super) fn static_mesh_asset_ref_candidates( + world: &mut World, + kind: AssetRefCandidateKind, +) -> Vec { + let mesh_thumbnails = mesh_thumbnail_texture_by_asset_ref(world); + let Some(registry) = world.get_resource::() else { + return Vec::new(); + }; + let catalog = world.get_resource::(); + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + + for record in ®istry.records { + let Some(settings) = record.import_settings.model() else { + continue; + }; + let Some(manifest_path) = settings.static_mesh_manifest_path.as_deref() else { + continue; + }; + let Ok(manifest) = load_static_mesh_manifest(manifest_path) else { + continue; + }; + let selection = AssetSelection::File(manifest.source.path.clone()); + let folder_path = catalog + .and_then(|assets| { + assets + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(manifest.source.path.as_str())) + .map(|asset| asset.folder_path.clone()) + }) + .unwrap_or_else(|| fallback_asset_folder(&manifest.source.path)); + + for part in &manifest.parts { + let source_material_ref = source_material_ref_for_part(&manifest.asset_id, part); + let candidate = match kind { + AssetRefCandidateKind::Mesh => { + let sub_asset_id = if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id.clone() + }; + AssetRefCandidate { + reference: EditorAssetRef::new( + manifest.asset_id.clone(), + sub_asset_id.clone(), + part.name.clone(), + ), + label: part.name.clone(), + detail: format!("{} | {}", manifest.label, manifest.source.path), + selection: selection.clone(), + folder_path: folder_path.clone(), + texture_id: mesh_thumbnails + .get(&(manifest.asset_id.clone(), sub_asset_id.clone())) + .copied(), + } + } + AssetRefCandidateKind::Material => { + let Some(material_ref) = source_material_ref else { + continue; + }; + AssetRefCandidate { + reference: material_ref, + label: part.material_slot_name.clone(), + detail: format!("{} | {}", manifest.label, manifest.source.path), + selection: selection.clone(), + folder_path: folder_path.clone(), + texture_id: None, + } + } + AssetRefCandidateKind::Texture => continue, + }; + + let key = ( + candidate.reference.asset_id.clone(), + candidate.reference.sub_asset_id.clone(), + ); + if seen.insert(key) { + candidates.push(candidate); + } + } + } + + candidates.sort_by(|a, b| a.detail.cmp(&b.detail).then(a.label.cmp(&b.label))); + candidates +} + +pub(super) fn texture_asset_candidates(world: &World) -> Vec { + let Some(catalog) = world.get_resource::() else { + return Vec::new(); + }; + let snapshot = world + .get_resource::() + .map(AssetThumbnailCache::snapshot); + let mut candidates: Vec<_> = catalog + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) + .filter_map(|asset| { + let path = asset.path.clone()?; + let texture_id = snapshot + .as_ref() + .and_then(|snapshot| snapshot.texture_for(asset)); + Some(TextureAssetCandidate { + label: asset.label.clone(), + path: path.clone(), + folder_path: asset.folder_path.clone(), + selection: AssetSelection::File(path), + texture_id, + }) + }) + .collect(); + candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path))); + candidates +} + +pub(super) fn texture_asset_ref_candidates(world: &World) -> Vec { + let Some(catalog) = world.get_resource::() else { + return Vec::new(); + }; + let Some(registry) = world.get_resource::() else { + return Vec::new(); + }; + let snapshot = world + .get_resource::() + .map(AssetThumbnailCache::snapshot); + let mut candidates: Vec<_> = catalog + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) + .filter_map(|asset| { + let path = asset.path.as_deref()?; + let record = find_asset_by_path(registry, path)?; + Some(AssetRefCandidate { + reference: EditorAssetRef::new( + record.id.as_string(), + "texture:source", + asset.label.clone(), + ) + .with_source_path(path), + label: asset.label.clone(), + detail: path.to_string(), + selection: AssetSelection::File(path.to_string()), + folder_path: asset.folder_path.clone(), + texture_id: snapshot + .as_ref() + .and_then(|snapshot| snapshot.texture_for(asset)), + }) + }) + .collect(); + candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail))); + candidates +} + +pub(super) fn brush_face_material_ref_candidates(world: &mut World) -> Vec { + let mut candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Material); + let material_assets = world + .get_resource::() + .map(|catalog| { + catalog + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Material)) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + prefetch_asset_thumbnails(world, &material_assets); + let thumbnail_snapshot = world + .get_resource::() + .map(AssetThumbnailCache::snapshot); + let Some(registry) = world.get_resource::() else { + return candidates; + }; + let mut seen: HashSet<_> = candidates + .iter() + .map(|candidate| { + ( + candidate.reference.asset_id.clone(), + candidate.reference.sub_asset_id.clone(), + ) + }) + .collect(); + for asset in &material_assets { + let Some(path) = asset.path.as_deref() else { + continue; + }; + let Some(record) = find_asset_by_path(registry, path) else { + continue; + }; + let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() { + "material:instance" + } else { + "material:source" + }; + let reference = EditorAssetRef::new(record.id.as_string(), sub_asset_id, &asset.label) + .with_source_path(path); + let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); + if !seen.insert(key) { + continue; + } + candidates.push(AssetRefCandidate { + reference, + label: asset.label.clone(), + detail: path.to_string(), + selection: AssetSelection::File(path.to_string()), + folder_path: asset.folder_path.clone(), + texture_id: thumbnail_snapshot + .as_ref() + .and_then(|snapshot| snapshot.texture_for(asset)), + }); + } + candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail))); + candidates +} + +pub(super) fn asset_ref_candidate_from_selection( + world: &World, + selection: &AssetSelection, + kind: AssetRefCandidateKind, +) -> Option { + let registry = world.get_resource::()?; + match (kind, selection) { + (AssetRefCandidateKind::Material, AssetSelection::File(path)) => { + let asset = world + .get_resource::()? + .assets + .iter() + .find(|asset| { + matches!(asset.kind, EditorAssetKind::Material) + && asset.path.as_deref() == Some(path.as_str()) + })?; + let record = find_asset_by_path(registry, path)?; + let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() { + "material:instance" + } else { + "material:source" + }; + Some(AssetRefCandidate { + reference: EditorAssetRef::new( + record.id.as_string(), + sub_asset_id, + asset.label.clone(), + ) + .with_source_path(path), + label: asset.label.clone(), + detail: path.clone(), + selection: selection.clone(), + folder_path: asset.folder_path.clone(), + texture_id: None, + }) + } + (AssetRefCandidateKind::Texture, AssetSelection::File(path)) => { + let asset = world + .get_resource::()? + .assets + .iter() + .find(|asset| { + matches!(asset.kind, EditorAssetKind::Texture) + && asset.path.as_deref() == Some(path.as_str()) + })?; + let record = find_asset_by_path(registry, path)?; + Some(AssetRefCandidate { + reference: EditorAssetRef::new( + record.id.as_string(), + "texture:source", + asset.label.clone(), + ) + .with_source_path(path), + label: asset.label.clone(), + detail: path.clone(), + selection: selection.clone(), + folder_path: asset.folder_path.clone(), + texture_id: None, + }) + } + ( + AssetRefCandidateKind::Material, + AssetSelection::SubAsset { + parent_path, + sub_asset_id, + label, + kind: AssetSubAssetKind::Material, + .. + }, + ) => { + let record = find_asset_by_path(registry, parent_path)?; + Some(AssetRefCandidate { + reference: EditorAssetRef::new( + record.id.as_string(), + sub_asset_id.clone(), + label.clone(), + ), + label: label.clone(), + detail: parent_path.clone(), + selection: selection.clone(), + folder_path: fallback_asset_folder(parent_path), + texture_id: None, + }) + } + ( + AssetRefCandidateKind::Texture, + AssetSelection::SubAsset { + parent_path, + sub_asset_id, + label, + kind: AssetSubAssetKind::Texture, + source_path, + }, + ) => { + let record = find_asset_by_path(registry, parent_path)?; + Some(AssetRefCandidate { + reference: EditorAssetRef::new( + record.id.as_string(), + sub_asset_id.clone(), + label.clone(), + ) + .with_source_path(source_path.clone().unwrap_or_else(|| parent_path.clone())), + label: label.clone(), + detail: source_path.clone().unwrap_or_else(|| parent_path.clone()), + selection: selection.clone(), + folder_path: fallback_asset_folder(parent_path), + texture_id: None, + }) + } + _ => None, + } +} + +pub(super) fn request_texture_asset_thumbnails(world: &mut World) { + let requests: Vec<_> = world + .get_resource::() + .map(|assets| { + assets + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) + .filter_map(|asset| Some((asset_cache_key(asset), asset.path.clone()?))) + .collect() + }) + .unwrap_or_default(); + if requests.is_empty() || world.get_resource::().is_none() { + return; + } + let asset_server = world.resource::().clone(); + world.resource_scope(|_world, mut cache: Mut| { + for (key, path) in requests { + cache.request_texture(key, path, &asset_server); + } + }); +} + +pub(super) fn texture_path_from_selection( + world: &World, + selection: &AssetSelection, +) -> Option { + match selection { + AssetSelection::File(path) => { + let asset = world + .get_resource::()? + .assets + .iter() + .find(|asset| { + matches!(asset.kind, EditorAssetKind::Texture) + && asset.path.as_deref() == Some(path.as_str()) + })?; + Some(TextureAssetCandidate { + label: asset.label.clone(), + path: path.clone(), + folder_path: asset.folder_path.clone(), + selection: selection.clone(), + texture_id: None, + }) + } + AssetSelection::SubAsset { + label, + kind: AssetSubAssetKind::Texture, + source_path: Some(path), + .. + } => { + if let Some(asset) = world.get_resource::().and_then(|assets| { + assets + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(path.as_str())) + }) { + return Some(TextureAssetCandidate { + label: asset.label.clone(), + path: path.clone(), + folder_path: asset.folder_path.clone(), + selection: selection.clone(), + texture_id: None, + }); + } + Some(TextureAssetCandidate { + label: label.clone(), + path: path.clone(), + folder_path: fallback_asset_folder(path), + selection: selection.clone(), + texture_id: None, + }) + } + _ => None, + } +} + +pub(super) fn source_material_ref_for_part( + asset_id: &str, + part: &crate::assets::static_mesh::StaticMeshPart, +) -> Option { + let material_label = part.material_label.as_deref()?; + let sub_asset_id = part + .material_id + .clone() + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(|| material_id_from_label(material_label)); + Some(EditorAssetRef::new( + asset_id.to_string(), + sub_asset_id, + part.material_slot_name.clone(), + )) +} + +pub(super) fn mesh_thumbnail_texture_by_asset_ref( + world: &mut World, +) -> HashMap<(String, String), egui::TextureId> { + let requests = world + .get_resource::() + .map(|registry| { + registry + .records + .iter() + .filter_map(|record| { + let manifest_path = record + .import_settings + .model()? + .static_mesh_manifest_path + .as_deref()?; + load_static_mesh_manifest(manifest_path).ok() + }) + .flat_map(|manifest| { + let legacy_skinned = if manifest.schema_version < 2 { + crate::assets::gltf_skinned_primitive_labels(&manifest.source.path) + } else { + HashSet::new() + }; + manifest.parts.into_iter().map(move |part| { + let sub_asset_id = if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id.clone() + }; + let key = + format!("subasset:{}#mesh:{}", manifest.source.path, sub_asset_id); + let requires_scene_instance = manifest.metadata.animation_count > 0 + || part.skinned + || legacy_skinned.contains(&part.mesh_label); + ( + manifest.asset_id.clone(), + sub_asset_id, + key, + manifest.source.path.clone(), + part.mesh_label, + part.material_label, + requires_scene_instance, + ) + }) + }) + .collect::>() + }) + .unwrap_or_default(); + if requests.is_empty() || world.get_resource::().is_none() { + return HashMap::new(); + } + world.resource_scope(|world, mut cache: Mut| { + if let Some(mut studio) = world.get_resource_mut::() { + for (_, _, key, path, mesh_label, material_label, requires_scene) in &requests { + cache.request_mesh_subasset( + key.clone(), + path.clone(), + mesh_label.clone(), + material_label.clone(), + *requires_scene, + &mut studio, + ); + } + } + }); + let snapshot = world.resource::().snapshot(); + requests + .into_iter() + .filter_map(|(asset_id, sub_asset_id, key, ..)| { + snapshot + .texture_ids + .get(&key) + .copied() + .map(|texture| ((asset_id, sub_asset_id), texture)) + }) + .collect() +} + +pub(super) fn fallback_asset_folder(path: &str) -> String { + Path::new(path) + .parent() + .and_then(|parent| parent.to_str()) + .filter(|folder| !folder.trim().is_empty()) + .unwrap_or(crate::assets::ASSETS_ROOT) + .replace('\\', "/") +} + +pub(super) fn locate_asset_ref( + world: &mut World, + asset: Option<&EditorAssetRef>, + candidates: &[AssetRefCandidate], +) { + let Some(asset) = asset.filter(|asset| asset.is_resolved()) else { + return; + }; + let Some(candidate) = candidates.iter().find(|candidate| { + candidate.reference.asset_id == asset.asset_id + && candidate.reference.sub_asset_id == asset.sub_asset_id + }) else { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = format!("Could not locate imported asset {}", asset.label); + } + return; + }; + + reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); +} + +pub(super) fn reveal_asset_in_browser( + world: &mut World, + folder_path: &str, + selection: &AssetSelection, +) { + if let Some(mut assets) = world.get_resource_mut::() { + assets.current_folder = folder_path.to_string(); + assets.select(selection.clone()); + } + crate::ui::request_editor_tab(world, EditorTab::AssetBrowser); +} diff --git a/crates/editor/src/ui/inspector/brush_terrain.rs b/crates/editor/src/ui/inspector/brush_terrain.rs new file mode 100644 index 0000000..54df14a --- /dev/null +++ b/crates/editor/src/ui/inspector/brush_terrain.rs @@ -0,0 +1,578 @@ +use super::*; + +pub(super) fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let material_candidates = brush_face_material_ref_candidates(world); + request_texture_asset_thumbnails(world); + let texture_candidates = texture_asset_ref_candidates(world); + let Some(mut brush) = world.get::(entity).cloned() else { + return; + }; + let original = brush.clone(); + let mut changed = false; + let mut reset_cube = false; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_BRUSH_DESC, "Brush", icons::CUBE), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Kind", |ui| { + egui::ComboBox::from_id_salt("brush_kind") + .selected_text(match brush.kind { + BrushKind::Additive => "Additive", + BrushKind::SubtractiveMarker => "Subtractive Marker", + }) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value(&mut brush.kind, BrushKind::Additive, "Additive") + .changed(); + changed |= ui + .selectable_value( + &mut brush.kind, + BrushKind::SubtractiveMarker, + "Subtractive Marker", + ) + .changed(); + }); + }); + property_row(ui, "Faces", |ui| { + ui.label(format!("{}", brush.faces.len())); + }); + property_row(ui, "Shadows", |ui| { + ui.horizontal_wrapped(|ui| { + changed |= ui.checkbox(&mut brush.cast_shadows, "Cast").changed(); + changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed(); + }); + }); + brush_validation_ui(ui, &brush); + changed |= selected_brush_face_controls( + world, + ui, + entity, + &mut brush, + &material_candidates, + &texture_candidates, + ); + if ui.button("Reset Cube Brush").clicked() { + reset_cube = true; + } + }); + apply_component_card_response(world, entity, card_response); + + if reset_cube { + brush = BrushDesc::default(); + changed = true; + } + if changed && brush != original { + set_brush_with_history(world, entity, brush); + } +} + +pub(super) fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let material_candidates = brush_face_material_ref_candidates(world); + let dragging_selection = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()); + let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| { + asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material) + }); + let Some(mut terrain) = world.get::(entity).cloned() else { + return; + }; + let original = terrain.clone(); + let mut changed = false; + let mut requested_resolution = terrain.resolution; + let mut remove_layer = None; + let mut swap_layers = None; + let mut locate_layer = None; + let mut accepted_drop = false; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_TERRAIN_DESC, "Terrain", icons::MOUNTAINS), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Grid", |ui| { + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut requested_resolution) + .range(2..=1025) + .suffix(" samples"), + ); + if ui + .add_enabled( + requested_resolution != terrain.resolution, + egui::Button::new("Resize Flat"), + ) + .on_hover_text("Replaces the current height grid with a flat grid") + .clicked() + { + let replacement = TerrainDesc::flat(requested_resolution); + terrain.resolution = replacement.resolution; + terrain.heights = replacement.heights; + terrain.material_weights.clear(); + terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1); + changed = true; + } + }); + }); + property_row(ui, "Sample Spacing", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.sample_spacing) + .range(0.01..=1000.0) + .speed(0.1) + .suffix(" m"), + ) + .changed(); + }); + property_row(ui, "Height Scale", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.height_scale) + .range(0.01..=10000.0) + .speed(0.1) + .suffix(" m"), + ) + .changed(); + }); + property_row(ui, "Chunk Size", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.chunk_quads) + .range(1..=terrain.resolution.saturating_sub(1)) + .suffix(" quads"), + ) + .changed(); + }); + property_row(ui, "Collision", |ui| { + changed |= ui + .checkbox(&mut terrain.generate_colliders, "Generate") + .changed(); + }); + property_row(ui, "Shadows", |ui| { + ui.horizontal_wrapped(|ui| { + changed |= ui.checkbox(&mut terrain.cast_shadows, "Cast").changed(); + changed |= ui + .checkbox(&mut terrain.receive_shadows, "Receive") + .changed(); + }); + }); + ui.add_space(6.0); + ui.separator(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Material Layers").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled( + terrain.material_layers.len() < shared::TERRAIN_MATERIAL_LAYER_LIMIT, + egui::Button::new(phosphor_icon(icons::PLUS, 16.0)), + ) + .on_hover_text("Add a terrain blend channel") + .clicked() + { + let material = if terrain.material_layers.is_empty() { + terrain.base_material.take() + } else { + None + }; + terrain.material_layers.push(shared::TerrainMaterialLayer { + material, + ..Default::default() + }); + changed = true; + } + }); + }); + if terrain.material_layers.is_empty() { + ui.label( + egui::RichText::new( + terrain + .base_material + .as_ref() + .map(|material| format!("Legacy base: {}", material.label)) + .unwrap_or_else(|| "No layers assigned; visible terrain fallback".into()), + ) + .color(TEXT_DIM) + .small(), + ); + } + for index in 0..terrain.material_layers.len() { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Layer {}", index + 1)) + .strong() + .color(if index == 0 { + SELECTION_BG_MUTED + } else { + TEXT_DIM + }), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button_small(ui, icons::TRASH, "Remove layer").clicked() { + remove_layer = Some(index); + } + if ui + .add_enabled( + index + 1 < terrain.material_layers.len(), + egui::Button::new(phosphor_icon(icons::ARROW_DOWN, 15.0)).frame(false), + ) + .on_hover_text("Move layer down") + .clicked() + { + swap_layers = Some((index, index + 1)); + } + if ui + .add_enabled( + index > 0, + egui::Button::new(phosphor_icon(icons::ARROW_UP, 15.0)).frame(false), + ) + .on_hover_text("Move layer up") + .clicked() + { + swap_layers = Some((index, index - 1)); + } + }); + }); + let response = asset_selector_row( + ui, + "Material", + icons::PALETTE, + terrain.material_layers[index].material.as_ref(), + None, + true, + &material_candidates, + material_drop_candidate.as_ref(), + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = response.selected { + terrain.material_layers[index].material = Some(selected); + changed = true; + } + if response.clear { + terrain.material_layers[index].material = None; + changed = true; + } + if response.locate { + locate_layer = Some(index); + } + accepted_drop |= response.accepted_drop; + property_row(ui, "UV Tiling", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.material_layers[index].uv_scale) + .range(0.01..=1024.0) + .speed(0.1) + .suffix("×"), + ) + .changed(); + }); + if !terrain.material_weights.is_empty() { + let covered = terrain + .material_weights + .iter() + .filter(|weights| weights[index] > 0) + .count(); + ui.label( + egui::RichText::new(format!( + "{} / {} samples carry this layer", + covered, + terrain.material_weights.len() + )) + .color(TEXT_DIM) + .small(), + ); + } + } + match terrain.validate() { + Ok(()) => { + ui.label( + egui::RichText::new(format!( + "{} heights • {}×{} chunks", + terrain.heights.len(), + (terrain.resolution - 1).div_ceil(terrain.chunk_quads), + (terrain.resolution - 1).div_ceil(terrain.chunk_quads) + )) + .color(crate::ui::theme::SUCCESS), + ); + } + Err(error) => { + ui.label(egui::RichText::new(error).color(crate::ui::theme::ERROR)); + } + } + }); + apply_component_card_response(world, entity, card_response); + + if let Some(index) = remove_layer { + remove_terrain_material_layer(&mut terrain, index); + changed = true; + } + if let Some((a, b)) = swap_layers { + terrain.material_layers.swap(a, b); + for weights in &mut terrain.material_weights { + weights.swap(a, b); + } + changed = true; + } + if let Some(index) = locate_layer { + locate_asset_ref( + world, + terrain + .material_layers + .get(index) + .and_then(|layer| layer.material.as_ref()), + &material_candidates, + ); + } + if accepted_drop { + clear_asset_drag(world); + } + + if changed && terrain != original { + let _ = reflected_component_transaction( + world, + entity, + "Edit Terrain", + shared::AUTHORING_COMPONENT_TERRAIN, + COMPONENT_TERRAIN_DESC, + move |world, entity| { + world.entity_mut(entity).insert(terrain); + Ok(()) + }, + ); + } +} + +pub(super) fn remove_terrain_material_layer(terrain: &mut TerrainDesc, index: usize) { + if index >= terrain.material_layers.len() { + return; + } + terrain.material_layers.remove(index); + if terrain.material_layers.is_empty() { + terrain.material_weights.clear(); + return; + } + for weights in &mut terrain.material_weights { + for channel in index..3 { + weights[channel] = weights[channel + 1]; + } + weights[3] = 0; + normalize_terrain_weights(weights, terrain.material_layers.len()); + } +} + +pub(super) fn normalize_terrain_weights(weights: &mut [u8; 4], layer_count: usize) { + for weight in weights.iter_mut().skip(layer_count) { + *weight = 0; + } + let sum: u16 = weights + .iter() + .take(layer_count) + .copied() + .map(u16::from) + .sum(); + if sum == 0 { + *weights = [255, 0, 0, 0]; + return; + } + let source = *weights; + let mut assigned = 0_u16; + for index in 0..layer_count { + weights[index] = ((u16::from(source[index]) * 255) / sum) as u8; + assigned += u16::from(weights[index]); + } + let largest = (0..layer_count) + .max_by_key(|index| source[*index]) + .unwrap_or(0); + weights[largest] = weights[largest].saturating_add((255 - assigned) as u8); +} + +pub(super) fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { + let report = validate_brush(brush); + if report.diagnostics.is_empty() { + return; + } + + ui.add_space(6.0); + egui::Frame::new() + .fill(egui::Color32::from_rgb(31, 32, 36)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + let has_errors = !report.is_valid(); + let title_color = if has_errors { + egui::Color32::from_rgb(255, 137, 129) + } else { + egui::Color32::from_rgb(255, 190, 110) + }; + ui.horizontal(|ui| { + let icon = if has_errors { + icons::WARNING + } else { + icons::INFO + }; + ui.label(phosphor_icon(icon, 14.0).color(title_color)); + ui.label( + egui::RichText::new(if has_errors { + "Brush geometry needs repair" + } else { + "Brush diagnostics" + }) + .color(title_color), + ); + }); + for diagnostic in report.diagnostics.iter().take(4) { + let color = match diagnostic.severity { + BrushDiagnosticSeverity::Error => egui::Color32::from_rgb(255, 137, 129), + BrushDiagnosticSeverity::Warning => egui::Color32::from_rgb(255, 190, 110), + }; + let face = diagnostic + .face + .as_ref() + .filter(|face| !face.is_empty()) + .map(|face| format!("{}: ", face.0)) + .unwrap_or_default(); + ui.label(egui::RichText::new(format!("{face}{}", diagnostic.message)).color(color)); + } + let hidden_count = report.diagnostics.len().saturating_sub(4); + if hidden_count > 0 { + ui.label(egui::RichText::new(format!("+{hidden_count} more")).color(TEXT_MUTED)); + } + }); +} + +pub(super) fn selected_brush_face_controls( + world: &mut World, + ui: &mut egui::Ui, + entity: Entity, + brush: &mut BrushDesc, + material_candidates: &[AssetRefCandidate], + texture_candidates: &[AssetRefCandidate], +) -> bool { + let Some(selection) = world.get_resource::() else { + return false; + }; + if selection.brush != Some(entity) { + return false; + } + let selected_faces: Vec<_> = selection + .elements + .iter() + .filter_map(|element| match element { + BrushElementKey::Face { face } => Some(face.clone()), + _ => None, + }) + .collect(); + if selected_faces.is_empty() { + return false; + } + + let dragging_selection = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()); + let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| { + asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material) + }); + let texture_drop_candidate = dragging_selection.as_ref().and_then(|selection| { + asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Texture) + }); + let mut changed = false; + ui.add_space(6.0); + ui.separator(); + ui.label( + egui::RichText::new(format!("{} selected face(s)", selected_faces.len())).color(TEXT_DIM), + ); + + for face_id in selected_faces { + let Some(face) = brush.faces.iter_mut().find(|face| face.id == face_id) else { + continue; + }; + let label = if face.id.0.trim().is_empty() { + "Face".to_string() + } else { + face.id.0.clone() + }; + egui::CollapsingHeader::new(label) + .default_open(true) + .show(ui, |ui| { + property_row(ui, "UV offset", |ui| { + changed |= ui + .add(egui::DragValue::new(&mut face.uv_offset.x).speed(0.05)) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut face.uv_offset.y).speed(0.05)) + .changed(); + }); + property_row(ui, "UV scale", |ui| { + changed |= ui + .add(egui::DragValue::new(&mut face.uv_scale.x).speed(0.05)) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut face.uv_scale.y).speed(0.05)) + .changed(); + }); + property_row(ui, "UV rotation", |ui| { + changed |= ui + .add(egui::DragValue::new(&mut face.uv_rotation).speed(1.0)) + .changed(); + }); + let material_response = asset_selector_row( + ui, + "Material", + icons::PALETTE, + face.material.as_ref(), + None, + true, + material_candidates, + material_drop_candidate.as_ref(), + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = material_response.selected { + face.material = Some(selected); + changed = true; + } + if material_response.clear { + face.material = None; + changed = true; + } + if material_response.locate { + locate_asset_ref(world, face.material.as_ref(), material_candidates); + } + if material_response.accepted_drop { + clear_asset_drag(world); + } + + let texture_response = asset_selector_row( + ui, + "Texture", + icons::IMAGE, + face.texture.as_ref(), + None, + true, + texture_candidates, + texture_drop_candidate.as_ref(), + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = texture_response.selected { + face.texture = Some(selected); + changed = true; + } + if texture_response.clear { + face.texture = None; + changed = true; + } + if texture_response.locate { + locate_asset_ref(world, face.texture.as_ref(), texture_candidates); + } + if texture_response.accepted_drop { + clear_asset_drag(world); + } + }); + } + + changed +} diff --git a/crates/editor/src/ui/inspector/component_add.rs b/crates/editor/src/ui/inspector/component_add.rs new file mode 100644 index 0000000..4594f35 --- /dev/null +++ b/crates/editor/src/ui/inspector/component_add.rs @@ -0,0 +1,452 @@ +use super::*; + +pub(super) fn component_category_label(category: EditorComponentCategory) -> &'static str { + match category { + EditorComponentCategory::Authoring => "Authoring", + EditorComponentCategory::Rendering => "Rendering", + EditorComponentCategory::Animation => "Animation", + EditorComponentCategory::Audio => "Audio", + EditorComponentCategory::Navigation => "Navigation", + EditorComponentCategory::Physics => "Physics", + EditorComponentCategory::Gameplay => "Gameplay", + EditorComponentCategory::Volumes => "Volumes", + EditorComponentCategory::Editor => "Editor", + } +} + +pub(super) struct ComponentAddState { + pub(super) addable: bool, + reason: Option, + required: Vec<&'static str>, + recommended: Vec<&'static str>, + conflicts: Vec<&'static str>, +} + +pub(super) fn descriptor_matches_search( + descriptor: &EditorComponentDescriptor, + search: &str, +) -> bool { + search.trim().is_empty() + || descriptor.display_name.to_lowercase().contains(search) + || descriptor.type_name.to_lowercase().contains(search) + || descriptor.description.to_lowercase().contains(search) + || descriptor + .search_terms + .iter() + .any(|term| term.to_lowercase().contains(search)) +} + +pub(super) fn component_add_state( + world: &World, + entity: Entity, + descriptor: &EditorComponentDescriptor, + descriptors: &[EditorComponentDescriptor], +) -> ComponentAddState { + let duplicate = component_present(world, entity, descriptor.type_name); + let conflicts = descriptor + .conflicts_with + .iter() + .copied() + .filter(|type_name| component_present(world, entity, type_name)) + .collect::>(); + let recommended = descriptor + .recommended + .iter() + .copied() + .filter(|type_name| !component_present(world, entity, type_name)) + .collect::>(); + let registry = world.resource::(); + let required = registry + .required_component_ids(descriptor) + .iter() + .filter_map(|id| registry.by_id(id)) + .filter(|required| !registry.component_present(world, entity, required.type_name)) + .map(|required| required.display_name) + .collect::>(); + let reason = if duplicate { + Some("Already present on this actor.".to_string()) + } else if !required.is_empty() { + Some(format!("Requires {}.", required.join(", "))) + } else if !conflicts.is_empty() { + Some(format!( + "Conflicts with {}.", + conflicts + .iter() + .map(|type_name| component_display_name(descriptors, type_name)) + .collect::>() + .join(", ") + )) + } else { + None + }; + ComponentAddState { + addable: reason.is_none(), + reason, + required, + recommended, + conflicts, + } +} + +pub(super) fn component_hover_text( + descriptor: &EditorComponentDescriptor, + state: &ComponentAddState, +) -> String { + let mut lines = vec![ + descriptor.description.to_string(), + format!("Type: {}", descriptor.type_name), + format!("Hydration: {}", descriptor.hydration_effect), + format!( + "Inspector: removable={} reorderable={}", + descriptor.removable, descriptor.reorderable + ), + ]; + if let Some(reason) = &state.reason { + lines.push(format!("Unavailable: {reason}")); + } + if !state.required.is_empty() { + lines.push(format!("Requires: {}", state.required.join(", "))); + } + if !state.recommended.is_empty() { + lines.push(format!( + "Recommended with: {}", + state.recommended.join(", ") + )); + } + if !state.conflicts.is_empty() { + lines.push(format!("Conflicts: {}", state.conflicts.join(", "))); + } + lines.push(format!("Search: {}", descriptor.search_terms.join(", "))); + lines.join("\n") +} + +pub(super) fn component_display_name( + descriptors: &[EditorComponentDescriptor], + type_name: &str, +) -> &'static str { + descriptors + .iter() + .find(|descriptor| descriptor.type_name == type_name) + .map(|descriptor| descriptor.display_name) + .unwrap_or("component") +} + +pub(super) fn component_present(world: &World, entity: Entity, type_name: &str) -> bool { + if let Some(registry) = world.get_resource::() { + if registry.by_type_name(type_name).is_some() { + return registry.component_present(world, entity, type_name); + } + } + match type_name { + COMPONENT_ANIMATION_CONTROLLER_DESC => { + world.get::(entity).is_some() + } + "shared::components::Primitive" => world.get::(entity).is_some(), + "shared::components::BrushDesc" => world.get::(entity).is_some(), + "shared::components::StaticMeshRenderer" => { + world.get::(entity).is_some() + } + COMPONENT_SKINNED_MESH_RENDERER => world.get::(entity).is_some(), + "shared::components::MaterialDesc" => { + world.get::(entity).is_some() && world.get::(entity).is_some() + } + "shared::components::LightDesc" => world.get::(entity).is_some(), + "shared::components::AudioSourceDesc" => world.get::(entity).is_some(), + "shared::components::AudioListenerDesc" => world.get::(entity).is_some(), + "shared::components::RigidBodyDesc" => world.get::(entity).is_some(), + "shared::components::ColliderDesc" => world.get::(entity).is_some(), + "shared::components::PlayerSpawn" => world.get::(entity).is_some(), + "shared::components::WeaponSpawn" => world.get::(entity).is_some(), + "shared::components::TriggerVolume" => world.get::(entity).is_some(), + "shared::components::TeamSpawn" => world.get::(entity).is_some(), + "shared::components::ObjectiveMarker" => world.get::(entity).is_some(), + "shared::components::PostProcessVolumeDesc" => { + world.get::(entity).is_some() + } + COMPONENT_NAVIGATION_BOUNDS => world.get::(entity).is_some(), + COMPONENT_NAVIGATION_OBSTACLE => world.get::(entity).is_some(), + COMPONENT_NAVIGATION_AREA => world.get::(entity).is_some(), + COMPONENT_NAVIGATION_LINK => world.get::(entity).is_some(), + _ => false, + } +} + +pub(super) fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) { + let descriptor = world + .resource::() + .by_type_name(type_name) + .cloned(); + if let Some(descriptor) = descriptor { + if !descriptor.addable || component_present(world, entity, descriptor.type_name) { + return; + } + if descriptor.type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { + let controller = + crate::ui::animation_inspector::default_controller_for_actor(world, entity); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Add Component", + descriptor.id, + descriptor.type_name, + move |world, entity| { + world.entity_mut(entity).insert(controller); + Ok(()) + }, + ); + } else if descriptor.type_name == COMPONENT_NAVIGATION_BOUNDS { + let bounds = navigation_bounds_for_entity(world, entity); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Add Component", + descriptor.id, + descriptor.type_name, + move |world, entity| { + world.entity_mut(entity).insert(bounds); + Ok(()) + }, + ); + } else { + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Add Component", + descriptor.id, + descriptor.type_name, + move |world, entity| { + crate::history::apply_reflected_default(world, entity, descriptor.type_name) + }, + ); + } + return; + } + match type_name { + COMPONENT_ANIMATION_CONTROLLER_DESC => { + let controller = + crate::ui::animation_inspector::default_controller_for_actor(world, entity); + insert_component(world, entity, move |world, e| { + world.entity_mut(e).insert(controller); + }); + } + "shared::components::Primitive" => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((shared::ActorKind::StaticMesh, Primitive::cuboid(Vec3::ONE))); + }), + "shared::components::BrushDesc" => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((shared::ActorKind::Brush, BrushDesc::default())); + }), + "shared::components::StaticMeshRenderer" => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((shared::ActorKind::StaticMesh, StaticMeshRenderer::default())); + }), + "shared::components::MaterialDesc" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(MaterialDesc::default()); + }), + "shared::components::LightDesc" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(LightDesc::default()); + }), + "shared::components::AudioSourceDesc" => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((shared::ActorKind::AudioSource, AudioSourceDesc::default())); + }), + "shared::components::AudioListenerDesc" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(( + shared::ActorKind::AudioListener, + AudioListenerDesc::default(), + )); + }), + "shared::components::RigidBodyDesc" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(RigidBodyDesc::default()); + }), + "shared::components::ColliderDesc" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(ColliderDesc::default()); + }), + "shared::components::PlayerSpawn" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(PlayerSpawn); + }), + "shared::components::WeaponSpawn" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(WeaponSpawn { + weapon_id: "rifle".into(), + }); + }), + "shared::components::TriggerVolume" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(TriggerVolume::default()); + }), + "shared::components::TeamSpawn" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(TeamSpawn { team_id: 0 }); + }), + "shared::components::ObjectiveMarker" => insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(ObjectiveMarker { + objective_id: "objective".into(), + }); + }), + COMPONENT_NAVIGATION_BOUNDS => insert_component(world, entity, |world, e| { + let bounds = navigation_bounds_for_entity(world, e); + world.entity_mut(e).insert((ActorKind::Navigation, bounds)); + }), + COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((ActorKind::Navigation, NavigationObstacle::default())); + }), + COMPONENT_NAVIGATION_AREA => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((ActorKind::Navigation, NavigationArea::default())); + }), + COMPONENT_NAVIGATION_LINK => insert_component(world, entity, |world, e| { + world + .entity_mut(e) + .insert((ActorKind::Navigation, NavigationLink::default())); + }), + "shared::components::PostProcessVolumeDesc" => { + insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(PostProcessVolumeDesc::default()); + }) + } + _ => {} + } +} + +pub(super) fn navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds { + world + .get::(entity) + .map(|actor_id| NavigationBounds::for_actor(&actor_id.0)) + .unwrap_or_else(|| NavigationBounds::for_actor(&uuid::Uuid::new_v4().to_string())) +} + +pub(super) fn reset_navigation_bounds_for_entity( + world: &World, + entity: Entity, +) -> NavigationBounds { + let mut bounds = navigation_bounds_for_entity(world, entity); + if let Some(current) = world.get::(entity) { + bounds.artifact_path.clone_from(¤t.artifact_path); + } + bounds +} + +pub(super) fn insert_component( + world: &mut World, + entity: Entity, + insert: impl FnOnce(&mut World, Entity), +) { + let before = crate::history::snapshot_entity(world, entity); + let old_kind = before.as_ref().map(|s| s.actor_kind); + insert(world, entity); + let after = crate::history::snapshot_entity(world, entity); + if let (Some(before), Some(after)) = (before, after) { + if let Some(old) = old_kind { + if old != after.actor_kind { + crate::history::set_actor_kind_with_history(world, entity, old, after.actor_kind); + } + } + crate::history::push_command( + world, + crate::history::EditorCommand::AddComponent { + entity, + snapshot: diff_added(&before, &after), + }, + ); + } +} + +pub(super) fn diff_added( + before: &EditorEntitySnapshot, + after: &EditorEntitySnapshot, +) -> EditorEntitySnapshot { + EditorEntitySnapshot { + actor_id: None, + actor_kind: after.actor_kind, + actor_name: None, + name: None, + transform: after.transform, + primitive: after + .primitive + .clone() + .filter(|_| before.primitive.is_none()), + brush: after.brush.clone().filter(|_| before.brush.is_none()), + static_mesh_renderer: after + .static_mesh_renderer + .clone() + .filter(|_| before.static_mesh_renderer.is_none()), + skinned_mesh_renderer: after + .skinned_mesh_renderer + .clone() + .filter(|_| before.skinned_mesh_renderer.is_none()), + material: after.material.clone().filter(|_| before.material.is_none()), + material_override: after + .material_override + .clone() + .filter(|_| before.material_override.is_none()), + rigid_body: after.rigid_body.filter(|_| before.rigid_body.is_none()), + collider: after.collider.clone().filter(|_| before.collider.is_none()), + physics: after.physics.clone().filter(|_| before.physics.is_none()), + light: after.light.clone().filter(|_| before.light.is_none()), + animation_controller: after + .animation_controller + .clone() + .filter(|_| before.animation_controller.is_none()), + audio_source: after + .audio_source + .clone() + .filter(|_| before.audio_source.is_none()), + audio_listener: after + .audio_listener + .filter(|_| before.audio_listener.is_none()), + player_spawn: after.player_spawn && !before.player_spawn, + model: after.model.clone().filter(|_| before.model.is_none()), + prefab: after.prefab.clone().filter(|_| before.prefab.is_none()), + prefab_instance: after + .prefab_instance + .clone() + .filter(|_| before.prefab_instance.is_none()), + weapon_spawn: after + .weapon_spawn + .clone() + .filter(|_| before.weapon_spawn.is_none()), + trigger_volume: after + .trigger_volume + .clone() + .filter(|_| before.trigger_volume.is_none()), + post_process_volume: after + .post_process_volume + .clone() + .filter(|_| before.post_process_volume.is_none()), + team_spawn: after + .team_spawn + .clone() + .filter(|_| before.team_spawn.is_none()), + objective: after + .objective + .clone() + .filter(|_| before.objective.is_none()), + navigation_bounds: after + .navigation_bounds + .clone() + .filter(|_| before.navigation_bounds.is_none()), + navigation_obstacle: after + .navigation_obstacle + .clone() + .filter(|_| before.navigation_obstacle.is_none()), + navigation_area: after + .navigation_area + .clone() + .filter(|_| before.navigation_area.is_none()), + navigation_link: after + .navigation_link + .clone() + .filter(|_| before.navigation_link.is_none()), + hierarchy_sibling_index: after.hierarchy_sibling_index, + editor_visibility: after.editor_visibility, + inspector_order: None, + component_states: None, + children: Vec::new(), + } +} diff --git a/crates/editor/src/ui/inspector/component_chrome.rs b/crates/editor/src/ui/inspector/component_chrome.rs new file mode 100644 index 0000000..7fac561 --- /dev/null +++ b/crates/editor/src/ui/inspector/component_chrome.rs @@ -0,0 +1,518 @@ +use super::*; + +const COMPONENT_HEADER_HEIGHT: f32 = 50.0; +const COMPONENT_RAIL_X: f32 = 7.0; +const COMPONENT_CARET_CENTER_X: f32 = 26.0; +const COMPONENT_ICON_CENTER_X: f32 = 51.0; +const COMPONENT_IDENTITY_X: f32 = 69.0; +const COMPONENT_ACTIONS_WIDTH: f32 = 104.0; + +pub(super) fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool { + let mut changed = false; + property_row(ui, "Shader", |ui| { + egui::ComboBox::from_id_salt("material_shader_kind") + .selected_text(match material.shader.kind { + MaterialShaderKind::StandardLit => "Standard Lit", + MaterialShaderKind::Unlit => "Unlit", + MaterialShaderKind::Custom => "Custom", + }) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value( + &mut material.shader.kind, + MaterialShaderKind::StandardLit, + "Standard Lit", + ) + .changed(); + changed |= ui + .selectable_value( + &mut material.shader.kind, + MaterialShaderKind::Unlit, + "Unlit", + ) + .changed(); + changed |= ui + .selectable_value( + &mut material.shader.kind, + MaterialShaderKind::Custom, + "Custom", + ) + .changed(); + }); + }); + if matches!(material.shader.kind, MaterialShaderKind::Custom) { + changed |= option_string_ui(ui, "Shader schema", &mut material.shader.schema_path); + changed |= option_string_ui(ui, "WGSL shader", &mut material.shader.shader_path); + } + if !material.parameters.is_empty() { + ui.label(panel_heading("Shader Parameters")); + for parameter in &mut material.parameters { + changed |= material_parameter_ui(ui, parameter); + } + } + changed +} + +pub(super) fn material_parameter_ui(ui: &mut egui::Ui, parameter: &mut MaterialParameter) -> bool { + ui.horizontal_wrapped(|ui| { + ui.label(¶meter.name); + match &mut parameter.value { + MaterialParameterValue::Bool(value) => ui.checkbox(value, "").changed(), + MaterialParameterValue::Float(value) => ui + .add_sized( + [fit_width(ui, 64.0, 120.0), 20.0], + egui::DragValue::new(value) + .speed(0.01) + .min_decimals(2) + .max_decimals(4), + ) + .changed(), + MaterialParameterValue::Vec2(value) => { + let mut changed = false; + changed |= ui + .add_sized( + [fit_width(ui, 52.0, 88.0), 20.0], + egui::DragValue::new(&mut value.x).speed(0.01), + ) + .changed(); + changed |= ui + .add_sized( + [fit_width(ui, 52.0, 88.0), 20.0], + egui::DragValue::new(&mut value.y).speed(0.01), + ) + .changed(); + changed + } + MaterialParameterValue::Vec3(value) => { + let mut changed = false; + changed |= ui + .add_sized( + [fit_width(ui, 52.0, 88.0), 20.0], + egui::DragValue::new(&mut value.x).speed(0.01), + ) + .changed(); + changed |= ui + .add_sized( + [fit_width(ui, 52.0, 88.0), 20.0], + egui::DragValue::new(&mut value.y).speed(0.01), + ) + .changed(); + changed |= ui + .add_sized( + [fit_width(ui, 52.0, 88.0), 20.0], + egui::DragValue::new(&mut value.z).speed(0.01), + ) + .changed(); + changed + } + MaterialParameterValue::Color(value) => { + let mut rgba = [value.r, value.g, value.b, value.a]; + let changed = ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed(); + if changed { + *value = ColorDesc { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + }; + } + changed + } + MaterialParameterValue::Enum(value) => ui + .add_sized( + [ + fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH), + 20.0, + ], + egui::TextEdit::singleline(value), + ) + .changed(), + } + }) + .inner +} + +pub(crate) fn component_card( + ui: &mut egui::Ui, + context: &ComponentCardContext, + add_contents: impl FnOnce(&mut egui::Ui), +) -> ComponentCardResponse { + let mut response = ComponentCardResponse { + type_name: context.options.type_name, + ..Default::default() + }; + crate::ui::design_system::scope(ui, |ui| { + egui::Frame::new() + .fill(WIDGET_BG) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .show(ui, |ui| { + let width = ui.available_width().max(1.0); + let (header, header_response) = ui.allocate_exact_size( + egui::vec2(width, COMPONENT_HEADER_HEIGHT), + egui::Sense::click(), + ); + ui.painter().rect_filled( + egui::Rect::from_min_size( + header.min + egui::vec2(COMPONENT_RAIL_X, 7.0), + egui::vec2(3.0, 36.0), + ), + 2.0, + crate::ui::theme::SELECTION, + ); + let caret_rect = egui::Rect::from_center_size( + egui::pos2(header.left() + COMPONENT_CARET_CENTER_X, header.center().y), + egui::vec2(20.0, 28.0), + ); + if ui + .put( + caret_rect, + egui::Button::new(phosphor_icon( + if context.collapsed { + icons::CARET_RIGHT + } else { + icons::CARET_DOWN + }, + 12.0, + )) + .frame(false), + ) + .on_hover_text(if context.collapsed { + "Expand component" + } else { + "Collapse component" + }) + .clicked() + { + response.collapsed = Some(!context.collapsed); + } + ui.painter().text( + egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y), + egui::Align2::CENTER_CENTER, + context.options.icon.as_str(), + egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-regular".into())), + TEXT_DIM, + ); + ui.painter().text( + egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0), + egui::Align2::LEFT_CENTER, + context.options.title, + crate::ui::design_system::typography::TypeRole::Title.font(), + crate::ui::theme::TEXT, + ); + ui.painter().text( + egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0), + egui::Align2::LEFT_CENTER, + context.options.summary, + crate::ui::design_system::typography::TypeRole::Small.font(), + TEXT_MUTED, + ); + let actions_rect = egui::Rect::from_min_size( + egui::pos2( + header.right() - COMPONENT_ACTIONS_WIDTH - 8.0, + header.top() + 11.0, + ), + egui::vec2(COMPONENT_ACTIONS_WIDTH, 28.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + component_actions_menu(ui, context, &mut response); + let label = if context.active { "Active" } else { "Off" }; + let color = if context.active { + crate::ui::theme::SUCCESS + } else { + TEXT_MUTED + }; + let active = ui + .horizontal(|ui| { + let dot = if context.options.active_toggle { + status_dot_button(ui, color, "Toggle component") + } else { + status_dot(ui, color, "Component active"); + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) + }; + ui.label( + crate::ui::design_system::typography::TypeRole::Small + .text(label) + .color(TEXT_DIM), + ); + dot + }) + .inner; + if context.options.active_toggle && active.clicked() { + response.active = Some(!context.active); + } + }); + }); + if header_response.clicked() + && ui + .input(|input| input.pointer.interact_pos()) + .is_some_and(|pointer| { + !caret_rect.contains(pointer) && !actions_rect.contains(pointer) + }) + { + response.collapsed = Some(!context.collapsed); + } + if !context.collapsed { + egui::Frame::new() + .inner_margin(egui::Margin { + left: context.options.body_margin, + right: context.options.body_margin, + top: 10, + bottom: context.options.body_margin, + }) + .show(ui, |ui| { + ui.set_max_width(ui.available_width()); + add_contents(ui); + }); + } + }); + }); + ui.add_space(6.0); + response +} + +fn component_actions_menu( + ui: &mut egui::Ui, + context: &ComponentCardContext, + response: &mut ComponentCardResponse, +) { + let menu = ui.menu_button( + phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM), + |ui| { + ui.set_min_width(160.0); + if ui + .add_enabled(context.options.resettable, egui::Button::new("Reset")) + .clicked() + { + response.reset = true; + ui.close(); + } + if ui + .add_enabled(context.options.copyable, egui::Button::new("Copy Values")) + .clicked() + { + response.copy = true; + ui.close(); + } + if ui + .add_enabled(context.pasteable, egui::Button::new("Paste Values")) + .clicked() + { + response.paste = true; + ui.close(); + } + ui.separator(); + if ui + .add_enabled(context.can_move_up, egui::Button::new("Move Up")) + .clicked() + { + response.move_up = true; + ui.close(); + } + if ui + .add_enabled(context.can_move_down, egui::Button::new("Move Down")) + .clicked() + { + response.move_down = true; + ui.close(); + } + ui.separator(); + ui.add_enabled(false, egui::Button::new("Open Documentation")); + if ui + .add_enabled( + context.options.removable, + egui::Button::new(egui::RichText::new("Remove").color(crate::ui::theme::ERROR)), + ) + .clicked() + { + response.remove = true; + ui.close(); + } + }, + ); + menu.response.on_hover_text("Component actions"); +} + +pub(crate) fn property_row( + ui: &mut egui::Ui, + label: &str, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + if ui.available_width() < COMPACT_INSPECTOR_WIDTH { + ui.vertical(|ui| { + ui.label( + crate::ui::design_system::typography::TypeRole::Body + .text(label) + .color(crate::ui::theme::TEXT_DIM), + ); + add_contents(ui) + }) + .inner + } else { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), 30.0), + egui::Sense::hover(), + ); + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + let label_width = PROPERTY_LABEL_WIDTH.min(ui.available_width().max(1.0)); + ui.add_sized( + [label_width, 22.0], + egui::Label::new( + crate::ui::design_system::typography::TypeRole::Body + .text(label) + .color(crate::ui::theme::TEXT_DIM), + ), + ); + add_contents(ui) + }, + ) + .inner + } +} + +pub(crate) fn text_field_width(ui: &egui::Ui) -> f32 { + fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH).min(ui.available_width().max(1.0)) +} + +pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let mut material = world + .get::(entity) + .cloned() + .unwrap_or_default(); + request_texture_asset_thumbnails(world); + let texture_candidates = texture_asset_candidates(world); + let original = material.clone(); + let mut changed = false; + let mut options = ComponentCardOptions::removable( + COMPONENT_MATERIAL_DESC, + "Brush Fallback Material", + icons::PALETTE, + ); + options.removable = world.get::(entity).is_some(); + options.copyable = options.removable; + let card = component_card_context(world, entity, options); + let card_response = component_card(ui, &card, |ui| { + changed |= material_shader_ui(ui, &mut material); + let mut color = [ + material.base_color.r, + material.base_color.g, + material.base_color.b, + material.base_color.a, + ]; + property_row(ui, "Base color", |ui| { + if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() { + material.base_color = ColorDesc { + r: color[0], + g: color[1], + b: color[2], + a: color[3], + }; + changed = true; + } + }); + property_row(ui, "Metallic", |ui| { + changed |= ui + .add(egui::Slider::new(&mut material.metallic, 0.0..=1.0)) + .changed(); + }); + property_row(ui, "Roughness", |ui| { + changed |= ui + .add(egui::Slider::new(&mut material.roughness, 0.0..=1.0)) + .changed(); + }); + + let mut emissive_color = [ + material.emissive_color.r, + material.emissive_color.g, + material.emissive_color.b, + material.emissive_color.a, + ]; + property_row(ui, "Emissive", |ui| { + if ui + .color_edit_button_rgba_unmultiplied(&mut emissive_color) + .changed() + { + material.emissive_color = ColorDesc { + r: emissive_color[0], + g: emissive_color[1], + b: emissive_color[2], + a: emissive_color[3], + }; + changed = true; + } + }); + property_row(ui, "Emissive nits", |ui| { + changed |= ui + .add(egui::Slider::new( + &mut material.emissive_intensity, + 0.0..=20_000.0, + )) + .changed(); + }); + + changed |= texture_asset_picker_ui( + world, + ui, + "Base color texture", + &mut material.base_color_texture, + &texture_candidates, + ); + changed |= texture_asset_picker_ui( + world, + ui, + "Emissive texture", + &mut material.emissive_texture, + &texture_candidates, + ); + changed |= texture_asset_picker_ui( + world, + ui, + "Normal map", + &mut material.normal_map_texture, + &texture_candidates, + ); + changed |= texture_asset_picker_ui( + world, + ui, + "Metallic/roughness texture", + &mut material.metallic_roughness_texture, + &texture_candidates, + ); + + if crate::assets::materials::material_asset_picker_ui( + world, + ui, + entity, + &mut material, + &original, + ) { + changed = true; + } + }); + apply_component_card_response(world, entity, card_response); + + if changed && !material_eq(&original, &material) { + set_material_with_history(world, entity, material); + } +} + +#[cfg(test)] +mod component_header_geometry_tests { + use super::*; + + #[test] + fn current_penpot_inspector_header_geometry_is_exact() { + assert_eq!(COMPONENT_HEADER_HEIGHT, 50.0); + assert_eq!(COMPONENT_RAIL_X, 7.0); + assert_eq!(COMPONENT_CARET_CENTER_X, 26.0); + assert_eq!(COMPONENT_ICON_CENTER_X, 51.0); + assert_eq!(COMPONENT_IDENTITY_X, 69.0); + assert_eq!(COMPONENT_ACTIONS_WIDTH, 104.0); + } +} diff --git a/crates/editor/src/ui/inspector/component_lifecycle.rs b/crates/editor/src/ui/inspector/component_lifecycle.rs new file mode 100644 index 0000000..610976d --- /dev/null +++ b/crates/editor/src/ui/inspector/component_lifecycle.rs @@ -0,0 +1,772 @@ +use super::*; + +pub(crate) fn apply_component_card_response( + world: &mut World, + entity: Entity, + response: ComponentCardResponse, +) { + let type_name = response_type_name(&response).unwrap_or_default(); + if type_name.is_empty() { + return; + } + apply_component_card_response_for_type(world, entity, type_name, response); +} + +pub(super) fn apply_component_card_response_for_type( + world: &mut World, + entity: Entity, + type_name: &'static str, + response: ComponentCardResponse, +) { + if let Some(collapsed) = response.collapsed { + let key = component_card_key(world, entity, type_name); + let mut panel_state = world.resource_mut::(); + if collapsed { + panel_state.collapsed_components.insert(key); + } else { + panel_state.collapsed_components.remove(&key); + } + } + + if let Some(active) = response.active { + let mut states = world + .get::(entity) + .cloned() + .unwrap_or_else(|| AuthoringComponentStates { + states: world + .get::(entity) + .map(|order| order.component_states.clone()) + .unwrap_or_default(), + }); + let component_id = world + .resource::() + .stable_id_for_type(type_name); + states.set_component_active(component_id, active); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Set Component Active", + "editor.component_states", + "shared::components::AuthoringComponentStates", + move |world, entity| { + world.entity_mut(entity).insert(states); + Ok(()) + }, + ); + } + + if response.move_up || response.move_down { + let mut order = world + .get::(entity) + .cloned() + .unwrap_or_default(); + let present = present_component_type_names(world, entity); + let offset = if response.move_up { -1 } else { 1 }; + let moved = world + .resource::() + .move_component(&mut order, type_name, offset, &present); + if moved { + set_inspector_order_with_history(world, entity, order); + } + } + + if response.copy { + copy_component(world, entity, type_name); + } + if response.paste { + paste_component(world, entity, type_name); + } + if response.reset { + reset_component(world, entity, type_name); + } + if response.remove { + remove_registered_component(world, entity, type_name); + } +} + +pub(super) fn response_type_name(_response: &ComponentCardResponse) -> Option<&'static str> { + Some(_response.type_name) +} + +pub(super) fn component_card_key(world: &World, entity: Entity, type_name: &str) -> String { + let actor_key = world + .get::(entity) + .map(|id| id.0.as_str()) + .filter(|id| !id.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{entity:?}")); + format!("{actor_key}::{type_name}") +} + +pub(super) fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static str> { + if let Some(registry) = world.get_resource::() { + return registry + .descriptors + .iter() + .filter(|descriptor| { + !descriptor.hidden + && (descriptor.type_name != COMPONENT_MATERIAL_DESC + || world.get::(entity).is_some()) + && registry.component_present(world, entity, descriptor.type_name) + }) + .map(|descriptor| descriptor.type_name) + .collect(); + } + let mut present = Vec::new(); + if world.get::(entity).is_some() { + present.push(COMPONENT_ANIMATION_CONTROLLER_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_STATIC_MESH_RENDERER); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_SKINNED_MESH_RENDERER); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_PRIMITIVE); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_BRUSH_DESC); + } + if world.get::(entity).is_some() && world.get::(entity).is_some() { + present.push(COMPONENT_MATERIAL_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_LIGHT_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_AUDIO_SOURCE_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_AUDIO_LISTENER_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_RIGID_BODY_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_COLLIDER_DESC); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_PHYSICS_BODY); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_PLAYER_SPAWN); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_WEAPON_SPAWN); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_TRIGGER_VOLUME); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_TEAM_SPAWN); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_OBJECTIVE_MARKER); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_PREFAB_INSTANCE); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_POST_PROCESS_VOLUME); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_PROJECT_SUN); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_NAVIGATION_BOUNDS); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_NAVIGATION_OBSTACLE); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_NAVIGATION_AREA); + } + if world.get::(entity).is_some() { + present.push(COMPONENT_NAVIGATION_LINK); + } + present +} + +pub(super) fn copy_component(world: &mut World, entity: Entity, type_name: &str) { + let descriptor = world + .resource::() + .by_type_name(type_name) + .cloned(); + if let Some(descriptor) = descriptor { + if let Ok(Some(value)) = crate::history::capture_reflected_component( + world, + entity, + descriptor.id, + descriptor.type_name, + ) { + world.resource_mut::().component = + Some(CopiedComponent::Reflected(value)); + return; + } + } + let component = match type_name { + COMPONENT_ANIMATION_CONTROLLER_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::AnimationControllerDesc), + COMPONENT_PRIMITIVE => world + .get::(entity) + .cloned() + .map(CopiedComponent::Primitive), + COMPONENT_BRUSH_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::BrushDesc), + COMPONENT_STATIC_MESH_RENDERER => world + .get::(entity) + .cloned() + .map(CopiedComponent::StaticMeshRenderer), + COMPONENT_MATERIAL_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::MaterialDesc), + COMPONENT_LIGHT_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::LightDesc), + COMPONENT_AUDIO_SOURCE_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::AudioSourceDesc), + COMPONENT_AUDIO_LISTENER_DESC => world + .get::(entity) + .copied() + .map(CopiedComponent::AudioListenerDesc), + COMPONENT_RIGID_BODY_DESC => world + .get::(entity) + .copied() + .map(CopiedComponent::RigidBodyDesc), + COMPONENT_COLLIDER_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::ColliderDesc), + COMPONENT_PHYSICS_BODY => world + .get::(entity) + .cloned() + .map(CopiedComponent::PhysicsBody), + COMPONENT_WEAPON_SPAWN => world + .get::(entity) + .cloned() + .map(CopiedComponent::WeaponSpawn), + COMPONENT_TRIGGER_VOLUME => world + .get::(entity) + .cloned() + .map(CopiedComponent::TriggerVolume), + COMPONENT_TEAM_SPAWN => world + .get::(entity) + .cloned() + .map(CopiedComponent::TeamSpawn), + COMPONENT_OBJECTIVE_MARKER => world + .get::(entity) + .cloned() + .map(CopiedComponent::ObjectiveMarker), + COMPONENT_POST_PROCESS_VOLUME => world + .get::(entity) + .cloned() + .map(CopiedComponent::PostProcessVolume), + COMPONENT_PREFAB_INSTANCE => world + .get::(entity) + .cloned() + .map(CopiedComponent::PrefabInstance), + COMPONENT_NAVIGATION_BOUNDS => world + .get::(entity) + .cloned() + .map(CopiedComponent::NavigationBounds), + COMPONENT_NAVIGATION_OBSTACLE => world + .get::(entity) + .cloned() + .map(CopiedComponent::NavigationObstacle), + COMPONENT_NAVIGATION_AREA => world + .get::(entity) + .cloned() + .map(CopiedComponent::NavigationArea), + COMPONENT_NAVIGATION_LINK => world + .get::(entity) + .cloned() + .map(CopiedComponent::NavigationLink), + _ => None, + }; + if let Some(component) = component { + world.resource_mut::().component = Some(component); + } +} + +pub(super) fn paste_component(world: &mut World, entity: Entity, type_name: &str) { + let component = world + .get_resource::() + .and_then(|clipboard| clipboard.component.clone()); + if let Some(CopiedComponent::Reflected(value)) = component.as_ref() { + if value.type_path == type_name { + let value = value.clone(); + let component_id = value.component_id.clone(); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Paste Component", + &component_id, + type_name, + move |world, entity| { + crate::history::apply_reflected_component( + world, + entity, + type_name, + Some(&value), + ) + }, + ); + } + return; + } + match component { + Some(CopiedComponent::Reflected(_)) => {} + Some(CopiedComponent::AnimationControllerDesc(value)) + if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC => + { + set_animation_controller_with_history(world, entity, value); + } + Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => { + set_primitive_with_history(world, entity, value); + } + Some(CopiedComponent::BrushDesc(value)) if type_name == COMPONENT_BRUSH_DESC => { + set_brush_with_history(world, entity, value); + } + Some(CopiedComponent::StaticMeshRenderer(value)) + if type_name == COMPONENT_STATIC_MESH_RENDERER => + { + set_static_mesh_renderer_with_history(world, entity, value); + } + Some(CopiedComponent::MaterialDesc(value)) if type_name == COMPONENT_MATERIAL_DESC => { + set_material_with_history(world, entity, value); + } + Some(CopiedComponent::LightDesc(value)) if type_name == COMPONENT_LIGHT_DESC => { + set_light_with_history(world, entity, value); + } + Some(CopiedComponent::AudioSourceDesc(value)) + if type_name == COMPONENT_AUDIO_SOURCE_DESC => + { + set_audio_source_with_history(world, entity, value); + } + Some(CopiedComponent::AudioListenerDesc(value)) + if type_name == COMPONENT_AUDIO_LISTENER_DESC => + { + set_audio_listener_with_history(world, entity, value); + } + Some(CopiedComponent::RigidBodyDesc(value)) if type_name == COMPONENT_RIGID_BODY_DESC => { + set_rigid_body_with_history(world, entity, value); + } + Some(CopiedComponent::ColliderDesc(value)) if type_name == COMPONENT_COLLIDER_DESC => { + set_collider_with_history(world, entity, value); + } + Some(CopiedComponent::PhysicsBody(value)) if type_name == COMPONENT_PHYSICS_BODY => { + set_physics_with_history(world, entity, value); + } + Some(CopiedComponent::PostProcessVolume(value)) + if type_name == COMPONENT_POST_PROCESS_VOLUME => + { + set_post_process_volume_with_history(world, entity, value); + } + Some(CopiedComponent::WeaponSpawn(value)) if type_name == COMPONENT_WEAPON_SPAWN => { + insert_direct_component(world, entity, value); + } + Some(CopiedComponent::TriggerVolume(value)) if type_name == COMPONENT_TRIGGER_VOLUME => { + insert_direct_component(world, entity, value); + } + Some(CopiedComponent::TeamSpawn(value)) if type_name == COMPONENT_TEAM_SPAWN => { + insert_direct_component(world, entity, value); + } + Some(CopiedComponent::ObjectiveMarker(value)) + if type_name == COMPONENT_OBJECTIVE_MARKER => + { + insert_direct_component(world, entity, value); + } + Some(CopiedComponent::PrefabInstance(value)) if type_name == COMPONENT_PREFAB_INSTANCE => { + insert_direct_component(world, entity, value); + } + Some(CopiedComponent::NavigationBounds(mut value)) + if type_name == COMPONENT_NAVIGATION_BOUNDS => + { + value.artifact_path = world + .get::(entity) + .map(|bounds| bounds.artifact_path.clone()) + .unwrap_or_else(|| navigation_bounds_for_entity(world, entity).artifact_path); + crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + bounds: Some(value), + ..Default::default() + }, + ); + } + Some(CopiedComponent::NavigationObstacle(value)) + if type_name == COMPONENT_NAVIGATION_OBSTACLE => + { + crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + obstacle: Some(value), + ..Default::default() + }, + ); + } + Some(CopiedComponent::NavigationArea(value)) if type_name == COMPONENT_NAVIGATION_AREA => { + crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + area: Some(value), + ..Default::default() + }, + ); + } + Some(CopiedComponent::NavigationLink(value)) if type_name == COMPONENT_NAVIGATION_LINK => { + crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + link: Some(value), + ..Default::default() + }, + ); + } + _ => {} + } +} + +pub(super) fn reset_component(world: &mut World, entity: Entity, type_name: &str) { + let descriptor = world + .resource::() + .by_type_name(type_name) + .cloned(); + if let Some(descriptor) = descriptor { + let component_id = descriptor.id; + let type_path = descriptor.type_name; + if type_path == COMPONENT_ANIMATION_CONTROLLER_DESC { + let controller = + crate::ui::animation_inspector::default_controller_for_actor(world, entity); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Reset Component", + component_id, + type_path, + move |world, entity| { + world.entity_mut(entity).insert(controller); + Ok(()) + }, + ); + } else if type_path == COMPONENT_NAVIGATION_BOUNDS { + let bounds = reset_navigation_bounds_for_entity(world, entity); + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Reset Component", + component_id, + type_path, + move |world, entity| { + world.entity_mut(entity).insert(bounds); + Ok(()) + }, + ); + } else { + let _ = crate::history::reflected_component_transaction( + world, + entity, + "Reset Component", + component_id, + type_path, + move |world, entity| { + crate::history::apply_reflected_default(world, entity, type_path) + }, + ); + } + return; + } + match type_name { + COMPONENT_ANIMATION_CONTROLLER_DESC => { + let controller = + crate::ui::animation_inspector::default_controller_for_actor(world, entity); + set_animation_controller_with_history(world, entity, controller); + } + COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()), + COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()), + COMPONENT_STATIC_MESH_RENDERER => { + set_static_mesh_renderer_with_history(world, entity, StaticMeshRenderer::default()); + } + COMPONENT_MATERIAL_DESC => { + set_material_with_history(world, entity, MaterialDesc::default()) + } + COMPONENT_LIGHT_DESC => set_light_with_history(world, entity, LightDesc::default()), + COMPONENT_AUDIO_SOURCE_DESC => { + set_audio_source_with_history(world, entity, AudioSourceDesc::default()) + } + COMPONENT_AUDIO_LISTENER_DESC => { + set_audio_listener_with_history(world, entity, AudioListenerDesc::default()) + } + COMPONENT_RIGID_BODY_DESC => { + set_rigid_body_with_history(world, entity, RigidBodyDesc::default()); + } + COMPONENT_COLLIDER_DESC => { + set_collider_with_history(world, entity, ColliderDesc::default()) + } + COMPONENT_PHYSICS_BODY => set_physics_with_history(world, entity, PhysicsBody::default()), + COMPONENT_POST_PROCESS_VOLUME => { + set_post_process_volume_with_history(world, entity, PostProcessVolumeDesc::default()); + } + COMPONENT_NAVIGATION_BOUNDS => crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + bounds: Some(reset_navigation_bounds_for_entity(world, entity)), + ..Default::default() + }, + ), + COMPONENT_NAVIGATION_OBSTACLE => crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + obstacle: Some(NavigationObstacle::default()), + ..Default::default() + }, + ), + COMPONENT_NAVIGATION_AREA => crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + area: Some(NavigationArea::default()), + ..Default::default() + }, + ), + COMPONENT_NAVIGATION_LINK => crate::history::set_navigation_with_history( + world, + entity, + crate::history::NavigationComponentState { + link: Some(NavigationLink::default()), + ..Default::default() + }, + ), + COMPONENT_WEAPON_SPAWN => insert_direct_component( + world, + entity, + WeaponSpawn { + weapon_id: "rifle".into(), + }, + ), + COMPONENT_TRIGGER_VOLUME => { + insert_direct_component(world, entity, TriggerVolume::default()) + } + COMPONENT_TEAM_SPAWN => insert_direct_component(world, entity, TeamSpawn { team_id: 0 }), + COMPONENT_OBJECTIVE_MARKER => insert_direct_component( + world, + entity, + ObjectiveMarker { + objective_id: "objective".into(), + }, + ), + _ => {} + } +} + +pub(super) fn insert_direct_component( + world: &mut World, + entity: Entity, + component: T, +) { + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(component); + } + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.mark_dirty(); + } +} + +pub(super) fn remove_registered_component(world: &mut World, entity: Entity, type_name: &str) { + let descriptor = world + .resource::() + .by_type_name(type_name) + .cloned(); + if let Some(descriptor) = descriptor { + if !descriptor.removable { + return; + } + let dependents = world + .resource::() + .present_dependents(world, entity, descriptor.id) + .iter() + .map(|dependent| dependent.display_name) + .collect::>(); + if !dependents.is_empty() { + world + .resource_mut::() + .set_status(format!( + "Remove blocked: required by {}. Remove dependent components first.", + dependents.join(", ") + )); + return; + } + let result = crate::history::reflected_component_transaction( + world, + entity, + "Remove Component", + descriptor.id, + descriptor.type_name, + |world, entity| { + crate::history::apply_reflected_component(world, entity, descriptor.type_name, None) + }, + ); + if result.is_ok() && type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { + crate::ui::animation_inspector::stop_preview_if_actor(world, entity); + } + return; + } + let Some(before) = crate::history::snapshot_entity(world, entity) else { + return; + }; + let removed_dedicated_audio_kind = matches!( + (type_name, before.actor_kind), + (COMPONENT_AUDIO_SOURCE_DESC, ActorKind::AudioSource) + | (COMPONENT_AUDIO_LISTENER_DESC, ActorKind::AudioListener) + ); + let removed_dedicated_navigation_kind = before.actor_kind == ActorKind::Navigation + && matches!( + type_name, + COMPONENT_NAVIGATION_BOUNDS + | COMPONENT_NAVIGATION_OBSTACLE + | COMPONENT_NAVIGATION_AREA + | COMPONENT_NAVIGATION_LINK + ); + let removed_dedicated_skinned_kind = + type_name == COMPONENT_SKINNED_MESH_RENDERER && before.actor_kind == ActorKind::SkinnedMesh; + let removed = if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + match type_name { + COMPONENT_ANIMATION_CONTROLLER_DESC if before.animation_controller.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_PRIMITIVE if before.primitive.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_BRUSH_DESC if before.brush.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_SKINNED_MESH_RENDERER if before.skinned_mesh_renderer.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_MATERIAL_DESC if before.material.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_LIGHT_DESC if before.light.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_AUDIO_SOURCE_DESC if before.audio_source.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_AUDIO_LISTENER_DESC if before.audio_listener.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_COLLIDER_DESC if before.collider.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_PHYSICS_BODY if before.physics.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_PLAYER_SPAWN if before.player_spawn => { + entity_mut.remove::(); + true + } + COMPONENT_WEAPON_SPAWN if before.weapon_spawn.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_TRIGGER_VOLUME if before.trigger_volume.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_TEAM_SPAWN if before.team_spawn.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_OBJECTIVE_MARKER if before.objective.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_PREFAB_INSTANCE if before.prefab_instance.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_POST_PROCESS_VOLUME if before.post_process_volume.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_NAVIGATION_BOUNDS if before.navigation_bounds.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_NAVIGATION_OBSTACLE if before.navigation_obstacle.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_NAVIGATION_AREA if before.navigation_area.is_some() => { + entity_mut.remove::(); + true + } + COMPONENT_NAVIGATION_LINK if before.navigation_link.is_some() => { + entity_mut.remove::(); + true + } + _ => false, + } + } else { + false + }; + if !removed { + return; + } + if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC { + crate::ui::animation_inspector::stop_preview_if_actor(world, entity); + } + if removed_dedicated_audio_kind + || removed_dedicated_navigation_kind + || removed_dedicated_skinned_kind + { + let fallback = world + .get_entity(entity) + .ok() + .and_then(infer_actor_kind) + .unwrap_or(ActorKind::Empty); + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + entity_mut.insert(fallback); + } + } + if let Some(after) = crate::history::snapshot_entity(world, entity) { + let snapshot = diff_added(&after, &before); + crate::history::push_command( + world, + crate::history::EditorCommand::RemoveComponent { entity, snapshot }, + ); + } +} diff --git a/crates/editor/src/ui/inspector/dispatch.rs b/crates/editor/src/ui/inspector/dispatch.rs new file mode 100644 index 0000000..078fe0a --- /dev/null +++ b/crates/editor/src/ui/inspector/dispatch.rs @@ -0,0 +1,383 @@ +use super::*; + +pub fn authoring_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + if !world + .get_entity(entity) + .is_ok_and(|entity_ref| entity_ref.contains::()) + { + return; + } + + let present = present_component_type_names(world, entity); + let ordered = if let Some(order) = world.get::(entity) { + world + .resource::() + .ordered_components(order, &present) + } else { + present + }; + for type_name in ordered { + let inspector = world + .resource::() + .inspector(type_name); + if let Some(inspector) = inspector { + ui.push_id(("component_inspector", type_name), |ui| { + inspector(world, ui, entity); + }); + } else { + ui.colored_label( + crate::ui::theme::ERROR, + format!("No Inspector renderer registered for {type_name}"), + ); + } + } +} + +pub(crate) fn register_builtin_component_inspectors(world: &mut World) { + let registrations: &[(&str, crate::ui::component_registry::ComponentInspectorFn)] = &[ + ( + COMPONENT_ANIMATION_CONTROLLER_DESC, + crate::ui::animation_inspector::animation_controller_inspector_ui, + ), + (COMPONENT_STATIC_MESH_RENDERER, static_mesh_renderer_ui), + (COMPONENT_SKINNED_MESH_RENDERER, skinned_mesh_renderer_ui), + (COMPONENT_BRUSH_DESC, brush_editor_ui), + (COMPONENT_TERRAIN_DESC, terrain_editor_ui), + (COMPONENT_PRIMITIVE, primitive_editor_ui), + (COMPONENT_MATERIAL_DESC, material_editor_ui), + (COMPONENT_LIGHT_DESC, light_editor_ui), + ( + COMPONENT_AUDIO_SOURCE_DESC, + crate::ui::audio_inspector::audio_source_inspector_ui, + ), + ( + COMPONENT_AUDIO_LISTENER_DESC, + crate::ui::audio_inspector::audio_listener_inspector_ui, + ), + (COMPONENT_RIGID_BODY_DESC, rigid_body_editor_ui), + (COMPONENT_COLLIDER_DESC, collider_editor_ui), + (COMPONENT_PHYSICS_BODY, physics_editor_ui), + (COMPONENT_PLAYER_SPAWN, player_spawn_ui), + (COMPONENT_WEAPON_SPAWN, weapon_spawn_ui), + (COMPONENT_TRIGGER_VOLUME, trigger_volume_ui), + (COMPONENT_TEAM_SPAWN, team_spawn_ui), + (COMPONENT_OBJECTIVE_MARKER, objective_ui), + (COMPONENT_PREFAB_INSTANCE, prefab_instance_ui), + ( + COMPONENT_POST_PROCESS_VOLUME, + crate::ui::post_process_volume_ui::post_process_volume_inspector_ui, + ), + (COMPONENT_PROJECT_SUN, project_sun_ui), + ( + COMPONENT_NAVIGATION_BOUNDS, + crate::ui::navigation_inspector::navigation_bounds_inspector_ui, + ), + ( + COMPONENT_NAVIGATION_OBSTACLE, + crate::ui::navigation_inspector::navigation_obstacle_inspector_ui, + ), + ( + COMPONENT_NAVIGATION_AREA, + crate::ui::navigation_inspector::navigation_area_inspector_ui, + ), + ( + COMPONENT_NAVIGATION_LINK, + crate::ui::navigation_inspector::navigation_link_inspector_ui, + ), + ]; + let mut registry = world.resource_mut::(); + for &(type_name, inspector) in registrations { + registry + .register_inspector_for_existing(type_name, inspector) + .unwrap_or_else(|error| panic!("built-in Inspector registration failed: {error}")); + } +} + +pub(crate) fn add_component_footer(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + ui.add_space(4.0); + let button_response = egui::Frame::new() + .fill(WIDGET_BG) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + let open_for_entity = add_component_picker_open_for(world, entity); + let label = if open_for_entity { + "Close Add Component" + } else { + "+ Add Component" + }; + if ui.button(label).clicked() { + if let Some(mut state) = world.get_resource_mut::() { + if open_for_entity { + state.add_component_open = false; + state.add_component_target = None; + } else { + state.add_component_open = true; + state.add_component_focus_search = true; + state.add_component_scroll_selected = true; + state.add_component_selected_index = 0; + state.add_component_target = Some(entity); + state.add_component_search.clear(); + } + } + } + }); + + if add_component_picker_open_for(world, entity) { + add_component_picker_shelf(world, ui, entity, button_response.response.rect); + } +} + +pub(super) fn add_component_picker_open_for(world: &World, entity: Entity) -> bool { + world + .get_resource::() + .is_some_and(|state| state.add_component_open && state.add_component_target == Some(entity)) +} + +pub(super) fn shelf_list_max_height(visible_space: f32) -> f32 { + (visible_space - 108.0).clamp(56.0, 320.0) +} + +pub(super) fn add_component_picker_shelf( + world: &mut World, + ui: &mut egui::Ui, + target: Entity, + anchor: egui::Rect, +) { + if world.get_entity(target).is_err() { + if let Some(mut state) = world.get_resource_mut::() { + state.add_component_open = false; + state.add_component_target = None; + } + return; + } + + let descriptors = world + .resource::() + .descriptors + .clone(); + + let visible = ui.clip_rect().intersect(ui.ctx().content_rect()); + let space_above = (anchor.min.y - visible.top()).max(0.0); + let space_below = (visible.bottom() - anchor.max.y).max(0.0); + let direction = if space_below >= space_above { + AddComponentShelfDirection::Down + } else { + AddComponentShelfDirection::Up + }; + let visible_space = match direction { + AddComponentShelfDirection::Up => space_above, + AddComponentShelfDirection::Down => space_below, + }; + let list_max_height = shelf_list_max_height(visible_space); + let shelf_height = (list_max_height + 108.0).min((visible_space - 4.0).max(96.0)); + let x = anchor + .min + .x + .clamp(visible.left(), visible.right() - anchor.width()); + let y = match direction { + AddComponentShelfDirection::Up => anchor.min.y - shelf_height - 4.0, + AddComponentShelfDirection::Down => anchor.max.y + 4.0, + } + .clamp( + visible.top(), + (visible.bottom() - shelf_height).max(visible.top()), + ); + let shelf_width = anchor.width().max(260.0).min(visible.width()); + + egui::Area::new(egui::Id::new(("add_component_shelf", target))) + .order(egui::Order::Foreground) + .fixed_pos(egui::pos2(x, y)) + .show(ui.ctx(), |ui| { + ui.set_width(shelf_width); + add_component_picker_shelf_contents(world, ui, target, &descriptors, list_max_height); + }); +} + +pub(super) fn add_component_picker_shelf_contents( + world: &mut World, + ui: &mut egui::Ui, + target: Entity, + descriptors: &[EditorComponentDescriptor], + list_max_height: f32, +) { + egui::Frame::new() + .fill(PANEL_BG_DARK) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(panel_heading("Add Component")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button_small(ui, icons::X, "Close Add Component").clicked() { + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + }); + }); + ui.add_space(4.0); + + let mut search_input = world + .resource::() + .add_component_search + .clone(); + let search_response = ui.add( + egui::TextEdit::singleline(&mut search_input) + .hint_text("Search components...") + .desired_width(f32::INFINITY), + ); + if search_response.changed() { + let mut state = world.resource_mut::(); + state.add_component_search = search_input.clone(); + state.add_component_scroll_selected = true; + state.add_component_selected_index = 0; + } + if world + .resource::() + .add_component_focus_search + { + search_response.request_focus(); + world + .resource_mut::() + .add_component_focus_search = false; + } + + let search = search_input.to_lowercase(); + let filtered = filtered_component_descriptors(descriptors, &search); + { + let mut state = world.resource_mut::(); + if !filtered.is_empty() { + state.add_component_selected_index = + state.add_component_selected_index.min(filtered.len() - 1); + } else { + state.add_component_selected_index = 0; + } + } + + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) + && !filtered.is_empty() + { + let mut state = world.resource_mut::(); + state.add_component_selected_index = + (state.add_component_selected_index + 1) % filtered.len(); + state.add_component_scroll_selected = true; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) + && !filtered.is_empty() + { + let mut state = world.resource_mut::(); + state.add_component_selected_index = if state.add_component_selected_index == 0 { + filtered.len() - 1 + } else { + state.add_component_selected_index - 1 + }; + state.add_component_scroll_selected = true; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + let selected_index = world + .resource::() + .add_component_selected_index; + if let Some(descriptor) = filtered.get(selected_index) { + let add_state = component_add_state(world, target, descriptor, descriptors); + if add_state.addable { + insert_registered_component(world, target, descriptor.type_name); + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + } + } + + ui.separator(); + if filtered.is_empty() { + ui.label(egui::RichText::new("No matching components").color(TEXT_DIM)); + return; + } + + egui::ScrollArea::vertical() + .max_height(list_max_height) + .show(ui, |ui| { + let (selected_index, scroll_selected) = { + let state = world.resource::(); + ( + state.add_component_selected_index, + state.add_component_scroll_selected, + ) + }; + let mut last_category = None; + for (index, descriptor) in filtered.iter().enumerate() { + if last_category != Some(descriptor.category) { + if last_category.is_some() { + ui.separator(); + } + ui.label(panel_heading(component_category_label(descriptor.category))); + last_category = Some(descriptor.category); + } + + let add_state = component_add_state(world, target, descriptor, descriptors); + let selected = index == selected_index; + let row = ui + .horizontal(|ui| { + ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM)); + ui.add_enabled( + add_state.addable, + egui::Button::selectable(selected, descriptor.display_name), + ) + }) + .inner + .on_hover_text(component_hover_text(descriptor, &add_state)); + if selected && scroll_selected { + row.scroll_to_me(Some(egui::Align::Center)); + } + if row.clicked() { + world + .resource_mut::() + .add_component_selected_index = index; + if add_state.addable { + insert_registered_component(world, target, descriptor.type_name); + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + } + } + }); + world + .resource_mut::() + .add_component_scroll_selected = false; + }); +} + +pub(super) fn filtered_component_descriptors<'a>( + descriptors: &'a [EditorComponentDescriptor], + search: &str, +) -> Vec<&'a EditorComponentDescriptor> { + [ + EditorComponentCategory::Authoring, + EditorComponentCategory::Rendering, + EditorComponentCategory::Animation, + EditorComponentCategory::Audio, + EditorComponentCategory::Navigation, + EditorComponentCategory::Physics, + EditorComponentCategory::Gameplay, + EditorComponentCategory::Volumes, + ] + .into_iter() + .flat_map(|category| { + descriptors.iter().filter(move |descriptor| { + descriptor.addable + && !descriptor.hidden + && descriptor.category == category + && descriptor_matches_search(descriptor, search) + }) + }) + .collect() +} diff --git a/crates/editor/src/ui/inspector/imported_material_preview.rs b/crates/editor/src/ui/inspector/imported_material_preview.rs new file mode 100644 index 0000000..89caadf --- /dev/null +++ b/crates/editor/src/ui/inspector/imported_material_preview.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ImportedMaterialPreviewKey { + asset_id: String, + sub_asset_id: String, + source: String, + fingerprint: String, +} + +#[derive(Resource, Default)] +struct ImportedMaterialPreviewCache { + entries: HashMap>, +} + +pub(super) fn imported_material_parameters_ui( + world: &mut World, + ui: &mut egui::Ui, + reference: &MaterialRef, +) { + let record = world.get_resource::().and_then(|registry| { + registry + .records + .iter() + .find(|record| record.id.as_string() == reference.0.asset_id) + }); + let source = reference + .0 + .source_path + .clone() + .or_else(|| record.map(|record| record.path.clone())); + let Some(source) = source else { + ui.colored_label(crate::ui::theme::ERROR, "Material reference is unresolved"); + return; + }; + let key = ImportedMaterialPreviewKey { + asset_id: reference.0.asset_id.clone(), + sub_asset_id: reference.0.sub_asset_id.clone(), + source: source.clone(), + fingerprint: record + .and_then(|record| record.source_fingerprint.as_ref()) + .map(|fingerprint| fingerprint.content_hash.clone()) + .unwrap_or_default(), + }; + if !world.contains_resource::() { + world.init_resource::(); + } + let cached = world + .resource::() + .entries + .get(&key) + .cloned(); + let preview = cached.unwrap_or_else(|| { + let result = crate::assets::plan_model_material_extraction( + Path::new(&source), + Path::new("assets/materials"), + ) + .map_err(|error| format!("Imported material parameters are unavailable: {error}")) + .and_then(|planned| { + planned + .into_iter() + .find(|material| { + material + .asset + .provenance + .as_ref() + .is_some_and(|provenance| { + provenance.source_sub_asset_id == reference.0.sub_asset_id + }) + }) + .map(|material| material.asset) + .ok_or_else(|| "Imported material slot is missing".to_string()) + }); + world + .resource_mut::() + .entries + .insert(key, result.clone()); + result + }); + let asset = match preview { + Ok(asset) => asset, + Err(error) => { + ui.colored_label(crate::ui::theme::ERROR, error); + return; + } + }; + let mut inputs = asset.inputs.clone(); + ui.add_enabled_ui(false, |ui| { + property_row(ui, "Shader", |ui| { + ui.label(match asset.shader.kind { + MaterialShaderKind::StandardLit => "Standard Lit", + MaterialShaderKind::Unlit => "Unlit", + MaterialShaderKind::Custom => "Custom", + }); + }); + egui::CollapsingHeader::new("Surface Options") + .default_open(true) + .show(ui, |ui| { + ui.label(format!("Alpha mode: {:?}", asset.render_state.alpha_mode)); + let mut double_sided = asset.render_state.double_sided; + ui.checkbox(&mut double_sided, "Double sided"); + }); + material_input_schema_editor(ui, &standard_lit_input_schema(), &mut inputs, &[], None); + }); + ui.small(egui::RichText::new("Imported source · read-only").color(TEXT_DIM)); +} diff --git a/crates/editor/src/ui/inspector/material_slots.rs b/crates/editor/src/ui/inspector/material_slots.rs new file mode 100644 index 0000000..85d668c --- /dev/null +++ b/crates/editor/src/ui/inspector/material_slots.rs @@ -0,0 +1,728 @@ +use super::imported_material_preview::imported_material_parameters_ui; +use super::*; + +pub(super) fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize) { + if entry.id.is_empty() { + entry.id = ComponentInstanceId::new(format!("slot:{index}")); + } + if entry.material_slot_id.is_empty() { + entry.material_slot_id = ComponentInstanceId::new(format!("slot:{}", entry.id.0)); + } +} + +pub(super) fn status_dot(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) { + let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, color); + response.on_hover_text(tooltip); +} + +pub(super) fn status_dot_button( + ui: &mut egui::Ui, + color: egui::Color32, + tooltip: &str, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::click()); + let color = if response.hovered() { + color.linear_multiply(1.2) + } else { + color + }; + ui.painter().circle_filled(rect.center(), 4.0, color); + response.on_hover_text(tooltip) +} + +pub(super) fn material_drop_invalid_reason( + world: &World, + valid_material_candidate: bool, +) -> Option<&'static str> { + if valid_material_candidate { + return None; + } + let assets = world.get_resource::()?; + let selection = assets.dragging_selection()?; + let texture = matches!( + selection, + AssetSelection::SubAsset { + kind: AssetSubAssetKind::Texture, + .. + } + ) || assets + .asset_for_selection(selection) + .is_some_and(|asset| matches!(asset.kind, EditorAssetKind::Texture)); + Some(if texture { + "Textures cannot be assigned directly; create or edit a Material" + } else { + "Only a project Material or Material Instance can be assigned" + }) +} + +#[derive(Default)] +pub(super) struct MaterialSlotWidgetResponse { + pub(super) changed: bool, + pub(super) accepted_drop: bool, +} + +pub(super) fn material_slot_widget_ui( + world: &mut World, + ui: &mut egui::Ui, + entity: Entity, + slot: &mut shared::MaterialSlot, + candidates: &[AssetRefCandidate], + drop_candidate: Option<&AssetRefCandidate>, + invalid_drop_reason: Option<&'static str>, +) -> MaterialSlotWidgetResponse { + let mut response = MaterialSlotWidgetResponse::default(); + let actor_widget_id = stable_actor_widget_id(world, entity); + let project_default = world + .get_resource::() + .and_then(|defaults| defaults.default_material.clone()); + let model_material = model_material_for_actor_slot(world, entity, &slot.id); + let (effective, inherited_badge) = if let Some(reference) = slot.material.clone() { + (Some(reference), None) + } else if let Some((reference, badge)) = model_material { + (Some(reference), Some(badge)) + } else if let Some(reference) = project_default { + ( + Some(reference), + Some(crate::ui::materials::MaterialLayerBadge::ProjectDefault), + ) + } else { + ( + None, + Some(crate::ui::materials::MaterialLayerBadge::DefaultGrid), + ) + }; + let texture_id = effective.as_ref().and_then(|reference| { + candidates + .iter() + .find(|candidate| { + candidate.reference.asset_id == reference.0.asset_id + && candidate.reference.sub_asset_id == reference.0.sub_asset_id + }) + .and_then(|candidate| candidate.texture_id) + }); + let source_layer = + inherited_badge == Some(crate::ui::materials::MaterialLayerBadge::ModelSource); + let built_in_layer = + inherited_badge == Some(crate::ui::materials::MaterialLayerBadge::DefaultGrid); + let effective_path = effective + .as_ref() + .and_then(|reference| reference.0.source_path.as_deref()); + let is_project_material = !source_layer + && effective_path.is_some_and(|path| MaterialAsset::load_from_path(path).is_ok()); + let is_project_instance = !source_layer + && effective_path.is_some_and(|path| MaterialInstanceAsset::load_from_path(path).is_ok()); + let broken_project_reference = effective.is_some() + && !source_layer + && !built_in_layer + && !is_project_material + && !is_project_instance; + let mut health = effective + .as_ref() + .and_then(|reference| reference.0.source_path.as_deref()) + .and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }) + .map_or_else( + || { + if broken_project_reference { + crate::ui::materials::MaterialHealth::Broken + } else { + crate::ui::materials::MaterialHealth::Healthy + } + }, + |snapshot| { + use crate::asset_documents::{AuthoredDocumentState, DerivedProcessingState}; + if matches!( + snapshot.state, + AuthoredDocumentState::ExternalConflict | AuthoredDocumentState::SaveFailed + ) || snapshot.processing == DerivedProcessingState::Failed + || (broken_project_reference && !snapshot.dirty) + { + crate::ui::materials::MaterialHealth::Broken + } else if snapshot.dirty { + crate::ui::materials::MaterialHealth::Dirty + } else if matches!( + snapshot.processing, + DerivedProcessingState::Queued | DerivedProcessingState::Processing + ) { + crate::ui::materials::MaterialHealth::Processing + } else { + crate::ui::materials::MaterialHealth::Healthy + } + }, + ); + let is_imported_source = effective.is_some() && source_layer; + if health == crate::ui::materials::MaterialHealth::Healthy + && (is_imported_source || built_in_layer) + { + health = crate::ui::materials::MaterialHealth::ReadOnly; + } + let shader_kind = crate::ui::materials::material_shader_kind(world, effective_path); + let thumbnail = if broken_project_reference { + crate::ui::materials::MaterialThumbnailPresentation::Failed + } else if let Some(texture) = texture_id { + crate::ui::materials::MaterialThumbnailPresentation::Ready(texture) + } else { + crate::ui::materials::MaterialThumbnailPresentation::Pending + }; + let picker_candidates = candidates + .iter() + .filter(|candidate| { + candidate + .reference + .source_path + .as_deref() + .is_some_and(|path| { + MaterialAsset::load_from_path(path).is_ok() + || MaterialInstanceAsset::load_from_path(path).is_ok() + }) + }) + .map(|candidate| crate::ui::materials::MaterialPickerCandidate { + reference: candidate.reference.clone(), + label: candidate.label.clone(), + detail: candidate.detail.clone(), + thumbnail: candidate.texture_id.map_or( + crate::ui::materials::MaterialThumbnailPresentation::Pending, + crate::ui::materials::MaterialThumbnailPresentation::Ready, + ), + }) + .collect::>(); + let picker_drop_candidate = + drop_candidate.map(|candidate| crate::ui::materials::MaterialPickerCandidate { + reference: candidate.reference.clone(), + label: candidate.label.clone(), + detail: candidate.detail.clone(), + thumbnail: candidate.texture_id.map_or( + crate::ui::materials::MaterialThumbnailPresentation::Pending, + crate::ui::materials::MaterialThumbnailPresentation::Ready, + ), + }); + let view = crate::ui::materials::MaterialSlotPanelViewModel { + slot_id: slot.id.0.clone(), + label: effective + .as_ref() + .map(|reference| reference.0.label.clone()) + .unwrap_or_else(|| "Default Grid".into()), + path: effective_path.map(ToOwned::to_owned), + shader: crate::ui::materials::material_shader_label(world, effective_path), + shader_kind, + can_edit_shader: is_project_material, + thumbnail, + inherited: inherited_badge, + health, + assigned: slot.material.as_ref().map(|reference| reference.0.clone()), + effective: effective.as_ref().map(|reference| reference.0.clone()), + candidates: picker_candidates, + drop_candidate: picker_drop_candidate, + invalid_drop_reason: invalid_drop_reason.map(ToOwned::to_owned), + can_locate: effective.as_ref().is_some_and(|reference| { + candidates.iter().any(|candidate| { + candidate.reference.asset_id == reference.0.asset_id + && candidate.reference.sub_asset_id == reference.0.sub_asset_id + }) + }), + can_clear: slot.material.is_some(), + can_extract: is_imported_source, + can_create_instance: is_project_material, + }; + + let widget_slot_id = slot.id.0.clone(); + ui.push_id( + ( + "material_slot_widget", + actor_widget_id.as_str(), + widget_slot_id, + ), + |ui| { + let panel = crate::ui::materials::material_slot_panel( + ui, + (actor_widget_id.as_str(), slot.id.0.as_str()), + &view, + |ui| { + let Some(reference) = effective.as_ref() else { + ui.label( + egui::RichText::new( + "Default Grid is immutable. Assign a project Material to edit parameters.", + ) + .small() + .color(TEXT_DIM), + ); + return; + }; + if broken_project_reference { + ui.colored_label( + crate::ui::theme::ERROR, + "Material reference is broken; rendering Default Grid", + ); + } else if let Some(path) = reference.0.source_path.as_deref() { + if is_project_material || is_project_instance { + crate::ui::materials::inline_material_document_editor( + world, ui, path, + ); + } else { + imported_material_parameters_ui(world, ui, reference); + } + } else { + imported_material_parameters_ui(world, ui, reference); + } + }, + |ui| { + ui.small(format!("Stable slot: {}", slot.id.0)); + if let Some(reference) = effective.as_ref() { + ui.small(format!("Asset: {}", reference.0.asset_id)); + ui.small(format!("Subasset: {}", reference.0.sub_asset_id)); + if let Some(path) = reference.0.source_path.as_deref() { + ui.small(path); + } + } else { + ui.small("Immutable engine-owned fallback"); + } + }, + ); + for action in panel.actions { + debug_assert_eq!(action.slot_id, slot.id.0); + match action.action { + crate::ui::materials::MaterialSlotAction::Assign { + reference, + from_drop, + } => { + slot.material = Some(MaterialRef::new(reference)); + response.changed = true; + response.accepted_drop |= from_drop; + } + crate::ui::materials::MaterialSlotAction::BrowseLibrary => { + crate::ui::request_editor_tab( + world, + crate::ui::EditorTab::AssetBrowser, + ); + } + crate::ui::materials::MaterialSlotAction::Locate => { + locate_asset_ref( + world, + effective.as_ref().map(|reference| &reference.0), + candidates, + ); + } + crate::ui::materials::MaterialSlotAction::Clear => { + slot.material = None; + response.changed = true; + } + crate::ui::materials::MaterialSlotAction::CreateInstance => { + if let Some(reference) = effective.as_ref() { + create_instance_and_assign_slot(world, entity, slot, reference); + } + } + crate::ui::materials::MaterialSlotAction::ExtractEditable => { + if let Some(reference) = effective.as_ref() { + extract_slot_material(world, entity, slot, reference); + } + } + crate::ui::materials::MaterialSlotAction::SetShader(kind) => { + if let Some(path) = effective_path { + crate::ui::materials::set_material_shader_kind(world, path, kind); + } + } + } + } + }, + ); + let property_block = world + .get::(entity) + .and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id)) + .cloned(); + if property_block_promotion_ui(ui, property_block.as_ref(), slot.material.is_some()) { + if let (Some(base), Some(block)) = (slot.material.clone(), property_block) { + open_property_block_promotion_review( + world, + entity, + slot.id.clone(), + slot.name.clone(), + base, + block, + ); + } + } + response +} + +fn stable_actor_widget_id(world: &World, entity: Entity) -> String { + world + .get::(entity) + .map(|actor_id| format!("actor:{}", actor_id.0)) + .unwrap_or_else(|| format!("runtime:{entity:?}")) +} + +fn model_material_for_actor_slot( + world: &World, + entity: Entity, + slot_id: &ComponentInstanceId, +) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> { + let asset_id = world + .get::(entity) + .and_then(|renderer| { + renderer + .slots + .iter() + .find(|part| part.material_slot_id == *slot_id) + .map(|part| part.mesh.asset_id.clone()) + }) + .or_else(|| { + world + .get::(entity) + .map(|renderer| renderer.asset_id.clone()) + })?; + let record = world + .get_resource::()? + .records + .iter() + .find(|record| record.id.as_string() == asset_id)?; + let settings = record.model_import(); + match crate::assets::static_mesh::material_selection(settings, &slot_id.0) { + ModelMaterialSelection::Project(reference) => Some(( + reference.clone(), + crate::ui::materials::MaterialLayerBadge::ModelDefault, + )), + ModelMaterialSelection::Default => None, + ModelMaterialSelection::Source => { + let manifest = settings + .static_mesh_manifest_path + .as_deref() + .and_then(|path| load_static_mesh_manifest(path).ok())?; + let part = manifest.parts.iter().find(|part| { + let part_id = if part.id.trim().is_empty() { + part_id_from_label(&part.mesh_label) + } else { + part.id.clone() + }; + slot_id.0 == format!("slot:{part_id}") + })?; + let material_id = part + .material_id + .clone() + .filter(|id| !id.trim().is_empty()) + .or_else(|| part.material_label.as_deref().map(material_id_from_label))?; + Some(( + MaterialRef::new(EditorAssetRef::new( + manifest.asset_id, + material_id, + part.material_slot_name.clone(), + )), + crate::ui::materials::MaterialLayerBadge::ModelSource, + )) + } + } +} + +fn create_instance_and_assign_slot( + world: &mut World, + entity: Entity, + slot: &shared::MaterialSlot, + reference: &MaterialRef, +) { + let empty_block = MaterialPropertyBlock { + slot_id: slot.id.clone(), + parameters: Vec::new(), + textures: Vec::new(), + }; + let result = plan_material_instance_assignment( + world, + entity, + reference, + &empty_block, + &slot.name, + false, + ) + .and_then(|transaction| commit_property_block_promotion(world, &transaction)); + world.resource_mut::().status = match result { + Ok(path) => format!("Created and assigned Material Instance {}", path.display()), + Err(error) => error, + }; +} + +fn extract_slot_material( + world: &mut World, + entity: Entity, + slot: &shared::MaterialSlot, + reference: &MaterialRef, +) { + let source = reference.0.source_path.clone().or_else(|| { + world + .get_resource::() + .and_then(|registry| { + registry + .records + .iter() + .find(|record| record.id.as_string() == reference.0.asset_id) + }) + .map(|record| record.path.clone()) + }); + if let Some(source) = source { + crate::ui::asset_browser::begin_actor_material_extraction( + world, + &source, + entity, + slot.id.clone(), + reference.0.sub_asset_id.clone(), + ); + } else { + world.resource_mut::().status = + "Material source is unresolved and cannot be extracted".into(); + } +} + +pub(super) fn thumbnail_for_mesh( + mesh: &EditorAssetRef, + candidates: &[AssetRefCandidate], +) -> Option { + candidates + .iter() + .find(|candidate| { + candidate.reference.asset_id == mesh.asset_id + && candidate.reference.sub_asset_id == mesh.sub_asset_id + }) + .and_then(|candidate| candidate.texture_id) +} + +#[expect( + clippy::too_many_arguments, + reason = "asset selector rows keep immediate-mode UI inputs explicit" +)] +pub(super) fn asset_selector_row( + ui: &mut egui::Ui, + label: &str, + icon: Icon, + asset: Option<&EditorAssetRef>, + inherited_asset: Option<&EditorAssetRef>, + clearable: bool, + candidates: &[AssetRefCandidate], + drop_candidate: Option<&AssetRefCandidate>, + invalid_drop_reason: Option<&'static str>, + actions: AssetSelectorActions, +) -> AssetSelectorResponse { + let mut response = AssetSelectorResponse::default(); + if ui.available_width() < COMPACT_INSPECTOR_WIDTH { + ui.vertical(|ui| { + ui.label(label); + let control_width = ui.available_width().max(1.0); + asset_selector_control( + ui, + icon, + asset, + inherited_asset, + clearable, + control_width, + candidates, + drop_candidate, + invalid_drop_reason, + actions, + &mut response, + ); + }); + } else { + ui.horizontal(|ui| { + let row_width = ui.available_width().max(1.0); + let label_width = ASSET_SELECTOR_LABEL_WIDTH.min(row_width); + let control_width = (row_width - label_width - ui.spacing().item_spacing.x).max(1.0); + ui.add_sized([label_width, 20.0], egui::Label::new(label)); + asset_selector_control( + ui, + icon, + asset, + inherited_asset, + clearable, + control_width, + candidates, + drop_candidate, + invalid_drop_reason, + actions, + &mut response, + ); + }); + } + response +} + +#[expect( + clippy::too_many_arguments, + reason = "asset selector controls keep immediate-mode UI inputs explicit" +)] +pub(super) fn asset_selector_control( + ui: &mut egui::Ui, + icon: Icon, + asset: Option<&EditorAssetRef>, + inherited_asset: Option<&EditorAssetRef>, + clearable: bool, + control_width: f32, + candidates: &[AssetRefCandidate], + drop_candidate: Option<&AssetRefCandidate>, + invalid_drop_reason: Option<&'static str>, + actions: AssetSelectorActions, + response: &mut AssetSelectorResponse, +) { + let display_asset = asset.or(inherited_asset); + let inherited = asset.is_none() && inherited_asset.is_some(); + let selector_width = control_width.min(ui.available_width()).max(1.0); + exact_region( + ui, + egui::vec2(selector_width, ASSET_SELECTOR_HEIGHT), + egui::Layout::top_down(egui::Align::Min), + |ui| { + let contained_clip = ui.clip_rect().intersect(ui.max_rect()); + ui.set_clip_rect(contained_clip); + let rect = ui.max_rect(); + let valid_drag = drop_candidate.is_some(); + let drop_hovered = valid_drag && ui.rect_contains_pointer(rect); + let invalid_hovered = invalid_drop_reason.is_some() && ui.rect_contains_pointer(rect); + let stroke = if drop_hovered { + egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(125, 198, 255)) + } else if invalid_hovered { + egui::Stroke::new(2.0_f32, crate::ui::theme::ERROR) + } else if valid_drag { + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(58, 88, 122)) + } else { + egui::Stroke::new(1.0_f32, BORDER) + }; + let fill = if drop_hovered { + egui::Color32::from_rgb(29, 57, 86) + } else if invalid_hovered { + egui::Color32::from_rgb(69, 31, 35) + } else { + WIDGET_BG.linear_multiply(0.75) + }; + let inner_width = (selector_width - 18.0).max(1.0); + egui::Frame::new() + .fill(fill) + .stroke(stroke) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.set_width(inner_width); + ui.horizontal(|ui| { + ui.add_sized( + [18.0, 20.0], + egui::Label::new(phosphor_icon(icon, 14.0).color(TEXT_DIM)), + ); + let action_width = match actions { + AssetSelectorActions::Full if clearable => 78.0, + AssetSelectorActions::Full => 52.0, + }; + let text_width = (ui.available_width() - action_width) + .max(1.0) + .min(ui.available_width().max(1.0)); + ui.vertical(|ui| { + ui.set_max_width(text_width); + let name = display_asset + .map(|asset| asset.label.as_str()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or("(none)"); + ui.add(egui::Label::new(name).truncate()); + let id = invalid_hovered + .then(|| invalid_drop_reason.unwrap_or_default().to_string()) + .or_else(|| { + display_asset.map(|asset| { + if inherited { + format!("Inherited | {}", asset.sub_asset_id) + } else { + asset.sub_asset_id.clone() + } + }) + }) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(|| "No imported asset selected".to_string()); + ui.add( + egui::Label::new(egui::RichText::new(id).color( + if invalid_hovered { + crate::ui::theme::ERROR + } else { + TEXT_DIM + }, + )) + .truncate(), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if actions == AssetSelectorActions::Full && clearable { + let clear = ui.add_enabled( + asset.is_some(), + egui::Button::new(phosphor_icon(icons::X, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ); + if clear.on_hover_text("Clear actor override").clicked() { + response.clear = true; + } + } + if actions == AssetSelectorActions::Full { + let locate = ui.add_enabled( + display_asset.is_some_and(EditorAssetRef::is_resolved), + egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ); + if locate.on_hover_text("Locate in content browser").clicked() { + response.locate = true; + } + } + if candidates.is_empty() { + ui.add_enabled( + false, + egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ) + .on_hover_text("No imported assets available"); + } else { + let menu = + ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| { + ui.set_min_width(220.0); + for candidate in candidates { + let selected = display_asset + .is_some_and(|asset| asset == &candidate.reference); + let clicked = ui + .selectable_label( + selected, + candidate.label.as_str(), + ) + .on_hover_text(candidate.detail.as_str()) + .clicked(); + if clicked { + response.selected = + Some(candidate.reference.clone()); + ui.close(); + } + } + }); + menu.response.on_hover_text("Browse assets"); + } + }); + }); + }); + if drop_hovered && ui.input(|input| input.pointer.any_released()) { + if let Some(candidate) = drop_candidate { + response.selected = Some(candidate.reference.clone()); + response.accepted_drop = true; + } + } + }, + ); +} + +#[cfg(test)] +mod widget_identity_tests { + use super::*; + + #[test] + fn endurance_guard_actor_widget_identity_survives_runtime_entity_replacement() { + let mut world = World::new(); + let first = world.spawn(ActorId::new("persistent-actor")).id(); + let first_key = stable_actor_widget_id(&world, first); + world.despawn(first); + let replacement = world.spawn(ActorId::new("persistent-actor")).id(); + + assert_eq!(first_key, stable_actor_widget_id(&world, replacement)); + assert_eq!(first_key, "actor:persistent-actor"); + } +} diff --git a/crates/editor/src/ui/inspector/mesh_renderers.rs b/crates/editor/src/ui/inspector/mesh_renderers.rs new file mode 100644 index 0000000..01e77fc --- /dev/null +++ b/crates/editor/src/ui/inspector/mesh_renderers.rs @@ -0,0 +1,425 @@ +use super::*; + +pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let material_candidates = brush_face_material_ref_candidates(world); + let material_drop_candidate = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()) + .and_then(|selection| { + asset_ref_candidate_from_selection(world, &selection, AssetRefCandidateKind::Material) + }); + let invalid_material_drop = + material_drop_invalid_reason(world, material_drop_candidate.is_some()); + let Some(mut renderer) = world.get::(entity).cloned() else { + return; + }; + let original = renderer.clone(); + let mut changed = false; + let mut accepted_drop = false; + let mut options = ComponentCardOptions::removable( + COMPONENT_SKINNED_MESH_RENDERER, + "Skinned Mesh Renderer", + icons::PERSON_SIMPLE_RUN, + ); + options.active_toggle = false; + options.removable = world.get::(entity).is_none(); + options.resettable = false; + options.copyable = false; + options.summary = "Skeleton-bound renderer"; + let context = component_card_context(world, entity, options); + let response = component_card(ui, &context, |ui| { + property_row(ui, "Source", |ui| { + ui.add( + egui::Label::new(if renderer.path.trim().is_empty() { + "Unassigned" + } else { + renderer.path.as_str() + }) + .truncate(), + ); + }); + property_row(ui, "Scene", |ui| { + ui.label(renderer.scene_index.to_string()); + }); + property_row(ui, "Asset ID", |ui| { + ui.add( + egui::Label::new(if renderer.asset_id.trim().is_empty() { + "Legacy/path-only" + } else { + renderer.asset_id.as_str() + }) + .truncate(), + ); + }); + ui.label( + egui::RichText::new( + "Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.", + ) + .small() + .color(TEXT_DIM), + ); + ui.add_space(8.0); + if renderer.materials.slots.is_empty() { + ui.label( + egui::RichText::new("No imported slots; reimport the source model") + .small() + .color(TEXT_DIM), + ); + } + let materials = crate::ui::materials::MaterialsSectionViewModel { + slot_ids: renderer + .materials + .slots + .iter() + .map(|slot| slot.id.0.clone()) + .collect(), + }; + crate::ui::materials::materials_section(ui, &materials, |ui| { + for slot in &mut renderer.materials.slots { + let widget = material_slot_widget_ui( + world, + ui, + entity, + slot, + &material_candidates, + material_drop_candidate.as_ref(), + invalid_material_drop, + ); + changed |= widget.changed; + accepted_drop |= widget.accepted_drop; + ui.add_space(8.0); + } + }); + for orphan in &renderer.materials.orphaned_assignments { + ui.label( + egui::RichText::new(format!( + "Orphaned: {} ({})", + orphan.last_known_name, orphan.slot_id.0 + )) + .small() + .color(egui::Color32::YELLOW), + ); + } + if world.get::(entity).is_some() { + ui.label( + egui::RichText::new( + "Remove the Animation Controller before removing its renderer.", + ) + .small() + .color(TEXT_MUTED), + ); + } + }); + apply_component_card_response(world, entity, response); + if accepted_drop { + clear_asset_drag(world); + } + if changed && renderer != original { + let result = reflected_component_transaction( + world, + entity, + "Assign Skinned Material Slot", + shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, + COMPONENT_SKINNED_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(renderer); + Ok(()) + }, + ); + if let Err(error) = result { + world.resource_mut::().status = error; + } + } +} + +pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh); + let material_candidates = brush_face_material_ref_candidates(world); + let material_drop_candidate = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()) + .and_then(|selection| { + asset_ref_candidate_from_selection(world, &selection, AssetRefCandidateKind::Material) + }); + let invalid_material_drop = + material_drop_invalid_reason(world, material_drop_candidate.is_some()); + let Some(mut renderer) = world.get::(entity).cloned() else { + return; + }; + let original = renderer.clone(); + let mut changed = false; + let mut accepted_drop = false; + let mut remove_entry = None; + for index in 0..renderer.slots.len() { + ensure_slot_id(&mut renderer.slots[index], index); + let part = &renderer.slots[index]; + if renderer.materials.slot(&part.material_slot_id).is_none() { + renderer.materials.slots.push(shared::MaterialSlot { + id: part.material_slot_id.clone(), + name: part.name.clone(), + material: None, + }); + } + } + + let mut options = ComponentCardOptions::removable( + COMPONENT_STATIC_MESH_RENDERER, + "Static Mesh Renderer", + icons::CUBE, + ); + options.summary = "Renderer array"; + options.body_margin = 0; + let card = component_card_context(world, entity, options); + let card_response = component_card(ui, &card, |ui| { + let add_slot = renderer_array_header(ui, renderer.slots.len()); + ui.add_space(10.0); + if renderer.slots.is_empty() { + ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM)); + } + + let (entries, material_set) = (&mut renderer.slots, &mut renderer.materials); + for (index, entry) in entries.iter_mut().enumerate() { + ensure_slot_id(entry, index); + let thumbnail = thumbnail_for_mesh(&entry.mesh, &mesh_candidates); + let renderer_open_id = ui.make_persistent_id(("static_renderer_open", &entry.id.0)); + let mut renderer_open = ui + .ctx() + .data_mut(|data| data.get_persisted::(renderer_open_id)) + .unwrap_or(index == 0); + + renderer_panel(ui, |ui| { + renderer_panel_header( + ui, + index, + &entry.name, + thumbnail, + &mut renderer_open, + |ui| { + status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active"); + if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() { + remove_entry = Some(index); + } + ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM)); + }, + ); + if !renderer_open { + return; + } + let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| { + renderer_properties_header(ui); + property_row(ui, "Name", |ui| { + changed |= ui + .add_sized( + [text_field_width(ui), 20.0], + egui::TextEdit::singleline(&mut entry.name), + ) + .changed(); + }); + let mesh_response = asset_selector_row( + ui, + "Mesh", + icons::CUBE, + Some(&entry.mesh), + None, + false, + &mesh_candidates, + None, + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = mesh_response.selected { + entry.mesh = selected; + changed = true; + } + if mesh_response.locate { + locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates); + } + + ui.horizontal_wrapped(|ui| { + changed |= ui.checkbox(&mut entry.visible, "Visible").changed(); + changed |= ui + .checkbox(&mut entry.cast_shadows, "Cast shadows") + .changed(); + changed |= ui + .checkbox(&mut entry.receive_shadows, "Receive shadows") + .changed(); + }); + }; + + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| draw_fields(ui, entry)); + renderer_material_slots_header(ui, 1); + if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) { + let widget = renderer_material_slot_row(ui, index, |ui| { + material_slot_widget_ui( + world, + ui, + entity, + slot, + &material_candidates, + material_drop_candidate.as_ref(), + invalid_material_drop, + ) + }); + changed |= widget.changed; + accepted_drop |= widget.accepted_drop; + } + }); + ui.ctx() + .data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open)); + } + + if add_slot { + let index = renderer.slots.len(); + let entry = StaticMeshRendererEntry { + id: ComponentInstanceId::new(format!("slot:{index}")), + material_slot_id: ComponentInstanceId::new(format!("slot:manual:{index}")), + ..Default::default() + }; + renderer.materials.slots.push(shared::MaterialSlot { + id: entry.material_slot_id.clone(), + name: format!("Material {index}"), + material: None, + }); + renderer.slots.push(entry); + changed = true; + } + }); + apply_component_card_response(world, entity, card_response); + if accepted_drop { + clear_asset_drag(world); + } + + if let Some(index) = remove_entry { + let removed = renderer.slots.remove(index); + if let Some(slot_index) = renderer + .materials + .slots + .iter() + .position(|slot| slot.id == removed.material_slot_id) + { + let slot = renderer.materials.slots.remove(slot_index); + if let Some(material) = slot.material { + renderer + .materials + .orphaned_assignments + .push(shared::OrphanedMaterialAssignment { + slot_id: slot.id, + last_known_name: slot.name, + material, + }); + } + } + changed = true; + } + if changed && renderer != original { + set_static_mesh_renderer_with_history(world, entity, renderer); + } +} + +fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool { + let palette = crate::ui::design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), 40.0), + egui::Sense::hover(), + ); + ui.painter().rect_filled(rect, 4.0, palette.elevated); + ui.painter().text( + rect.left_center() + egui::vec2(12.0, 0.0), + egui::Align2::LEFT_CENTER, + icons::SQUARES_FOUR.as_str(), + egui::FontId::new(16.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(36.0, 0.0), + egui::Align2::LEFT_CENTER, + "Mesh renderers", + crate::ui::design_system::typography::TypeRole::Section.font(), + palette.text_primary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(132.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("{count}"), + crate::ui::design_system::typography::TypeRole::Small.font(), + palette.text_muted, + ); + let add_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 116.0, rect.top() + 7.0), + egui::vec2(104.0, 26.0), + ); + ui.put( + add_rect, + egui::Button::new(crate::ui::design_system::controls::icon_label( + icons::PLUS, + "Add renderer", + crate::ui::design_system::typography::TypeRole::Body, + 12.0, + palette.text_primary, + )), + ) + .clicked() +} + +fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) { + let palette = crate::ui::design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), 38.0), + egui::Sense::hover(), + ); + ui.painter().text( + rect.left_center() + egui::vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + "Material slots", + crate::ui::design_system::typography::TypeRole::Section.font(), + palette.text_primary, + ); + ui.painter().text( + rect.right_center() - egui::vec2(10.0, 0.0), + egui::Align2::RIGHT_CENTER, + format!("{count}"), + crate::ui::design_system::typography::TypeRole::Small.font(), + palette.text_muted, + ); +} + +fn renderer_properties_header(ui: &mut egui::Ui) { + let palette = crate::ui::design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), 18.0), + egui::Sense::hover(), + ); + ui.painter().text( + rect.left_center(), + egui::Align2::LEFT_CENTER, + "Renderer properties", + crate::ui::design_system::typography::TypeRole::Caption.font(), + palette.text_muted, + ); +} + +fn renderer_material_slot_row( + ui: &mut egui::Ui, + index: usize, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let palette = crate::ui::design_system::palette(ui); + ui.horizontal_top(|ui| { + let (index_rect, _) = ui.allocate_exact_size(egui::vec2(31.0, 60.0), egui::Sense::hover()); + ui.painter().text( + index_rect.center_top() + egui::vec2(0.0, 18.0), + egui::Align2::CENTER_CENTER, + index.to_string(), + crate::ui::design_system::typography::TypeRole::Body.font(), + palette.text_muted, + ); + ui.vertical(|ui| { + ui.set_width(ui.available_width().max(1.0)); + add_contents(ui) + }) + .inner + }) + .inner +} diff --git a/crates/editor/src/ui/inspector/primitive_domains.rs b/crates/editor/src/ui/inspector/primitive_domains.rs new file mode 100644 index 0000000..fdc5bce --- /dev/null +++ b/crates/editor/src/ui/inspector/primitive_domains.rs @@ -0,0 +1,612 @@ +use super::*; + +pub(super) fn clear_asset_drag(world: &mut World) { + if let Some(mut assets) = world.get_resource_mut::() { + assets.clear_drag(); + } +} + +pub(super) fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let material_candidates = brush_face_material_ref_candidates(world); + let material_drop_candidate = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()) + .and_then(|selection| { + asset_ref_candidate_from_selection(world, &selection, AssetRefCandidateKind::Material) + }); + let invalid_material_drop = + material_drop_invalid_reason(world, material_drop_candidate.is_some()); + let Some(mut primitive) = world.get::(entity).cloned() else { + return; + }; + let original = primitive.clone(); + let mut changed = false; + let mut accepted_drop = false; + let mut options = + ComponentCardOptions::removable(COMPONENT_PRIMITIVE, "Primitive", icons::CUBE); + options.summary = "Shape · Size · Materials"; + let card = component_card_context(world, entity, options); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Shape", |ui| { + ui.horizontal_wrapped(|ui| { + for (label, shape) in [ + ("Box", PrimitiveShape::Box), + ("Sphere", PrimitiveShape::Sphere), + ("Ramp", PrimitiveShape::Ramp), + ] { + changed |= ui + .selectable_value(&mut primitive.shape, shape, label) + .changed(); + } + }); + }); + property_row(ui, "Size X", |ui| { + changed |= ui + .add(egui::Slider::new(&mut primitive.size.x, 0.1..=1000.0)) + .changed(); + }); + property_row(ui, "Size Y", |ui| { + changed |= ui + .add(egui::Slider::new(&mut primitive.size.y, 0.1..=1000.0)) + .changed(); + }); + property_row(ui, "Size Z", |ui| { + changed |= ui + .add(egui::Slider::new(&mut primitive.size.z, 0.1..=1000.0)) + .changed(); + }); + ui.add_space(8.0); + let materials = crate::ui::materials::MaterialsSectionViewModel { + slot_ids: vec![primitive.surface.id.0.clone()], + }; + let widget = crate::ui::materials::materials_section(ui, &materials, |ui| { + material_slot_widget_ui( + world, + ui, + entity, + &mut primitive.surface, + &material_candidates, + material_drop_candidate.as_ref(), + invalid_material_drop, + ) + }); + changed |= widget.changed; + accepted_drop |= widget.accepted_drop; + }); + apply_component_card_response(world, entity, card_response); + if accepted_drop { + clear_asset_drag(world); + } + if changed && primitive != original { + set_primitive_with_history(world, entity, primitive); + } +} + +pub(super) fn light_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut light) = world.get::(entity).cloned() else { + return; + }; + let _original = light.clone(); + let mut changed = false; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_LIGHT_DESC, "Light", icons::LIGHTBULB), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Kind", |ui| { + ui.horizontal_wrapped(|ui| { + for (label, kind) in [ + ("Point", AuthoringLightKind::Point), + ("Spot", AuthoringLightKind::Spot), + ("Directional", AuthoringLightKind::Directional), + ] { + if ui.selectable_label(light.kind == kind, label).clicked() { + let color = light.color; + light = LightDesc { + color, + ..LightDesc::for_kind(kind) + }; + changed = true; + } + } + }); + }); + let solari_disabled = solari_disables_local_light(world, &light); + if solari_disabled { + ui.colored_label( + egui::Color32::from_rgb(255, 180, 100), + "Point and spot lights are disabled while Solari is active. Use a directional light or emissive material, or switch GI to Forward.", + ); + } + ui.add_enabled_ui(!solari_disabled, |ui| { + let mut color = [light.color.r, light.color.g, light.color.b, light.color.a]; + property_row(ui, "Color", |ui| { + if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() { + light.color = ColorDesc { + r: color[0], + g: color[1], + b: color[2], + a: color[3], + }; + changed = true; + } + }); + let intensity_label = match light.kind { + AuthoringLightKind::Directional => "Intensity (lux)", + _ => "Intensity (lumens)", + }; + let intensity_max = match light.kind { + AuthoringLightKind::Directional => AUTHORING_DIRECTIONAL_LUX_MAX, + _ => AUTHORING_POINT_SPOT_LUMENS_MAX, + }; + property_row(ui, intensity_label, |ui| { + ui.horizontal_wrapped(|ui| { + if ui + .add( + egui::Slider::new(&mut light.intensity, 0.0..=intensity_max) + .show_value(false), + ) + .changed() + { + changed = true; + } + if ui + .add_sized( + [fit_width(ui, 72.0, 120.0), 20.0], + egui::DragValue::new(&mut light.intensity) + .range(0.0..=intensity_max) + .speed(intensity_max * 0.001), + ) + .changed() + { + changed = true; + } + }); + }); + if matches!( + light.kind, + AuthoringLightKind::Point | AuthoringLightKind::Spot + ) { + property_row(ui, "Range (m)", |ui| { + changed |= ui + .add(egui::Slider::new(&mut light.range, 0.0..=100.0)) + .changed(); + }); + } + if matches!(light.kind, AuthoringLightKind::Spot) { + property_row(ui, "Inner angle", |ui| { + changed |= ui + .add(egui::Slider::new(&mut light.inner_angle_deg, 1.0..=80.0)) + .changed(); + }); + property_row(ui, "Outer angle", |ui| { + changed |= ui + .add(egui::Slider::new(&mut light.outer_angle_deg, 1.0..=90.0)) + .changed(); + }); + } + property_row(ui, "Shadows", |ui| { + changed |= ui.checkbox(&mut light.shadows, "Cast shadows").changed(); + }); + if matches!(light.kind, AuthoringLightKind::Directional) { + ui.small("Controls project sun while this directional exists."); + } + }); + }); + apply_component_card_response(world, entity, card_response); + if changed { + set_light_with_history(world, entity, light); + } +} + +pub(super) fn solari_disables_local_light(world: &World, light: &LightDesc) -> bool { + matches!( + light.kind, + AuthoringLightKind::Point | AuthoringLightKind::Spot + ) && world + .get_resource::() + .is_some_and(|profile| profile.gi_path == settings::GiPath::SolariDeferred) + && world + .get_resource::() + .is_some_and(|caps| caps.rt_supported) +} + +pub(super) fn rigid_body_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut body) = world.get::(entity).copied() else { + return; + }; + let original = body; + let mut changed = false; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_RIGID_BODY_DESC, "Rigid Body", icons::SPHERE), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Body", |ui| { + ui.horizontal_wrapped(|ui| { + for (label, kind) in [ + ("Static", AuthoringRigidBody::Static), + ("Kinematic", AuthoringRigidBody::Kinematic), + ("Dynamic", AuthoringRigidBody::Dynamic), + ] { + changed |= ui.selectable_value(&mut body.body, kind, label).changed(); + } + }); + }); + }); + apply_component_card_response(world, entity, card_response); + if changed && body != original { + set_rigid_body_with_history(world, entity, body); + } +} + +pub(super) fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh); + let diagnostic_entry = diagnose_collider(world, entity); + let Some(mut collider) = world.get::(entity).cloned() else { + return; + }; + let original = collider.clone(); + let mut changed = false; + let renderer_meshes: Vec = world + .get::(entity) + .map(|renderer| { + renderer + .slots + .iter() + .map(|slot| slot.mesh.clone()) + .collect() + }) + .unwrap_or_default(); + + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_COLLIDER_DESC, "Collider", icons::SELECTION), + ); + let card_response = component_card(ui, &card, |ui| { + if let Some(entry) = diagnostic_entry.as_ref() { + let (label, color) = match entry.overlay_status { + ColliderOverlayStatus::Valid => ("Ready", egui::Color32::from_rgb(112, 210, 144)), + ColliderOverlayStatus::Trigger => { + ("Trigger", egui::Color32::from_rgb(86, 195, 235)) + } + ColliderOverlayStatus::Disabled => { + ("Disabled", egui::Color32::from_rgb(143, 151, 163)) + } + ColliderOverlayStatus::Warning => { + ("Warning", egui::Color32::from_rgb(242, 173, 72)) + } + ColliderOverlayStatus::Error => ("Invalid", egui::Color32::from_rgb(244, 91, 99)), + }; + ui.horizontal_wrapped(|ui| { + ui.colored_label(color, egui::RichText::new(label).strong()); + ui.label(egui::RichText::new(entry.shape_label).color(TEXT_DIM)); + if entry.runtime_ready { + ui.label(egui::RichText::new("Hydrated").color(TEXT_MUTED).small()); + } + }); + for diagnostic in &entry.diagnostics { + let color = match diagnostic.severity { + ColliderDiagnosticSeverity::Info => TEXT_MUTED, + ColliderDiagnosticSeverity::Warning => egui::Color32::from_rgb(242, 173, 72), + ColliderDiagnosticSeverity::Error => egui::Color32::from_rgb(244, 91, 99), + }; + ui.label( + egui::RichText::new(&diagnostic.message) + .color(color) + .small(), + ); + ui.label( + egui::RichText::new(&diagnostic.repair) + .color(TEXT_MUTED) + .small(), + ); + } + if entry.highest_severity() == Some(ColliderDiagnosticSeverity::Error) + && ui.small_button("Reset shape").clicked() + { + collider.shape = ColliderShapeDesc::default(); + collider.enabled = true; + changed = true; + } + ui.add_space(2.0); + } + property_row(ui, "Mode", |ui| { + changed |= ui.checkbox(&mut collider.enabled, "Enabled").changed(); + changed |= ui.checkbox(&mut collider.is_trigger, "Trigger").changed(); + }); + property_row(ui, "Shape", |ui| { + let mut shape_kind = collider_shape_kind(&collider.shape); + egui::ComboBox::from_id_salt(("collider_shape_kind", entity)) + .selected_text(shape_kind) + .show_ui(ui, |ui| { + for label in ["Box", "Sphere", "Capsule", "Static Mesh"] { + if ui.selectable_label(shape_kind == label, label).clicked() { + shape_kind = label; + } + } + }); + if shape_kind != collider_shape_kind(&collider.shape) { + collider.shape = + convert_collider_shape(&collider.shape, shape_kind, renderer_meshes.clone()); + changed = true; + } + }); + + match &mut collider.shape { + ColliderShapeDesc::Cuboid { + x_length, + y_length, + z_length, + } => { + changed |= dimension_drag(ui, "X", x_length); + changed |= dimension_drag(ui, "Y", y_length); + changed |= dimension_drag(ui, "Z", z_length); + } + ColliderShapeDesc::Sphere { radius } => { + changed |= dimension_drag(ui, "Radius", radius); + } + ColliderShapeDesc::Capsule { radius, height } => { + changed |= dimension_drag(ui, "Radius", radius); + changed |= dimension_drag(ui, "Height", height); + } + ColliderShapeDesc::StaticMesh { meshes, .. } => { + if meshes.is_empty() { + ui.label(egui::RichText::new("No mesh collider sources").color(TEXT_DIM)); + } + for mesh in meshes.iter_mut() { + let mesh_response = asset_selector_row( + ui, + "Mesh", + icons::CUBE, + Some(mesh), + None, + false, + &mesh_candidates, + None, + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = mesh_response.selected { + *mesh = selected; + changed = true; + } + if mesh_response.locate { + locate_asset_ref(world, Some(mesh), &mesh_candidates); + } + } + if !renderer_meshes.is_empty() && ui.button("Use renderer meshes").clicked() { + *meshes = renderer_meshes.clone(); + changed = true; + } + } + } + }); + apply_component_card_response(world, entity, card_response); + + if changed && collider != original { + set_collider_with_history(world, entity, collider); + } +} + +pub(super) fn collider_shape_kind(shape: &ColliderShapeDesc) -> &'static str { + match shape { + ColliderShapeDesc::Cuboid { .. } => "Box", + ColliderShapeDesc::Sphere { .. } => "Sphere", + ColliderShapeDesc::Capsule { .. } => "Capsule", + ColliderShapeDesc::StaticMesh { .. } => "Static Mesh", + } +} + +pub(super) fn convert_collider_shape( + previous: &ColliderShapeDesc, + shape_kind: &str, + renderer_meshes: Vec, +) -> ColliderShapeDesc { + let dimensions = match previous { + ColliderShapeDesc::Cuboid { + x_length, + y_length, + z_length, + } => Vec3::new(*x_length, *y_length, *z_length), + ColliderShapeDesc::Sphere { radius } => Vec3::splat(*radius * 2.0), + ColliderShapeDesc::Capsule { radius, height } => { + Vec3::new(*radius * 2.0, *height, *radius * 2.0) + } + ColliderShapeDesc::StaticMesh { .. } => Vec3::ONE, + } + .max(Vec3::splat(0.001)); + + match shape_kind { + "Sphere" => ColliderShapeDesc::Sphere { + radius: dimensions.max_element() * 0.5, + }, + "Capsule" => ColliderShapeDesc::Capsule { + radius: dimensions.x.max(dimensions.z) * 0.5, + height: dimensions.y, + }, + "Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes), + _ => ColliderShapeDesc::Cuboid { + x_length: dimensions.x, + y_length: dimensions.y, + z_length: dimensions.z, + }, + } +} + +pub(super) fn dimension_drag(ui: &mut egui::Ui, label: &str, value: &mut f32) -> bool { + property_row(ui, label, |ui| { + ui.add_sized( + [fit_width(ui, 72.0, 120.0), 20.0], + egui::DragValue::new(value) + .range(0.001..=10_000.0) + .speed(0.05) + .min_decimals(2) + .max_decimals(3), + ) + .changed() + }) +} + +pub(super) fn physics_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut body) = world.get::(entity).cloned() else { + return; + }; + let _original = body.clone(); + let mut changed = false; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_PHYSICS_BODY, "Physics", icons::SPHERE), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Body", |ui| { + ui.horizontal_wrapped(|ui| { + for (label, kind) in [ + ("Static", AuthoringRigidBody::Static), + ("Kinematic", AuthoringRigidBody::Kinematic), + ("Dynamic", AuthoringRigidBody::Dynamic), + ] { + if ui.selectable_label(body.body == kind, label).clicked() { + body.body = kind; + changed = true; + } + } + }); + }); + }); + apply_component_card_response(world, entity, card_response); + if changed { + set_physics_with_history(world, entity, body); + } +} + +pub(super) fn player_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + if world.get::(entity).is_none() { + return; + } + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable( + COMPONENT_PLAYER_SPAWN, + "Player Spawn", + icons::PERSON_SIMPLE_RUN, + ), + ); + let card_response = component_card(ui, &card, |ui| { + ui.label( + egui::RichText::new("Uses this actor transform as a player start.").color(TEXT_DIM), + ); + }); + apply_component_card_response(world, entity, card_response); +} + +pub(super) fn weapon_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut spawn) = world.get::(entity).cloned() else { + return; + }; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_WEAPON_SPAWN, "Weapon Spawn", icons::CROSSHAIR), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Weapon ID", |ui| { + ui.add_sized( + [text_field_width(ui), 20.0], + egui::TextEdit::singleline(&mut spawn.weapon_id), + ); + }); + }); + apply_component_card_response(world, entity, card_response); + if let Ok(mut e) = world.get_entity_mut(entity) { + e.insert(spawn); + } +} + +pub(super) fn trigger_volume_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut trigger) = world.get::(entity).cloned() else { + return; + }; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable( + COMPONENT_TRIGGER_VOLUME, + "Trigger Volume", + icons::SELECTION, + ), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Event", |ui| { + ui.add_sized( + [text_field_width(ui), 20.0], + egui::TextEdit::singleline(&mut trigger.event_name), + ); + }); + property_row(ui, "Half X", |ui| { + ui.add(egui::Slider::new(&mut trigger.half_extents.x, 0.1..=50.0)); + }); + property_row(ui, "Half Y", |ui| { + ui.add(egui::Slider::new(&mut trigger.half_extents.y, 0.1..=50.0)); + }); + property_row(ui, "Half Z", |ui| { + ui.add(egui::Slider::new(&mut trigger.half_extents.z, 0.1..=50.0)); + }); + }); + apply_component_card_response(world, entity, card_response); + if let Ok(mut e) = world.get_entity_mut(entity) { + e.insert(trigger); + } +} + +pub(super) fn team_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut spawn) = world.get::(entity).cloned() else { + return; + }; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_TEAM_SPAWN, "Team Spawn", icons::FLAG), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Team", |ui| { + ui.add(egui::Slider::new(&mut spawn.team_id, 0..=8)); + }); + }); + apply_component_card_response(world, entity, card_response); + if let Ok(mut e) = world.get_entity_mut(entity) { + e.insert(spawn); + } +} + +pub(super) fn objective_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + let Some(mut marker) = world.get::(entity).cloned() else { + return; + }; + let card = component_card_context( + world, + entity, + ComponentCardOptions::removable(COMPONENT_OBJECTIVE_MARKER, "Objective", icons::TARGET), + ); + let card_response = component_card(ui, &card, |ui| { + property_row(ui, "Objective ID", |ui| { + ui.add_sized( + [text_field_width(ui), 20.0], + egui::TextEdit::singleline(&mut marker.objective_id), + ); + }); + }); + apply_component_card_response(world, entity, card_response); + if let Ok(mut e) = world.get_entity_mut(entity) { + e.insert(marker); + } +} + +pub(super) fn prefab_instance_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + crate::assets::prefab_overrides::prefab_instance_inspector_ui(world, ui, entity); +} diff --git a/crates/editor/src/ui/inspector/property_blocks.rs b/crates/editor/src/ui/inspector/property_blocks.rs new file mode 100644 index 0000000..2a8e223 --- /dev/null +++ b/crates/editor/src/ui/inspector/property_blocks.rs @@ -0,0 +1,655 @@ +use super::*; + +pub(super) fn plan_property_block_promotion( + world: &World, + entity: Entity, + base: &MaterialRef, + block: &MaterialPropertyBlock, + slot_name: &str, +) -> Result { + plan_material_instance_assignment(world, entity, base, block, slot_name, true) +} + +pub(super) fn plan_material_instance_assignment( + world: &World, + entity: Entity, + base: &MaterialRef, + block: &MaterialPropertyBlock, + slot_name: &str, + include_property_block: bool, +) -> Result { + let selected_path = base.0.source_path.as_deref().ok_or_else(|| { + "select a project Material before promoting this property block".to_string() + })?; + let project_root = world + .get_resource::() + .map(|workspace| PathBuf::from(&workspace.root)) + .unwrap_or_else(|| PathBuf::from(".")); + let selected_relative = project_relative_path(&project_root, Path::new(selected_path))?; + let selected_absolute = project_root.join(&selected_relative); + let (selected_text, selected_snapshot) = read_snapshotted_text(&selected_absolute)?; + let mut source_snapshots = vec![PromotionSourceSnapshot { + path: selected_absolute, + snapshot: selected_snapshot, + }]; + let (direct_base, mut parameters, mut textures, base_label, direct_base_asset) = if let Ok( + material, + ) = + ron::from_str::(&selected_text) + { + ( + base.clone(), + Vec::new(), + Vec::new(), + material.label.clone(), + material, + ) + } else if let Ok(instance) = ron::from_str::(&selected_text) { + let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| { + "the selected Material Instance has no direct project base".to_string() + })?; + let base_relative = project_relative_path(&project_root, Path::new(base_path))?; + let base_absolute = project_root.join(base_relative); + let (base_text, base_snapshot) = read_snapshotted_text(&base_absolute)?; + let direct_base_asset = ron::from_str::(&base_text) + .map_err(|_| "the selected Material Instance base is missing or invalid".to_string())?; + source_snapshots.push(PromotionSourceSnapshot { + path: base_absolute, + snapshot: base_snapshot, + }); + ( + instance.base, + instance.overrides.values, + instance.overrides.textures, + instance.label, + direct_base_asset, + ) + } else { + return Err( + "Default Grid and imported source materials cannot be promotion bases; select a project Material first" + .to_string(), + ); + }; + let schema_path = direct_base_asset + .shader_ref + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .or(direct_base_asset.shader.schema_path.as_deref()); + let schema = if let Some(schema_path) = schema_path { + let schema_relative = project_relative_path(&project_root, Path::new(schema_path))?; + let schema_absolute = project_root.join(schema_relative); + let (schema_text, schema_snapshot) = read_snapshotted_text(&schema_absolute)?; + source_snapshots.push(PromotionSourceSnapshot { + path: schema_absolute, + snapshot: schema_snapshot, + }); + Some( + ron::from_str::(&schema_text).map_err(|error| { + format!("the selected Material shader schema is invalid: {error}") + })?, + ) + } else { + None + }; + let custom_surface = schema + .as_ref() + .and_then(|schema| schema.wgsl_path.as_deref()) + .is_some(); + if include_property_block { + blacksite_surface::validate_property_block_for_promotion( + &project_root, + block, + schema.as_ref(), + custom_surface, + )?; + } + let variation_kind = if include_property_block { + "Override" + } else { + "Instance" + }; + let mut promoted = MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: format!("{base_label} {slot_name} {variation_kind}"), + base: direct_base, + overrides: shared::MaterialInputSet { + values: std::mem::take(&mut parameters), + textures: std::mem::take(&mut textures), + }, + }; + if include_property_block { + promoted.merge_overrides(block.parameters.clone(), block.textures.clone()); + } + + let current_folder = world + .get_resource::() + .map(|assets| assets.current_folder.clone()) + .unwrap_or_else(|| ASSETS_ROOT.to_string()); + let destination_folder = + if current_folder.starts_with(ASSETS_ROOT) && current_folder != BUILTINS_FOLDER { + PathBuf::from(current_folder) + } else { + selected_relative + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(ASSETS_ROOT)) + }; + let stem: String = promoted + .label + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + let stem = stem.trim_matches('_').to_string(); + let mut index = 0usize; + let destination_path = loop { + let suffix = if index == 0 { + String::new() + } else { + format!("_{index}") + }; + let candidate = destination_folder.join(format!("{stem}{suffix}.material-instance.ron")); + if !project_root.join(&candidate).exists() { + break candidate; + } + index += 1; + }; + let bytes = ron::ser::to_string_pretty(&promoted, ron::ser::PrettyConfig::default()) + .map(String::into_bytes) + .map_err(|error| format!("could not serialize promoted Material Instance: {error}"))?; + let destination_absolute = project_root.join(&destination_path); + let target_snapshot = FileSnapshot::capture(&destination_absolute)?; + if target_snapshot != FileSnapshot::missing() { + return Err(format!( + "{} already exists; refresh the promotion review", + destination_path.display() + )); + } + Ok(PendingPropertyBlockPromotion { + entity, + slot_id: block.slot_id.clone(), + slot_name: slot_name.to_string(), + base: base.clone(), + block: block.clone(), + project_root, + destination_path, + destination_absolute, + target_snapshot, + source_snapshots, + instance: promoted, + bytes, + clear_property_block: include_property_block, + }) +} + +pub(super) fn read_snapshotted_text(path: &Path) -> Result<(String, FileSnapshot), String> { + let bytes = + fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + let snapshot = FileSnapshot::from_loaded_bytes(path, &bytes); + let text = String::from_utf8(bytes) + .map_err(|error| format!("{} is not UTF-8: {error}", path.display()))?; + Ok((text, snapshot)) +} + +pub(super) fn project_relative_path(project_root: &Path, path: &Path) -> Result { + let relative = if path.is_absolute() { + path.strip_prefix(project_root).map_err(|_| { + format!( + "{} is outside project root {}", + path.display(), + project_root.display() + ) + })? + } else { + path + }; + if relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) || !relative.starts_with(ASSETS_ROOT) + { + return Err(format!( + "{} is not a safe project asset path", + path.display() + )); + } + Ok(relative.to_path_buf()) +} + +pub(super) fn validate_property_block_promotion( + world: &World, + promotion: &PendingPropertyBlockPromotion, +) -> Result<(), String> { + if promotion.clear_property_block { + let current_block = world + .get::(promotion.entity) + .and_then(|blocks| { + blocks + .slots + .iter() + .find(|block| block.slot_id == promotion.slot_id) + }) + .ok_or_else(|| "the runtime property block no longer exists".to_string())?; + if current_block != &promotion.block { + return Err( + "the runtime property block changed; refresh the promotion review".to_string(), + ); + } + } + let current_base = world + .get::(promotion.entity) + .and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) + .and_then(|slot| slot.material.as_ref()) + .or_else(|| { + world + .get::(promotion.entity) + .and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) + .and_then(|slot| slot.material.as_ref()) + }) + .or_else(|| { + world + .get::(promotion.entity) + .filter(|primitive| primitive.surface.id == promotion.slot_id) + .and_then(|primitive| primitive.surface.material.as_ref()) + }) + .ok_or_else(|| "the target renderer slot is missing or has no project base".to_string())?; + if current_base != &promotion.base { + return Err("the target renderer slot changed; refresh the promotion review".to_string()); + } + for source in &promotion.source_snapshots { + if FileSnapshot::capture(&source.path)? != source.snapshot { + return Err(format!( + "{} changed after the promotion review opened", + source.path.display() + )); + } + } + if FileSnapshot::capture(&promotion.destination_absolute)? != promotion.target_snapshot { + return Err(format!( + "{} changed after the promotion review opened", + promotion.destination_path.display() + )); + } + Ok(()) +} + +pub(super) fn commit_property_block_promotion( + world: &mut World, + promotion: &PendingPropertyBlockPromotion, +) -> Result { + validate_property_block_promotion(world, promotion)?; + let registry_path = promotion.project_root.join(content_pipeline::REGISTRY_PATH); + let runtime_catalog_path = promotion + .project_root + .join(content_pipeline::RUNTIME_CATALOG_PATH); + let registry_bytes_before = fs::read(®istry_path).ok(); + let runtime_catalog_bytes_before = fs::read(&runtime_catalog_path).ok(); + publish_authored_file( + world, + &promotion.destination_absolute, + &promotion.bytes, + &promotion.target_snapshot, + FileWriteIntent::MaterialInstance, + )?; + let normalized = promotion + .destination_path + .to_string_lossy() + .replace('\\', "/"); + let registry_before = world + .get_resource::() + .ok_or_else(|| "Asset registry is unavailable".to_string())? + .document(); + let registry_result = if let Some(mut registry) = world.get_resource_mut::() { + ensure_asset_record_at( + &mut registry, + &promotion.project_root, + normalized.clone(), + promotion.instance.label.clone(), + "MaterialInstance", + ) + .map(|record| record.id.as_string()) + } else { + Err("Asset registry is unavailable".to_string()) + }; + let id = match registry_result { + Ok(id) => id, + Err(error) => { + return match remove_published_file(&promotion.destination_absolute) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error}; rollback failed: {rollback_error}")), + }; + } + }; + let reference = MaterialRef::new( + EditorAssetRef::new(id, "material:instance", promotion.instance.label.clone()) + .with_source_path(&normalized), + ); + let history_label = if promotion.clear_property_block { + "Promote Material Property Block" + } else { + "Create Material Instance and Assign" + }; + let transaction = if let Some(mut renderer) = + world.get::(promotion.entity).cloned() + { + let slot = renderer + .materials + .slot_mut(&promotion.slot_id) + .ok_or_else(|| "the target skinned renderer slot is missing".to_string())?; + slot.material = Some(reference); + reflected_component_transaction( + world, + promotion.entity, + history_label, + shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, + COMPONENT_SKINNED_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(renderer); + Ok(()) + }, + ) + } else if let Some(mut renderer) = world.get::(promotion.entity).cloned() { + let slot = renderer + .materials + .slot_mut(&promotion.slot_id) + .ok_or_else(|| "the target static renderer slot is missing".to_string())?; + slot.material = Some(reference); + reflected_component_transaction( + world, + promotion.entity, + history_label, + shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER, + COMPONENT_STATIC_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(renderer); + Ok(()) + }, + ) + } else if let Some(mut primitive) = world.get::(promotion.entity).cloned() { + if primitive.surface.id != promotion.slot_id { + return Err("the target primitive material slot is missing".into()); + } + primitive.surface.material = Some(reference); + reflected_component_transaction( + world, + promotion.entity, + history_label, + shared::AUTHORING_COMPONENT_PRIMITIVE, + COMPONENT_PRIMITIVE, + move |world, entity| { + world.entity_mut(entity).insert(primitive); + Ok(()) + }, + ) + } else { + Err("the target entity no longer has a supported renderer".to_string()) + }; + if let Err(error) = transaction { + let mut rollback_errors = Vec::new(); + if let Err(rollback_error) = remove_published_file(&promotion.destination_absolute) { + rollback_errors.push(rollback_error); + } + if let Some(mut registry) = world.get_resource_mut::() { + registry.schema_version = registry_before.schema_version; + registry.defaults = registry_before.defaults; + registry.records = registry_before.records; + registry.index_dirty = false; + } + if let Err(rollback_error) = + restore_optional_file(®istry_path, registry_bytes_before.as_deref()) + { + rollback_errors.push(rollback_error); + } + if let Err(rollback_error) = restore_optional_file( + &runtime_catalog_path, + runtime_catalog_bytes_before.as_deref(), + ) { + rollback_errors.push(rollback_error); + } + if !rollback_errors.is_empty() { + return Err(format!( + "{error}; promotion rollback failed: {}", + rollback_errors.join("; ") + )); + } + return Err(error); + } + if promotion.clear_property_block { + clear_promoted_property_block(world, promotion.entity, &promotion.slot_id); + } + Ok(promotion.destination_path.clone()) +} + +pub(super) fn remove_published_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("could not remove {}: {error}", path.display())), + } +} + +pub(super) fn restore_optional_file(path: &Path, bytes: Option<&[u8]>) -> Result<(), String> { + if let Some(bytes) = bytes { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::write(path, bytes) + .map_err(|error| format!("could not restore {}: {error}", path.display())) + } else { + remove_published_file(path) + } +} + +pub(super) fn clear_promoted_property_block( + world: &mut World, + entity: Entity, + slot_id: &ComponentInstanceId, +) { + let mut remove_component = false; + if let Some(mut blocks) = world.get_mut::(entity) { + blocks.slots.retain(|block| &block.slot_id != slot_id); + remove_component = blocks.slots.is_empty(); + } + if remove_component { + world.entity_mut(entity).remove::(); + } +} + +pub(super) fn property_block_promotion_ui( + ui: &mut egui::Ui, + block: Option<&MaterialPropertyBlock>, + has_project_base: bool, +) -> bool { + let Some(block) = block else { + return false; + }; + ui.separator(); + ui.small( + egui::RichText::new(format!( + "Runtime overrides: {} parameters, {} textures", + block.parameters.len(), + block.textures.len() + )) + .color(TEXT_DIM), + ); + let clicked = ui + .add_enabled( + has_project_base, + egui::Button::new("Promote to Material Instance"), + ) + .clicked(); + if !has_project_base { + ui.small( + egui::RichText::new("Assign a project Material or Instance first").color(TEXT_MUTED), + ); + } + clicked +} + +pub(super) fn open_property_block_promotion_review( + world: &mut World, + entity: Entity, + slot_id: ComponentInstanceId, + slot_name: String, + base: MaterialRef, + block: MaterialPropertyBlock, +) { + if slot_id != block.slot_id { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = "the runtime property block targets a different slot".to_string(); + } + return; + } + match plan_property_block_promotion(world, entity, &base, &block, &slot_name) { + Ok(pending) => { + let mut review = world.resource_mut::(); + review.pending = Some(pending); + review.error = None; + } + Err(error) => { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = error; + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PromotionReviewAction { + Cancel, + Refresh, + Commit, +} + +pub(crate) fn property_block_promotion_review_ui(world: &mut World, context: &egui::Context) { + let (pending, error) = { + let review = world.resource::(); + (review.pending.clone(), review.error.clone()) + }; + let Some(pending) = pending else { + return; + }; + let mut action = context + .input(|input| input.key_pressed(egui::Key::Escape)) + .then_some(PromotionReviewAction::Cancel); + egui::Window::new("Promote Material Property Block") + .id(egui::Id::new("property_block_promotion_review")) + .collapsible(false) + .resizable(true) + .default_width(560.0) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(context, |ui| { + ui.label( + "Review the exact reusable Material Instance before publishing the slot assignment.", + ); + ui.add_space(8.0); + egui::Grid::new("property_block_promotion_summary") + .num_columns(2) + .spacing([12.0, 6.0]) + .show(ui, |ui| { + ui.label("Slot"); + ui.label(format!("{} ({})", pending.slot_name, pending.slot_id.0)); + ui.end_row(); + ui.label("Selected base"); + ui.label(&pending.base.0.label); + ui.end_row(); + ui.label("Direct base"); + ui.label(&pending.instance.base.0.label); + ui.end_row(); + ui.label("Target"); + ui.add( + egui::Label::new(pending.destination_path.display().to_string()).wrap(), + ); + ui.end_row(); + ui.label("Sparse overrides"); + ui.label(format!( + "{} parameters, {} textures", + pending.instance.overrides.values.len(), + pending.instance.overrides.textures.len() + )); + ui.end_row(); + }); + ui.add_space(8.0); + ui.small( + egui::RichText::new( + "The runtime block remains active until the file, registry record, and exact scene slot all commit successfully.", + ) + .color(TEXT_DIM), + ); + if let Some(error) = error.as_deref() { + ui.add_space(8.0); + ui.colored_label(egui::Color32::LIGHT_RED, error); + } + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = Some(PromotionReviewAction::Cancel); + } + if ui.button("Refresh Review").clicked() { + action = Some(PromotionReviewAction::Refresh); + } + if ui + .button("Create Instance and Assign Slot") + .clicked() + { + action = Some(PromotionReviewAction::Commit); + } + }); + }); + match action { + Some(PromotionReviewAction::Cancel) => { + let mut review = world.resource_mut::(); + review.pending = None; + review.error = None; + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = "Property block promotion cancelled".to_string(); + } + } + Some(PromotionReviewAction::Refresh) => match plan_property_block_promotion( + world, + pending.entity, + &pending.base, + &pending.block, + &pending.slot_name, + ) { + Ok(refreshed) => { + let mut review = world.resource_mut::(); + review.pending = Some(refreshed); + review.error = None; + } + Err(error) => world.resource_mut::().error = Some(error), + }, + Some(PromotionReviewAction::Commit) => { + match commit_property_block_promotion(world, &pending) { + Ok(path) => { + let mut review = world.resource_mut::(); + review.pending = None; + review.error = None; + world.resource_mut::().refresh(); + invalidate_on_catalog_refresh(world); + if let Some(mut scene_io) = world.get_resource_mut::() + { + scene_io.status = format!("Promoted property block to {}", path.display()); + } + } + Err(error) => { + world.resource_mut::().error = + Some(error.clone()); + if let Some(mut scene_io) = world.get_resource_mut::() + { + scene_io.status = error; + } + } + } + } + None => {} + } +} diff --git a/crates/editor/src/ui/inspector/renderer_panel.rs b/crates/editor/src/ui/inspector/renderer_panel.rs new file mode 100644 index 0000000..7bf6195 --- /dev/null +++ b/crates/editor/src/ui/inspector/renderer_panel.rs @@ -0,0 +1,124 @@ +//! Penpot renderer-panel chrome shared by static renderer entries. + +use super::*; + +const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0; +const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 58.0; + +pub(super) fn renderer_panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(WIDGET_BG.linear_multiply(0.82)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .show(ui, |ui| { + ui.set_max_width(ui.available_width()); + add_contents(ui); + }); + ui.add_space(10.0); +} + +pub(super) fn renderer_panel_header( + ui: &mut egui::Ui, + index: usize, + slot_name: &str, + texture_id: Option, + open: &mut bool, + add_actions: impl FnOnce(&mut egui::Ui), +) { + let palette = crate::ui::design_system::palette(ui); + let height = if *open { + EXPANDED_RENDERER_HEADER_HEIGHT + } else { + COLLAPSED_RENDERER_HEADER_HEIGHT + }; + let (rect, header_response) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), height), + egui::Sense::click(), + ); + ui.painter().rect_filled( + egui::Rect::from_min_size( + rect.min + egui::vec2(6.0, 8.0), + egui::vec2(3.0, height - 16.0), + ), + 2.0, + palette.accent, + ); + ui.painter().text( + rect.left_center() + egui::vec2(20.0, 0.0), + egui::Align2::CENTER_CENTER, + if *open { + icons::CARET_DOWN.as_str() + } else { + icons::CARET_RIGHT.as_str() + }, + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + let preview_size = if *open { 58.0 } else { 40.0 }; + let preview = egui::Rect::from_center_size( + egui::pos2(rect.left() + 63.0, rect.center().y), + egui::vec2(preview_size, preview_size), + ); + ui.painter().rect( + preview, + 4.0, + PANEL_BG_DARK, + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + if let Some(texture_id) = texture_id { + ui.painter().image( + texture_id, + preview.shrink(3.0), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + let label = if slot_name.trim().is_empty() { + "Mesh renderer" + } else { + slot_name.trim() + }; + ui.painter().text( + egui::pos2(rect.left() + 104.0, rect.center().y - 8.0), + egui::Align2::LEFT_CENTER, + label, + crate::ui::design_system::typography::TypeRole::Title.font(), + palette.text_primary, + ); + ui.painter().text( + egui::pos2(rect.left() + 104.0, rect.center().y + 10.0), + egui::Align2::LEFT_CENTER, + format!("Renderer {index}"), + crate::ui::design_system::typography::TypeRole::Small.font(), + palette.text_muted, + ); + let action_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 150.0, rect.center().y - 14.0), + egui::vec2(140.0, 28.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + add_actions, + ); + }); + if header_response.clicked() + && ui + .input(|input| input.pointer.interact_pos()) + .is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer)) + { + *open = !*open; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renderer_headers_match_current_penpot_heights() { + assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0); + assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 58.0); + } +} diff --git a/crates/editor/src/ui/inspector/sun_textures.rs b/crates/editor/src/ui/inspector/sun_textures.rs new file mode 100644 index 0000000..6e47920 --- /dev/null +++ b/crates/editor/src/ui/inspector/sun_textures.rs @@ -0,0 +1,326 @@ +use super::*; + +pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { + if world.get::(entity).is_none() { + return; + } + + let rendering = world + .resource::() + .rendering + .clone(); + let scene_override_active = has_scene_sun_override(world); + let mut create_override = false; + let mut options = ComponentCardOptions::fixed(COMPONENT_PROJECT_SUN, "Project Sun", icons::SUN); + options.resettable = false; + let card = component_card_context(world, entity, options); + let card_response = component_card(ui, &card, |ui| { + ui.label("Runtime default world lighting from assets/project.ron."); + property_row(ui, "Illuminance", |ui| { + ui.small(format!("{:.0} lux", rendering.sun_illuminance)); + }); + property_row(ui, "Ambient", |ui| { + ui.small(format!( + "{:.2}, {:.2}, {:.2} @ {:.1}", + rendering.ambient_color[0], + rendering.ambient_color[1], + rendering.ambient_color[2], + rendering.ambient_brightness + )); + }); + property_row(ui, "Shadows", |ui| { + ui.small(format!( + "{} cascades, max {:.0}m", + rendering.shadow_cascades, rendering.shadow_max_distance + )); + }); + if scene_override_active { + ui.label(egui::RichText::new("Scene sun override is active.").weak()); + } else if ui.button("Create Scene Sun Override").clicked() { + create_override = true; + } + }); + apply_component_card_response(world, entity, card_response); + + if create_override { + let sun = create_scene_sun_override_from_project_settings(world); + crate::ui::request_ui_selection(world, &[sun]); + } +} + +pub(super) fn has_scene_sun_override(world: &mut World) -> bool { + world + .query_filtered::<( + &shared::LightDesc, + Option<&AuthoringComponentStates>, + Option<&InspectorOrder>, + ), With>() + .iter(world) + .any(|(light, states, legacy_order)| { + authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC) + && matches!(light.kind, shared::AuthoringLightKind::Directional) + }) +} + +pub(super) fn texture_asset_picker_ui( + world: &mut World, + ui: &mut egui::Ui, + label: &str, + value: &mut Option, + candidates: &[TextureAssetCandidate], +) -> bool { + let current_path = value.clone(); + let dragging_selection = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()); + let drop_candidate = dragging_selection + .as_ref() + .and_then(|selection| texture_path_from_selection(world, selection)); + let mut selected_path: Option = None; + let mut clear = false; + let mut locate = false; + let mut accepted_drop = false; + let current_candidate = current_path.as_ref().and_then(|path| { + candidates + .iter() + .find(|candidate| candidate.path == *path) + .cloned() + }); + + property_row(ui, label, |ui| { + let control_width = ui + .available_width() + .max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0))); + let row_height = 30.0; + let (rect, _response) = + ui.allocate_exact_size(egui::vec2(control_width, row_height), egui::Sense::hover()); + let valid_drag = drop_candidate.is_some(); + let row_hovered = ui.rect_contains_pointer(rect); + let drop_hovered = valid_drag && row_hovered; + let stroke = if drop_hovered { + egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(125, 198, 255)) + } else if valid_drag { + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(58, 88, 122)) + } else if row_hovered { + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(92, 102, 118)) + } else { + egui::Stroke::new(1.0_f32, BORDER) + }; + let fill = if drop_hovered { + egui::Color32::from_rgb(29, 57, 86) + } else if valid_drag { + WIDGET_BG.linear_multiply(0.88) + } else if row_hovered { + WIDGET_BG.linear_multiply(1.05) + } else { + WIDGET_BG.linear_multiply(0.75) + }; + ui.painter() + .rect(rect, 4.0, fill, stroke, egui::StrokeKind::Inside); + if drop_hovered { + let badge_rect = egui::Rect::from_min_size( + rect.right_top() + egui::vec2(-82.0, 4.0), + egui::vec2(74.0, 16.0), + ); + ui.painter().rect( + badge_rect, + 3.0, + egui::Color32::from_rgb(35, 95, 155), + egui::Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().text( + badge_rect.center(), + egui::Align2::CENTER_CENTER, + "Drop texture", + egui::FontId::new(10.0, egui::FontFamily::Proportional), + egui::Color32::from_rgb(225, 241, 255), + ); + } + + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect.shrink2(egui::vec2(6.0, 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let preview_size = 22.0; + let (preview_rect, _preview_response) = + child.allocate_exact_size(egui::vec2(preview_size, preview_size), egui::Sense::hover()); + child.painter().rect( + preview_rect, + 3.0, + PANEL_BG_DARK, + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + if let Some(texture_id) = current_candidate + .as_ref() + .and_then(|candidate| candidate.texture_id) + { + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + child.painter().image( + texture_id, + preview_rect.shrink(1.0), + uv, + egui::Color32::WHITE, + ); + } else { + child.painter().text( + preview_rect.center(), + egui::Align2::CENTER_CENTER, + icons::IMAGE.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + TEXT_DIM, + ); + } + + let action_width = 92.0; + let text_width = (child.available_width() - action_width).max(1.0); + child.allocate_ui_with_layout( + egui::vec2(text_width, 22.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let display_label = current_candidate + .as_ref() + .map(|candidate| candidate.label.as_str()) + .or(current_path.as_deref()) + .unwrap_or("(none)"); + ui.add_sized( + [text_width, 20.0], + egui::Label::new(egui::RichText::new(display_label).color( + if current_path.is_some() { + TEXT_DIM.linear_multiply(1.45) + } else { + TEXT_DIM + }, + )) + .truncate(), + ); + }, + ); + + child.allocate_ui_with_layout( + egui::vec2(action_width, 28.0), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + let clear_response = ui.add_enabled( + current_path.is_some(), + egui::Button::new(phosphor_icon(icons::X, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ); + if clear_response.on_hover_text("Clear texture").clicked() { + clear = true; + } + let locate_response = ui.add_enabled( + current_path.is_some(), + egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ); + if locate_response + .on_hover_text("Locate in content browser") + .clicked() + { + locate = true; + } + if candidates.is_empty() { + ui.add_enabled( + false, + egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0)) + .frame(false) + .min_size(egui::vec2(22.0, 22.0)), + ) + .on_hover_text("No texture assets available"); + } else { + let menu = ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| { + ui.set_min_width(240.0); + for candidate in candidates { + let selected = current_path.as_deref() == Some(candidate.path.as_str()); + if ui + .selectable_label(selected, candidate.label.as_str()) + .on_hover_text(candidate.path.as_str()) + .clicked() + { + selected_path = Some(candidate.path.clone()); + ui.close(); + } + } + }); + menu.response.on_hover_text("Browse textures"); + } + }, + ); + + if drop_hovered && ui.input(|input| input.pointer.any_released()) { + if let Some(candidate) = drop_candidate.as_ref() { + selected_path = Some(candidate.path.clone()); + accepted_drop = true; + } + } + }); + + if locate { + locate_texture_asset(world, current_path.as_deref(), candidates); + } + if accepted_drop { + if let Some(mut assets) = world.get_resource_mut::() { + assets.clear_drag(); + } + } + if clear { + *value = None; + return current_path.is_some(); + } + if let Some(path) = selected_path { + let changed = current_path.as_deref() != Some(path.as_str()); + *value = Some(path); + return changed; + } + + false +} + +pub(super) fn locate_texture_asset( + world: &mut World, + path: Option<&str>, + candidates: &[TextureAssetCandidate], +) { + let Some(path) = path else { + return; + }; + let Some(candidate) = candidates.iter().find(|candidate| candidate.path == path) else { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = format!("Could not locate texture {path}"); + } + return; + }; + + reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); +} + +pub(super) fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option) -> bool { + let mut text = value.clone().unwrap_or_default(); + let before = text.clone(); + property_row(ui, label, |ui| { + ui.horizontal_wrapped(|ui| { + let clear_width = 52.0; + let field_width = (ui.available_width() - clear_width - ui.spacing().item_spacing.x) + .max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0))) + .min(TEXT_FIELD_MAX_WIDTH) + .min(ui.available_width().max(1.0)); + ui.add_sized([field_width, 20.0], egui::TextEdit::singleline(&mut text)); + if ui.button("Clear").clicked() { + text.clear(); + } + }); + }); + let changed = before != text; + *value = if text.trim().is_empty() { + None + } else { + Some(text) + }; + changed +} diff --git a/crates/editor/src/ui/inspector/tests/a.rs b/crates/editor/src/ui/inspector/tests/a.rs new file mode 100644 index 0000000..e04b728 --- /dev/null +++ b/crates/editor/src/ui/inspector/tests/a.rs @@ -0,0 +1,228 @@ + use crate::assets::EditorAsset; + use crate::history::{apply_command_undo, EditorHistory}; + use crate::ui::{DockTabRequest, UiState}; + + #[test] + fn component_collapse_persists_while_ui_state_is_scoped_out() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let entity = world + .spawn((ActorId::new("collapsed-component"), Transform::IDENTITY)) + .id(); + world.insert_resource(UiState::default_layout()); + + world.resource_scope::(|world, _ui_state| { + assert!(!world.contains_resource::()); + apply_component_card_response( + world, + entity, + ComponentCardResponse { + type_name: COMPONENT_TRANSFORM, + collapsed: Some(true), + ..Default::default() + }, + ); + let context = component_card_context( + world, + entity, + ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::CUBE), + ); + assert!(context.collapsed); + }); + } + + #[test] + fn locate_texture_requests_asset_browser_while_ui_state_is_scoped_out() { + let path = "assets/textures/scoped-locate.png"; + let folder = "assets/textures"; + let selection = AssetSelection::File(path.to_string()); + let mut world = World::new(); + world.insert_resource(EditorAssets { + folders: Vec::new(), + assets: vec![EditorAsset { + label: "Scoped Locate".into(), + path: Some(path.into()), + folder_path: folder.into(), + kind: EditorAssetKind::Texture, + }], + current_folder: crate::assets::ASSETS_ROOT.into(), + selected: None, + selections: Vec::new(), + selection_anchor: None, + dragging: None, + status: String::new(), + catalog_revision: 1, + }); + world.init_resource::(); + world.insert_resource(UiState::default_layout()); + let candidate = TextureAssetCandidate { + label: "Scoped Locate".into(), + path: path.into(), + folder_path: folder.into(), + selection: selection.clone(), + texture_id: None, + }; + + world.resource_scope::(|world, _ui_state| { + assert!(!world.contains_resource::()); + locate_texture_asset(world, Some(path), &[candidate]); + }); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, folder); + assert_eq!(assets.selected.as_ref(), Some(&selection)); + assert_eq!( + world.resource::().0, + Some(EditorTab::AssetBrowser) + ); + } + + #[test] + fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() { + let mut world = World::new(); + world.init_resource::(); + let original = ColliderDesc::static_cuboid(Vec3::new(2.0, 4.0, 6.0)); + let entity = world.spawn((LevelObject, original.clone())).id(); + + let sphere = ColliderDesc { + shape: convert_collider_shape(&original.shape, "Sphere", Vec::new()), + ..original.clone() + }; + assert_eq!(sphere.shape, ColliderShapeDesc::Sphere { radius: 3.0 }); + + set_collider_with_history(&mut world, entity, sphere.clone()); + assert_eq!(world.resource::().undo_depth(), 1); + assert_eq!(world.get::(entity), Some(&sphere)); + + apply_command_undo(&mut world); + assert_eq!(world.get::(entity), Some(&original)); + } + + fn promotion_test_root(label: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "blacksite-property-promotion-{label}-{}-{nonce}", + std::process::id() + )) + } + + fn promotion_test_world( + root: &Path, + ) -> ( + App, + Entity, + ComponentInstanceId, + MaterialRef, + MaterialPropertyBlock, + ) { + let material_folder = root.join("assets/materials"); + std::fs::create_dir_all(&material_folder).unwrap(); + let base_reference = MaterialRef::new( + EditorAssetRef::new("material-id", "material:source", "Promotion Base") + .with_source_path("assets/materials/promotion_base.ron"), + ); + let base_asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Promotion Base".into(), + shader: shared::ShaderRefDesc::default(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + provenance: None, + inputs: shared::MaterialInputSet::default(), + }; + std::fs::write( + material_folder.join("promotion_base.ron"), + ron::ser::to_string_pretty(&base_asset, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + let selected_reference = MaterialRef::new( + EditorAssetRef::new("instance-id", "material:instance", "Promotion Variant") + .with_source_path("assets/materials/promotion_variant.material-instance.ron"), + ); + let selected_instance = MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Promotion Variant".into(), + base: base_reference.clone(), + overrides: shared::MaterialInputSet { + values: vec![MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(0.4), + }], + textures: Vec::new(), + }, + }; + std::fs::write( + material_folder.join("promotion_variant.material-instance.ron"), + ron::ser::to_string_pretty(&selected_instance, ron::ser::PrettyConfig::default()) + .unwrap(), + ) + .unwrap(); + + let mut app = App::new(); + app.add_plugins(shared::SharedTypesPlugin); + let world = app.world_mut(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(ProjectWorkspace { + root: root.to_string_lossy().into_owned(), + project_id: "promotion-test".into(), + project_kind: settings::ProjectKind::Game, + last_opened_scene: None, + settings_path: "assets/project.ron".into(), + settings_dirty: false, + layout_dirty: false, + }); + world.insert_resource(EditorAssets { + folders: Vec::new(), + assets: Vec::new(), + current_folder: "assets/materials".into(), + selected: None, + selections: Vec::new(), + selection_anchor: None, + dragging: None, + status: String::new(), + catalog_revision: 1, + }); + world.insert_resource(AssetRegistry::default()); + let slot_id = ComponentInstanceId::new("slot:body"); + let renderer = StaticMeshRenderer { + slots: Vec::new(), + materials: shared::RendererMaterialSet { + slots: vec![shared::RendererMaterialSlot { + id: slot_id.clone(), + name: "Body".into(), + material: Some(selected_reference.clone()), + }], + orphaned_assignments: Vec::new(), + }, + }; + let block = MaterialPropertyBlock { + slot_id: slot_id.clone(), + parameters: vec![ + MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(0.8), + }, + MaterialParameter { + name: "metallic".into(), + value: MaterialParameterValue::Float(0.2), + }, + ], + textures: Vec::new(), + }; + let entity = world + .spawn(( + LevelObject, + ActorKind::StaticMesh, + renderer, + MaterialPropertyBlocks { + slots: vec![block.clone()], + }, + )) + .id(); + (app, entity, slot_id, selected_reference, block) + } diff --git a/crates/editor/src/ui/inspector/tests/b.rs b/crates/editor/src/ui/inspector/tests/b.rs new file mode 100644 index 0000000..a9b17da --- /dev/null +++ b/crates/editor/src/ui/inspector/tests/b.rs @@ -0,0 +1,228 @@ +#[test] + fn property_block_promotion_is_direct_base_exact_slot_and_one_history_edit() { + let root = promotion_test_root("success"); + let (mut app, entity, slot_id, selected_reference, block) = promotion_test_world(&root); + let world = app.world_mut(); + let promotion = + plan_property_block_promotion(world, entity, &selected_reference, &block, "Body") + .unwrap(); + assert!(!promotion.destination_absolute.exists()); + assert_eq!(promotion.instance.base.0.asset_id, "material-id"); + assert_eq!(promotion.instance.overrides.values.len(), 2); + + let published = commit_property_block_promotion(world, &promotion).unwrap(); + + assert_eq!(published, promotion.destination_path); + assert!(promotion.destination_absolute.exists()); + assert!(world.get::(entity).is_none()); + assert_eq!(world.resource::().undo_depth(), 1); + let renderer = world.get::(entity).unwrap(); + let assigned = renderer + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref() + .unwrap(); + assert_eq!(assigned.0.sub_asset_id, "material:instance"); + assert_eq!( + assigned.0.source_path.as_deref(), + Some(published.to_str().unwrap()) + ); + let authored = + MaterialInstanceAsset::load_from_path(promotion.destination_absolute.to_str().unwrap()) + .unwrap(); + assert_eq!(authored.base.0.asset_id, "material-id"); + assert_eq!(authored.overrides, promotion.instance.overrides); + assert!(world + .resource::() + .records + .iter() + .any(|record| record.path == published.to_string_lossy())); + + apply_command_undo(world); + let restored = world + .get::(entity) + .unwrap() + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref() + .unwrap(); + assert_eq!(restored, &selected_reference); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn create_instance_and_assign_keeps_runtime_block_and_uses_one_scene_history_edit() { + let root = promotion_test_root("create-instance"); + let (mut app, entity, slot_id, selected_reference, block) = promotion_test_world(&root); + let empty = MaterialPropertyBlock { + slot_id: slot_id.clone(), + parameters: Vec::new(), + textures: Vec::new(), + }; + let world = app.world_mut(); + let transaction = plan_material_instance_assignment( + world, + entity, + &selected_reference, + &empty, + "Body", + false, + ) + .unwrap(); + assert!(!transaction.clear_property_block); + assert!(transaction.instance.label.ends_with("Body Instance")); + assert_eq!(transaction.instance.overrides.values.len(), 1); + + let published = commit_property_block_promotion(world, &transaction).unwrap(); + + assert!(transaction.destination_absolute.exists()); + assert_eq!(world.resource::().undo_depth(), 1); + assert_eq!( + world + .get::(entity) + .and_then(|blocks| blocks.slots.first()), + Some(&block) + ); + let assigned = world + .get::(entity) + .unwrap() + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref() + .unwrap(); + assert_eq!(assigned.0.source_path.as_deref(), published.to_str()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn property_block_promotion_cancel_and_external_conflict_are_non_mutating() { + let root = promotion_test_root("conflict"); + let (mut app, entity, slot_id, selected_reference, block) = promotion_test_world(&root); + let world = app.world_mut(); + let promotion = + plan_property_block_promotion(world, entity, &selected_reference, &block, "Body") + .unwrap(); + + assert_eq!(world.resource::().undo_depth(), 0); + assert_eq!(world.resource::().records.len(), 0); + assert_eq!( + world.get::(entity).unwrap().slots, + vec![block.clone()] + ); + assert_eq!( + world + .get::(entity) + .unwrap() + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref(), + Some(&selected_reference) + ); + + std::fs::write(&promotion.destination_absolute, b"external owner").unwrap(); + let error = commit_property_block_promotion(world, &promotion).unwrap_err(); + assert!(error.contains("changed after the promotion review")); + assert_eq!( + std::fs::read(&promotion.destination_absolute).unwrap(), + b"external owner" + ); + assert_eq!(world.resource::().undo_depth(), 0); + assert_eq!(world.resource::().records.len(), 0); + assert_eq!( + world.get::(entity).unwrap().slots, + vec![block] + ); + assert_eq!( + world + .get::(entity) + .unwrap() + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref(), + Some(&selected_reference) + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn property_block_promotion_rejects_invalid_and_stale_runtime_inputs() { + let root = promotion_test_root("invalid"); + let (mut app, entity, _slot_id, selected_reference, block) = promotion_test_world(&root); + let world = app.world_mut(); + let mut invalid = block.clone(); + invalid.parameters.push(MaterialParameter { + name: "undeclared_standard_property".into(), + value: MaterialParameterValue::Float(0.5), + }); + let error = + plan_property_block_promotion(world, entity, &selected_reference, &invalid, "Body") + .unwrap_err(); + assert!(error.contains("not supported by Standard materials")); + assert_eq!(world.resource::().undo_depth(), 0); + assert_eq!(world.resource::().records.len(), 0); + + let promotion = + plan_property_block_promotion(world, entity, &selected_reference, &block, "Body") + .unwrap(); + std::fs::write( + root.join("assets/materials/promotion_variant.material-instance.ron"), + b"externally changed", + ) + .unwrap(); + let error = commit_property_block_promotion(world, &promotion).unwrap_err(); + assert!(error.contains("changed after the promotion review")); + assert!(!promotion.destination_absolute.exists()); + assert_eq!(world.resource::().undo_depth(), 0); + assert_eq!(world.resource::().records.len(), 0); + assert_eq!( + world.get::(entity).unwrap().slots, + vec![block] + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn property_block_promotion_rolls_back_file_and_catalog_when_scene_edit_fails() { + let root = promotion_test_root("rollback"); + let (mut app, entity, slot_id, selected_reference, block) = promotion_test_world(&root); + let world = app.world_mut(); + let promotion = + plan_property_block_promotion(world, entity, &selected_reference, &block, "Body") + .unwrap(); + world.entity_mut(entity).remove::(); + + let error = commit_property_block_promotion(world, &promotion).unwrap_err(); + + assert!(error.contains("actor is not editable")); + assert!(!promotion.destination_absolute.exists()); + assert!(!root.join(content_pipeline::REGISTRY_PATH).exists()); + assert!(!root.join(content_pipeline::RUNTIME_CATALOG_PATH).exists()); + assert_eq!(world.resource::().undo_depth(), 0); + assert_eq!(world.resource::().records.len(), 0); + assert_eq!( + world.get::(entity).unwrap().slots, + vec![block] + ); + assert_eq!( + world + .get::(entity) + .unwrap() + .materials + .slot(&slot_id) + .unwrap() + .material + .as_ref(), + Some(&selected_reference) + ); + std::fs::remove_dir_all(root).unwrap(); + } diff --git a/crates/editor/src/ui/layout.rs b/crates/editor/src/ui/layout.rs index 8f6ee85..2db42b6 100644 --- a/crates/editor/src/ui/layout.rs +++ b/crates/editor/src/ui/layout.rs @@ -1,7 +1,7 @@ //! Dock layout persistence in user preferences. use bevy::prelude::*; -use egui_dock::{DockState, NodeIndex}; +use egui_dock::{DockState, Node, NodeIndex}; use serde::{Deserialize, Serialize}; use crate::project_io::{write_user_preferences, UserPreferences}; @@ -24,6 +24,37 @@ pub struct LayoutSaveTimer { } const SAVE_DEBOUNCE_SECS: f32 = 1.0; +pub const INSPECTOR_MIN_WIDTH: f32 = crate::ui::design_system::INSPECTOR_MIN_WIDTH; + +/// Clamp the nearest horizontal split that owns the Inspector leaf. +pub fn clamp_inspector_width( + dock_state: &mut DockState, + panel_nodes: PanelNodes, + available_width: f32, +) { + let tree = dock_state.main_surface_mut(); + let mut child = panel_nodes.inspector(); + while let Some(parent) = child.parent() { + if matches!(tree[parent], Node::Horizontal(_)) { + let parent_width = tree[parent] + .rect() + .filter(|rect| rect.width().is_finite() && rect.width() > 0.0) + .map_or(available_width, |rect| rect.width()) + .max(1.0); + let requested = + (INSPECTOR_MIN_WIDTH.min(parent_width * 0.8) / parent_width).clamp(0.1, 0.9); + if let Node::Horizontal(split) = &mut tree[parent] { + if child.is_left() { + split.fraction = split.fraction.max(requested); + } else { + split.fraction = split.fraction.min(1.0 - requested); + } + } + break; + } + child = parent; + } +} pub fn default_dock_layout() -> DockLayoutSnapshot { let mut dock_state = DockState::new(vec![EditorTab::Viewport]); @@ -68,6 +99,8 @@ pub fn load_dock_layout(prefs: &UserPreferences) -> DockLayoutSnapshot { }; snapshot.panel_nodes = Some(PanelNodes::discover(&snapshot.dock_state, fallback)); } + let panel_nodes = snapshot.panel_nodes.expect("panel nodes normalized above"); + clamp_inspector_width(&mut snapshot.dock_state, panel_nodes, 1280.0); snapshot } @@ -146,3 +179,58 @@ pub fn reset_and_save_layout(world: &mut World) -> DockLayoutSnapshot { layout } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspector_split_is_clamped_to_the_420_pixel_floor() { + let mut layout = default_dock_layout(); + let panels = layout.panel_nodes.expect("default panel nodes"); + clamp_inspector_width(&mut layout.dock_state, panels, 1280.0); + let tree = layout.dock_state.main_surface(); + let mut child = panels.inspector(); + while let Some(parent) = child.parent() { + if let Node::Horizontal(split) = &tree[parent] { + let inspector_fraction = if child.is_left() { + split.fraction + } else { + 1.0 - split.fraction + }; + assert!(inspector_fraction * 1280.0 >= INSPECTOR_MIN_WIDTH); + return; + } + child = parent; + } + panic!("Inspector must have a horizontal split ancestor"); + } + + #[test] + fn impossible_small_hosts_keep_a_finite_bounded_split() { + let mut layout = default_dock_layout(); + let panels = layout.panel_nodes.expect("default panel nodes"); + clamp_inspector_width(&mut layout.dock_state, panels, 500.0); + for node in layout.dock_state.main_surface().iter() { + if let Node::Horizontal(split) = node { + assert!(split.fraction.is_finite()); + assert!((0.1..=0.9).contains(&split.fraction)); + } + } + } + + #[test] + fn resize_sweep_keeps_inspector_fraction_finite_and_bounded() { + let mut layout = default_dock_layout(); + let panels = layout.panel_nodes.expect("default panel nodes"); + for width in (960..=2560).step_by(17) { + clamp_inspector_width(&mut layout.dock_state, panels, width as f32); + for node in layout.dock_state.main_surface().iter() { + if let Node::Horizontal(split) = node { + assert!(split.fraction.is_finite()); + assert!((0.1..=0.9).contains(&split.fraction)); + } + } + } + } +} diff --git a/crates/editor/src/ui/material_library.rs b/crates/editor/src/ui/material_library.rs index 4c4788f..4a9446e 100644 --- a/crates/editor/src/ui/material_library.rs +++ b/crates/editor/src/ui/material_library.rs @@ -8,19 +8,21 @@ use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::icons; use shared::{ - BrushDesc, MaterialAsset, MaterialDesc, MaterialInstanceAsset, SkinnedMeshRenderer, + BrushDesc, MaterialAsset, MaterialDesc, MaterialInstanceAsset, Primitive, SkinnedMeshRenderer, StaticMeshRenderer, }; +use super::asset_card::{draw_asset_card, draw_asset_status_rail, AssetCardStatus}; use crate::asset_db::{ensure_asset_record, AssetRegistry}; use crate::assets::{ - draw_asset_cell_with, invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection, - AssetThumbnailCache, EditorAsset, EditorAssetKind, EditorAssets, ThumbnailCacheSnapshot, + invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection, AssetThumbnailCache, + EditorAsset, EditorAssetKind, EditorAssets, ThumbnailCacheSnapshot, }; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; use crate::scene_io::SceneIo; -use super::asset_browser::{create_material_instance_from_base, top_level_asset_details_panel}; +use super::asset_browser::top_level_asset_details_panel; +use super::materials::create_material_instance_from_base; use super::theme::{ panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG, @@ -99,6 +101,7 @@ struct MaterialLibraryRow { kind: MaterialDocumentKind, scene_users: Vec, dependent_instances: usize, + dirty: bool, error: Option, } @@ -422,7 +425,12 @@ fn material_grid( for chunk in rows.chunks(columns) { ui.horizontal(|ui| { for row in chunk { - let response = draw_asset_cell_with( + let document_status = row.asset.path.as_deref().and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }); + let response = draw_asset_card( ui, &row.asset, cache.texture_for(&row.asset), @@ -430,6 +438,11 @@ fn material_grid( cache.is_failed(&row.asset).then_some("failed"), selected.as_ref() == Some(&row.selection), thumbnail_size, + AssetCardStatus::for_document( + document_status.as_ref(), + row.dirty, + cache.is_failed(&row.asset), + ), ); draw_material_grid_badges(ui, &response, row); handle_material_response(world, response, row, selected_entities); @@ -452,7 +465,7 @@ fn draw_material_grid_badges(ui: &egui::Ui, response: &egui::Response, row: &Mat kind_rect, 3.0, ELEVATED_BG, - egui::Stroke::new(1.0, kind_color), + egui::Stroke::new(1.0_f32, kind_color), egui::StrokeKind::Inside, ); ui.painter().text( @@ -464,14 +477,14 @@ fn draw_material_grid_badges(ui: &egui::Ui, response: &egui::Response, row: &Mat ); if !row.scene_users.is_empty() { let usage_rect = egui::Rect::from_min_size( - response.rect.right_top() + egui::vec2(-35.0, 6.0), + response.rect.left_top() + egui::vec2(40.0, 6.0), egui::vec2(29.0, 17.0), ); ui.painter().rect( usage_rect, 3.0, ELEVATED_BG, - egui::Stroke::new(1.0, SUCCESS), + egui::Stroke::new(1.0_f32, SUCCESS), egui::StrokeKind::Inside, ); ui.painter().text( @@ -484,84 +497,6 @@ fn draw_material_grid_badges(ui: &egui::Ui, response: &egui::Response, row: &Mat } } -fn material_list( - world: &mut World, - ui: &mut egui::Ui, - rows: &[MaterialLibraryRow], - selected: &Option, - cache: &ThumbnailCacheSnapshot, - selected_entities: &mut SelectedEntities, -) { - for row in rows { - let is_selected = selected.as_ref() == Some(&row.selection); - let height = 42.0; - let (rect, response) = ui.allocate_exact_size( - egui::vec2(ui.available_width(), height), - egui::Sense::click_and_drag(), - ); - ui.painter().rect( - rect, - 3.0, - if is_selected { - crate::ui::theme::SELECTION_BG_MUTED - } else if response.hovered() { - ELEVATED_BG - } else { - WIDGET_BG - }, - egui::Stroke::new(1.0, if is_selected { SELECTION } else { BORDER }), - egui::StrokeKind::Inside, - ); - let thumb_rect = egui::Rect::from_min_size( - rect.left_top() + egui::vec2(5.0, 5.0), - egui::vec2(32.0, 32.0), - ); - if let Some(texture) = cache.texture_for(&row.asset) { - ui.painter().image( - texture, - thumb_rect, - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } else { - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - icons::PALETTE.as_str(), - egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-regular".into())), - if row.error.is_some() { ERROR } else { TEXT }, - ); - } - ui.painter().text( - egui::pos2(44.0 + rect.left(), rect.center().y - 7.0), - egui::Align2::LEFT_CENTER, - row.asset.label.as_str(), - egui::FontId::proportional(13.0), - TEXT, - ); - ui.painter().text( - egui::pos2(44.0 + rect.left(), rect.center().y + 9.0), - egui::Align2::LEFT_CENTER, - row.asset.path.as_deref().unwrap_or(""), - egui::FontId::monospace(10.0), - TEXT_MUTED, - ); - ui.painter().text( - rect.right_center() - egui::vec2(10.0, 0.0), - egui::Align2::RIGHT_CENTER, - format!( - "{} | {} scene | {} instances", - row.kind.label(), - row.scene_users.len(), - row.dependent_instances - ), - egui::FontId::proportional(11.0), - if row.error.is_some() { ERROR } else { TEXT_DIM }, - ); - handle_material_response(world, response, row, selected_entities); - } -} - fn handle_material_response( world: &mut World, response: egui::Response, @@ -621,6 +556,10 @@ fn handle_material_response( }); } +#[path = "material_library/list.rs"] +mod list; +use list::material_list; + fn material_rows(world: &mut World) -> Vec { let usages = collect_scene_material_usage(world); let documents = world.resource::().documents.clone(); @@ -637,6 +576,12 @@ fn material_rows(world: &mut World) -> Vec { .into_iter() .map(|document| { let normalized = normalize_path(document.asset.path.as_deref().unwrap_or_default()); + let dirty = document.asset.path.as_deref().is_some_and(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + .is_some_and(|snapshot| snapshot.dirty) + }); MaterialLibraryRow { selection: document.selection, scene_users: usages.get(&normalized).cloned().unwrap_or_default(), @@ -646,6 +591,7 @@ fn material_rows(world: &mut World) -> Vec { .unwrap_or_default(), asset: document.asset, kind: document.kind, + dirty, error: document.error, } }) @@ -719,6 +665,24 @@ fn collect_scene_material_usage(world: &mut World) -> HashMap)>(); + for (entity, primitive, name) in primitives.iter(world) { + if let Some(path) = primitive + .surface + .effective_material() + .and_then(|reference| reference.0.source_path.as_deref()) + { + add_usage( + &mut usages, + path, + MaterialSceneUser { + entity, + actor_label: actor_label(entity, name), + detail: "Primitive / Surface".into(), + }, + ); + } + } let mut materials = world.query::<(Entity, &MaterialDesc, Option<&Name>)>(); for (entity, material, name) in materials.iter(world) { if let Some(path) = material.material_asset_path.as_deref() { @@ -855,7 +819,11 @@ fn select_scene_user(world: &mut World, selected_entities: &mut SelectedEntities } fn create_material_asset(world: &mut World) -> Result<(), String> { - let path = next_material_path(Path::new(MATERIALS_FOLDER)); + create_material_asset_in(world, Path::new(MATERIALS_FOLDER)) +} + +pub(crate) fn create_material_asset_in(world: &mut World, folder: &Path) -> Result<(), String> { + let path = next_material_path(folder); let label = path .file_stem() .and_then(|stem| stem.to_str()) @@ -874,10 +842,11 @@ fn create_material_asset(world: &mut World) -> Result<(), String> { let asset = MaterialAsset { schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, label: label.clone(), - shader: None, + shader: Default::default(), shader_ref: None, render_state: shared::MaterialRenderState::default(), - material: MaterialDesc::default(), + provenance: None, + inputs: shared::MaterialInputSet::from_material_desc(&MaterialDesc::default()), }; let text = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) .map_err(|error| format!("could not serialize new material: {error}"))?; @@ -900,7 +869,7 @@ fn create_material_asset(world: &mut World) -> Result<(), String> { { let mut assets = world.resource_mut::(); assets.refresh(); - assets.current_folder = MATERIALS_FOLDER.into(); + assets.current_folder = normalize_path(&folder.to_string_lossy()); assets.select(AssetSelection::File(catalog_path.clone())); } invalidate_on_catalog_refresh(world); @@ -930,6 +899,20 @@ mod tests { ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot, }; + #[test] + fn new_material_path_uses_the_requested_content_folder() { + let root = std::env::temp_dir().join(format!( + "blacksite-material-create-folder-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("new_material.ron"), b"occupied").unwrap(); + + assert_eq!(next_material_path(&root), root.join("new_material_2.ron")); + + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn usage_counts_exact_authored_material_paths() { let material_path = "assets/materials/steel.ron"; @@ -940,7 +923,6 @@ mod tests { slots: vec![RendererMaterialSlot { id: ComponentInstanceId::new("slot:body"), name: "Body".into(), - source_material: None, material: Some(MaterialRef::new( EditorAssetRef::new("steel", "material:source", "Steel") .with_source_path(material_path), @@ -989,6 +971,7 @@ mod tests { }, ], dependent_instances: 1, + dirty: false, error: None, }; let mut state = MaterialLibraryState { @@ -1015,10 +998,11 @@ mod tests { let base = MaterialAsset { schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, label: "Base".into(), - shader: None, + shader: shared::ShaderRefDesc::default(), shader_ref: None, render_state: Default::default(), - material: MaterialDesc::default(), + provenance: None, + inputs: shared::MaterialInputSet::default(), }; std::fs::write( &base_path, @@ -1032,8 +1016,7 @@ mod tests { EditorAssetRef::new("base", "material:source", "Base") .with_source_path(base_path.to_string_lossy().into_owned()), ), - parameters: Vec::new(), - textures: Vec::new(), + overrides: shared::MaterialInputSet::default(), }; std::fs::write( &instance_path, @@ -1055,8 +1038,11 @@ mod tests { ], current_folder: root.to_string_lossy().into_owned(), selected: None, + selections: Vec::new(), + selection_anchor: None, dragging: None, status: String::new(), + catalog_revision: 1, }) .init_resource::() .add_systems(Update, refresh_material_library_catalog); diff --git a/crates/editor/src/ui/material_library/list.rs b/crates/editor/src/ui/material_library/list.rs new file mode 100644 index 0000000..286b4f2 --- /dev/null +++ b/crates/editor/src/ui/material_library/list.rs @@ -0,0 +1,101 @@ +use super::*; + +pub(super) fn material_list( + world: &mut World, + ui: &mut egui::Ui, + rows: &[MaterialLibraryRow], + selected: &Option, + cache: &ThumbnailCacheSnapshot, + selected_entities: &mut SelectedEntities, +) { + for row in rows { + let document_status = row.asset.path.as_deref().and_then(|path| { + world + .get_resource::() + .and_then(|store| store.snapshot_for_path(path)) + }); + let status = AssetCardStatus::for_document( + document_status.as_ref(), + row.dirty, + row.error.is_some() || cache.is_failed(&row.asset), + ); + let is_selected = selected.as_ref() == Some(&row.selection); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 42.0), + egui::Sense::click_and_drag(), + ); + ui.painter().rect( + rect, + 3.0, + if is_selected { + crate::ui::theme::SELECTION_BG_MUTED + } else if response.hovered() { + ELEVATED_BG + } else { + WIDGET_BG + }, + egui::Stroke::new(1.0_f32, if is_selected { SELECTION } else { BORDER }), + egui::StrokeKind::Inside, + ); + let thumb_rect = egui::Rect::from_min_size( + rect.left_top() + egui::vec2(5.0, 5.0), + egui::vec2(32.0, 32.0), + ); + if let Some(texture) = cache.texture_for(&row.asset) { + ui.painter().image( + texture, + thumb_rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + ui.painter().text( + thumb_rect.center(), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-regular".into())), + if row.error.is_some() { ERROR } else { TEXT }, + ); + } + let metadata_width = if rect.width() >= 500.0 { 190.0 } else { 0.0 }; + let text_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + 44.0, rect.top() + 4.0), + egui::pos2(rect.right() - metadata_width - 24.0, rect.bottom() - 4.0), + ); + let text_painter = ui.painter().with_clip_rect(text_rect); + text_painter.text( + egui::pos2(text_rect.left(), rect.center().y - 7.0), + egui::Align2::LEFT_CENTER, + row.asset.label.as_str(), + egui::FontId::proportional(13.0), + TEXT, + ); + text_painter.text( + egui::pos2(text_rect.left(), rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + row.asset.path.as_deref().unwrap_or(""), + egui::FontId::monospace(10.0), + TEXT_MUTED, + ); + if metadata_width > 0.0 { + let metadata_rect = egui::Rect::from_min_max( + egui::pos2(rect.right() - metadata_width - 18.0, rect.top()), + egui::pos2(rect.right() - 18.0, rect.bottom()), + ); + ui.painter().with_clip_rect(metadata_rect).text( + metadata_rect.right_center(), + egui::Align2::RIGHT_CENTER, + format!( + "{} · {} scene · {} instances", + row.kind.label(), + row.scene_users.len(), + row.dependent_instances + ), + egui::FontId::proportional(11.0), + if row.error.is_some() { ERROR } else { TEXT_DIM }, + ); + } + draw_asset_status_rail(ui, rect, status); + handle_material_response(world, response, row, selected_entities); + } +} diff --git a/crates/editor/src/ui/materials/advanced_preview.rs b/crates/editor/src/ui/materials/advanced_preview.rs new file mode 100644 index 0000000..8905fc6 --- /dev/null +++ b/crates/editor/src/ui/materials/advanced_preview.rs @@ -0,0 +1,281 @@ +//! Non-interactive Penpot v2.3.1 renderer-control preview. + +use bevy_egui::egui; +use egui_phosphor_icons::icons; + +use crate::ui::design_system::typography::TypeRole; +use crate::ui::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT}; + +const PLANNED_TOOLTIP: &str = "Planned — renderer support pending"; + +pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) { + let open_id = ui.make_persistent_id("material_unsupported_advanced_open"); + let reveal_id = open_id.with("reveal_complete_body"); + let mut open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(false); + let width = ui.available_width().max(1.0); + let mode = super::layout::ResponsiveMaterialLayout::for_width(width); + let height = if open { + if mode == super::layout::ResponsiveMaterialLayout::Transient { + 254.0 + } else { + ADVANCED_EXPANDED_HEIGHT + } + } else { + ADVANCED_COLLAPSED_HEIGHT + }; + let palette = design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().rect( + rect, + 5.0, + palette.section, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 30.0)); + ui.painter().rect_filled(header, 4.0, palette.elevated); + let header_response = ui.interact(header, open_id.with("header"), egui::Sense::click()); + if header_response.clicked() { + open = !open; + ui.ctx().data_mut(|data| data.insert_temp(reveal_id, open)); + } + header_response.on_hover_text(if open { + "Collapse Advanced preview" + } else { + "Expand Advanced preview" + }); + ui.painter().text( + header.left_center() + egui::vec2(12.0, 0.0), + egui::Align2::LEFT_CENTER, + if open { + icons::CARET_DOWN.as_str() + } else { + icons::CARET_RIGHT.as_str() + }, + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + ui.painter().text( + header.left_center() + egui::vec2(30.0, 0.0), + egui::Align2::LEFT_CENTER, + "Advanced", + TypeRole::Section.font(), + palette.text_primary, + ); + if !open && rect.width() >= 300.0 { + ui.painter().text( + header.right_center() - egui::vec2(12.0, 0.0), + egui::Align2::RIGHT_CENTER, + PLANNED_TOOLTIP, + TypeRole::Small.font(), + palette.text_muted, + ); + } + if open { + match mode { + super::layout::ResponsiveMaterialLayout::Reference => reference_controls(ui, rect), + super::layout::ResponsiveMaterialLayout::Compact => { + compact_reference_controls(ui, rect) + } + super::layout::ResponsiveMaterialLayout::Transient => compact_controls(ui, rect), + } + } + let reveal_complete_body = ui + .ctx() + .data_mut(|data| data.get_temp::(reveal_id)) + .unwrap_or(false); + if open && reveal_complete_body && rect.height() > ADVANCED_COLLAPSED_HEIGHT { + ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); + ui.ctx().data_mut(|data| data.remove::(reveal_id)); + } + ui.ctx() + .data_mut(|data| data.insert_persisted(open_id, open)); +} + +fn reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field(ui, rect, 28.0, 42.0, "Culling", "Back"); + disabled_field(ui, rect, 28.0, 76.0, "Render Queue", "From Shader"); + disabled_toggle(ui, rect, 328.0, 42.0, "Depth Write", true); + disabled_toggle(ui, rect, 328.0, 76.0, "Receive Shadows", true); + planned_note(ui, rect, 28.0, 124.0); +} + +fn compact_reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field_at(ui, rect, 20.0, 90.0, 90.0, 42.0, "Culling", "Back"); + disabled_field_at( + ui, + rect, + 20.0, + 90.0, + 90.0, + 76.0, + "Render Queue", + "From Shader", + ); + disabled_toggle_at( + ui, + rect, + 196.0, + rect.width() - 38.0, + 42.0, + "Depth Write", + true, + ); + disabled_toggle_at( + ui, + rect, + 196.0, + rect.width() - 38.0, + 76.0, + "Receive Shadows", + true, + ); + planned_note(ui, rect, 20.0, 124.0); +} + +fn compact_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field(ui, rect, 20.0, 42.0, "Culling", "Back"); + disabled_field(ui, rect, 20.0, 78.0, "Render Queue", "From Shader"); + disabled_toggle(ui, rect, 20.0, 124.0, "Depth Write", true); + disabled_toggle(ui, rect, 20.0, 160.0, "Receive Shadows", true); + planned_note(ui, rect, 20.0, 210.0); +} + +fn disabled_field(ui: &mut egui::Ui, rect: egui::Rect, x: f32, y: f32, label: &str, value: &str) { + disabled_field_at(ui, rect, x, x + 106.0, 150.0, y, label, value); +} + +#[expect( + clippy::too_many_arguments, + reason = "explicit Penpot preview geometry" +)] +fn disabled_field_at( + ui: &mut egui::Ui, + rect: egui::Rect, + label_x: f32, + field_x: f32, + field_width: f32, + y: f32, + label: &str, + value: &str, +) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(label_x, y), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_muted, + ); + let field = egui::Rect::from_min_size( + egui::pos2(rect.left() + field_x, rect.top() + y - 7.0), + egui::vec2(field_width.min(rect.width() - field_x - 8.0).max(1.0), 24.0), + ); + paint_disabled_field(ui, field, value); +} + +fn disabled_toggle( + ui: &mut egui::Ui, + rect: egui::Rect, + x: f32, + y: f32, + label: &str, + enabled: bool, +) { + disabled_toggle_at(ui, rect, x, x + 106.0, y, label, enabled); +} + +fn disabled_toggle_at( + ui: &mut egui::Ui, + rect: egui::Rect, + label_x: f32, + toggle_x: f32, + y: f32, + label: &str, + enabled: bool, +) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(label_x, y), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_muted, + ); + let toggle = egui::Rect::from_min_size( + egui::pos2(rect.left() + toggle_x, rect.top() + y - 8.0), + egui::vec2(30.0, 16.0), + ); + ui.painter().rect( + toggle, + 8.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let thumb_x = if enabled { + toggle.right() - 8.0 + } else { + toggle.left() + 8.0 + }; + ui.painter().circle_filled( + egui::pos2(thumb_x, toggle.center().y), + 5.0, + palette.text_muted, + ); + ui.interact( + toggle, + ui.make_persistent_id(("advanced_toggle", label)), + egui::Sense::hover(), + ) + .on_hover_text(PLANNED_TOOLTIP); +} + +fn paint_disabled_field(ui: &mut egui::Ui, rect: egui::Rect, value: &str) { + let palette = design_system::palette(ui); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.left_center() + egui::vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + value, + TypeRole::Body.font(), + palette.text_muted, + ); + ui.interact( + rect, + ui.make_persistent_id(("advanced_field", value)), + egui::Sense::hover(), + ) + .on_hover_text(PLANNED_TOOLTIP); +} + +fn planned_note(ui: &egui::Ui, rect: egui::Rect, x: f32, y: f32) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(x, y), + egui::Align2::LEFT_CENTER, + PLANNED_TOOLTIP, + TypeRole::Small.font(), + palette.text_muted, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_advanced_preview_matches_v231_heights() { + assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0); + assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0); + } +} diff --git a/crates/editor/src/ui/materials/asset_editor.rs b/crates/editor/src/ui/materials/asset_editor.rs new file mode 100644 index 0000000..e75eaca --- /dev/null +++ b/crates/editor/src/ui/materials/asset_editor.rs @@ -0,0 +1,228 @@ +use super::*; + +pub(crate) fn material_asset_editor( + world: &mut World, + ui: &mut egui::Ui, + asset: &EditorAsset, + path: &str, + show_document_chrome: bool, +) { + let document_key = crate::asset_documents::ensure_material_document(world, path, &asset.label); + let shader_schemas = shader_schema_assets(world); + let mut create_instance = false; + let mut draft_asset = world + .resource::() + .material(&document_key) + .cloned() + .expect("ensured Material document"); + super::editor::prefetch_bound_material_textures(world, [&draft_asset.inputs]); + let texture_assets = material_texture_candidates(world); + let texture_drop_candidate = world + .resource::() + .dragging_selection() + .and_then(|selection| { + texture_assets + .iter() + .find(|candidate| candidate.selection == *selection) + }) + .cloned(); + let original = draft_asset.clone(); + let mut accepted_texture_drop = false; + if show_document_chrome { + if let Some(snapshot) = world + .resource::() + .snapshot(&document_key) + { + authored_document_status_ui(ui, &snapshot); + if let Some(error) = snapshot.error.as_ref() { + ui.colored_label(egui::Color32::YELLOW, error); + } + } + if let Some(provenance) = draft_asset.provenance.as_ref() { + egui::CollapsingHeader::new("Source & Diagnostics") + .id_salt(("material_source", path)) + .default_open(false) + .show(ui, |ui| material_source_diagnostics_ui(ui, provenance)); + } + compact_text_edit(ui, "Label", &mut draft_asset.label); + } + let input_schema = + material_shader_settings(ui, &mut draft_asset, &shader_schemas, show_document_chrome); + material_surface_options_ui( + ui, + &mut draft_asset.render_state, + Some(&mut draft_asset.inputs), + ); + ui.add_space(8.0); + let input_response = material_input_schema_editor( + ui, + &input_schema, + &mut draft_asset.inputs, + &texture_assets, + texture_drop_candidate.as_ref(), + ); + accepted_texture_drop |= input_response.accepted_drop; + if let Some(selection) = input_response.locate { + world.resource_mut::().select(selection); + crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); + } + if input_response.browse_library { + crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); + } + if show_document_chrome + && ui + .add_sized( + [ui.available_width().max(1.0), 24.0], + egui::Button::new("Create Instance"), + ) + .clicked() + { + create_instance = true; + } + let create_instance_label = draft_asset.label.clone(); + + if draft_asset != original { + crate::asset_documents::update_material_document(world, &document_key, draft_asset.clone()); + } + if create_instance { + create_material_instance_from_base(world, path, &create_instance_label); + } + if accepted_texture_drop { + world.resource_mut::().clear_drag(); + } +} + +fn material_source_diagnostics_ui(ui: &mut egui::Ui, provenance: &shared::MaterialProvenance) { + egui::Grid::new("material_provenance") + .num_columns(2) + .spacing([8.0, 3.0]) + .show(ui, |ui| { + ui.small("Asset"); + ui.small(&provenance.source_path); + ui.end_row(); + ui.small("Source slot"); + ui.small(format!( + "{} ({})", + provenance.source_label, provenance.source_sub_asset_id + )); + ui.end_row(); + ui.small("Fingerprint"); + ui.monospace(&provenance.source_fingerprint); + ui.end_row(); + }); + ui.small("Reimport comparison never overwrites an edited project material automatically."); +} + +pub(super) fn material_surface_options_ui( + ui: &mut egui::Ui, + state: &mut shared::MaterialRenderState, + packed_inputs: Option<&mut shared::MaterialInputSet>, +) { + let palette = crate::ui::design_system::palette(ui); + let width = ui.available_width().max(1.0); + let height = match super::layout::ResponsiveMaterialLayout::for_width(width) { + super::layout::ResponsiveMaterialLayout::Reference => { + crate::ui::design_system::SURFACE_SECTION_HEIGHT + } + super::layout::ResponsiveMaterialLayout::Compact => { + crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT + } + super::layout::ResponsiveMaterialLayout::Transient => 100.0, + }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().rect( + rect, + 5.0, + palette.section, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let geometry = super::layout::SurfaceSectionGeometry::for_rect(rect); + ui.painter().text( + geometry.title.left_center(), + egui::Align2::LEFT_CENTER, + "Surface", + crate::ui::design_system::typography::TypeRole::Section.font(), + palette.text_primary, + ); + clipped_rect_ui(ui, geometry.blend, |ui| { + surface_blend_controls(ui, state); + }); + if let Some(inputs) = packed_inputs { + clipped_rect_ui(ui, geometry.packed, |ui| { + packed_mode_controls(ui, inputs); + }); + } + ui.painter().text( + geometry.double_sided.left_center() + egui::vec2(7.0, 0.0), + egui::Align2::LEFT_CENTER, + "Double Sided", + crate::ui::design_system::typography::TypeRole::Body.font(), + palette.text_secondary, + ); + let switch_rect = egui::Rect::from_min_size( + geometry.double_sided.min + egui::vec2(76.0, 3.0), + egui::vec2(31.0, 16.0), + ); + clipped_rect_ui(ui, switch_rect, |ui| { + crate::ui::design_system::controls::switch(ui, &mut state.double_sided) + .on_hover_text("Render both sides of the surface"); + }); +} + +fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut shared::MaterialInputSet) { + let arm = super::inputs::packed_map_mode(inputs) == super::inputs::PackedMapMode::Arm; + match crate::ui::design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) { + Some(false) => super::inputs::apply_separate_texture_preset(inputs), + Some(true) => super::inputs::apply_arm_texture_preset(inputs), + None => {} + } +} + +fn surface_blend_controls(ui: &mut egui::Ui, state: &mut shared::MaterialRenderState) { + let width = ui.available_width().max(1.0); + let show_cutoff = state.alpha_mode == MaterialAlphaMode::Cutout; + let combo_width = if show_cutoff { + (width - 58.0).max(1.0) + } else { + width + }; + let combo_rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(combo_width, 24.0)); + clipped_rect_ui(ui, combo_rect, |ui| { + egui::ComboBox::from_id_salt("material_alpha_mode") + .selected_text(match state.alpha_mode { + MaterialAlphaMode::Opaque => "Opaque", + MaterialAlphaMode::Cutout => "Cutout", + }) + .width(combo_width) + .show_ui(ui, |ui| { + ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Opaque, "Opaque"); + ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Cutout, "Cutout"); + }); + }); + if show_cutoff { + let cutoff_rect = egui::Rect::from_min_size( + ui.min_rect().min + egui::vec2(combo_width + 4.0, 1.0), + egui::vec2(54.0_f32.min(width - combo_width - 4.0), 22.0), + ); + ui.put( + cutoff_rect, + egui::DragValue::new(&mut state.alpha_cutoff) + .range(0.0..=1.0) + .speed(0.01), + ) + .on_hover_text("Alpha cutoff"); + } +} + +fn clipped_rect_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + add_contents(ui) + }) + .inner +} diff --git a/crates/editor/src/ui/materials/editor.rs b/crates/editor/src/ui/materials/editor.rs new file mode 100644 index 0000000..8d8f547 --- /dev/null +++ b/crates/editor/src/ui/materials/editor.rs @@ -0,0 +1,541 @@ +use super::*; + +/// Reuses the guarded Content Browser material editor inside an actor material-slot foldout. +pub(crate) fn inline_material_document_editor( + world: &mut World, + ui: &mut egui::Ui, + path: &str, +) -> bool { + let Some(asset) = world + .get_resource::() + .and_then(|assets| { + assets + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(path)) + }) + .cloned() + else { + ui.colored_label( + crate::ui::theme::ERROR, + "Material is missing from the content catalog", + ); + return false; + }; + let loaded_kind = world + .resource::() + .snapshot_for_path(path) + .map(|snapshot| snapshot.key.kind); + let kind = loaded_kind.or_else(|| { + if MaterialInstanceAsset::load_from_path(path).is_ok() { + Some(crate::asset_documents::AuthoredAssetDocumentKind::MaterialInstance) + } else if MaterialAsset::load_from_path(path).is_ok() { + Some(crate::asset_documents::AuthoredAssetDocumentKind::Material) + } else { + None + } + }); + ui.push_id(("inline_material_document", path), |ui| match kind { + Some(crate::asset_documents::AuthoredAssetDocumentKind::MaterialInstance) => { + material_instance_asset_editor(world, ui, &asset, path, false); + } + Some(crate::asset_documents::AuthoredAssetDocumentKind::Material) => { + material_asset_editor(world, ui, &asset, path, false); + } + _ => { + ui.colored_label(crate::ui::theme::ERROR, "Material document is invalid"); + } + }); + true +} + +pub(crate) fn material_shader_label(world: &World, path: Option<&str>) -> String { + let kind = material_shader_kind(world, path); + match kind { + Some(MaterialShaderKind::StandardLit) => "Standard Lit".into(), + Some(MaterialShaderKind::Unlit) => "Unlit".into(), + Some(MaterialShaderKind::Custom) => "Custom Surface".into(), + None if path.is_none() => "Default Grid".into(), + None => "Imported Source".into(), + } +} + +pub(crate) fn material_shader_kind( + world: &World, + path: Option<&str>, +) -> Option { + let path = path?; + let documents = world.get_resource::(); + let asset = documents + .and_then(|store| store.snapshot_for_path(path)) + .and_then(|snapshot| documents.and_then(|store| store.material(&snapshot.key))) + .cloned() + .or_else(|| MaterialAsset::load_from_path(path).ok()); + asset.map(|asset| asset.shader.kind).or_else(|| { + MaterialInstanceAsset::load_from_path(path) + .ok() + .and_then(|instance| instance.base.0.source_path) + .and_then(|base| MaterialAsset::load_from_path(&base).ok()) + .map(|asset| asset.shader.kind) + }) +} + +pub(crate) fn set_material_shader_kind(world: &mut World, path: &str, kind: MaterialShaderKind) { + let Some(asset) = world + .get_resource::() + .and_then(|assets| { + assets + .assets + .iter() + .find(|asset| asset.path.as_deref() == Some(path)) + }) + .cloned() + else { + return; + }; + let key = crate::asset_documents::ensure_material_document(world, path, &asset.label); + let Some(mut draft) = world + .resource::() + .material(&key) + .cloned() + else { + return; + }; + if draft.shader.kind == kind { + return; + } + draft.shader.kind = kind; + if draft.shader.kind != MaterialShaderKind::Custom { + draft.shader_ref = None; + draft.shader.schema_path = None; + draft.shader.shader_path = None; + } + crate::asset_documents::update_material_document(world, &key, draft); +} + +pub(crate) fn create_material_instance_from_base( + world: &mut World, + base_path: &str, + base_label: &str, +) { + let base_reference = world + .get_resource_mut::() + .ok_or_else(|| "Asset registry is unavailable".to_string()) + .and_then(|mut registry| { + ensure_asset_record( + &mut registry, + base_path.to_string(), + base_label.to_string(), + "Material", + ) + .map(|record| { + MaterialRef::new( + EditorAssetRef::new( + record.id.as_string(), + "material:source", + base_label.to_string(), + ) + .with_source_path(base_path), + ) + }) + }); + let base_reference = match base_reference { + Ok(reference) => reference, + Err(error) => { + world.resource_mut::().status = error; + return; + } + }; + let base = Path::new(base_path); + let parent = base + .parent() + .unwrap_or_else(|| Path::new("assets/materials")); + let stem = base + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("material"); + let mut index = 1usize; + let path = loop { + let suffix = if index == 1 { + "instance".to_string() + } else { + format!("instance_{index}") + }; + let candidate = parent.join(format!("{stem}_{suffix}.ron")); + if !candidate.exists() { + break candidate; + } + index += 1; + }; + let instance = MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: format!("{base_label} Instance"), + base: base_reference, + overrides: MaterialInputSet::default(), + }; + let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize material instance: {error}")) + .and_then(|text| { + publish_authored_file( + world, + &path, + text.as_bytes(), + &FileSnapshot::missing(), + FileWriteIntent::MaterialInstance, + ) + }); + if let Err(error) = result { + world.resource_mut::().status = error; + return; + } + let normalized = path.to_string_lossy().replace('\\', "/"); + let registry_error = + if let Some(mut registry) = world.get_resource_mut::() { + ensure_asset_record( + &mut registry, + normalized.clone(), + instance.label.clone(), + "Material", + ) + .map(|_| ()) + .and_then(|_| save_registry(®istry)) + .err() + } else { + Some("Asset registry is unavailable".to_string()) + }; + if let Some(error) = registry_error { + world.resource_mut::().status = + format!("Created {normalized}, but registry update failed: {error}"); + return; + } + world.resource_mut::().refresh(); + world + .resource_mut::() + .select(AssetSelection::File(normalized.clone())); + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = format!("Created material instance {normalized}"); +} + +pub(super) fn shader_schema_assets(world: &World) -> Vec<(String, String, EditorAssetRef)> { + let registry = world.get_resource::(); + let mut schemas: Vec<(String, String, EditorAssetRef)> = world + .resource::() + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::ShaderSchema)) + .filter_map(|asset| { + let path = asset.path.clone()?; + let record = registry.and_then(|registry| find_asset_by_path(registry, &path))?; + Some(( + asset.label.clone(), + path.clone(), + EditorAssetRef::new(record.id.as_string(), "shader:schema", asset.label.clone()) + .with_source_path(path), + )) + }) + .collect(); + schemas.sort_by(|a, b| natural_cmp(&a.0, &b.0)); + schemas +} + +pub(super) fn material_texture_candidates(world: &mut World) -> Vec { + let Some(registry) = world.get_resource::() else { + return Vec::new(); + }; + let assets = &world.resource::().assets; + let thumbnails = world + .get_resource::() + .map(crate::assets::AssetThumbnailCache::snapshot); + let mut textures = Vec::new(); + let mut seen = HashSet::new(); + + for asset in assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) + { + let Some(path) = asset.path.as_deref() else { + continue; + }; + let Some(record) = find_asset_by_path(registry, path) else { + continue; + }; + let reference = + EditorAssetRef::new(record.id.as_string(), "texture:source", asset.label.clone()) + .with_source_path(path); + if !seen.insert((reference.asset_id.clone(), reference.sub_asset_id.clone())) { + continue; + } + textures.push(MaterialTextureCandidate { + label: asset.label.clone(), + detail: path.to_string(), + reference, + selection: AssetSelection::File(path.to_string()), + thumbnail: thumbnails.as_ref().map_or( + MaterialThumbnailPresentation::Pending, + |cache| { + if let Some(texture) = cache.texture_for(asset) { + MaterialThumbnailPresentation::Ready(texture) + } else if cache.is_failed(asset) { + MaterialThumbnailPresentation::Failed + } else { + MaterialThumbnailPresentation::Pending + } + }, + ), + }); + } + + for asset in assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Model)) + { + let Some(parent_path) = asset.path.as_deref() else { + continue; + }; + let Some(record) = find_asset_by_path(registry, parent_path) else { + continue; + }; + let Some(manifest) = record + .model_import() + .static_mesh_manifest_path + .as_deref() + .and_then(|path| load_static_mesh_manifest(path).ok()) + else { + continue; + }; + for source_path in manifest + .source + .dependencies + .iter() + .filter(|path| is_material_texture_path(path)) + { + let label = Path::new(source_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(source_path) + .to_string(); + let sub_asset_id = format!("texture:{}", stable_material_subasset_slug(source_path)); + let reference = + EditorAssetRef::new(record.id.as_string(), sub_asset_id.clone(), label.clone()) + .with_source_path(source_path); + if !seen.insert((reference.asset_id.clone(), reference.sub_asset_id.clone())) { + continue; + } + textures.push(MaterialTextureCandidate { + label: label.clone(), + detail: format!("{} | {source_path}", asset.label), + reference, + selection: AssetSelection::SubAsset { + parent_path: parent_path.to_string(), + sub_asset_id, + label, + kind: AssetSubAssetKind::Texture, + source_path: Some(source_path.clone()), + }, + thumbnail: MaterialThumbnailPresentation::Pending, + }); + } + } + + textures.sort_by(|left, right| { + natural_cmp(&left.label, &right.label) + .then_with(|| natural_cmp(&left.detail, &right.detail)) + }); + textures +} + +/// Requests thumbnails only for textures currently visible through authored material inputs. +/// +/// Candidate enumeration must remain metadata-only. Eagerly prefetching the full project texture +/// catalog pins every source image in `EguiUserTextures`; a handful of Poly Haven 2K/4K maps can +/// otherwise exhaust VRAM before Solari allocates its view reservoirs. +pub(super) fn prefetch_bound_material_textures<'a>( + world: &mut World, + input_sets: impl IntoIterator, +) { + let references = input_sets + .into_iter() + .flat_map(|inputs| inputs.textures.iter()) + .filter_map(|binding| binding.texture.as_ref()) + .map(|reference| (reference.asset_id.clone(), reference.source_path.clone())) + .collect::>(); + if references.is_empty() { + return; + } + + let registry = world.get_resource::(); + let assets = world + .resource::() + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) + .filter(|asset| { + let path = asset.path.as_deref(); + let asset_id = path + .and_then(|path| registry.and_then(|registry| find_asset_by_path(registry, path))) + .map(|record| record.id.as_string()); + references.iter().any(|(reference_id, reference_path)| { + asset_id.as_deref() == Some(reference_id.as_str()) + || path.is_some_and(|path| reference_path.as_deref() == Some(path)) + }) + }) + .cloned() + .collect::>(); + prefetch_asset_thumbnails(world, &assets); +} + +fn is_material_texture_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "webp" | "ktx2" + ) + }) +} + +fn stable_material_subasset_slug(label: &str) -> String { + let mut slug = String::new(); + for ch in label.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + } else if !slug.ends_with('_') { + slug.push('_'); + } + } + slug.trim_matches('_').to_string() +} + +pub(super) fn material_shader_settings( + ui: &mut egui::Ui, + asset: &mut MaterialAsset, + shader_schemas: &[(String, String, EditorAssetRef)], + show_kind: bool, +) -> MaterialInputSchema { + if show_kind { + shader_kind_picker(ui, &mut asset.shader.kind); + } + if matches!(asset.shader.kind, MaterialShaderKind::Custom) { + let mut selected_schema = asset.shader.schema_path.clone(); + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new("Schema")); + egui::ComboBox::from_id_salt("asset_material_shader_schema") + .selected_text( + selected_schema + .as_deref() + .and_then(|path| { + shader_schemas + .iter() + .find(|(_, schema_path, _)| schema_path == path) + .map(|(label, _, _)| label.as_str()) + }) + .unwrap_or("(none)"), + ) + .show_ui(ui, |ui| { + if ui + .selectable_label(selected_schema.is_none(), "(none)") + .clicked() + { + selected_schema = None; + } + for (label, path, _) in shader_schemas { + if ui + .selectable_label(selected_schema.as_deref() == Some(path), label) + .clicked() + { + selected_schema = Some(path.clone()); + } + } + }); + }); + if selected_schema != asset.shader.schema_path { + asset.shader.schema_path = selected_schema.clone(); + if let Some(path) = selected_schema.as_deref() { + asset.shader_ref = shader_schemas + .iter() + .find(|(_, schema_path, _)| schema_path == path) + .map(|(_, _, reference)| reference.clone()); + if let Err(error) = apply_shader_schema(asset, path) { + ui.colored_label(egui::Color32::YELLOW, error); + } + } else { + asset.shader_ref = None; + } + } + optional_path_edit(ui, "WGSL shader", &mut asset.shader.shader_path); + if let Some(path) = asset.shader.schema_path.clone() { + ui.horizontal(|ui| { + if ui.button("Reload Schema").clicked() { + if let Err(error) = apply_shader_schema(asset, &path) { + ui.colored_label(egui::Color32::YELLOW, error); + } + } + ui.small(egui::RichText::new(path).color(TEXT_DIM)); + }); + } + } else { + asset.shader_ref = None; + asset.shader.schema_path = None; + asset.shader.shader_path = None; + } + + if matches!(asset.shader.kind, MaterialShaderKind::Custom) { + asset + .shader + .schema_path + .as_deref() + .and_then(|path| ShaderSchemaAsset::load_from_path(path).ok()) + .map(|asset| asset.schema) + .unwrap_or_default() + } else { + standard_lit_input_schema() + } +} + +pub(super) fn apply_shader_schema(asset: &mut MaterialAsset, path: &str) -> Result<(), String> { + let schema = ShaderSchemaAsset::load_from_path(path)?; + asset.shader.kind = schema.kind; + asset.shader.schema_path = Some(path.to_string()); + if asset.shader.shader_path.is_none() { + asset.shader.shader_path = schema.wgsl_path.clone(); + } + + for property in &schema.schema.inputs { + if property.texture.is_some() + || matches!(property.property_type, ShaderPropertyType::Texture) + { + let binding = + ensure_material_texture_binding(&mut asset.inputs.textures, &property.name); + if let Some(texture) = property.texture.as_ref() { + binding.channel = texture.default_channel; + } + } + if matches!(property.property_type, ShaderPropertyType::Texture) { + continue; + } + let default = property + .default_value + .clone() + .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); + match asset + .inputs + .values + .iter_mut() + .find(|parameter| parameter.name == property.name) + { + Some(parameter) => { + if !parameter_value_matches_property(¶meter.value, &property.property_type) { + parameter.value = default; + } + } + None => asset.inputs.values.push(MaterialParameter { + name: property.name.clone(), + value: default, + }), + } + } + + Ok(()) +} diff --git a/crates/editor/src/ui/materials/inputs.rs b/crates/editor/src/ui/materials/inputs.rs new file mode 100644 index 0000000..d8f7b4b --- /dev/null +++ b/crates/editor/src/ui/materials/inputs.rs @@ -0,0 +1,648 @@ +use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section}; +use super::*; + +use crate::ui::design_system; +use crate::ui::design_system::color_picker::{color_control, color_control_with_intensity}; +use crate::ui::design_system::controls::{ + scalar_control, section_frame, section_header, section_header_with_summary, switch, +}; +use crate::ui::design_system::property_grid::PropertyGridGeometry; +use crate::ui::design_system::typography::TypeRole; + +pub(crate) fn material_input_schema_editor( + ui: &mut egui::Ui, + schema: &MaterialInputSchema, + inputs: &mut MaterialInputSet, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, +) -> MaterialInputEditorResponse { + material_input_schema_editor_with_inheritance( + ui, + schema, + inputs, + None, + texture_assets, + drop_candidate, + ) +} + +pub(super) fn material_input_schema_editor_with_inheritance( + ui: &mut egui::Ui, + schema: &MaterialInputSchema, + inputs: &mut MaterialInputSet, + inherited: Option<&MaterialInputSet>, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, +) -> MaterialInputEditorResponse { + let mut response = MaterialInputEditorResponse::default(); + let has_standard_packed_inputs = ["occlusion", "roughness", "metallic"] + .iter() + .all(|name| schema.inputs.iter().any(|input| input.name == *name)); + let mut groups = schema.groups.clone(); + if groups.is_empty() { + groups.push(shared::MaterialInputGroupDesc { + id: String::new(), + display_name: "Properties".into(), + order: 0, + advanced: false, + }); + } + groups.sort_by_key(|group| group.order); + for group in groups { + let mut properties = schema + .inputs + .iter() + .filter(|property| { + property.group == group.id + && matches!( + property.presentation, + shared::MaterialInputPresentation::Row + ) + }) + .collect::>(); + let companions = schema + .inputs + .iter() + .filter(|property| property.group == group.id) + .filter_map(|property| match &property.presentation { + shared::MaterialInputPresentation::Companion { owner } => { + Some((owner.as_str(), property)) + } + shared::MaterialInputPresentation::Row => None, + }) + .collect::>(); + properties.sort_by_key(|property| property.order); + if properties.is_empty() { + continue; + } + if group.id == "uv_transform" { + material_uv_transform_ui(ui, inputs, inherited); + ui.add_space(8.0); + continue; + } + let open_id = ui.make_persistent_id(("material_input_group_open", &group.id)); + let mut open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(!group.advanced); + section_frame(ui).show(ui, |ui| { + if group.id == "surface_inputs" { + let summary = format!("{} bindings", properties.len()); + section_header_with_summary(ui, "Inputs", Some(&summary), &mut open); + } else { + section_header(ui, &group.display_name, &mut open); + } + if open { + let rows = material_input_rows( + ui, + &properties, + &companions, + inputs, + texture_assets, + drop_candidate, + ); + response.accepted_drop |= rows.accepted_drop; + response.locate = response.locate.take().or(rows.locate); + response.browse_library |= rows.browse_library; + } + }); + ui.ctx() + .data_mut(|data| data.insert_persisted(open_id, open)); + ui.add_space(8.0); + } + if has_standard_packed_inputs { + super::advanced_preview::unsupported_advanced_preview(ui); + } + response +} + +fn material_uv_transform_ui( + ui: &mut egui::Ui, + inputs: &mut MaterialInputSet, + inherited: Option<&MaterialInputSet>, +) { + let mut offset = material_vec2_value(inputs, "uv_offset", Vec2::ZERO); + let mut tiling = material_vec2_value(inputs, "uv_tiling", Vec2::ONE); + let reset_offset = inherited.map_or(Vec2::ZERO, |inputs| { + material_vec2_value(inputs, "uv_offset", Vec2::ZERO) + }); + let reset_tiling = inherited.map_or(Vec2::ONE, |inputs| { + material_vec2_value(inputs, "uv_tiling", Vec2::ONE) + }); + let original = (offset, tiling); + uv_transform_section(ui, &mut offset, &mut tiling, reset_offset, reset_tiling); + if (offset, tiling) != original { + set_material_vec2_value(inputs, "uv_offset", offset); + set_material_vec2_value(inputs, "uv_tiling", tiling); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PackedMapMode { + Arm, + Separate, +} + +pub(super) fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode { + let channels = [ + ("occlusion", TextureChannel::R), + ("roughness", TextureChannel::G), + ("metallic", TextureChannel::B), + ]; + if channels.iter().all(|(name, channel)| { + inputs + .texture(name) + .is_some_and(|binding| binding.channel == *channel) + }) { + PackedMapMode::Arm + } else { + PackedMapMode::Separate + } +} + +pub(super) fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) { + let shared_texture = ["occlusion", "roughness", "metallic"] + .iter() + .find_map(|name| { + inputs + .texture(name) + .and_then(|binding| binding.texture.clone()) + }); + for (name, channel) in [ + ("occlusion", TextureChannel::R), + ("roughness", TextureChannel::G), + ("metallic", TextureChannel::B), + ] { + let binding = ensure_material_texture_binding(&mut inputs.textures, name); + binding.channel = channel; + if let Some(texture) = shared_texture.clone() { + binding.texture = Some(texture); + } + } +} + +pub(super) fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) { + for name in ["occlusion", "roughness", "metallic"] { + ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R; + } +} + +pub(super) fn material_input_rows( + ui: &mut egui::Ui, + properties: &[&MaterialInputDesc], + companions: &[(&str, &MaterialInputDesc)], + inputs: &mut MaterialInputSet, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, +) -> MaterialInputEditorResponse { + let palette = design_system::palette(ui); + let mut response = MaterialInputEditorResponse::default(); + let geometry = PropertyGridGeometry::for_width(ui.available_width()); + // Property rows are a contiguous Penpot table. Default egui item spacing otherwise adds four + // pixels between every nominally 31 px row and breaks the reference geometry. + ui.spacing_mut().item_spacing.y = 0.0; + for (row, property) in properties.iter().enumerate() { + let companion = companions + .iter() + .find_map(|(owner, companion)| (*owner == property.name).then_some(*companion)); + ui.push_id(("material_input_row", &property.name), |ui| { + let width = ui.available_width().max(1.0); + let (row_rect, _) = ui + .allocate_exact_size(egui::vec2(width, geometry.row_height), egui::Sense::hover()); + ui.painter().rect_filled( + row_rect, + 0.0, + if row.is_multiple_of(2) { + palette.row_even + } else { + palette.row_odd + }, + ); + let rects = geometry.row_rects(row_rect); + rect_ui(ui, rects.label, |ui| material_input_label(ui, property)); + rect_ui(ui, rects.value, |ui| { + material_input_value_ui(ui, property, companion, inputs) + }); + rect_ui(ui, rects.channel, |ui| { + material_input_channel_ui(ui, property, inputs) + }); + if material_input_uses_texture(property) { + let texture = rect_ui(ui, rects.texture_group, |ui| { + material_input_texture_ui( + ui, + property, + inputs, + texture_assets, + drop_candidate, + rects.inline_texture_actions, + ) + }); + response.accepted_drop |= texture.accepted_drop; + response.locate = response.locate.take().or(texture.locate); + response.browse_library |= texture.browse_library; + } + }); + } + response +} + +fn rect_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + add_contents(ui) + }) + .inner +} + +pub(super) fn material_input_label(ui: &mut egui::Ui, property: &MaterialInputDesc) { + let palette = design_system::palette(ui); + ui.add( + egui::Label::new( + TypeRole::Body + .text(&property.display_name) + .color(palette.text_secondary), + ) + .truncate(), + ) + .on_hover_text(&property.tooltip); +} + +pub(super) fn material_input_uses_texture(property: &MaterialInputDesc) -> bool { + property.texture.is_some() || matches!(property.property_type, ShaderPropertyType::Texture) +} + +pub(super) fn material_input_value_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + companion: Option<&MaterialInputDesc>, + inputs: &mut MaterialInputSet, +) { + let MaterialInputSet { values, .. } = inputs; + ui.horizontal(|ui| { + if matches!(property.property_type, ShaderPropertyType::Texture) { + ui.label(egui::RichText::new("Texture").small().color(TEXT_DIM)); + } else { + let default = property + .default_value + .clone() + .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); + let mut value = values + .iter() + .find(|value| value.name == property.name) + .map(|parameter| parameter.value.clone()) + .unwrap_or(default); + if !parameter_value_matches_property(&value, &property.property_type) { + value = default_value_for_shader_property(&property.property_type); + } + let original = value.clone(); + if matches!(property.property_type, ShaderPropertyType::Color) + && companion.is_some_and(|input| input.name == "emissive_intensity") + { + let companion = companion.expect("checked companion"); + let companion_default = companion + .default_value + .clone() + .unwrap_or(MaterialParameterValue::Float(0.0)); + let mut companion_value = values + .iter() + .find(|value| value.name == companion.name) + .map(|parameter| parameter.value.clone()) + .unwrap_or(companion_default); + if !parameter_value_matches_property(&companion_value, &companion.property_type) { + companion_value = default_value_for_shader_property(&companion.property_type); + } + let original_companion = companion_value.clone(); + if let ( + MaterialParameterValue::Color(color), + MaterialParameterValue::Float(intensity), + ) = (&mut value, &mut companion_value) + { + let mut rgba = [color.r, color.g, color.b, color.a]; + let picker = color_control_with_intensity( + ui, + "material_emissive_color", + &property.display_name, + &mut rgba, + intensity, + ); + if picker.changed { + *color = ColorDesc { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + }; + } + } + if value != original { + upsert_material_parameter(values, &property.name, value); + } + if companion_value != original_companion { + upsert_material_parameter(values, &companion.name, companion_value); + } + } else { + material_parameter_control_ui( + ui, + &property.display_name, + &property.property_type, + &mut value, + ); + if value != original { + upsert_material_parameter(values, &property.name, value); + } + } + } + }); +} + +fn upsert_material_parameter( + values: &mut Vec, + name: &str, + value: MaterialParameterValue, +) { + if let Some(parameter) = values.iter_mut().find(|parameter| parameter.name == name) { + parameter.value = value; + } else { + values.push(MaterialParameter { + name: name.to_string(), + value, + }); + } +} + +fn material_input_channel_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + inputs: &mut MaterialInputSet, +) { + let palette = design_system::palette(ui); + if property + .texture + .as_ref() + .is_some_and(|texture| texture.allow_channel_override) + { + let original = material_property_texture_binding(&inputs.textures, property); + let mut binding = original.clone(); + material_texture_channel_ui(ui, property, &mut binding); + if binding != original { + upsert_material_texture_binding(&mut inputs.textures, binding); + } + } else { + let channel = property.texture.as_ref().map(|texture| { + if matches!(texture.semantic, shared::TextureSemantic::Color) { + "RGB" + } else { + texture_channel_label(texture.default_channel) + } + }); + if let Some(channel) = channel { + readonly_channel(ui, channel); + } else { + ui.label(TypeRole::Body.text("—").color(palette.text_muted)); + } + } +} + +pub(super) fn material_input_texture_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + inputs: &mut MaterialInputSet, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, + inline_actions: bool, +) -> TextureSlotResponse { + if !material_input_uses_texture(property) { + return TextureSlotResponse::default(); + } + let original = material_property_texture_binding(&inputs.textures, property); + let mut binding = original.clone(); + let response = texture_binding_ui( + ui, + &mut binding, + texture_assets, + drop_candidate, + inline_actions, + ); + if binding != original { + upsert_material_texture_binding(&mut inputs.textures, binding); + } + response +} + +pub(super) fn material_parameter_control_ui( + ui: &mut egui::Ui, + label: &str, + property_type: &ShaderPropertyType, + value: &mut MaterialParameterValue, +) { + if !parameter_value_matches_property(value, property_type) { + *value = default_value_for_shader_property(property_type); + } + match value { + MaterialParameterValue::Bool(value) => { + switch(ui, value); + } + MaterialParameterValue::Float(value) => { + let (min, max) = match property_type { + ShaderPropertyType::Float { min, max } => (*min, *max), + _ => (None, None), + }; + match (min, max) { + (Some(min), Some(max)) => { + scalar_control(ui, label, value, min..=max, usize::from(max <= 1_000.0) * 2); + } + _ => { + ui.add(egui::DragValue::new(value).speed(0.01)); + } + } + } + MaterialParameterValue::Vec2(value) => { + ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X ")); + ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y ")); + } + MaterialParameterValue::Vec3(value) => { + ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X ")); + ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y ")); + ui.add(egui::DragValue::new(&mut value.z).speed(0.01).prefix("Z ")); + } + MaterialParameterValue::Color(value) => { + let mut rgba = [value.r, value.g, value.b, value.a]; + let response = color_control(ui, "material_color", label, &mut rgba); + if response.changed { + *value = ColorDesc { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + }; + } + } + MaterialParameterValue::Enum(value) => { + let options = match property_type { + ShaderPropertyType::Enum { options } => options.as_slice(), + _ => &[], + }; + egui::ComboBox::from_id_salt(("material_enum", value.as_str())) + .selected_text(value.as_str()) + .show_ui(ui, |ui| { + for option in options { + ui.selectable_value(value, option.clone(), option); + } + }); + } + } +} + +fn readonly_channel(ui: &mut egui::Ui, channel: &str) { + let palette = design_system::palette(ui); + let (rect, response) = ui.allocate_exact_size(egui::vec2(48.0, 22.0), egui::Sense::hover()); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.left_center() + egui::vec2(9.0, 0.0), + egui::Align2::LEFT_CENTER, + channel, + TypeRole::Body.font(), + palette.text_secondary, + ); + ui.painter().text( + rect.right_center() - egui::vec2(8.0, 0.0), + egui::Align2::CENTER_CENTER, + egui_phosphor_icons::icons::CARET_DOWN.as_str(), + egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_muted, + ); + response.on_hover_text("Channel is fixed by this material input"); +} + +pub(super) fn default_value_for_shader_property( + property_type: &ShaderPropertyType, +) -> MaterialParameterValue { + match property_type { + ShaderPropertyType::Bool => MaterialParameterValue::Bool(false), + ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0), + ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO), + ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO), + ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()), + ShaderPropertyType::Enum { options } => { + MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default()) + } + ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0), + } +} + +pub(super) fn parameter_value_matches_property( + value: &MaterialParameterValue, + property_type: &ShaderPropertyType, +) -> bool { + matches!( + (value, property_type), + (MaterialParameterValue::Bool(_), ShaderPropertyType::Bool) + | ( + MaterialParameterValue::Float(_), + ShaderPropertyType::Float { .. } + ) + | (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2) + | (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3) + | (MaterialParameterValue::Color(_), ShaderPropertyType::Color) + | ( + MaterialParameterValue::Enum(_), + ShaderPropertyType::Enum { .. } + ) + ) +} + +pub(super) fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new(label)); + ui.add_sized( + [fit_width(ui, 100.0, f32::INFINITY), 22.0], + egui::TextEdit::singleline(value), + ); + }); +} + +pub(super) fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) { + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new("Shader")); + shader_kind_combo(ui, kind, "asset_material_shader_kind"); + }); +} + +pub(super) fn shader_kind_combo( + ui: &mut egui::Ui, + kind: &mut MaterialShaderKind, + salt: impl std::hash::Hash, +) { + egui::ComboBox::from_id_salt(salt) + .selected_text(match kind { + MaterialShaderKind::StandardLit => "Standard Lit", + MaterialShaderKind::Unlit => "Unlit", + MaterialShaderKind::Custom => "Custom Surface", + }) + .width(150.0_f32.min(ui.available_width().max(1.0))) + .show_ui(ui, |ui| { + ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit"); + ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit"); + ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom Surface"); + }); +} + +pub(super) fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option) { + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new(label)); + let mut text = value.clone().unwrap_or_default(); + if ui + .add_sized( + [fit_width(ui, 80.0, f32::INFINITY), 22.0], + egui::TextEdit::singleline(&mut text), + ) + .changed() + { + *value = if text.trim().is_empty() { + None + } else { + Some(text) + }; + } + if ui.button("Clear").clicked() { + *value = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() { + let context = egui::Context::default(); + context.set_fonts(crate::ui::fonts::editor_font_definitions()); + let mut inputs = MaterialInputSet::default(); + let original = inputs.clone(); + let schema = shared::standard_lit_input_schema(); + let _ = context.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 900.0), + )), + ..Default::default() + }, + |ui| { + material_input_schema_editor(ui, &schema, &mut inputs, &[], None); + }, + ); + assert_eq!(inputs, original); + } +} diff --git a/crates/editor/src/ui/materials/instance_editor.rs b/crates/editor/src/ui/materials/instance_editor.rs new file mode 100644 index 0000000..061adab --- /dev/null +++ b/crates/editor/src/ui/materials/instance_editor.rs @@ -0,0 +1,199 @@ +use super::*; + +pub(crate) fn material_instance_asset_editor( + world: &mut World, + ui: &mut egui::Ui, + asset: &EditorAsset, + path: &str, + show_document_chrome: bool, +) { + let document_key = + crate::asset_documents::ensure_material_instance_document(world, path, &asset.label); + let mut draft = world + .resource::() + .material_instance(&document_key) + .cloned() + .expect("ensured Material Instance document"); + let base_asset = load_live_base_asset(world, &draft.base); + super::editor::prefetch_bound_material_textures( + world, + base_asset + .iter() + .map(|asset| &asset.inputs) + .chain(std::iter::once(&draft.overrides)), + ); + let texture_assets = material_texture_candidates(world); + let texture_drop_candidate = world + .resource::() + .dragging_selection() + .and_then(|selection| { + texture_assets + .iter() + .find(|candidate| candidate.selection == *selection) + }) + .cloned(); + let original = draft.clone(); + + if show_document_chrome { + instance_document_chrome(world, ui, &document_key, &mut draft); + } + + let Some(base_asset) = base_asset else { + ui.colored_label( + ERROR, + "Base Material is missing; this instance renders Default Grid", + ); + if draft != original { + crate::asset_documents::update_material_instance_document(world, &document_key, draft); + } + return; + }; + let schema = material_schema_for_asset(&base_asset).unwrap_or_else(standard_lit_input_schema); + if show_document_chrome { + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new("Shader")); + ui.label(shader_kind_label(base_asset.shader.kind)); + ui.small(egui::RichText::new("Inherited").color(TEXT_MUTED)); + }); + } + ui.add_enabled_ui(false, |ui| { + let mut render_state = base_asset.render_state; + super::asset_editor::material_surface_options_ui( + ui, + &mut render_state, + Some(&mut draft.overrides), + ); + }); + ui.add_space(8.0); + let input_response = super::instance_inputs::material_instance_input_schema_editor( + ui, + &schema, + &base_asset.inputs, + &mut draft.overrides, + &texture_assets, + texture_drop_candidate.as_ref(), + ); + if let Some(selection) = input_response.locate.clone() { + world.resource_mut::().select(selection); + crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); + } + if input_response.browse_library { + crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); + } + if draft != original { + crate::asset_documents::update_material_instance_document(world, &document_key, draft); + } + if input_response.accepted_drop { + world.resource_mut::().clear_drag(); + } +} + +fn instance_document_chrome( + world: &mut World, + ui: &mut egui::Ui, + document_key: &crate::asset_documents::AuthoredAssetDocumentKey, + draft: &mut MaterialInstanceAsset, +) { + if let Some(snapshot) = world + .resource::() + .snapshot(document_key) + { + authored_document_status_ui(ui, &snapshot); + if let Some(error) = snapshot.error.as_ref() { + ui.colored_label(WARNING, error); + } + } + compact_text_edit(ui, "Label", &mut draft.label); + let candidates = material_base_candidates(world); + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new("Base material")); + let selected = candidates + .iter() + .find(|(_, candidate)| candidate.0.asset_id == draft.base.0.asset_id) + .map(|(label, _)| label.as_str()) + .unwrap_or("Missing base"); + egui::ComboBox::from_id_salt("material_instance_base") + .selected_text(selected) + .show_ui(ui, |ui| { + for (label, reference) in &candidates { + if ui + .selectable_label(reference.0.asset_id == draft.base.0.asset_id, label) + .clicked() + { + draft.base = reference.clone(); + } + } + }); + }); + ui.separator(); +} + +fn load_live_base_asset(world: &mut World, reference: &MaterialRef) -> Option { + let path = reference.0.source_path.as_deref()?; + let key = crate::asset_documents::ensure_material_document(world, path, "Base Material"); + world + .resource::() + .material(&key) + .cloned() +} + +fn material_base_candidates(world: &World) -> Vec<(String, MaterialRef)> { + let registry = world.get_resource::(); + let documents = world.get_resource::(); + let mut candidates = world + .resource::() + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Material)) + .filter_map(|asset| { + let path = asset.path.as_deref()?; + let is_material = documents + .and_then(|store| store.snapshot_for_path(path)) + .map_or_else( + || MaterialAsset::load_from_path(path).is_ok(), + |snapshot| { + snapshot.key.kind + == crate::asset_documents::AuthoredAssetDocumentKind::Material + }, + ); + if !is_material { + return None; + } + let record = registry.and_then(|registry| find_asset_by_path(registry, path))?; + Some(( + asset.label.clone(), + MaterialRef::new( + EditorAssetRef::new( + record.id.as_string(), + "material:source", + asset.label.clone(), + ) + .with_source_path(path), + ), + )) + }) + .collect::>(); + candidates.sort_by(|left, right| natural_cmp(&left.0, &right.0)); + candidates +} + +fn material_schema_for_asset(asset: &MaterialAsset) -> Option { + if asset.shader.kind != MaterialShaderKind::Custom { + return Some(standard_lit_input_schema()); + } + asset + .shader_ref + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + .or(asset.shader.schema_path.as_deref()) + .and_then(|path| ShaderSchemaAsset::load_from_path(path).ok()) + .map(|schema| schema.schema) +} + +const fn shader_kind_label(kind: MaterialShaderKind) -> &'static str { + match kind { + MaterialShaderKind::StandardLit => "Standard Lit", + MaterialShaderKind::Unlit => "Unlit", + MaterialShaderKind::Custom => "Custom Surface", + } +} diff --git a/crates/editor/src/ui/materials/instance_inputs.rs b/crates/editor/src/ui/materials/instance_inputs.rs new file mode 100644 index 0000000..a3a2826 --- /dev/null +++ b/crates/editor/src/ui/materials/instance_inputs.rs @@ -0,0 +1,158 @@ +//! Sparse Material Instance authoring through the same Penpot input renderer as Materials. + +use super::*; + +pub(super) fn material_instance_input_schema_editor( + ui: &mut egui::Ui, + schema: &MaterialInputSchema, + base: &MaterialInputSet, + overrides: &mut MaterialInputSet, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, +) -> MaterialInputEditorResponse { + let normalized_base = normalized_inputs(schema, base); + let mut effective = normalized_base.clone(); + overlay_inputs(&mut effective, overrides); + + let response = super::inputs::material_input_schema_editor_with_inheritance( + ui, + schema, + &mut effective, + Some(&normalized_base), + texture_assets, + drop_candidate, + ); + *overrides = sparse_difference(&normalized_base, &effective); + response +} + +fn normalized_inputs(schema: &MaterialInputSchema, source: &MaterialInputSet) -> MaterialInputSet { + let mut normalized = source.clone(); + for property in &schema.inputs { + if !matches!(property.property_type, ShaderPropertyType::Texture) + && !normalized + .values + .iter() + .any(|value| value.name == property.name) + { + normalized.values.push(MaterialParameter { + name: property.name.clone(), + value: property + .default_value + .clone() + .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)), + }); + } + if let Some(texture) = property.texture.as_ref() { + if !normalized + .textures + .iter() + .any(|binding| binding.name == property.name) + { + normalized.textures.push(MaterialTextureBinding { + name: property.name.clone(), + texture: None, + channel: texture.default_channel, + }); + } + } + } + normalized +} + +fn overlay_inputs(target: &mut MaterialInputSet, overlay: &MaterialInputSet) { + for value in &overlay.values { + upsert_value(&mut target.values, value.clone()); + } + for texture in &overlay.textures { + upsert_texture(&mut target.textures, texture.clone()); + } +} + +fn sparse_difference(base: &MaterialInputSet, effective: &MaterialInputSet) -> MaterialInputSet { + MaterialInputSet { + values: effective + .values + .iter() + .filter(|value| base.values.iter().find(|base| base.name == value.name) != Some(*value)) + .cloned() + .collect(), + textures: effective + .textures + .iter() + .filter(|texture| { + base.textures.iter().find(|base| base.name == texture.name) != Some(*texture) + }) + .cloned() + .collect(), + } +} + +fn upsert_value(values: &mut Vec, value: MaterialParameter) { + if let Some(current) = values.iter_mut().find(|current| current.name == value.name) { + *current = value; + } else { + values.push(value); + } +} + +fn upsert_texture(textures: &mut Vec, texture: MaterialTextureBinding) { + if let Some(current) = textures + .iter_mut() + .find(|current| current.name == texture.name) + { + *current = texture; + } else { + textures.push(texture); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_editor_retains_only_values_different_from_base() { + let schema = standard_lit_input_schema(); + let base = normalized_inputs(&schema, &MaterialInputSet::default()); + let mut effective = base.clone(); + let roughness = effective + .values + .iter_mut() + .find(|value| value.name == "roughness") + .expect("roughness default"); + roughness.value = MaterialParameterValue::Float(0.2); + + let overrides = sparse_difference(&base, &effective); + + assert_eq!(overrides.values.len(), 1); + assert_eq!(overrides.values[0].name, "roughness"); + assert!(overrides.textures.is_empty()); + } + + #[test] + fn instance_editor_clearing_back_to_base_removes_override() { + let schema = standard_lit_input_schema(); + let base = normalized_inputs(&schema, &MaterialInputSet::default()); + let overrides = sparse_difference(&base, &base); + assert!(overrides.values.is_empty()); + assert!(overrides.textures.is_empty()); + } + + #[test] + fn instance_overlay_preserves_unknown_custom_schema_values() { + let mut effective = MaterialInputSet::default(); + let overrides = MaterialInputSet { + values: vec![MaterialParameter { + name: "graph_custom".into(), + value: MaterialParameterValue::Float(3.0), + }], + textures: Vec::new(), + }; + overlay_inputs(&mut effective, &overrides); + assert_eq!( + sparse_difference(&MaterialInputSet::default(), &effective), + overrides + ); + } +} diff --git a/crates/editor/src/ui/materials/layout.rs b/crates/editor/src/ui/materials/layout.rs new file mode 100644 index 0000000..3dfd3ab --- /dev/null +++ b/crates/editor/src/ui/materials/layout.rs @@ -0,0 +1,343 @@ +//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests. + +use bevy_egui::egui; + +use crate::ui::design_system::{ + ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT, COLLAPSED_HEADER_HEIGHT, HEADER_HEIGHT, + INPUT_SECTION_HEADER_HEIGHT, MATERIAL_IDENTITY_WIDTH, MATERIAL_PREVIEW_SIZE, OUTER_PADDING, + PARAMETER_ROW_HEIGHT, SECTION_GAP, SURFACE_SECTION_HEIGHT, UV_SECTION_HEIGHT, +}; + +/// Smallest canvas that can still contain the exported compact Surface controls. +/// +/// The Penpot board owns a 372 px parameter section. In the actor Inspector, component-card +/// chrome can temporarily reduce that canvas by a few pixels even while the Inspector itself is +/// at its supported 420 px floor. The fixed compact controls only require 362 px, so keep the +/// intended two-line layout until that real containment limit and reserve `Transient` for actual +/// sub-minimum docking frames. +pub(super) const COMPACT_SECTION_WIDTH: f32 = 357.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsiveMaterialLayout { + Reference, + Compact, + Transient, +} + +impl ResponsiveMaterialLayout { + pub(super) fn for_width(width: f32) -> Self { + if width + f32::EPSILON >= crate::ui::design_system::property_grid::WIDE_SECTION_WIDTH { + Self::Reference + } else if width + f32::EPSILON >= COMPACT_SECTION_WIDTH { + Self::Compact + } else { + Self::Transient + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct SurfaceSectionGeometry { + pub height: f32, + pub title: egui::Rect, + pub blend: egui::Rect, + pub packed: egui::Rect, + pub double_sided: egui::Rect, +} + +impl SurfaceSectionGeometry { + pub(super) fn for_rect(rect: egui::Rect) -> Self { + let local = |x: f32, y: f32, width: f32, height: f32| { + let x = x.clamp(0.0, rect.width()); + let y = y.clamp(0.0, rect.height()); + egui::Rect::from_min_size( + rect.min + egui::vec2(x, y), + egui::vec2( + width.max(0.0).min(rect.width() - x), + height.max(0.0).min(rect.height() - y), + ), + ) + }; + match ResponsiveMaterialLayout::for_width(rect.width()) { + ResponsiveMaterialLayout::Reference => Self { + height: SURFACE_SECTION_HEIGHT, + title: local(10.0, 10.0, 48.0, 26.0), + blend: local(66.0, 10.0, 250.0, 26.0), + packed: local(324.0, 11.0, 128.0, 24.0), + double_sided: local(460.0, 12.0, 99.0, 22.0), + }, + ResponsiveMaterialLayout::Compact => Self { + height: crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT, + title: local(10.0, 10.0, 48.0, 26.0), + blend: local(66.0, 10.0, 50.0, 26.0), + packed: local(124.0, 11.0, 128.0, 24.0), + double_sided: local(260.0, 12.0, 99.0, 22.0), + }, + ResponsiveMaterialLayout::Transient => Self { + height: 100.0, + title: local(10.0, 10.0, 48.0, 26.0), + blend: local(66.0, 10.0, (rect.width() - 76.0).clamp(1.0, 250.0), 26.0), + packed: local(10.0, 42.0, (rect.width() - 20.0).clamp(0.0, 128.0), 24.0), + double_sided: local(146.0, 43.0, (rect.width() - 156.0).clamp(0.0, 99.0), 22.0), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct UvGroupGeometry { + pub label: egui::Rect, + pub x: egui::Rect, + pub y: egui::Rect, + pub reset: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct UvSectionGeometry { + pub height: f32, + pub title: egui::Rect, + pub offset: UvGroupGeometry, + pub tiling: UvGroupGeometry, +} + +impl UvSectionGeometry { + pub(super) fn for_rect(rect: egui::Rect) -> Self { + let mode = ResponsiveMaterialLayout::for_width(rect.width()); + let title = egui::Rect::from_min_size( + rect.min + egui::vec2(12.0, 7.0), + egui::vec2(20.0_f32.min(rect.width()), 40.0_f32.min(rect.height())), + ); + if mode == ResponsiveMaterialLayout::Transient { + let group_width = (rect.width() - 20.0).max(1.0); + return Self { + height: 100.0, + title, + offset: uv_group(rect.min + egui::vec2(42.0, 7.0), group_width), + tiling: uv_group(rect.min + egui::vec2(42.0, 47.0), group_width), + }; + } + if mode == ResponsiveMaterialLayout::Reference { + Self { + height: UV_SECTION_HEIGHT, + title, + offset: uv_group(rect.min + egui::vec2(104.667, 7.0), 165.0), + tiling: uv_group(rect.min + egui::vec2(332.333, 7.0), 164.0), + } + } else { + Self { + height: crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT, + title, + offset: uv_group(rect.min + egui::vec2(118.0, 7.0), 165.0), + tiling: uv_group(rect.min + egui::vec2(118.5, 47.0), 164.0), + } + } + } +} + +fn uv_group(origin: egui::Pos2, group_width: f32) -> UvGroupGeometry { + let local = |x: f32, y: f32, control_width: f32, height: f32| { + let x = x.clamp(0.0, group_width.max(0.0)); + egui::Rect::from_min_size( + origin + egui::vec2(x, y), + egui::vec2(control_width.max(0.0).min(group_width - x), height), + ) + }; + let reset = (group_width >= 164.0).then(|| local(group_width - 24.0, 8.0, 24.0, 24.0)); + UvGroupGeometry { + label: local(0.0, 10.0, 25.0, 20.0), + x: local(30.0, 8.0, 50.0, 24.0), + y: local(86.0, 8.0, 50.0, 24.0), + reset, + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct MaterialPanelGeometry { + pub header: f32, + pub collapsed_header: f32, + pub asset_zone: f32, + pub surface: f32, + pub input_header: f32, + pub input_row: f32, + pub uv: f32, + pub advanced_collapsed: f32, + pub advanced_expanded: f32, + pub preview: f32, + pub identity: f32, + pub outer_padding: f32, + pub section_gap: f32, +} + +impl MaterialPanelGeometry { + pub(super) const REFERENCE: Self = Self { + header: HEADER_HEIGHT, + collapsed_header: COLLAPSED_HEADER_HEIGHT, + asset_zone: HEADER_HEIGHT, + surface: SURFACE_SECTION_HEIGHT, + input_header: INPUT_SECTION_HEADER_HEIGHT, + input_row: PARAMETER_ROW_HEIGHT, + uv: UV_SECTION_HEIGHT, + advanced_collapsed: ADVANCED_COLLAPSED_HEIGHT, + advanced_expanded: ADVANCED_EXPANDED_HEIGHT, + preview: MATERIAL_PREVIEW_SIZE, + identity: MATERIAL_IDENTITY_WIDTH, + outer_padding: OUTER_PADDING, + section_gap: SECTION_GAP, + }; + + #[cfg(test)] + pub(super) const fn slot_width(inspector_width: f32) -> f32 { + inspector_width - OUTER_PADDING * 2.0 + } + + #[cfg(test)] + pub(super) const fn parameter_width(slot_width: f32) -> f32 { + slot_width - 27.0 + } + + #[cfg(test)] + pub(super) const fn inputs_height(compact: bool, primary_rows: usize) -> f32 { + INPUT_SECTION_HEADER_HEIGHT + + primary_rows as f32 + * if compact { + crate::ui::design_system::COMPACT_PARAMETER_ROW_HEIGHT + } else { + PARAMETER_ROW_HEIGHT + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn final_penpot_reference_geometry_is_exact() { + let geometry = MaterialPanelGeometry::REFERENCE; + assert_eq!(geometry.header, 82.0); + assert_eq!(geometry.collapsed_header, 52.0); + assert_eq!(geometry.asset_zone, 82.0); + assert_eq!(geometry.surface, 46.0); + assert_eq!(geometry.input_header, 24.0); + assert_eq!(geometry.input_row, 32.0); + assert_eq!(geometry.uv, 54.0); + assert_eq!(geometry.advanced_collapsed, 30.0); + assert_eq!(geometry.advanced_expanded, 178.0); + assert_eq!(geometry.preview, 64.0); + assert_eq!(geometry.identity, 229.5); + assert_eq!(620.0 - OUTER_PADDING * 2.0, 596.0); + assert_eq!(420.0 - OUTER_PADDING * 2.0, 396.0); + assert_eq!(MaterialPanelGeometry::slot_width(620.0), 596.0); + assert_eq!(MaterialPanelGeometry::slot_width(420.0), 396.0); + assert_eq!(MaterialPanelGeometry::parameter_width(596.0), 569.0); + assert_eq!(MaterialPanelGeometry::parameter_width(396.0), 369.0); + assert_eq!(MaterialPanelGeometry::inputs_height(false, 6), 216.0); + assert_eq!(MaterialPanelGeometry::inputs_height(true, 6), 372.0); + } + + #[test] + fn surface_geometry_matches_both_exported_references() { + let wide = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(569.0, 46.0), + )); + assert_eq!( + wide.title, + egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(48.0, 26.0)) + ); + assert_eq!( + wide.blend, + egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(250.0, 26.0)) + ); + assert_eq!( + wide.packed, + egui::Rect::from_min_size(egui::pos2(324.0, 11.0), egui::vec2(128.0, 24.0)) + ); + assert_eq!( + wide.double_sided, + egui::Rect::from_min_size(egui::pos2(460.0, 12.0), egui::vec2(99.0, 22.0)) + ); + + let narrow = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(369.0, 46.0), + )); + assert_eq!( + narrow.blend, + egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(50.0, 26.0)) + ); + assert_eq!( + narrow.packed, + egui::Rect::from_min_size(egui::pos2(124.0, 11.0), egui::vec2(128.0, 24.0)) + ); + assert_eq!( + narrow.double_sided, + egui::Rect::from_min_size(egui::pos2(260.0, 12.0), egui::vec2(99.0, 22.0)) + ); + } + + #[test] + fn uv_geometry_centers_title_and_keeps_independent_resets() { + for (width, height) in [(569.0, 54.0), (369.0, 94.0)] { + let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height)); + let geometry = UvSectionGeometry::for_rect(section); + assert_eq!(geometry.title.min, egui::pos2(12.0, 7.0)); + assert_eq!(geometry.offset.x.size(), egui::vec2(50.0, 24.0)); + assert_eq!(geometry.offset.y.size(), egui::vec2(50.0, 24.0)); + assert!(geometry.offset.reset.is_some()); + assert!(geometry.tiling.reset.is_some()); + for rect in [ + geometry.title, + geometry.offset.label, + geometry.offset.x, + geometry.offset.y, + geometry.offset.reset.expect("reference reset"), + geometry.tiling.label, + geometry.tiling.x, + geometry.tiling.y, + geometry.tiling.reset.expect("reference reset"), + ] { + assert!(section.contains_rect(rect)); + assert!(rect.is_finite()); + } + } + } + + #[test] + fn transient_geometry_stays_finite_and_bounded() { + let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 100.0)); + let surface = SurfaceSectionGeometry::for_rect(section); + let uv = UvSectionGeometry::for_rect(section); + for rect in [ + surface.title, + surface.blend, + surface.packed, + surface.double_sided, + ] { + assert!(rect.is_finite()); + assert!(section.contains_rect(rect)); + } + for rect in [ + uv.title, + uv.offset.label, + uv.offset.x, + uv.offset.y, + uv.tiling.label, + uv.tiling.x, + uv.tiling.y, + ] { + assert!(rect.is_finite()); + assert!(section.contains_rect(rect)); + } + } + + #[test] + fn inspector_chrome_reduced_floor_keeps_the_penpot_compact_surface() { + let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 46.0)); + let surface = SurfaceSectionGeometry::for_rect(section); + assert_eq!(surface.height, 46.0); + assert_eq!(surface.packed.min, egui::pos2(124.0, 11.0)); + assert_eq!(surface.packed.max, egui::pos2(252.0, 35.0)); + assert_eq!(surface.double_sided.min, egui::pos2(260.0, 12.0)); + } +} diff --git a/crates/editor/src/ui/materials/mod.rs b/crates/editor/src/ui/materials/mod.rs new file mode 100644 index 0000000..0229726 --- /dev/null +++ b/crates/editor/src/ui/materials/mod.rs @@ -0,0 +1,113 @@ +//! Shared Material authoring presentation contracts. +//! +//! World queries and scene mutations stay with callers. Widgets render a compact view model and +//! return explicit actions, which keeps Inspector, Content Browser, and Material Library behavior +//! consistent without giving UI code arbitrary world access. + +use std::collections::HashSet; +use std::path::Path; + +use bevy::prelude::{Vec2, Vec3, World}; +use bevy_egui::egui; +use egui_phosphor_icons::{icons, Icon}; +use shared::{ + standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialAsset, + MaterialInputDesc, MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, + MaterialParameter, MaterialParameterValue, MaterialRef, MaterialShaderKind, + MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset, TextureChannel, +}; + +use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry}; +use crate::assets::static_mesh::load_static_mesh_manifest; +use crate::assets::{ + invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection, AssetSubAssetKind, + EditorAsset, EditorAssetKind, EditorAssets, +}; +use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; +use crate::scene_io::SceneIo; +use crate::ui::document_status::authored_document_status_ui; + +use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING}; + +mod advanced_preview; +mod asset_editor; +mod editor; +mod inputs; +mod instance_editor; +mod instance_inputs; +mod layout; +mod panel; +mod pickers; +mod texture_inputs; +mod uv_transform; + +pub(crate) use asset_editor::material_asset_editor; +pub(crate) use editor::{ + create_material_instance_from_base, inline_material_document_editor, material_shader_kind, + material_shader_label, set_material_shader_kind, +}; +use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets}; +pub(crate) use inputs::material_input_schema_editor; +use inputs::*; +pub(crate) use instance_editor::material_instance_asset_editor; +pub(crate) use panel::{ + material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge, + MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel, + MaterialsSectionViewModel, +}; +use texture_inputs::*; + +const PHOSPHOR: &str = "phosphor-regular"; + +#[derive(Clone, Debug)] +pub(crate) struct MaterialTextureCandidate { + label: String, + detail: String, + reference: EditorAssetRef, + selection: AssetSelection, + thumbnail: MaterialThumbnailPresentation, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum MaterialThumbnailPresentation { + Ready(egui::TextureId), + Pending, + Failed, +} + +#[derive(Clone, Copy)] +enum TextureSlotSelection<'a> { + Reference(Option<&'a EditorAssetRef>), +} + +#[derive(Default)] +struct TextureSlotResponse { + selected: Option, + locate: Option, + clear: bool, + accepted_drop: bool, + browse_library: bool, +} + +#[derive(Default)] +pub(crate) struct MaterialInputEditorResponse { + pub accepted_drop: bool, + pub locate: Option, + pub browse_library: bool, +} + +fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { + let available = ui.available_width().max(1.0); + available.min(max).max(min.min(available)) +} + +fn icon_text(icon: Icon, size: f32) -> egui::RichText { + egui::RichText::new(icon.as_str()).font(egui::FontId::new( + size, + egui::FontFamily::Name(PHOSPHOR.into()), + )) +} + +fn natural_cmp(left: &str, right: &str) -> std::cmp::Ordering { + left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase()) +} diff --git a/crates/editor/src/ui/materials/panel.rs b/crates/editor/src/ui/materials/panel.rs new file mode 100644 index 0000000..4e98c77 --- /dev/null +++ b/crates/editor/src/ui/materials/panel.rs @@ -0,0 +1,743 @@ +//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors. + +use bevy_egui::egui; +use egui_phosphor_icons::icons; +use shared::{EditorAssetRef, MaterialShaderKind}; + +use super::layout::MaterialPanelGeometry; +use super::MaterialThumbnailPresentation; +use crate::ui::design_system::controls::{icon_button, icon_label, status}; +use crate::ui::design_system::typography::TypeRole; +use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT}; +use crate::ui::widgets::phosphor_icon; + +const SLOT_ACCENT_X: f32 = 8.0; +const SLOT_CARET_CENTER_X: f32 = 23.0; +const SLOT_PREVIEW_X: f32 = 35.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MaterialLayerBadge { + ModelSource, + ModelDefault, + ProjectDefault, + DefaultGrid, +} + +impl MaterialLayerBadge { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::ModelSource => "Model Source", + Self::ModelDefault => "Model Default", + Self::ProjectDefault => "Project Default", + Self::DefaultGrid => "Default Grid", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MaterialHealth { + Healthy, + Dirty, + Processing, + Broken, + ReadOnly, +} + +impl MaterialHealth { + fn presentation(self, palette: DesignPalette) -> (egui::Color32, &'static str, &'static str) { + match self { + Self::Healthy => ( + palette.healthy, + "Compiled", + "Material is resolved and up to date", + ), + Self::Dirty => ( + palette.warning, + "Unsaved", + "Material has unsaved in-memory changes", + ), + Self::Processing => ( + palette.accent, + "Processing", + "Derived material data is processing", + ), + Self::Broken => ( + palette.error, + "Failed", + "Material reference or processing failed", + ), + Self::ReadOnly => ( + palette.text_muted, + "Read only", + "This material cannot be edited in place", + ), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct MaterialPickerCandidate { + pub reference: EditorAssetRef, + pub label: String, + pub detail: String, + pub thumbnail: MaterialThumbnailPresentation, +} + +#[derive(Debug, Clone)] +pub(crate) struct MaterialSlotPanelViewModel { + pub slot_id: String, + pub label: String, + pub path: Option, + pub shader: String, + pub shader_kind: Option, + pub can_edit_shader: bool, + pub thumbnail: MaterialThumbnailPresentation, + pub inherited: Option, + pub health: MaterialHealth, + pub assigned: Option, + pub effective: Option, + pub candidates: Vec, + pub drop_candidate: Option, + pub invalid_drop_reason: Option, + pub can_locate: bool, + pub can_clear: bool, + pub can_extract: bool, + pub can_create_instance: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum MaterialSlotAction { + Assign { + reference: EditorAssetRef, + from_drop: bool, + }, + BrowseLibrary, + Locate, + Clear, + ExtractEditable, + CreateInstance, + SetShader(MaterialShaderKind), +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct MaterialSlotPanelAction { + pub slot_id: String, + pub action: MaterialSlotAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MaterialsSectionViewModel { + pub slot_ids: Vec, +} + +pub(crate) struct MaterialSlotPanelResponse { + pub actions: Vec, +} + +pub(crate) fn materials_section( + ui: &mut egui::Ui, + _view: &MaterialsSectionViewModel, + add_slots: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let start = ui.next_widget_position(); + let expanded = actor_materials_rect(ui.max_rect(), start.y); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(expanded) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(ui.clip_rect()); + let result = design_system::scope(&mut child, |ui| { + let palette = design_system::palette(ui); + ui.allocate_ui_with_layout( + egui::vec2( + ui.available_width().max(1.0), + design_system::MATERIALS_HEADING_HEIGHT, + ), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label( + TypeRole::Title + .text("Materials") + .color(palette.text_primary), + ); + }, + ); + ui.add_space(8.0); + add_slots(ui) + }); + // `min_rect` is not a safe content-height signal for a child hosted by a ScrollArea: nested + // frames may temporarily inherit the parent's virtual extent even though their layout cursor + // has only advanced through finite authored rows. Advancing from that rect creates a phantom + // tail after multi-slot renderers and lets the Inspector scrollbar enter an empty region. + // The cursor is the authoritative end of this top-down section. + let used_bottom = materials_used_bottom(child.next_widget_position().y, start.y); + ui.advance_cursor_after_rect(egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left(), start.y), + egui::pos2(ui.max_rect().right(), used_bottom), + )); + result +} + +fn actor_materials_rect(body: egui::Rect, start_y: f32) -> egui::Rect { + // A ScrollArea content Ui has an effectively unbounded max-rect in its scrolling direction. + // Carrying that bottom edge into the child gives the Materials section a phantom multi-screen + // height even though its painted contents are finite. Seed only the exported width here; egui + // grows the zero-height child as headings and slots are actually allocated. + egui::Rect::from_min_size( + egui::pos2(body.left(), start_y), + egui::vec2(body.width(), 0.0), + ) +} + +fn materials_used_bottom(cursor_y: f32, start_y: f32) -> f32 { + if cursor_y.is_finite() { + cursor_y.max(start_y) + } else { + start_y + } +} + +pub(crate) fn material_slot_panel( + ui: &mut egui::Ui, + id: impl std::hash::Hash, + view: &MaterialSlotPanelViewModel, + content_ui: impl FnOnce(&mut egui::Ui), + diagnostics_ui: impl FnOnce(&mut egui::Ui), +) -> MaterialSlotPanelResponse { + design_system::scope(ui, |ui| { + let palette = design_system::palette(ui); + let id = ui.make_persistent_id(id); + let open_id = id.with("parameters_open"); + let mut parameters_open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(true); + let mut slot_actions = Vec::new(); + + egui::Frame::new() + .fill(palette.panel) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(egui::CornerRadius::same(8)) + .show(ui, |ui| { + slot_header( + ui, + id, + view, + &mut parameters_open, + &mut slot_actions, + diagnostics_ui, + ); + if parameters_open { + parameter_body(ui, content_ui); + } + ui.ctx() + .data_mut(|data| data.insert_persisted(open_id, parameters_open)); + }); + MaterialSlotPanelResponse { + actions: slot_actions + .into_iter() + .map(|action| MaterialSlotPanelAction { + slot_id: view.slot_id.clone(), + action, + }) + .collect(), + } + }) +} + +fn slot_header( + ui: &mut egui::Ui, + id: egui::Id, + view: &MaterialSlotPanelViewModel, + open: &mut bool, + actions: &mut Vec, + diagnostics_ui: impl FnOnce(&mut egui::Ui), +) { + let palette = design_system::palette(ui); + let height = if *open { + HEADER_HEIGHT + } else { + MaterialPanelGeometry::REFERENCE.collapsed_header + }; + let width = ui.available_width().max(1.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click()); + ui.painter().rect_filled(rect, 7.0, palette.recessed); + ui.painter().rect_filled( + egui::Rect::from_min_size( + rect.min + egui::vec2(SLOT_ACCENT_X, 8.0), + egui::vec2(3.0, height - 16.0), + ), + 2.0, + palette.accent, + ); + let caret_rect = egui::Rect::from_center_size( + egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y), + egui::vec2(18.0, 28.0), + ); + let caret = ui.put( + caret_rect, + egui::Button::new(phosphor_icon( + if *open { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }, + 12.0, + )) + .frame(false), + ); + if caret.clicked() { + *open = !*open; + } + let preview_size = if *open { 64.0 } else { 32.0 }; + let preview_rect = egui::Rect::from_min_size( + egui::pos2( + rect.left() + SLOT_PREVIEW_X, + rect.center().y - preview_size * 0.5, + ), + egui::vec2(preview_size, preview_size), + ); + let preview = material_thumbnail_at(ui, view, preview_rect); + let action_width = 140.0; + let identity_left = preview_rect.right() + 6.0; + let actions_left = rect.right() - action_width - 8.0; + let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH) + .min(actions_left) + .max(identity_left + 1.0); + let identity_rect = egui::Rect::from_min_max( + egui::pos2(identity_left, rect.top() + if *open { 8.0 } else { 7.0 }), + egui::pos2( + identity_right, + rect.bottom() - if *open { 8.0 } else { 7.0 }, + ), + ); + let identity = ui + .scope_builder(egui::UiBuilder::new().max_rect(identity_rect), |ui| { + ui.set_clip_rect(identity_rect.intersect(ui.clip_rect())); + if *open { + material_identity(ui, view, actions) + } else { + compact_material_identity(ui, view) + } + }) + .inner; + let actions_rect = egui::Rect::from_min_size( + egui::pos2(actions_left, rect.top() + 7.0), + egui::vec2(action_width, height - 14.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { + if *open { + ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { + let (color, label, tooltip) = view.health.presentation(palette); + ui.horizontal(|ui| status(ui, color, label, tooltip)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + overflow_menu(ui, view, actions, diagnostics_ui); + if view.can_clear + && icon_button(ui, icons::X, "Clear actor assignment", true).clicked() + { + actions.push(MaterialSlotAction::Clear); + } + if view.can_locate + && icon_button(ui, icons::CROSSHAIR, "Locate in Content Browser", true) + .clicked() + { + actions.push(MaterialSlotAction::Locate); + } + if icon_button(ui, icons::FOLDER_OPEN, "Browse project materials", true) + .clicked() + { + actions.push(MaterialSlotAction::BrowseLibrary); + } + }); + }); + } else { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + overflow_menu(ui, view, actions, diagnostics_ui); + let (color, label, tooltip) = view.health.presentation(palette); + ui.horizontal(|ui| status(ui, color, label, tooltip)); + }); + } + }); + if response.clicked() { + if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) { + if !caret_rect.contains(pointer) + && !preview_rect.contains(pointer) + && !identity_rect.contains(pointer) + && !actions_rect.contains(pointer) + { + *open = !*open; + } + } + } + super::pickers::material_picker_popup( + ui, + id.with("material_picker"), + &preview.union(identity), + view, + actions, + ); + if response.hovered() + && view.drop_candidate.is_some() + && ui.input(|input| input.pointer.any_released()) + { + if let Some(candidate) = view.drop_candidate.as_ref() { + actions.push(MaterialSlotAction::Assign { + reference: candidate.reference.clone(), + from_drop: true, + }); + } + } + if response.hovered() { + if let Some(reason) = view.invalid_drop_reason.as_deref() { + response.on_hover_text(reason); + } + } +} + +fn compact_material_identity( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, +) -> egui::Response { + let palette = design_system::palette(ui); + let name = ui + .add( + egui::Label::new( + TypeRole::Section + .text(&view.label) + .color(palette.text_primary), + ) + .truncate() + .sense(egui::Sense::click()), + ) + .on_hover_text(&view.label); + let detail_text = view + .inherited + .map(MaterialLayerBadge::label) + .unwrap_or(view.shader.as_str()); + let detail = ui + .add( + egui::Label::new(TypeRole::Small.text(detail_text).color(palette.text_muted)) + .truncate() + .sense(egui::Sense::click()), + ) + .on_hover_text(detail_text); + name.union(detail) +} + +fn parameter_body(ui: &mut egui::Ui, content_ui: impl FnOnce(&mut egui::Ui)) { + let palette = design_system::palette(ui); + egui::Frame::new() + .fill(palette.panel) + .inner_margin(egui::Margin { + left: 19, + right: 8, + top: 8, + bottom: 8, + }) + .show(ui, |ui| { + ui.label(TypeRole::Caption.text("PARAMETERS").color(palette.accent)); + let spine_x = ui.min_rect().left() - 7.0; + let spine_top = ui.cursor().top(); + content_ui(ui); + let spine_bottom = ui.min_rect().bottom().max(spine_top); + ui.painter().vline( + spine_x, + spine_top..=spine_bottom, + egui::Stroke::new(1.0_f32, palette.accent), + ); + }); +} + +fn material_identity( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, +) -> egui::Response { + let palette = design_system::palette(ui); + let name = ui + .add( + egui::Label::new( + TypeRole::Section + .text(&view.label) + .color(palette.text_primary), + ) + .truncate() + .sense(egui::Sense::click()), + ) + .on_hover_text(&view.label); + let path = view.path.as_deref().unwrap_or("Engine built-in material"); + let identity = view.inherited.map_or_else( + || path.to_string(), + |layer| format!("{path} · {}", layer.label()), + ); + let detail = ui + .add( + egui::Label::new(TypeRole::Small.text(&identity).color(palette.text_muted)) + .truncate() + .sense(egui::Sense::click()), + ) + .on_hover_text(&identity); + if view.can_edit_shader { + if let Some(mut kind) = view.shader_kind { + egui::ComboBox::from_id_salt("material_identity_shader_kind") + .selected_text(&view.shader) + .width(150.0_f32.min(ui.available_width().max(1.0))) + .show_ui(ui, |ui| { + ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit"); + ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit"); + ui.selectable_value(&mut kind, MaterialShaderKind::Custom, "Custom Surface"); + }); + if Some(kind) != view.shader_kind { + actions.push(MaterialSlotAction::SetShader(kind)); + } + } + } else { + ui.add_sized( + [150.0_f32.min(ui.available_width().max(1.0)), 22.0], + egui::Label::new( + TypeRole::Body + .text(&view.shader) + .color(palette.text_secondary), + ), + ); + } + name.union(detail) +} + +fn material_thumbnail_at( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, + rect: egui::Rect, +) -> egui::Response { + let palette = design_system::palette(ui); + let response = ui.interact( + rect, + ui.make_persistent_id(("material_preview", &view.slot_id)), + egui::Sense::click(), + ); + let size = rect.width(); + let cell = size / 4.0; + for row in 0..4 { + for column in 0..4 { + ui.painter().rect_filled( + egui::Rect::from_min_size( + rect.min + egui::vec2(column as f32 * cell, row as f32 * cell), + egui::vec2(cell, cell), + ) + .intersect(rect), + 0.0, + if (row + column) % 2 == 0 { + palette.checker + } else { + palette.recessed + }, + ); + } + } + match view.thumbnail { + MaterialThumbnailPresentation::Ready(texture) => { + ui.painter().image( + texture, + rect.shrink(2.0), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + MaterialThumbnailPresentation::Pending => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::CIRCLE_NOTCH.as_str(), + egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + } + MaterialThumbnailPresentation::Failed => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::WARNING.as_str(), + egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.error, + ); + } + } + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + response.on_hover_text(format!("Open {} material picker", view.label)) +} + +fn overflow_menu( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, + diagnostics_ui: impl FnOnce(&mut egui::Ui), +) { + let palette = design_system::palette(ui); + ui.menu_button(phosphor_icon(icons::DOTS_THREE, 16.0), |ui| { + if view.can_locate && menu_action(ui, icons::CROSSHAIR, "Locate", palette.text_primary) { + actions.push(MaterialSlotAction::Locate); + ui.close(); + } + if menu_action( + ui, + icons::FOLDER_OPEN, + "Browse Asset Library", + palette.text_primary, + ) { + actions.push(MaterialSlotAction::BrowseLibrary); + ui.close(); + } + if view.can_clear && menu_action(ui, icons::X, "Clear material", palette.text_primary) { + actions.push(MaterialSlotAction::Clear); + ui.close(); + } + if view.can_create_instance + && menu_action( + ui, + icons::COPY, + "Create Instance and Assign", + palette.text_primary, + ) + { + actions.push(MaterialSlotAction::CreateInstance); + ui.close(); + } + if view.can_extract + && menu_action(ui, icons::EXPORT, "Extract Editable…", palette.text_primary) + { + actions.push(MaterialSlotAction::ExtractEditable); + ui.close(); + } + ui.separator(); + ui.label(TypeRole::Section.text("Source & Diagnostics")); + diagnostics_ui(ui); + }) + .response + .on_hover_text("Material actions and diagnostics"); +} + +fn menu_action( + ui: &mut egui::Ui, + icon: egui_phosphor_icons::Icon, + label: &str, + color: egui::Color32, +) -> bool { + ui.add(egui::Button::new(icon_label( + icon, + label, + TypeRole::Control, + 14.0, + color, + ))) + .clicked() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view() -> MaterialSlotPanelViewModel { + MaterialSlotPanelViewModel { + slot_id: "slot:test".into(), + label: "M_Wood_Planks".into(), + path: Some("materials/wood/M_Wood_Planks.mat".into()), + shader: "Standard Lit".into(), + shader_kind: Some(MaterialShaderKind::StandardLit), + can_edit_shader: true, + thumbnail: MaterialThumbnailPresentation::Pending, + inherited: None, + health: MaterialHealth::Healthy, + assigned: None, + effective: None, + candidates: Vec::new(), + drop_candidate: None, + invalid_drop_reason: None, + can_locate: true, + can_clear: true, + can_extract: false, + can_create_instance: true, + } + } + + #[test] + fn slot_header_insets_match_the_current_penpot_export() { + assert_eq!(SLOT_ACCENT_X, 8.0); + assert_eq!(SLOT_CARET_CENTER_X, 23.0); + assert_eq!(SLOT_PREVIEW_X, 35.0); + assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0); + } + + #[test] + fn actor_component_body_preserves_penpot_slot_widths() { + let wide_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(596.0, 700.0)); + let compact_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(396.0, 700.0)); + + let wide = actor_materials_rect(wide_body, 32.0); + let compact = actor_materials_rect(compact_body, 32.0); + + assert_eq!(wide.width(), 596.0); + assert_eq!(compact.width(), 396.0); + assert_eq!(wide.left(), 0.0); + assert_eq!(compact.left(), 0.0); + assert_eq!(wide.top(), 32.0); + assert_eq!(compact.top(), 32.0); + assert_eq!(wide.height(), 0.0); + assert_eq!(compact.height(), 0.0); + } + + #[test] + fn materials_height_uses_the_finite_layout_cursor_not_a_virtual_scroll_extent() { + assert_eq!(materials_used_bottom(948.0, 320.0), 948.0); + assert_eq!(materials_used_bottom(240.0, 320.0), 320.0); + assert_eq!(materials_used_bottom(f32::INFINITY, 320.0), 320.0); + } + + #[test] + fn penpot_direct_assignment_has_no_redundant_explicit_badge() { + assert!(view().inherited.is_none()); + } + + #[test] + fn penpot_assignment_action_carries_exact_stable_reference() { + let reference = EditorAssetRef::new("asset-id", "material:source", "M_Wood"); + let action = MaterialSlotPanelAction { + slot_id: "slot:test".into(), + action: MaterialSlotAction::Assign { + reference: reference.clone(), + from_drop: false, + }, + }; + assert_eq!( + action, + MaterialSlotPanelAction { + slot_id: "slot:test".into(), + action: MaterialSlotAction::Assign { + reference, + from_drop: false, + }, + } + ); + } + + #[test] + fn penpot_supported_material_states_share_one_view_model() { + for health in [ + MaterialHealth::Healthy, + MaterialHealth::Dirty, + MaterialHealth::Processing, + MaterialHealth::Broken, + MaterialHealth::ReadOnly, + ] { + let mut view = view(); + view.health = health; + let palette = DesignPalette::from(crate::ui::theme::BLACKSITE_PALETTE); + assert!(!view.health.presentation(palette).1.is_empty()); + } + } +} diff --git a/crates/editor/src/ui/materials/pickers.rs b/crates/editor/src/ui/materials/pickers.rs new file mode 100644 index 0000000..a2ec639 --- /dev/null +++ b/crates/editor/src/ui/materials/pickers.rs @@ -0,0 +1,411 @@ +//! Penpot-styled material and texture asset pickers. + +use bevy_egui::egui; +use egui_phosphor_icons::icons; +use shared::EditorAssetRef; + +use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel}; +use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse}; +use crate::ui::design_system; +use crate::ui::design_system::controls::icon_label; +use crate::ui::design_system::typography::TypeRole; + +const MATERIAL_PICKER_WIDTH: f32 = 244.0; +const TEXTURE_PICKER_WIDTH: f32 = 420.0; +const PICKER_ROW_HEIGHT: f32 = 44.0; +const PICKER_THUMBNAIL: f32 = 36.0; + +pub(super) fn material_picker_popup( + _ui: &mut egui::Ui, + id: egui::Id, + anchor: &egui::Response, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, +) { + egui::Popup::menu(anchor) + .id(id) + .width(MATERIAL_PICKER_WIDTH) + .show(|ui| { + ui.label( + TypeRole::Section + .text("Material Assets") + .color(design_system::palette(ui).text_primary), + ); + if let Some(current) = view.effective.as_ref() { + if let Some(candidate) = find_material_candidate(&view.candidates, current) { + material_menu_row(ui, candidate, true, actions); + } else { + unresolved_material_menu_row(ui, current); + } + } + + let recent_keys = recent_keys(ui, "material_picker_recents"); + let recent = candidates_for_recent_keys(&view.candidates, &recent_keys); + for candidate in recent.into_iter().take(4) { + let selected = view + .effective + .as_ref() + .is_some_and(|current| same_reference(current, &candidate.reference)); + if !selected { + material_menu_row(ui, candidate, false, actions); + } + } + picker_footer(ui, view.assigned.is_some(), actions); + }); +} + +pub(super) fn texture_picker_popup( + _ui: &mut egui::Ui, + id: egui::Id, + anchor: &egui::Response, + candidates: &[MaterialTextureCandidate], + selected: Option<&MaterialTextureCandidate>, + response: &mut TextureSlotResponse, +) { + egui::Popup::menu(anchor) + .id(id) + .width(TEXTURE_PICKER_WIDTH) + .show(|ui| { + picker_heading( + ui, + "Select Texture", + "Project textures and imported subassets", + ); + if let Some(current) = selected { + picker_section_label(ui, "CURRENT"); + texture_candidate_row(ui, current, true, response); + } + let recent_keys = recent_keys(ui, "texture_picker_recents"); + let recent = candidates_for_recent_texture_keys(candidates, &recent_keys); + if !recent.is_empty() { + picker_section_label(ui, "RECENT"); + for candidate in recent.into_iter().take(4) { + texture_candidate_row(ui, candidate, false, response); + } + } + picker_section_label(ui, "PROJECT TEXTURES"); + egui::ScrollArea::vertical() + .id_salt(id.with("project_textures")) + .max_height(240.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + if candidates.is_empty() { + empty_picker_row(ui, "No project textures"); + } + for candidate in candidates { + let selected = selected.is_some_and(|current| { + same_reference(¤t.reference, &candidate.reference) + }); + texture_candidate_row(ui, candidate, selected, response); + } + }); + let palette = design_system::palette(ui); + ui.separator(); + if ui + .add(egui::Button::new(icon_label( + icons::FOLDER_OPEN, + "Browse Asset Library", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + response.browse_library = true; + ui.close(); + } + if selected.is_some() + && ui + .add(egui::Button::new(icon_label( + icons::X, + "Clear Texture", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + response.clear = true; + ui.close(); + } + }); +} + +fn picker_heading(ui: &mut egui::Ui, title: &str, subtitle: &str) { + let palette = design_system::palette(ui); + ui.label(TypeRole::Title.text(title).color(palette.text_primary)); + ui.label(TypeRole::Small.text(subtitle).color(palette.text_muted)); + ui.separator(); +} + +fn picker_section_label(ui: &mut egui::Ui, label: &str) { + let palette = design_system::palette(ui); + ui.add_space(4.0); + ui.label(TypeRole::Caption.text(label).color(palette.text_muted)); +} + +fn material_menu_row( + ui: &mut egui::Ui, + candidate: &MaterialPickerCandidate, + selected: bool, + actions: &mut Vec, +) { + let response = picker_asset_row( + ui, + &candidate.label, + &candidate.detail, + candidate.thumbnail, + selected, + ); + if response.clicked() { + remember_recent(ui, "material_picker_recents", &candidate.reference); + actions.push(MaterialSlotAction::Assign { + reference: candidate.reference.clone(), + from_drop: false, + }); + ui.close(); + } +} + +fn texture_candidate_row( + ui: &mut egui::Ui, + candidate: &MaterialTextureCandidate, + selected: bool, + response: &mut TextureSlotResponse, +) { + let row = picker_asset_row( + ui, + &candidate.label, + &candidate.detail, + candidate.thumbnail, + selected, + ); + if row.clicked() { + remember_recent(ui, "texture_picker_recents", &candidate.reference); + response.selected = Some(candidate.clone()); + ui.close(); + } +} + +fn picker_asset_row( + ui: &mut egui::Ui, + label: &str, + detail: &str, + thumbnail: MaterialThumbnailPresentation, + selected: bool, +) -> egui::Response { + let palette = design_system::palette(ui); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), PICKER_ROW_HEIGHT), + egui::Sense::click(), + ); + let fill = if selected { + palette.accent_dark + } else if response.hovered() { + palette.elevated + } else { + palette.recessed + }; + ui.painter().rect( + rect, + 5.0, + fill, + egui::Stroke::new( + 1.0_f32, + if selected { + palette.accent + } else { + palette.border + }, + ), + egui::StrokeKind::Inside, + ); + let thumb = egui::Rect::from_min_size( + rect.left_top() + egui::vec2(4.0, 4.0), + egui::vec2(PICKER_THUMBNAIL, PICKER_THUMBNAIL), + ); + paint_thumbnail(ui, thumb, thumbnail); + let text_rect = egui::Rect::from_min_max( + egui::pos2(thumb.right() + 8.0, rect.top() + 5.0), + egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(text_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + child + .add(egui::Label::new(TypeRole::Body.text(label).color(palette.text_primary)).truncate()) + .on_hover_text(label); + child + .add(egui::Label::new(TypeRole::Small.text(detail).color(palette.text_muted)).truncate()) + .on_hover_text(detail); + response +} + +fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnailPresentation) { + let palette = design_system::palette(ui); + ui.painter().rect_filled(rect, 4.0, palette.control); + match thumbnail { + MaterialThumbnailPresentation::Ready(texture) => { + ui.painter().image( + texture, + rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + MaterialThumbnailPresentation::Pending => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::CIRCLE_NOTCH.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_muted, + ); + } + MaterialThumbnailPresentation::Failed => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::WARNING.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.error, + ); + } + } + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); +} + +fn picker_footer(ui: &mut egui::Ui, can_clear: bool, actions: &mut Vec) { + let palette = design_system::palette(ui); + ui.separator(); + if ui + .add(egui::Button::new(icon_label( + icons::FOLDER_OPEN, + "Browse Asset Library", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + actions.push(MaterialSlotAction::BrowseLibrary); + ui.close(); + } + if can_clear + && ui + .add(egui::Button::new(icon_label( + icons::X, + "Clear Material", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + actions.push(MaterialSlotAction::Clear); + ui.close(); + } +} + +fn unresolved_material_menu_row(ui: &mut egui::Ui, current: &EditorAssetRef) { + let palette = design_system::palette(ui); + ui.add_sized( + [ui.available_width().max(1.0), 28.0], + egui::Button::new(TypeRole::Body.text(¤t.label).color(palette.error)).selected(true), + ) + .on_hover_text("The currently assigned material could not be resolved"); +} + +fn empty_picker_row(ui: &mut egui::Ui, message: &str) { + let palette = design_system::palette(ui); + ui.add_sized( + [ui.available_width().max(1.0), 32.0], + egui::Label::new(TypeRole::Body.text(message).color(palette.text_muted)), + ); +} + +fn same_reference(left: &EditorAssetRef, right: &EditorAssetRef) -> bool { + left.asset_id == right.asset_id && left.sub_asset_id == right.sub_asset_id +} + +fn find_material_candidate<'a>( + candidates: &'a [MaterialPickerCandidate], + reference: &EditorAssetRef, +) -> Option<&'a MaterialPickerCandidate> { + candidates + .iter() + .find(|candidate| same_reference(&candidate.reference, reference)) +} + +fn recent_keys(ui: &egui::Ui, salt: &'static str) -> Vec<(String, String)> { + let id = ui.make_persistent_id(salt); + ui.ctx() + .data_mut(|data| data.get_persisted::>(id)) + .unwrap_or_default() +} + +fn remember_recent(ui: &egui::Ui, salt: &'static str, reference: &EditorAssetRef) { + let id = ui.make_persistent_id(salt); + let mut keys = recent_keys(ui, salt); + let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); + keys.retain(|current| current != &key); + keys.insert(0, key); + keys.truncate(8); + ui.ctx().data_mut(|data| data.insert_persisted(id, keys)); +} + +fn candidates_for_recent_keys<'a>( + candidates: &'a [MaterialPickerCandidate], + keys: &[(String, String)], +) -> Vec<&'a MaterialPickerCandidate> { + keys.iter() + .filter_map(|(asset_id, sub_asset_id)| { + candidates.iter().find(|candidate| { + candidate.reference.asset_id == *asset_id + && candidate.reference.sub_asset_id == *sub_asset_id + }) + }) + .collect() +} + +fn candidates_for_recent_texture_keys<'a>( + candidates: &'a [MaterialTextureCandidate], + keys: &[(String, String)], +) -> Vec<&'a MaterialTextureCandidate> { + keys.iter() + .filter_map(|(asset_id, sub_asset_id)| { + candidates.iter().find(|candidate| { + candidate.reference.asset_id == *asset_id + && candidate.reference.sub_asset_id == *sub_asset_id + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_picker_reference_comparison_uses_stable_identity_not_label() { + let left = EditorAssetRef::new("asset", "material:source", "Left"); + let right = EditorAssetRef::new("asset", "material:source", "Renamed"); + assert!(same_reference(&left, &right)); + } + + #[test] + fn penpot_picker_geometry_matches_penpot_assets() { + assert_eq!(MATERIAL_PICKER_WIDTH, 244.0); + assert_eq!(TEXTURE_PICKER_WIDTH, 420.0); + assert_eq!(PICKER_ROW_HEIGHT, 44.0); + assert_eq!(PICKER_THUMBNAIL, 36.0); + } +} diff --git a/crates/editor/src/ui/materials/texture_inputs.rs b/crates/editor/src/ui/materials/texture_inputs.rs new file mode 100644 index 0000000..22c7c7b --- /dev/null +++ b/crates/editor/src/ui/materials/texture_inputs.rs @@ -0,0 +1,408 @@ +//! Texture/channel controls shared by Material and Material Instance input tables. + +use super::*; + +use crate::ui::design_system; +use crate::ui::design_system::typography::TypeRole; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TextureFieldAdornment { + TypedThumbnail, + BrokenReference, + Empty, +} + +fn texture_field_adornment( + presentation: Option, + current_present: bool, +) -> TextureFieldAdornment { + if presentation.is_some() { + TextureFieldAdornment::TypedThumbnail + } else if current_present { + TextureFieldAdornment::BrokenReference + } else { + TextureFieldAdornment::Empty + } +} + +pub(super) fn material_property_texture_binding( + textures: &[MaterialTextureBinding], + property: &MaterialInputDesc, +) -> MaterialTextureBinding { + textures + .iter() + .find(|texture| texture.name == property.name) + .cloned() + .unwrap_or_else(|| MaterialTextureBinding { + name: property.name.clone(), + texture: None, + channel: property + .texture + .as_ref() + .map_or(TextureChannel::Rgba, |desc| desc.default_channel), + }) +} + +pub(super) fn upsert_material_texture_binding( + textures: &mut Vec, + binding: MaterialTextureBinding, +) { + if let Some(existing) = textures + .iter_mut() + .find(|texture| texture.name == binding.name) + { + *existing = binding; + } else { + textures.push(binding); + } +} + +pub(super) fn material_texture_channel_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + binding: &mut MaterialTextureBinding, +) { + let allow_override = property + .texture + .as_ref() + .is_some_and(|texture| texture.allow_channel_override); + if !allow_override { + return; + } + egui::ComboBox::from_id_salt(("texture_channel", &property.name)) + .selected_text(texture_channel_label(binding.channel)) + .width(48.0) + .show_ui(ui, |ui| { + for channel in [ + TextureChannel::R, + TextureChannel::G, + TextureChannel::B, + TextureChannel::A, + ] { + ui.selectable_value( + &mut binding.channel, + channel, + texture_channel_label(channel), + ); + } + }); +} + +pub(super) fn texture_channel_label(channel: TextureChannel) -> &'static str { + match channel { + TextureChannel::R => "R", + TextureChannel::G => "G", + TextureChannel::B => "B", + TextureChannel::A => "A", + TextureChannel::Rgb => "RGB", + TextureChannel::Rgba => "RGBA", + } +} + +pub(super) fn ensure_material_texture_binding<'a>( + textures: &'a mut Vec, + name: &str, +) -> &'a mut MaterialTextureBinding { + let index = textures + .iter() + .position(|texture| texture.name == name) + .unwrap_or_else(|| { + textures.push(MaterialTextureBinding { + name: name.to_string(), + texture: None, + channel: TextureChannel::Rgba, + }); + textures.len() - 1 + }); + &mut textures[index] +} + +pub(super) fn texture_binding_ui( + ui: &mut egui::Ui, + binding: &mut MaterialTextureBinding, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, + inline_actions: bool, +) -> TextureSlotResponse { + let response = texture_slot_ui( + ui, + format!("binding_{}", binding.name), + TextureSlotSelection::Reference(binding.texture.as_ref()), + "(none)", + texture_assets, + drop_candidate, + inline_actions, + ); + if response.clear { + binding.texture = None; + } else if let Some(candidate) = response.selected.as_ref() { + binding.texture = Some(candidate.reference.clone()); + } + response +} + +pub(super) fn texture_slot_ui( + ui: &mut egui::Ui, + slot_id: impl std::hash::Hash, + current: TextureSlotSelection<'_>, + empty_label: &str, + candidates: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, + inline_actions: bool, +) -> TextureSlotResponse { + let palette = design_system::palette(ui); + let mut result = TextureSlotResponse::default(); + ui.push_id(slot_id, |ui| { + let width = ui.available_width().max(1.0); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + let group = outer; + let field_width = if inline_actions { + (group.width() - 60.0).max(1.0) + } else { + (group.width() - 30.0).max(1.0) + }; + let field_rect = egui::Rect::from_min_size(group.min, egui::vec2(field_width, 22.0)); + let locate_rect = egui::Rect::from_min_size( + group.min + egui::vec2(field_width + 6.0, 0.0), + egui::vec2(24.0, 22.0), + ); + let clear_rect = inline_actions.then(|| { + egui::Rect::from_min_size( + group.min + egui::vec2(field_width + 36.0, 0.0), + egui::vec2(24.0, 22.0), + ) + }); + let selected_candidate = candidates.iter().find(|candidate| match current { + TextureSlotSelection::Reference(reference) => reference.is_some_and(|reference| { + reference.asset_id == candidate.reference.asset_id + && reference.sub_asset_id == candidate.reference.sub_asset_id + }), + }); + let current_present = match current { + TextureSlotSelection::Reference(reference) => reference.is_some(), + }; + let display_label = selected_candidate + .map(|candidate| candidate.label.as_str()) + .or_else(|| match current { + TextureSlotSelection::Reference(reference) => { + reference.map(|reference| reference.label.as_str()) + } + }) + .unwrap_or(empty_label); + let presentation = selected_candidate.map(|candidate| candidate.thumbnail); + let adornment = texture_field_adornment(presentation, current_present); + let label_color = if current_present { + palette.text_primary + } else { + palette.text_muted + }; + ui.painter().rect( + field_rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let field = ui.interact( + field_rect, + ui.make_persistent_id("texture_field"), + egui::Sense::click(), + ); + let image_rect = egui::Rect::from_center_size( + egui::pos2(field_rect.left() + 14.0, field_rect.center().y), + egui::vec2(16.0, 16.0), + ); + match selected_candidate.map(|candidate| candidate.thumbnail) { + Some(MaterialThumbnailPresentation::Ready(texture)) => { + ui.painter().image( + texture, + image_rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + Some(MaterialThumbnailPresentation::Pending) => { + paint_field_icon(ui, image_rect, icons::CIRCLE_NOTCH, palette.text_muted); + } + Some(MaterialThumbnailPresentation::Failed) => { + paint_field_icon(ui, image_rect, icons::WARNING, palette.error); + } + None => paint_field_icon( + ui, + image_rect, + if adornment == TextureFieldAdornment::BrokenReference { + icons::WARNING + } else { + icons::IMAGE + }, + if adornment == TextureFieldAdornment::BrokenReference { + palette.error + } else { + palette.text_muted + }, + ), + } + let text_rect = egui::Rect::from_min_max( + egui::pos2(field_rect.left() + 27.0, field_rect.top()), + egui::pos2(field_rect.right() - 6.0, field_rect.bottom()), + ); + // `Label::truncate` centers its galley when forced into the full fixed field rectangle. + // Paint the truncated galley at the Penpot text origin instead: the typed thumbnail and + // the asset name then form one stable left-aligned identity regardless of field width. + let galley = egui::WidgetText::from(TypeRole::Body.text(display_label).color(label_color)) + .into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + text_rect.width(), + egui::FontSelection::Default, + ); + let text_pos = egui::pos2( + text_rect.left(), + text_rect.center().y - galley.size().y * 0.5, + ); + let text_painter = ui + .painter() + .with_clip_rect(text_rect.intersect(ui.clip_rect())); + text_painter.galley(text_pos, galley, label_color); + field.clone().on_hover_text(display_label); + let drop_hovered = drop_candidate.is_some() && field.hovered(); + if drop_hovered { + ui.painter().rect_stroke( + field_rect, + 4.0, + egui::Stroke::new(2.0_f32, palette.accent), + egui::StrokeKind::Inside, + ); + } + super::pickers::texture_picker_popup( + ui, + field.id.with("texture_picker"), + &field, + candidates, + selected_candidate, + &mut result, + ); + if let Some(clear_rect) = clear_rect { + let locate = ui.put( + locate_rect, + egui::Button::new(icon_text(icons::CROSSHAIR, 14.0)) + .min_size(locate_rect.size()) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)), + ); + if selected_candidate.is_some() && locate.clicked() { + result.locate = selected_candidate.map(|candidate| candidate.selection.clone()); + } + locate.on_hover_text(if selected_candidate.is_some() { + "Locate texture in Content Browser" + } else { + "No texture to locate" + }); + let clear = ui.put( + clear_rect, + egui::Button::new(icon_text(icons::X, 14.0)) + .min_size(clear_rect.size()) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)), + ); + if current_present && clear.clicked() { + result.clear = true; + } + clear.on_hover_text(if current_present { + "Clear texture" + } else { + "No texture to clear" + }); + } else { + transient_texture_actions( + ui, + locate_rect, + selected_candidate, + current_present, + &mut result, + ); + } + if drop_hovered && ui.input(|input| input.pointer.any_released()) { + if let Some(candidate) = drop_candidate { + result.selected = Some(candidate.clone()); + result.accepted_drop = true; + } + } + }); + result +} + +fn transient_texture_actions( + ui: &mut egui::Ui, + rect: egui::Rect, + selected_candidate: Option<&MaterialTextureCandidate>, + current_present: bool, + result: &mut TextureSlotResponse, +) { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + ui.menu_button(icon_text(icons::DOTS_THREE, 14.0), |ui| { + if ui + .add_enabled(selected_candidate.is_some(), egui::Button::new("Locate")) + .clicked() + { + result.locate = selected_candidate.map(|candidate| candidate.selection.clone()); + ui.close(); + } + if ui + .add_enabled(current_present, egui::Button::new("Clear")) + .clicked() + { + result.clear = true; + ui.close(); + } + }) + .response + .on_hover_text("Texture actions"); + }); +} + +fn paint_field_icon( + ui: &egui::Ui, + rect: egui::Rect, + icon: egui_phosphor_icons::Icon, + color: egui::Color32, +) { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + color, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ready_texture_uses_its_thumbnail_without_a_redundant_asset_glyph() { + assert_eq!( + texture_field_adornment( + Some(MaterialThumbnailPresentation::Ready( + egui::TextureId::Managed(7) + )), + true, + ), + TextureFieldAdornment::TypedThumbnail + ); + } + + #[test] + fn unresolved_and_empty_texture_fields_keep_distinct_fallbacks() { + assert_eq!( + texture_field_adornment(None, true), + TextureFieldAdornment::BrokenReference + ); + assert_eq!( + texture_field_adornment(None, false), + TextureFieldAdornment::Empty + ); + } +} diff --git a/crates/editor/src/ui/materials/uv_transform.rs b/crates/editor/src/ui/materials/uv_transform.rs new file mode 100644 index 0000000..f67829e --- /dev/null +++ b/crates/editor/src/ui/materials/uv_transform.rs @@ -0,0 +1,131 @@ +//! Shared material UV-transform authoring used by Materials and Material Instances. + +use bevy::prelude::Vec2; +use bevy_egui::egui; +use egui_phosphor_icons::icons; +use shared::{MaterialInputSet, MaterialParameter, MaterialParameterValue}; + +use crate::ui::design_system; +use crate::ui::design_system::typography::TypeRole; + +pub(super) fn uv_transform_section( + ui: &mut egui::Ui, + offset: &mut Vec2, + tiling: &mut Vec2, + reset_offset: Vec2, + reset_tiling: Vec2, +) { + let palette = design_system::palette(ui); + let width = ui.available_width().max(1.0); + let height = match super::layout::ResponsiveMaterialLayout::for_width(width) { + super::layout::ResponsiveMaterialLayout::Reference => { + crate::ui::design_system::UV_SECTION_HEIGHT + } + super::layout::ResponsiveMaterialLayout::Compact => { + crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT + } + super::layout::ResponsiveMaterialLayout::Transient => 100.0, + }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().rect( + rect, + 5.0, + palette.section, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let geometry = super::layout::UvSectionGeometry::for_rect(rect); + child.painter().text( + geometry.title.left_center(), + egui::Align2::LEFT_CENTER, + "UV", + TypeRole::Section.font(), + palette.text_primary, + ); + vec2_group( + &mut child, + geometry.offset, + "Offset", + offset, + reset_offset, + "Reset UV offset", + ); + vec2_group( + &mut child, + geometry.tiling, + "Tiling", + tiling, + reset_tiling, + "Reset UV tiling", + ); +} + +fn vec2_group( + ui: &mut egui::Ui, + geometry: super::layout::UvGroupGeometry, + label: &str, + value: &mut Vec2, + reset: Vec2, + tooltip: &str, +) { + let palette = design_system::palette(ui); + ui.painter().text( + geometry.label.left_center(), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Small.font(), + palette.text_muted, + ); + ui.put( + geometry.x, + egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "), + ); + ui.put( + geometry.y, + egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "), + ); + if let Some(reset_rect) = geometry.reset { + let enabled = *value != reset; + let response = ui.put( + reset_rect, + egui::Button::new( + egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font( + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + ), + ), + ); + if enabled && response.clicked() { + *value = reset; + } + response.on_hover_text(tooltip); + } +} + +pub(super) fn material_vec2_value(inputs: &MaterialInputSet, name: &str, default: Vec2) -> Vec2 { + inputs + .values + .iter() + .find(|parameter| parameter.name == name) + .and_then(|parameter| match parameter.value { + MaterialParameterValue::Vec2(value) => Some(value), + _ => None, + }) + .unwrap_or(default) +} + +pub(super) fn set_material_vec2_value(inputs: &mut MaterialInputSet, name: &str, value: Vec2) { + if let Some(parameter) = inputs + .values + .iter_mut() + .find(|parameter| parameter.name == name) + { + parameter.value = MaterialParameterValue::Vec2(value); + } else { + inputs.values.push(MaterialParameter { + name: name.into(), + value: MaterialParameterValue::Vec2(value), + }); + } +} diff --git a/crates/editor/src/ui/menu.rs b/crates/editor/src/ui/menu.rs index 50f8baf..5f6bb2e 100644 --- a/crates/editor/src/ui/menu.rs +++ b/crates/editor/src/ui/menu.rs @@ -42,6 +42,23 @@ pub fn top_menu_bar( panel_nodes: &mut PanelNodes, viewport_rect: egui::Rect, ) { + let (save_all_shortcut, save_shortcut) = root_ui.ctx().input_mut(|input| { + let save_all = input.consume_shortcut(&egui::KeyboardShortcut::new( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::S, + )); + let save = !save_all + && input.consume_shortcut(&egui::KeyboardShortcut::new( + egui::Modifiers::CTRL, + egui::Key::S, + )); + (save_all, save) + }); + if save_all_shortcut { + crate::asset_documents::request_save_all(world); + } else if save_shortcut { + crate::asset_documents::request_contextual_save(world); + } egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { @@ -73,7 +90,11 @@ pub fn top_menu_bar( } }); if menu_item(ui, "Save", Some("Ctrl+S"), true).clicked() { - world.resource_mut::().request = Some(SceneIoRequest::Save); + crate::asset_documents::request_contextual_save(world); + ui.close(); + } + if menu_item(ui, "Save All", Some("Ctrl+Shift+S"), true).clicked() { + crate::asset_documents::request_save_all(world); ui.close(); } if menu_item(ui, "Save Scene As...", None, true).clicked() { @@ -101,7 +122,12 @@ pub fn top_menu_bar( } ui.separator(); if menu_item(ui, "Import Assets...", None, true).clicked() { - world.resource_mut::().request = Some(SceneIoRequest::ImportAssets); + let destination = world + .resource::() + .current_folder + .clone(); + world.resource_mut::().request = + Some(SceneIoRequest::ImportAssets { destination }); ui.close(); } if menu_item(ui, "Export Selection...", None, true).clicked() { diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 9052914..46975df 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -1,11 +1,14 @@ mod actor_inspector; mod animation_inspector; mod asset_browser; +mod asset_card; mod audio_inspector; mod build; pub mod component_registry; +pub(crate) mod design_system; mod diagnostics; mod dock_tabs; +pub(crate) mod document_status; mod fonts; pub(crate) mod helpers; mod hierarchy; @@ -14,6 +17,7 @@ pub mod hierarchy_state; pub(crate) mod inspector; mod layout; mod material_library; +pub(crate) mod materials; mod menu; pub(crate) mod navigation_inspector; mod play_controls; @@ -38,10 +42,7 @@ use crate::project_io::UserPreferences; use crate::selection::SelectedEntity; use crate::state::EditorMode; -pub(crate) use asset_browser::{ - adopt_material_conflict_save_as, reload_material_after_file_conflict, - validate_material_conflict_destination, -}; +pub(crate) use asset_browser::validate_material_conflict_destination; pub use build::BuildPanel; pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; pub use layout::LayoutSaveTimer; @@ -50,7 +51,9 @@ pub use viewport_chrome::ViewportUiState; use diagnostics::diagnostics_window; use dock_tabs::PanelNodes; use hierarchy_state::HierarchyPanelState; -use layout::{load_dock_layout, mark_layout_dirty_if_changed, tick_layout_save}; +use layout::{ + clamp_inspector_width, load_dock_layout, mark_layout_dirty_if_changed, tick_layout_save, +}; use menu::top_menu_bar; use status_bar::status_bar_ui; use theme::{apply_editor_theme, editor_dock_style}; @@ -238,9 +241,19 @@ impl UiState { rename_buffer: &mut self.rename_buffer, }; + clamp_inspector_width( + &mut self.dock_state, + self.panel_nodes, + root_ui.available_width(), + ); DockArea::new(&mut self.dock_state) .style(editor_dock_style(ctx)) .show_inside(root_ui, &mut viewer); + clamp_inspector_width( + &mut self.dock_state, + self.panel_nodes, + root_ui.available_width(), + ); }); if let Some(tab) = world.resource_mut::().0.take() { @@ -280,6 +293,7 @@ impl UiState { }); crate::collaboration::file_conflict_modal(world, ctx); + asset_browser::draw_asset_import_review_modal(world, ctx); if ctx.input(|input| input.key_pressed(egui::Key::F1)) { world.resource_mut::().shortcuts_open = true; @@ -375,6 +389,7 @@ impl Plugin for EditorUiPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -384,7 +399,14 @@ impl Plugin for EditorUiPlugin { init_hierarchy_panel_from_prefs .after(crate::project_io::load_user_preferences_on_startup), ) - .add_systems(Startup, validate_editor_component_registry) + .add_systems( + Startup, + ( + inspector::register_builtin_component_inspectors, + validate_editor_component_registry, + ) + .chain(), + ) .add_systems( Startup, apply_ui_state_from_prefs @@ -407,10 +429,11 @@ impl Plugin for EditorUiPlugin { } fn validate_editor_component_registry(world: &mut World) { - let errors = world - .resource::() - .validate(world) - .err(); + let registry = world.resource::(); + if !registry.visible_descriptors_have_inspectors() { + panic!("every visible built-in component must register an Inspector callback"); + } + let errors = registry.validate(world).err(); if let Some(errors) = errors { panic!( "invalid authoring component registry:\n{}", @@ -443,6 +466,7 @@ pub(crate) fn show_ui_system(world: &mut World) { world.resource_scope::(|world, mut ui_state| { ui_state.ui(world, egui_context.get_mut(), dt); }); + crate::asset_documents::asset_recovery_modal(world, egui_context.get_mut()); } struct TabViewer<'a> { @@ -483,17 +507,20 @@ impl egui_dock::TabViewer for TabViewer<'_> { self.rename_buffer, ); } - EditorTab::Inspector => match self.selected_entities.as_slice() { - &[entity] if self.world.get_entity(entity).is_ok() => { - actor_inspector::draw_actor_inspector(self.world, ui, entity); + EditorTab::Inspector => { + ui.set_min_width(layout::INSPECTOR_MIN_WIDTH); + match self.selected_entities.as_slice() { + &[entity] if self.world.get_entity(entity).is_ok() => { + actor_inspector::draw_actor_inspector(self.world, ui, entity); + } + &[entity] => { + ui.label(format!("Entity {entity:?} is no longer in the world.")); + } + entities => { + actor_inspector::draw_multi_actor_inspector(self.world, ui, entities); + } } - &[entity] => { - ui.label(format!("Entity {entity:?} is no longer in the world.")); - } - entities => { - actor_inspector::draw_multi_actor_inspector(self.world, ui, entities); - } - }, + } EditorTab::Toolbar => { ui.label("Toolbar moved below the menu bar (Play, spawn, save)."); } diff --git a/crates/editor/src/ui/scene_tabs.rs b/crates/editor/src/ui/scene_tabs.rs index ebc3e48..c3343cd 100644 --- a/crates/editor/src/ui/scene_tabs.rs +++ b/crates/editor/src/ui/scene_tabs.rs @@ -86,7 +86,7 @@ pub fn scene_tabs_chrome( egui::Button::new(text) .fill(if active { SELECTION_BG } else { WIDGET_BG }) .stroke(egui::Stroke::new( - 1.0, + 1.0_f32, if active { SELECTION } else { BORDER }, )) .min_size(egui::vec2(128.0, 20.0)), diff --git a/crates/editor/src/ui/status_bar.rs b/crates/editor/src/ui/status_bar.rs index 07eb8cb..2daa7b7 100644 --- a/crates/editor/src/ui/status_bar.rs +++ b/crates/editor/src/ui/status_bar.rs @@ -32,9 +32,12 @@ pub fn status_bar_ui( ui, |ui| { let status = primary_status_line(world); + let asset_dirty = world + .get_resource::() + .is_some_and(|store| store.has_dirty_documents()); ui.label( egui::RichText::new("●") - .color(if world.resource::().dirty { + .color(if world.resource::().dirty || asset_dirty { ACCENT } else { SUCCESS @@ -121,7 +124,19 @@ fn scene_line(scene_io: &SceneIo) -> String { fn primary_status_line(world: &World) -> String { let scene_io = world.resource::(); - format!("{} | {}", scene_io.status, scene_line(scene_io)) + let dirty_assets = world + .get_resource::() + .map_or(0, |store| store.dirty_count()); + if dirty_assets == 0 { + format!("{} | {}", scene_io.status, scene_line(scene_io)) + } else { + format!( + "{} | {} | {dirty_assets} unsaved asset{}", + scene_io.status, + scene_line(scene_io), + if dirty_assets == 1 { "" } else { "s" } + ) + } } #[cfg(test)] diff --git a/crates/editor/src/ui/theme.rs b/crates/editor/src/ui/theme.rs index 10bd4ea..4493c40 100644 --- a/crates/editor/src/ui/theme.rs +++ b/crates/editor/src/ui/theme.rs @@ -1,8 +1,37 @@ //! Blacksite editor visual system for egui and egui_dock chrome. -use bevy_egui::egui::{self, Color32, CornerRadius, FontId, Stroke, Visuals}; +use bevy_egui::egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals}; use egui_dock::{Style as DockStyle, TabInteractionStyle, TabStyle}; +/// Semantic editor colors shared by every panel and reusable control. +/// +/// The default values preserve Blacksite's existing visual identity. Keeping the palette as data +/// lets a future Editor Settings page select or override a theme without teaching individual +/// widgets about preferences or duplicating color constants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditorVisualPalette { + pub canvas: Color32, + pub panel: Color32, + pub panel_dark: Color32, + pub widget: Color32, + pub elevated: Color32, + pub accent: Color32, + pub accent_hover: Color32, + pub selection: Color32, + pub selection_secondary: Color32, + pub selection_bg: Color32, + pub selection_bg_muted: Color32, + pub text: Color32, + pub text_selected: Color32, + pub text_dim: Color32, + pub text_muted: Color32, + pub border: Color32, + pub border_strong: Color32, + pub success: Color32, + pub warning: Color32, + pub error: Color32, +} + pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10); pub const PANEL_BG: Color32 = Color32::from_rgb(13, 15, 18); pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(9, 11, 13); @@ -28,40 +57,80 @@ pub const AXIS_Y: Color32 = Color32::from_rgb(91, 201, 130); pub const AXIS_Z: Color32 = Color32::from_rgb(77, 147, 235); pub const VIEWPORT_OVERLAY: Color32 = Color32::from_rgba_premultiplied(7, 9, 11, 240); +pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette { + canvas: CANVAS_BG, + panel: PANEL_BG, + panel_dark: PANEL_BG_DARK, + widget: WIDGET_BG, + elevated: ELEVATED_BG, + accent: ACCENT, + accent_hover: ACCENT_HOVER, + selection: SELECTION, + selection_secondary: SELECTION_SECONDARY, + selection_bg: SELECTION_BG, + selection_bg_muted: SELECTION_BG_MUTED, + text: TEXT, + text_selected: TEXT_SELECTED, + text_dim: TEXT_DIM, + text_muted: TEXT_MUTED, + border: BORDER, + border_strong: BORDER_STRONG, + success: SUCCESS, + warning: WARNING, + error: ERROR, +}; + +const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette"; + +pub fn editor_palette(ctx: &egui::Context) -> EditorVisualPalette { + ctx.data_mut(|data| { + data.get_temp::(Id::new(EDITOR_PALETTE_ID)) + .unwrap_or(BLACKSITE_PALETTE) + }) +} + pub fn apply_editor_theme(ctx: &egui::Context) { + let palette = editor_palette(ctx); + apply_editor_theme_with_palette(ctx, palette); +} + +/// Applies and remembers a palette. Future preference loading can call this without changing any +/// panel implementation. +pub fn apply_editor_theme_with_palette(ctx: &egui::Context, palette: EditorVisualPalette) { + ctx.data_mut(|data| data.insert_temp(Id::new(EDITOR_PALETTE_ID), palette)); let mut visuals = Visuals::dark(); - visuals.panel_fill = PANEL_BG; - visuals.window_fill = WIDGET_BG; - visuals.extreme_bg_color = CANVAS_BG; - visuals.faint_bg_color = Color32::from_rgb(17, 19, 22); - visuals.code_bg_color = PANEL_BG_DARK; - visuals.text_edit_bg_color = Some(CANVAS_BG); - visuals.weak_text_color = Some(TEXT_MUTED); - visuals.widgets.noninteractive.bg_fill = PANEL_BG; - visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER); - visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_DIM); - visuals.widgets.inactive.bg_fill = WIDGET_BG; - visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(24, 27, 31); - visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER); - visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT); - visuals.widgets.hovered.bg_fill = ELEVATED_BG; - visuals.widgets.hovered.weak_bg_fill = ELEVATED_BG; - visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, BORDER_STRONG); - visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); - visuals.widgets.active.bg_fill = Color32::from_rgb(38, 42, 47); - visuals.widgets.active.weak_bg_fill = Color32::from_rgb(38, 42, 47); - visuals.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT); - visuals.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); - visuals.widgets.open.bg_fill = ELEVATED_BG; - visuals.widgets.open.weak_bg_fill = ELEVATED_BG; - visuals.widgets.open.bg_stroke = Stroke::new(1.0, BORDER_STRONG); - visuals.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_SELECTED); - visuals.selection.bg_fill = SELECTION_BG; - visuals.selection.stroke = Stroke::new(1.0, SELECTION); - visuals.hyperlink_color = SELECTION; - visuals.warn_fg_color = WARNING; - visuals.error_fg_color = ERROR; - visuals.window_stroke = Stroke::new(1.0, BORDER); + visuals.panel_fill = palette.panel; + visuals.window_fill = palette.widget; + visuals.extreme_bg_color = palette.canvas; + visuals.faint_bg_color = palette.panel_dark; + visuals.code_bg_color = palette.panel_dark; + visuals.text_edit_bg_color = Some(palette.canvas); + visuals.weak_text_color = Some(palette.text_muted); + visuals.widgets.noninteractive.bg_fill = palette.panel; + visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border); + visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, palette.text_dim); + visuals.widgets.inactive.bg_fill = palette.widget; + visuals.widgets.inactive.weak_bg_fill = palette.widget; + visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border); + visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text); + visuals.widgets.hovered.bg_fill = palette.elevated; + visuals.widgets.hovered.weak_bg_fill = palette.elevated; + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong); + visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_selected); + visuals.widgets.active.bg_fill = palette.elevated; + visuals.widgets.active.weak_bg_fill = palette.elevated; + visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent); + visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_selected); + visuals.widgets.open.bg_fill = palette.elevated; + visuals.widgets.open.weak_bg_fill = palette.elevated; + visuals.widgets.open.bg_stroke = Stroke::new(1.0_f32, palette.border_strong); + visuals.widgets.open.fg_stroke = Stroke::new(1.0_f32, palette.text_selected); + visuals.selection.bg_fill = palette.selection_bg; + visuals.selection.stroke = Stroke::new(1.0_f32, palette.selection); + visuals.hyperlink_color = palette.selection; + visuals.warn_fg_color = palette.warning; + visuals.error_fg_color = palette.error; + visuals.window_stroke = Stroke::new(1.0_f32, palette.border); visuals.window_corner_radius = CornerRadius::same(4); visuals.menu_corner_radius = CornerRadius::same(3); visuals.window_shadow = egui::epaint::Shadow { @@ -115,38 +184,39 @@ pub fn apply_editor_theme(ctx: &egui::Context) { } pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle { + let palette = editor_palette(ctx); let mut style = DockStyle::from_egui(ctx.global_style().as_ref()); style.dock_area_padding = Some(egui::Margin::same(1)); - style.main_surface_border_stroke = Stroke::new(1.0, BORDER); + style.main_surface_border_stroke = Stroke::new(1.0_f32, palette.border); style.main_surface_border_rounding = CornerRadius::ZERO; style.separator.width = 2.0; style.separator.extra_interact_width = 5.0; - style.separator.color_idle = BORDER; - style.separator.color_hovered = BORDER_STRONG; - style.separator.color_dragged = ACCENT_HOVER; + style.separator.color_idle = palette.border; + style.separator.color_hovered = palette.border_strong; + style.separator.color_dragged = palette.accent_hover; - style.tab_bar.bg_fill = PANEL_BG_DARK; + style.tab_bar.bg_fill = palette.panel_dark; style.tab_bar.height = 30.0; style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1); - style.tab_bar.hline_color = ACCENT; + style.tab_bar.hline_color = palette.accent; style.tab_bar.corner_radius = CornerRadius::ZERO; let active = TabInteractionStyle { - outline_color: ACCENT, - bg_fill: PANEL_BG, - text_color: TEXT_SELECTED, + outline_color: palette.accent, + bg_fill: palette.panel, + text_color: palette.text_selected, corner_radius: CornerRadius::same(1), }; let inactive = TabInteractionStyle { outline_color: Color32::TRANSPARENT, bg_fill: Color32::TRANSPARENT, - text_color: TEXT_DIM, + text_color: palette.text_dim, corner_radius: CornerRadius::same(1), }; let hovered = TabInteractionStyle { - outline_color: BORDER_STRONG, - bg_fill: ELEVATED_BG, - text_color: TEXT, + outline_color: palette.border_strong, + bg_fill: palette.elevated, + text_color: palette.text, corner_radius: CornerRadius::same(1), }; @@ -163,11 +233,11 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle { ..style.tab }; style.tab.tab_body.inner_margin = egui::Margin::same(4); - style.tab.tab_body.bg_fill = PANEL_BG; - style.tab.tab_body.stroke = Stroke::new(1.0, BORDER); - style.overlay.selection_color = SELECTION_BG; - style.overlay.button_color = SELECTION; - style.overlay.button_border_stroke = Stroke::new(1.0, SELECTION); + style.tab.tab_body.bg_fill = palette.panel; + style.tab.tab_body.stroke = Stroke::new(1.0_f32, palette.border); + style.overlay.selection_color = palette.selection_bg; + style.overlay.button_color = palette.selection; + style.overlay.button_border_stroke = Stroke::new(1.0_f32, palette.selection); style } @@ -180,7 +250,7 @@ pub fn status_bar_frame() -> egui::Frame { egui::Frame::new() .fill(PANEL_BG_DARK) .inner_margin(egui::Margin::symmetric(10, 3)) - .stroke(Stroke::new(1.0, BORDER)) + .stroke(Stroke::new(1.0_f32, BORDER)) } pub fn brand_mark_frame() -> egui::Frame { @@ -188,7 +258,7 @@ pub fn brand_mark_frame() -> egui::Frame { .fill(CANVAS_BG) .corner_radius(CornerRadius::same(2)) .inner_margin(egui::Margin::symmetric(8, 4)) - .stroke(Stroke::new(1.0, BORDER_STRONG)) + .stroke(Stroke::new(1.0_f32, BORDER_STRONG)) } pub fn viewport_toolbar_frame() -> egui::Frame { @@ -196,7 +266,7 @@ pub fn viewport_toolbar_frame() -> egui::Frame { .fill(VIEWPORT_OVERLAY) .corner_radius(CornerRadius::same(3)) .inner_margin(egui::Margin::same(5)) - .stroke(Stroke::new(1.0, BORDER_STRONG)) + .stroke(Stroke::new(1.0_f32, BORDER_STRONG)) .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 12, @@ -210,7 +280,7 @@ pub fn overlay_chip_frame() -> egui::Frame { .fill(VIEWPORT_OVERLAY) .corner_radius(CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(8, 5)) - .stroke(Stroke::new(1.0, BORDER_STRONG)) + .stroke(Stroke::new(1.0_f32, BORDER_STRONG)) } pub fn toolbar_group_frame() -> egui::Frame { @@ -218,5 +288,5 @@ pub fn toolbar_group_frame() -> egui::Frame { .fill(PANEL_BG_DARK) .corner_radius(CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(5, 3)) - .stroke(Stroke::new(1.0, BORDER)) + .stroke(Stroke::new(1.0_f32, BORDER)) } diff --git a/crates/editor/src/ui/toolbar.rs b/crates/editor/src/ui/toolbar.rs index bdc0f05..e6fd92a 100644 --- a/crates/editor/src/ui/toolbar.rs +++ b/crates/editor/src/ui/toolbar.rs @@ -94,14 +94,14 @@ pub fn toolbar_ui( ui.painter().line_segment( [toolbar_rect.left_bottom(), toolbar_rect.right_bottom()], - egui::Stroke::new(1.0, super::theme::BORDER), + egui::Stroke::new(1.0_f32, super::theme::BORDER), ); ui.painter().line_segment( [ toolbar_rect.left_bottom() + egui::vec2(8.0, 0.0), toolbar_rect.left_bottom() + egui::vec2(134.0, 0.0), ], - egui::Stroke::new(2.0, ACCENT), + egui::Stroke::new(2.0_f32, ACCENT), ); } diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 8ac3db3..3a34554 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -1,7 +1,7 @@ //! Unified viewport overlay chrome (toolbars, badges, HUD). use bevy::prelude::*; -use bevy_egui::{egui, EguiTextureHandle, EguiUserTextures}; +use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::icons; use transform_gizmo_bevy::prelude::GizmoOptions; @@ -14,7 +14,6 @@ use crate::assets::thumbnails::kind_icon; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; use crate::camera::EditorCamera; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; -use crate::render_target::ViewportRenderTarget; use crate::selection::{SelectedEntity, ViewportClick, ViewportPickStack}; use crate::state::PlayPossession; use crate::viewport::actor_icons::ActorIconSettings; @@ -24,6 +23,7 @@ use crate::viewport::physics_placement::{ begin_physics_placement, request_physics_placement, PhysicsPlacementPhase, PhysicsPlacementRequest, PhysicsPlacementState, }; +use crate::viewport::target_presenter::sync_viewport_target_texture; use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState}; use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState}; use crate::viewport::{ @@ -68,6 +68,7 @@ pub fn viewport_tab_ui( ) { let rect = ui.available_rect_before_wrap(); *viewport_rect = rect; + let viewport_texture = sync_viewport_target_texture(world); if rect.width() <= 1.0 || rect.height() <= 1.0 { *pointer_in_viewport = false; @@ -104,15 +105,7 @@ pub fn viewport_tab_ui( } } - if let Some(target) = world - .get_resource::() - .and_then(|target| target.0.clone()) - { - let mut textures = world.resource_mut::(); - let texture_id = textures - .image_id(target.image.id()) - .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(target.image.clone()))); - + if let Some(texture_id) = viewport_texture { let painter = ui.painter(); painter.rect_filled(rect, 0.0, egui::Color32::BLACK); painter.image( @@ -125,7 +118,7 @@ pub fn viewport_tab_ui( rect, 0.0, egui::Stroke::new( - 1.0, + 1.0_f32, if response.hovered() { BORDER_STRONG } else { @@ -576,7 +569,7 @@ fn scene_view_orientation_widget(world: &mut World, ctx: &egui::Context, scene_r }; painter.line_segment( [center, endpoint], - egui::Stroke::new(if facing { 2.2 } else { 1.2 }, color), + egui::Stroke::new(if facing { 2.2_f32 } else { 1.2_f32 }, color), ); painter.circle_filled(endpoint, if facing { 9.0 } else { 7.0 }, color); painter.text( @@ -1331,7 +1324,7 @@ fn viewport_asset_drop_ui( ui.painter().rect_stroke( viewport_rect.shrink(2.0), 0.0, - egui::Stroke::new(2.0, highlight_color), + egui::Stroke::new(2.0_f32, highlight_color), egui::StrokeKind::Inside, ); } @@ -1537,34 +1530,34 @@ fn draw_asset_drag_preview( if over_viewport { let painter = ui.painter(); painter.circle_filled(pointer, 3.0, highlight_color); - painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, highlight_color)); + painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5_f32, highlight_color)); painter.line_segment( [ pointer - egui::vec2(17.0, 0.0), pointer - egui::vec2(7.0, 0.0), ], - egui::Stroke::new(1.5, highlight_color), + egui::Stroke::new(1.5_f32, highlight_color), ); painter.line_segment( [ pointer + egui::vec2(7.0, 0.0), pointer + egui::vec2(17.0, 0.0), ], - egui::Stroke::new(1.5, highlight_color), + egui::Stroke::new(1.5_f32, highlight_color), ); painter.line_segment( [ pointer - egui::vec2(0.0, 17.0), pointer - egui::vec2(0.0, 7.0), ], - egui::Stroke::new(1.5, highlight_color), + egui::Stroke::new(1.5_f32, highlight_color), ); painter.line_segment( [ pointer + egui::vec2(0.0, 7.0), pointer + egui::vec2(0.0, 17.0), ], - egui::Stroke::new(1.5, highlight_color), + egui::Stroke::new(1.5_f32, highlight_color), ); } @@ -1636,9 +1629,9 @@ fn draw_asset_drag_preview( ), ] { ui.painter() - .line_segment([a, a + b], egui::Stroke::new(3.0, highlight_color)); + .line_segment([a, a + b], egui::Stroke::new(3.0_f32, highlight_color)); ui.painter() - .line_segment([a, a + c], egui::Stroke::new(3.0, highlight_color)); + .line_segment([a, a + c], egui::Stroke::new(3.0_f32, highlight_color)); } } } diff --git a/crates/editor/src/ui/widgets.rs b/crates/editor/src/ui/widgets.rs index 5283e16..46a20fe 100644 --- a/crates/editor/src/ui/widgets.rs +++ b/crates/editor/src/ui/widgets.rs @@ -49,7 +49,7 @@ pub fn transport_button_large( fill }; let stroke = egui::Stroke::new( - if response.hovered() { 1.5 } else { 1.0 }, + if response.hovered() { 1.5_f32 } else { 1.0_f32 }, if accent { SUCCESS.linear_multiply(1.15) } else if response.hovered() { @@ -74,7 +74,7 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Response { ui.add( Button::new(phosphor_icon(icon, 17.0)) .fill(WIDGET_BG.linear_multiply(0.78)) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .min_size(egui::vec2(32.0, 32.0)), ) .on_hover_text(tooltip) @@ -84,7 +84,7 @@ pub fn icon_button_small(ui: &mut Ui, icon: Icon, tooltip: &str) -> egui::Respon ui.add( Button::new(phosphor_icon(icon, 16.0)) .fill(WIDGET_BG.linear_multiply(0.72)) - .stroke(egui::Stroke::new(1.0, BORDER)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) .min_size(egui::vec2(27.0, 27.0)) .corner_radius(4.0), ) @@ -128,7 +128,7 @@ fn tool_button_with_color( Button::new(phosphor_icon(icon, 17.0)) .fill(fill) .stroke(egui::Stroke::new( - 1.0, + 1.0_f32, if selected { selected_stroke } else { BORDER }, )) .min_size(egui::vec2(30.0, 30.0)) diff --git a/crates/editor/src/viewport/material_drop.rs b/crates/editor/src/viewport/material_drop.rs index d9571a2..7993d22 100644 --- a/crates/editor/src/viewport/material_drop.rs +++ b/crates/editor/src/viewport/material_drop.rs @@ -4,7 +4,7 @@ use bevy::ecs::system::SystemParam; use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility}; use bevy::prelude::*; use shared::{ - BrushDesc, ComponentInstanceId, EditorAssetRef, HydratedRendererMaterialBinding, LevelObject, + BrushDesc, ComponentInstanceId, EditorAssetRef, HydratedMaterialSlotBinding, LevelObject, MaterialDesc, MaterialInstanceAsset, MaterialRef, Primitive, SkinnedMeshRenderer, StaticMeshRenderer, }; @@ -13,9 +13,7 @@ use crate::asset_db::{find_asset_by_path, AssetRegistry}; use crate::assets::materials::ensure_static_material_slots; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssetKind, EditorAssets}; use crate::camera::EditorCamera; -use crate::history::{ - reflected_component_transaction, set_brush_with_history, set_material_with_history, -}; +use crate::history::{reflected_component_transaction, set_brush_with_history}; use crate::infra::EditorOnly; use crate::operators::{ run_immediate_operator, ActiveOperator, OperatorAvailability, OperatorPhase, OperatorStatus, @@ -198,7 +196,6 @@ enum SurfaceDropPayload { Texture { label: String, reference: EditorAssetRef, - source_path: String, }, } @@ -237,10 +234,10 @@ enum MaterialDropSnapshot { before: SkinnedMeshRenderer, after: SkinnedMeshRenderer, }, - Material { + Primitive { entity: Entity, - before: Option>, - after: Box, + before: Box, + after: Box, }, Brush { entity: Entity, @@ -261,7 +258,7 @@ impl MaterialDropSnapshot { Self::SkinnedRenderer { after, .. } => { entity.insert(after.clone()); } - Self::Material { after, .. } => { + Self::Primitive { after, .. } => { entity.insert(after.as_ref().clone()); } Self::Brush { after, .. } => { @@ -282,12 +279,8 @@ impl MaterialDropSnapshot { Self::SkinnedRenderer { before, .. } => { entity.insert(before.clone()); } - Self::Material { before, .. } => { - if let Some(before) = before { - entity.insert(before.as_ref().clone()); - } else { - entity.remove::(); - } + Self::Primitive { before, .. } => { + entity.insert(before.as_ref().clone()); } Self::Brush { before, .. } => { entity.insert(before.clone()); @@ -299,7 +292,7 @@ impl MaterialDropSnapshot { match self { Self::StaticRenderer { entity, .. } | Self::SkinnedRenderer { entity, .. } - | Self::Material { entity, .. } + | Self::Primitive { entity, .. } | Self::Brush { entity, .. } => *entity, } } @@ -308,7 +301,7 @@ impl MaterialDropSnapshot { #[derive(SystemParam)] struct MaterialDropQueries<'w, 's> { cameras: Query<'w, 's, (&'static Camera, &'static GlobalTransform), With>, - bindings: Query<'w, 's, &'static HydratedRendererMaterialBinding>, + bindings: Query<'w, 's, &'static HydratedMaterialSlotBinding>, levels: Query<'w, 's, (), With>, parents: Query<'w, 's, &'static ChildOf>, editor_only: Query<'w, 's, (), With>, @@ -564,7 +557,6 @@ fn surface_drop_payload( asset.label.clone(), ) .with_source_path(path.clone()), - source_path: path.clone(), }), _ => Err(format!("{} is not a material or texture", asset.label)), } @@ -610,7 +602,6 @@ fn surface_drop_payload( label.clone(), ) .with_source_path(source_path.clone()), - source_path, }) } _ => Err("selection is not a material or texture".into()), @@ -684,14 +675,18 @@ fn begin_material_drop_preview( } } MaterialDropTargetKey::Primitive(owner) => { - if world.get::(*owner).is_none() { - return Err("primitive target no longer exists".into()); - } - let before = world.get::(*owner).cloned(); - let after = material_desc_for_surface(before.clone(), payload)?; - MaterialDropSnapshot::Material { + let SurfaceDropPayload::Material { reference, .. } = payload else { + return Err("Textures cannot be assigned directly to primitive material slots; create or edit a Material Instance".into()); + }; + let before = world + .get::(*owner) + .cloned() + .ok_or_else(|| "primitive target no longer exists".to_string())?; + let mut after = before.clone(); + after.surface.material = Some(reference.clone()); + MaterialDropSnapshot::Primitive { entity: *owner, - before: before.map(Box::new), + before: Box::new(before), after: Box::new(after), } } @@ -740,26 +735,6 @@ fn begin_material_drop_preview( }) } -fn material_desc_for_surface( - before: Option, - payload: &SurfaceDropPayload, -) -> Result { - match payload { - SurfaceDropPayload::Material { - resolved: Some(desc), - .. - } => Ok(desc.as_ref().clone()), - SurfaceDropPayload::Material { resolved: None, .. } => { - Err("Embedded source materials can only be assigned to renderer slots".into()) - } - SurfaceDropPayload::Texture { source_path, .. } => { - let mut material = before.unwrap_or_default(); - material.base_color_texture = Some(source_path.clone()); - Ok(material) - } - } -} - fn update_material_drop_session(world: &mut World) { let mut state = world .remove_resource::() @@ -956,10 +931,17 @@ fn commit_material_drop_snapshot( Ok(()) }, ), - MaterialDropSnapshot::Material { after, .. } => { - set_material_with_history(world, entity, *after); - Ok(()) - } + MaterialDropSnapshot::Primitive { after, .. } => reflected_component_transaction( + world, + entity, + "Assign Primitive Material Slot", + shared::AUTHORING_COMPONENT_PRIMITIVE, + shared::COMPONENT_PRIMITIVE, + move |world, entity| { + world.entity_mut(entity).insert(*after); + Ok(()) + }, + ), MaterialDropSnapshot::Brush { after, .. } => { set_brush_with_history(world, entity, after); Ok(()) @@ -1086,13 +1068,11 @@ mod tests { RendererMaterialSlot { id: slot_a.clone(), name: "A".into(), - source_material: None, material: None, }, RendererMaterialSlot { id: slot_b.clone(), name: "B".into(), - source_material: None, material: None, }, ], @@ -1251,7 +1231,6 @@ mod tests { slots: vec![RendererMaterialSlot { id: slot_id.clone(), name: "Body".into(), - source_material: None, material: None, }], orphaned_assignments: Vec::new(), @@ -1273,7 +1252,6 @@ mod tests { label: "Grid".into(), reference: EditorAssetRef::new("grid", "texture:source", "Grid") .with_source_path("assets/textures/grid.png"), - source_path: "assets/textures/grid.png".into(), }; let error = begin_material_drop_preview( @@ -1291,37 +1269,50 @@ mod tests { #[test] fn resolved_project_material_is_supported_for_primitives() { - let mut material = material_desc_for_surface(None, &material_payload("steel")).unwrap(); - assert_eq!(material.roughness, 0.2); + let mut app = test_app(); + let world = app.world_mut(); + let entity = world + .spawn((LevelObject, ActorKind::StaticMesh, Primitive::default())) + .id(); + let target = MaterialDropTarget::Primitive { + owner: entity, + owner_label: "Cube".into(), + hit_point: Vec3::ZERO, + hit_normal: Vec3::Y, + }; + let preview = begin_material_drop_preview( + world, + AssetSelection::File("assets/materials/steel.ron".into()), + &target, + &material_payload("steel"), + ) + .unwrap(); + + let primitive = world.get::(entity).unwrap(); assert_eq!( - material.material_asset_path.as_deref(), + primitive + .surface + .material + .as_ref() + .and_then(|material| material.0.source_path.as_deref()), Some("assets/materials/steel.ron") ); - material.base_color_texture = None; - let texture = SurfaceDropPayload::Texture { - label: "Grid".into(), - reference: EditorAssetRef::default(), - source_path: "assets/textures/grid.png".into(), - }; - let textured = material_desc_for_surface(Some(material), &texture).unwrap(); - assert_eq!( - textured.base_color_texture.as_deref(), - Some("assets/textures/grid.png") - ); + preview.snapshot.restore(world); + assert!(world + .get::(entity) + .unwrap() + .surface + .material + .is_none()); } #[test] fn locked_target_rejects_preview_without_mutation_or_history() { let mut app = test_app(); let world = app.world_mut(); - let original = MaterialDesc::default(); + let original = Primitive::default(); let entity = world - .spawn(( - LevelObject, - ActorKind::Empty, - Primitive::default(), - original.clone(), - )) + .spawn((LevelObject, ActorKind::Empty, original.clone())) .id(); let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default(); hierarchy.locked.insert(entity); @@ -1342,7 +1333,7 @@ mod tests { .unwrap_err(); assert!(error.contains("locked")); - assert_eq!(world.get::(entity), Some(&original)); + assert_eq!(world.get::(entity), Some(&original)); assert_eq!(world.resource::().undo_depth(), 0); assert!(!world.resource::().dirty); } diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 9842ae1..4770e6c 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -14,6 +14,7 @@ pub mod render_view; pub mod rendering_diagnostics; pub mod selection; pub mod selection_outline; +pub(crate) mod target_presenter; pub mod terrain_paint; pub mod terrain_sculpt; pub mod viewport_mode; diff --git a/crates/editor/src/viewport/target_presenter.rs b/crates/editor/src/viewport/target_presenter.rs new file mode 100644 index 0000000..cd8dcfa --- /dev/null +++ b/crates/editor/src/viewport/target_presenter.rs @@ -0,0 +1,104 @@ +//! Bounded Egui ownership for the editor viewport render target. + +use bevy::asset::AssetId; +use bevy::prelude::*; +use bevy_egui::{egui, EguiTextureHandle, EguiUserTextures}; + +use crate::render_target::ViewportRenderTarget; + +#[derive(Resource, Default, Debug, Clone, Copy)] +struct ViewportTextureRegistration { + image: Option>, +} + +/// Returns the Egui texture for the current viewport target while retaining exactly one strong +/// image registration. Replacements and an absent target relinquish the previous registration. +pub(crate) fn sync_viewport_target_texture(world: &mut World) -> Option { + if !world.contains_resource::() { + world.insert_resource(ViewportTextureRegistration::default()); + } + + let target = world + .get_resource::() + .and_then(|target| target.0.as_ref()) + .map(|target| target.image.clone()); + let next_image = target.as_ref().map(Handle::id); + let previous_image = world.resource::().image; + + if previous_image != next_image { + if let Some(previous_image) = previous_image { + world + .resource_mut::() + .remove_image(previous_image); + } + world.resource_mut::().image = None; + } + + let target = target?; + let image_id = target.id(); + let texture_id = { + let mut textures = world.resource_mut::(); + textures + .image_id(image_id) + .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(target))) + }; + world.resource_mut::().image = Some(image_id); + Some(texture_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render_target::ViewportTarget; + + fn world_with_target(image: Handle) -> World { + let mut world = World::new(); + world.insert_resource(EguiUserTextures::default()); + world.insert_resource(ViewportRenderTarget(Some(ViewportTarget { image }))); + world + } + + #[test] + fn viewport_replacement_releases_the_previous_egui_registration() { + let mut images = Assets::::default(); + let first = images.add(Image::default()); + let second = images.add(Image::default()); + let first_id = first.id(); + let second_id = second.id(); + let mut world = world_with_target(first); + + let first_texture = sync_viewport_target_texture(&mut world).expect("first registration"); + assert_eq!( + world.resource::().image_id(first_id), + Some(first_texture) + ); + + world.resource_mut::().0 = Some(ViewportTarget { image: second }); + sync_viewport_target_texture(&mut world).expect("replacement registration"); + assert!(world + .resource::() + .image_id(first_id) + .is_none()); + assert!(world + .resource::() + .image_id(second_id) + .is_some()); + + world.resource_mut::().0 = None; + assert!(sync_viewport_target_texture(&mut world).is_none()); + assert!(world + .resource::() + .image_id(second_id) + .is_none()); + } + + #[test] + fn stable_viewport_image_reuses_the_same_egui_texture() { + let mut images = Assets::::default(); + let image = images.add(Image::default()); + let mut world = world_with_target(image); + let first = sync_viewport_target_texture(&mut world).expect("first registration"); + let second = sync_viewport_target_texture(&mut world).expect("stable registration"); + assert_eq!(first, second); + } +} diff --git a/crates/game/src/launch.rs b/crates/game/src/launch.rs index 84d2224..36f0e2d 100644 --- a/crates/game/src/launch.rs +++ b/crates/game/src/launch.rs @@ -6,7 +6,10 @@ use bevy::app::PluginGroupBuilder; use bevy::asset::AssetPlugin; use bevy::log::{LogPlugin, DEFAULT_FILTER}; use bevy::prelude::*; -use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin}; +use bevy::window::{ + CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin, + WindowResizeConstraints, +}; /// Runtime switch for the HDR camera pass. /// @@ -53,13 +56,30 @@ pub fn editor_plugins(title: impl Into) -> PluginGroupBuilder { fn editor_window_plugin(title: impl Into) -> WindowPlugin { WindowPlugin { - primary_window: Some(primary_window(title)), + primary_window: Some(editor_window(title)), exit_condition: ExitCondition::DontExit, close_when_requested: false, ..default() } } +fn editor_window(title: impl Into) -> Window { + Window { + // A FIFO swapchain can spend roughly one second in each texture + // acquisition while an NVIDIA Wayland window is being interactively + // resized. Repeated acquire timeouts make the editor appear frozen. + // Prefer Immediate/Mailbox for the authoring host while retaining the + // normal VSync contract for packaged game windows. + present_mode: PresentMode::AutoNoVsync, + resize_constraints: WindowResizeConstraints { + min_width: 960.0, + min_height: 620.0, + ..default() + }, + ..primary_window(title) + } +} + fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder { DefaultPlugins .set(LogPlugin { @@ -105,7 +125,7 @@ pub fn hdr_enabled_from_env() -> bool { #[cfg(test)] mod tests { use super::{editor_window_plugin, primary_window, resolve_assets_directory}; - use bevy::window::{CompositeAlphaMode, ExitCondition}; + use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode}; #[test] fn primary_window_is_opaque() { @@ -113,6 +133,7 @@ mod tests { assert!(!window.transparent); assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque); + assert_eq!(window.present_mode, PresentMode::AutoVsync); } #[test] @@ -121,6 +142,13 @@ mod tests { assert!(!plugin.close_when_requested); assert!(matches!(plugin.exit_condition, ExitCondition::DontExit)); + assert_eq!( + plugin + .primary_window + .expect("editor primary window") + .present_mode, + PresentMode::AutoNoVsync + ); } #[test] diff --git a/crates/game/src/lib.rs b/crates/game/src/lib.rs index 5432071..6ebb272 100644 --- a/crates/game/src/lib.rs +++ b/crates/game/src/lib.rs @@ -56,7 +56,10 @@ pub struct GameSceneBootstrap { pub defer_default_level: bool, } -#[cfg(not(feature = "hot-reload"))] +// Unit tests exercise the deterministic in-process systems even when Cargo's all-feature gate +// enables `hot-reload`; the test binary does not publish the separate `libgame_hot` dylib watched +// by hot-lib-reloader. +#[cfg(any(not(feature = "hot-reload"), test))] mod systems { pub use game_hot::{ apply_ambient_light, apply_player_intent, grab_cursor, move_and_slide, @@ -67,7 +70,7 @@ mod systems { }; } -#[cfg(feature = "hot-reload")] +#[cfg(all(feature = "hot-reload", not(test)))] mod systems { pub use crate::hot::{ apply_ambient_light, apply_player_intent, grab_cursor, move_and_slide, @@ -78,7 +81,7 @@ mod systems { }; } -#[cfg(feature = "hot-reload")] +#[cfg(all(feature = "hot-reload", not(test)))] #[hot_lib_reloader::hot_module(dylib = "game_hot", lib_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/debug"))] mod hot { use avian3d::prelude::*; @@ -113,7 +116,7 @@ mod hot { pub fn subscribe() -> hot_lib_reloader::LibReloadObserver {} } -#[cfg(feature = "hot-reload")] +#[cfg(all(feature = "hot-reload", not(test)))] pub fn hot_reload_subscribe() -> hot_lib_reloader::LibReloadObserver { hot::subscribe() } diff --git a/crates/scene/Cargo.toml b/crates/scene/Cargo.toml index 0214a98..898b975 100644 --- a/crates/scene/Cargo.toml +++ b/crates/scene/Cargo.toml @@ -15,3 +15,4 @@ polyanya.workspace = true rerecast.workspace = true shared.workspace = true settings.workspace = true +uuid = { version = "1", features = ["serde"] } diff --git a/crates/scene/src/document.rs b/crates/scene/src/document.rs index 5b35357..796f237 100644 --- a/crates/scene/src/document.rs +++ b/crates/scene/src/document.rs @@ -743,7 +743,7 @@ mod tests { let body = "(resources: {}, entities: {})"; let document = SceneDocument::from_ron_text(body).unwrap(); let text = document.to_ron_text().unwrap(); - assert!(text.starts_with("(schema_version: 4,")); + assert!(text.starts_with("(schema_version: 6,")); assert!(SceneDocument::from_ron_text(&text) .unwrap() .entities diff --git a/crates/scene/src/lib.rs b/crates/scene/src/lib.rs index 022f39f..8080d0e 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -18,7 +18,8 @@ use serde::{Deserialize, Serialize}; pub use composition::{validate_composition_graph, validate_scene_composition}; pub use migrate::{ - migrate_v1_to_v2, migrate_v2_to_v3, migrate_v3_to_v4, DEFAULT_SUN_ILLUMINANCE_LUX, + migrate_v1_to_v2, migrate_v2_to_v3, migrate_v3_to_v4, migrate_v4_to_v5, + DEFAULT_SUN_ILLUMINANCE_LUX, }; pub use prefab::{ prefab_graph_revision, validate_prefab_graph, validate_prefab_graph_text, @@ -30,7 +31,7 @@ pub use project_validation::{ }; pub use upgrade::{upgrade_project, ProjectUpgradeChange, ProjectUpgradeReport}; -pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 4; +pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 6; /// Whether an asset-relative path names a tree excluded from runtime packages. pub fn is_runtime_package_excluded_tree(relative: &Path) -> bool { @@ -135,10 +136,13 @@ pub fn migrate_scene_text(text: &str) -> Result { }; let migrated_body = match version { - 0 | 1 => migrate_v3_to_v4(&migrate_v2_to_v3(&migrate_v1_to_v2(&body))), - 2 => migrate_v3_to_v4(&migrate_v2_to_v3(&body)), - 3 => migrate_v3_to_v4(&body), - 4 => body, + 0 | 1 => migrate_v4_to_v5(&migrate_v3_to_v4(&migrate_v2_to_v3(&migrate_v1_to_v2( + &body, + )))), + 2 => migrate_v4_to_v5(&migrate_v3_to_v4(&migrate_v2_to_v3(&body))), + 3 => migrate_v4_to_v5(&migrate_v3_to_v4(&body)), + 4 => migrate_v4_to_v5(&body), + 5 | 6 => body, _ => { return Err(format!( "scene schema version {version} is newer than supported version {CURRENT_SCENE_SCHEMA_VERSION}" @@ -228,7 +232,7 @@ mod tests { fn stamp_and_strip_round_trip() { let body = "(resources: {}, entities: {})"; let stamped = stamp_schema_version(body).unwrap(); - assert!(stamped.starts_with("(schema_version: 4,")); + assert!(stamped.starts_with(&format!("(schema_version: {CURRENT_SCENE_SCHEMA_VERSION},"))); let stripped = strip_schema_version(&stamped).unwrap(); assert_eq!(stripped, body); } @@ -237,7 +241,7 @@ mod tests { fn legacy_scene_migrates_to_current_schema() { let legacy = "(resources: {}, entities: {4294967133: ()})"; let migrated = migrate_scene_text(legacy).unwrap(); - assert!(migrated.contains("schema_version: 4")); + assert!(migrated.contains(&format!("schema_version: {CURRENT_SCENE_SCHEMA_VERSION}"))); } #[test] diff --git a/crates/scene/src/migrate.rs b/crates/scene/src/migrate.rs index 5ade779..5b5f29b 100644 --- a/crates/scene/src/migrate.rs +++ b/crates/scene/src/migrate.rs @@ -63,6 +63,15 @@ pub fn migrate_v3_to_v4(body: &str) -> String { body.to_string() } +/// Schema v5 moves primitive and mesh material ownership into stable material slots. +/// +/// Normal loading remains read-only, so the text migration only advances the in-memory schema. +/// `cargo upgrade-project --apply` materializes referenced Material assets and removes legacy +/// actor-level descriptors transactionally. +pub fn migrate_v4_to_v5(body: &str) -> String { + body.to_string() +} + fn backfill_actor_kinds(text: &str) -> String { let mut result = String::with_capacity(text.len() + 1024); let mut rest = text; diff --git a/crates/scene/src/project_validation.rs b/crates/scene/src/project_validation.rs index 8819c6b..11b0cdf 100644 --- a/crates/scene/src/project_validation.rs +++ b/crates/scene/src/project_validation.rs @@ -12,8 +12,8 @@ use shared::{ AnimationControllerDesc, AnimationDiagnosticSeverity, AnimationManifest, AssetSourceFingerprint, AudioListenerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset, MaterialDesc, MaterialInstanceAsset, - MaterialOverride, ModelRef, PostProcessEffectAsset, PostProcessVolumeDesc, PrefabInstance, - PrefabRef, RendererMaterialSet, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer, + MaterialOverride, MaterialSlotSet, ModelRef, PostProcessEffectAsset, PostProcessVolumeDesc, + PrefabInstance, PrefabRef, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer, TerrainDesc, ANIMATION_MANIFEST_SCHEMA_VERSION, AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER, NAVIGATION_GENERATED_ARTIFACT_DIRECTORY, @@ -192,7 +192,9 @@ fn validate_project_manifest( repair: "Repair the project audio buses so required IDs exist and all parent routes terminate at Master.".into(), }); } - if manifest.version != 1 || manifest.template_version > 1 { + if manifest.version != settings::SETTINGS_VERSION + || manifest.template_version > settings::PROJECT_TEMPLATE_VERSION + { report.findings.push(ProjectValidationFinding { severity: ValidationSeverity::Error, code: "project.incompatible_schema".into(), @@ -206,6 +208,17 @@ fn validate_project_manifest( repair: "Open the project in a compatible editor or run its project migration.".into(), }); } + if manifest.asset_roots != ["assets"] { + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Error, + code: "project.noncanonical_asset_roots".into(), + source_path: source.into(), + owner_actor_id: None, + reference: None, + message: "project asset_roots must be exactly [\"assets\"]".into(), + repair: "Run `cargo upgrade-project --project . --apply` to adopt the managed content workspace.".into(), + }); + } add_reference( project_root, report, @@ -330,6 +343,27 @@ struct RegistryAssetRecord { dependencies: Vec, } +impl From for RegistryAssetRecord { + fn from(record: shared::AssetRecord) -> Self { + let model = record.import_settings.model(); + Self { + id: RegistryAssetId(record.id.as_string()), + path: record.path, + kind_tag: format!("{:?}", record.kind), + source_fingerprint: record.source_fingerprint, + import_settings: RegistryImportSettings { + static_mesh_manifest_path: model + .and_then(|settings| settings.static_mesh_manifest_path.clone()), + animation_manifest_path: model + .and_then(|settings| settings.animation_manifest_path.clone()), + default_animation_clip_id: model + .and_then(|settings| settings.default_animation_clip_id.clone()), + }, + dependencies: record.dependencies, + } + } +} + #[derive(Debug, Clone)] struct AnimationRegistryRecord { source_path: String, @@ -367,13 +401,44 @@ fn validate_asset_registry( }); return animation_catalog; } - let Some(records) = parse_document::>( - &path, - "assets/.index/registry.ron", - "registry", - report, - ) else { - return animation_catalog; + let records = match std::fs::read_to_string(&path) { + Ok(source) => match shared::parse_asset_registry(&source) { + Ok(loaded) => loaded + .document + .records + .into_iter() + .map(RegistryAssetRecord::from) + .collect::>(), + // Validator fixtures and legacy projects may use symbolic IDs. Preserve the previous + // read-only validation view while production registry v2 remains UUID-typed. + Err(shared_error) => match ron::from_str::>(&source) { + Ok(records) => records, + Err(_) => { + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Error, + code: "registry.invalid".into(), + source_path: "assets/.index/registry.ron".into(), + owner_actor_id: None, + reference: None, + message: format!("could not parse registry: {shared_error}"), + repair: "Repair or regenerate the registry document.".into(), + }); + return animation_catalog; + } + }, + }, + Err(error) => { + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Error, + code: "registry.invalid".into(), + source_path: "assets/.index/registry.ron".into(), + owner_actor_id: None, + reference: None, + message: format!("could not read registry: {error}"), + repair: "Repair or regenerate the registry document.".into(), + }); + return animation_catalog; + } }; let mut ids = BTreeSet::new(); let mut paths = BTreeSet::new(); @@ -619,15 +684,8 @@ fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationR .into(), }); } - if let Some(shader) = asset.shader { - add_reference( - project_root, - report, - &source, - None, - "shader_schema", - &shader, - ); + if let Some(shader) = asset.shader.schema_path.as_deref() { + add_reference(project_root, report, &source, None, "shader_schema", shader); } if let Some(shader) = asset.shader_ref { collect_asset_ref( @@ -639,7 +697,12 @@ fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationR &shader, ); } - collect_material(project_root, report, &source, None, &asset.material); + let mut material = shared::MaterialDesc { + shader: asset.shader.clone(), + ..Default::default() + }; + asset.inputs.apply_to_material_desc(&mut material); + collect_material(project_root, report, &source, None, &material); continue; } if let Ok(instance) = ron::from_str::(&text) { @@ -684,7 +747,7 @@ fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationR "material", &instance.base.0, ); - for texture in instance.textures { + for texture in instance.overrides.textures { if let Some(reference) = texture.texture { collect_asset_ref(project_root, report, &source, None, "texture", &reference); } @@ -764,9 +827,17 @@ fn validate_shader_assets(project_root: &Path, report: &mut ProjectValidationRep } } } - for texture in asset.default_textures { - if let Some(reference) = texture.texture { - collect_asset_ref(project_root, report, &source, None, "texture", &reference); + for input in asset.schema.inputs { + if input.texture.is_some() && input.name.trim().is_empty() { + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Error, + code: "surface_shader.invalid_input".into(), + source_path: source.clone(), + owner_actor_id: None, + reference: None, + message: "shader texture input names cannot be empty".into(), + repair: "Assign a stable input name in the shader schema.".into(), + }); } } } @@ -798,6 +869,8 @@ struct StaticMeshManifestView { import: StaticMeshImportView, #[serde(default)] warnings: Vec, + #[serde(default)] + parts: Vec, } #[derive(Deserialize)] @@ -815,6 +888,18 @@ struct StaticMeshSourceView { struct StaticMeshImportView { #[serde(default)] material_policy: StaticMeshMaterialPolicyView, + #[serde(default)] + material_slots: Vec, +} + +#[derive(Default, Deserialize)] +struct StaticMeshPartView { + #[serde(default)] + id: String, + #[serde(default)] + material_id: Option, + #[serde(default)] + material_label: Option, } #[derive(Default, Deserialize, PartialEq, Eq)] @@ -877,7 +962,7 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid "static mesh source", ); let optional_source_textures = manifest.source.format.eq_ignore_ascii_case("fbx") - && manifest.import.material_policy == StaticMeshMaterialPolicyView::AuthoringOverride; + && !static_manifest_uses_source_materials(&manifest); add_import_dependencies( project_root, report, @@ -901,6 +986,35 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid } } +fn static_manifest_uses_source_materials(manifest: &StaticMeshManifestView) -> bool { + if manifest.import.material_policy == StaticMeshMaterialPolicyView::AuthoringOverride { + return false; + } + if manifest.parts.is_empty() { + return manifest.import.material_slots.is_empty() + || manifest + .import + .material_slots + .iter() + .any(|slot| matches!(slot.selection, shared::ModelMaterialSelection::Source)); + } + manifest.parts.iter().any(|part| { + if part.material_id.is_none() && part.material_label.is_none() { + return false; + } + if part.id.trim().is_empty() { + return true; + } + let slot_id = format!("slot:{}", part.id); + manifest + .import + .material_slots + .iter() + .find(|slot| slot.slot_id.0 == slot_id) + .is_none_or(|slot| matches!(slot.selection, shared::ModelMaterialSelection::Source)) + }) +} + fn add_import_dependencies( project_root: &Path, report: &mut ProjectValidationReport, @@ -955,7 +1069,7 @@ fn add_import_dependencies( reference: Some(missing.join(", ")), message: if optional_source_textures { format!( - "{} FBX source texture(s) are unavailable and intentionally ignored by Authoring Override", + "{} FBX source texture(s) are unavailable and intentionally unused by the model's per-slot material selections", missing.len() ) } else { @@ -965,10 +1079,10 @@ fn add_import_dependencies( ) }, repair: if optional_source_textures { - "No action is required while Authoring Override remains active; restore the texture bundle before selecting Source Materials." + "No action is required while every imported slot uses Project or Default; restore the texture bundle before selecting Source on a slot." .into() } else { - "Restore the referenced texture bundle and reimport, or select Authoring Override for a deliberately untextured asset." + "Restore the referenced texture bundle and reimport, or select Project/Default for every deliberately untextured slot." .into() }, }); @@ -1597,6 +1711,29 @@ fn validate_document( } let mut enabled_listeners = Vec::new(); for entity in &document.entities { + let has_legacy_material = entity + .components + .iter() + .any(|component| component.type_name == MATERIAL_DESC); + let uses_material_slots = entity.components.iter().any(|component| { + matches!( + component.type_name.as_str(), + "shared::components::Primitive" + | STATIC_MESH_RENDERER + | COMPONENT_SKINNED_MESH_RENDERER + ) + }); + if has_legacy_material && uses_material_slots { + report.findings.push(ProjectValidationFinding { + severity: ValidationSeverity::Warning, + code: "material.legacy_actor_component".into(), + source_path: source_path.clone(), + owner_actor_id: entity.actor_id.clone(), + reference: Some(MATERIAL_DESC.into()), + message: "primitive or mesh actor still stores a legacy actor-local MaterialDesc; the material-slot fallback is used until the project is upgraded".into(), + repair: "Run `cargo upgrade-project --project . --apply` to materialize a shared Material asset and assign the exact saved slot.".into(), + }); + } for component in &entity.components { if component.type_name == AUDIO_LISTENER_DESC { if let Ok(listener) = ron::from_str::(&component.ron) { @@ -2044,16 +2181,6 @@ fn collect_component_references( "mesh", &slot.mesh, ); - if let Some(material) = slot.material { - collect_asset_ref( - project_root, - report, - source_path, - actor_id, - "material", - &material, - ); - } } } BRUSH_DESC => { @@ -2207,13 +2334,10 @@ fn collect_renderer_material_references( report: &mut ProjectValidationReport, source_path: &str, actor_id: Option<&str>, - materials: &RendererMaterialSet, + materials: &MaterialSlotSet, ) { for slot in &materials.slots { - for reference in [slot.source_material.as_ref(), slot.material.as_ref()] - .into_iter() - .flatten() - { + if let Some(reference) = slot.material.as_ref() { collect_asset_ref( project_root, report, @@ -3255,7 +3379,7 @@ mod tests { std::fs::create_dir_all(root.join("assets/levels")).unwrap(); std::fs::create_dir_all(root.join("assets/.index")).unwrap(); std::fs::write(root.join("assets/.index/registry.ron"), "[]").unwrap(); - write_manifest(&root, 1); + write_manifest(&root, settings::SETTINGS_VERSION); root } @@ -3373,7 +3497,7 @@ mod tests { .find(|finding| finding.code == "import.external_texture_missing") .expect("Authoring Override should retain one actionable dependency state"); assert_eq!(finding.severity, ValidationSeverity::Info); - assert!(finding.message.contains("Authoring Override")); + assert!(finding.message.contains("per-slot material selections")); assert!(report.is_release_ready(), "{:?}", report.findings); assert_eq!(std::fs::read(&artifact).unwrap(), before); std::fs::remove_dir_all(root).unwrap(); @@ -3465,7 +3589,7 @@ mod tests { std::fs::write( root.join("assets/project.ron"), format!( - "(version:{version},template_version:1,default_level:\"assets/levels/main.scn.ron\",asset_roots:[\"assets/levels\"],capabilities:[])" + "(version:{version},template_version:2,default_level:\"assets/levels/main.scn.ron\",asset_roots:[\"assets\"],capabilities:[])" ), ) .unwrap(); @@ -4543,7 +4667,7 @@ mod tests { .unwrap(); std::fs::write( root.join("assets/project.ron"), - "(version:1,template_version:1,default_level:\"assets/custom/start.scn.ron\",asset_roots:[\"assets/levels\"],capabilities:[])", + "(version:2,template_version:2,default_level:\"assets/custom/start.scn.ron\",asset_roots:[\"assets\"],capabilities:[])", ) .unwrap(); @@ -4562,7 +4686,7 @@ mod tests { std::fs::write(root.join("assets/levels/splash.png"), b"not a scene").unwrap(); std::fs::write( root.join("assets/project.ron"), - "(version:1,template_version:1,default_level:\"assets/levels/splash.png\",asset_roots:[\"assets/levels\"],capabilities:[])", + "(version:2,template_version:2,default_level:\"assets/levels/splash.png\",asset_roots:[\"assets\"],capabilities:[])", ) .unwrap(); diff --git a/crates/scene/src/sample_pack.rs b/crates/scene/src/sample_pack.rs index d2375af..3d7bd68 100644 --- a/crates/scene/src/sample_pack.rs +++ b/crates/scene/src/sample_pack.rs @@ -63,7 +63,9 @@ impl EditorSampleArea { fn required_components(self) -> &'static [&'static str] { match self { Self::Brush => &[COMPONENT_BRUSH_DESC], - Self::Material => &[COMPONENT_MATERIAL_DESC], + // Materials are authored through renderer-owned slots. The Material Lab uses + // Primitive.surface slots; actor-local MaterialDesc is a brush-only legacy path. + Self::Material => &[COMPONENT_PRIMITIVE], Self::Terrain => &[COMPONENT_TERRAIN_DESC], Self::PhysicsPlacement => &[COMPONENT_RIGID_BODY_DESC, COMPONENT_COLLIDER_DESC], Self::Rendering => &[COMPONENT_POST_PROCESS_VOLUME], @@ -717,6 +719,7 @@ mod tests { fn valid_component_ron(type_name: &str) -> String { match type_name { COMPONENT_BRUSH_DESC => ron::to_string(&BrushDesc::default()).unwrap(), + COMPONENT_PRIMITIVE => ron::to_string(&Primitive::default()).unwrap(), COMPONENT_MATERIAL_DESC => ron::to_string(&MaterialDesc::default()).unwrap(), COMPONENT_TERRAIN_DESC => ron::to_string(&TerrainDesc::default()).unwrap(), COMPONENT_RIGID_BODY_DESC => ron::to_string(&RigidBodyDesc::default()).unwrap(), @@ -858,7 +861,7 @@ mod tests { fn every_required_area_payload_is_type_checked() { for malformed_type in [ COMPONENT_BRUSH_DESC, - COMPONENT_MATERIAL_DESC, + COMPONENT_PRIMITIVE, COMPONENT_TERRAIN_DESC, COMPONENT_RIGID_BODY_DESC, COMPONENT_COLLIDER_DESC, diff --git a/crates/scene/src/upgrade.rs b/crates/scene/src/upgrade.rs index 761a46f..7dfec88 100644 --- a/crates/scene/src/upgrade.rs +++ b/crates/scene/src/upgrade.rs @@ -4,15 +4,23 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use shared::{ - AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset, - MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset, - StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER, + AssetId, AssetKind, AssetRecord, AssetRegistryDocument, AuthoringComponentStates, + ComponentInstanceId, InspectorOrder, MaterialAsset, MaterialDesc, MaterialInputGroupDesc, + MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, MaterialOverride, + MaterialParameter, MaterialProvenance, MaterialRef, MaterialRenderState, MaterialSlot, + Primitive, ShaderPropertyType, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer, + COMPONENT_MATERIAL_DESC, COMPONENT_MATERIAL_OVERRIDE, COMPONENT_PRIMITIVE, + COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_STATIC_MESH_RENDERER, MATERIAL_ASSET_SCHEMA_VERSION, }; use crate::document::{SceneComponentBlob, SceneDocument}; +#[path = "upgrade/io.rs"] +mod io; +use io::{collect_files, normalize_text}; + const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder"; const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates"; @@ -34,10 +42,29 @@ struct PendingWrite { path: PathBuf, contents: String, kind: &'static str, + existed: bool, +} + +struct LegacyMaterialMigration<'a> { + root: &'a Path, + registry: AssetRegistryDocument, + material_writes: std::collections::BTreeMap, } pub fn upgrade_project(root: &Path, apply: bool) -> Result { let assets = root.join("assets"); + let registry_path = assets.join(".index/registry.ron"); + let registry_original = fs::read_to_string(®istry_path).unwrap_or_default(); + let registry = if registry_original.trim().is_empty() { + AssetRegistryDocument::default() + } else { + shared::parse_asset_registry(®istry_original)?.document + }; + let mut legacy_materials = LegacyMaterialMigration { + root, + registry, + material_writes: std::collections::BTreeMap::new(), + }; let mut files = Vec::new(); collect_files(&assets, &mut files)?; files.sort(); @@ -54,21 +81,41 @@ pub fn upgrade_project(root: &Path, apply: bool) -> Result Some((value, "scene-schema")), Err(error) => { warnings.push(format!("{normalized}: {error}")); None } } - } else if normalized.contains("/materials/") && normalized.ends_with(".ron") { - canonical_material_document(&path, &original) - } else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") { + } else if registered_kind == Some(AssetKind::ShaderSchema) + || normalized.ends_with(".shader.ron") + { canonical_shader_document(&original) + } else if matches!( + registered_kind, + Some(AssetKind::Material | AssetKind::MaterialInstance) + ) || normalized.ends_with(".material.ron") + || normalized.ends_with(".material-instance.ron") + { + canonical_material_document(&path, &original, registered_kind, &mut legacy_materials) } else { None }; @@ -80,10 +127,39 @@ pub fn upgrade_project(root: &Path, apply: bool) -> Result Result Result Result Result { +fn rollback_project_upgrade( + root: &Path, + backup_root: &Path, + changes: &[ProjectUpgradeChange], + staged: &[(PathBuf, PathBuf)], + created_directories: &[PathBuf], +) { + for change in changes { + let backup = backup_root.join(&change.path); + let destination = root.join(&change.path); + if backup.exists() { + let _ = fs::copy(backup, destination); + } else { + let _ = fs::remove_file(destination); + } + } + for (temporary, _) in staged { + let _ = fs::remove_file(temporary); + } + for directory in created_directories.iter().rev() { + let _ = fs::remove_dir(directory); + } + let _ = fs::remove_dir_all(backup_root); +} + +fn canonical_project_settings(text: &str) -> Option<(String, &'static str)> { + let mut project = ron::from_str::(text).ok()?; + project.version = settings::SETTINGS_VERSION; + project.template_version = settings::PROJECT_TEMPLATE_VERSION; + project.asset_roots = vec!["assets".into()]; + settings::save_project_settings_to_string(&project) + .ok() + .map(|contents| (contents, "project-content-settings-v2")) +} + +fn canonical_scene_document( + text: &str, + legacy_materials: &mut LegacyMaterialMigration<'_>, +) -> Result { let mut document = SceneDocument::from_ron_text(text)?; for entity in &mut document.entities { + let legacy_material = entity + .components + .iter() + .find(|component| component.type_name == COMPONENT_MATERIAL_DESC) + .map(|component| { + ron::from_str::(&component.ron) + .map_err(|error| format!("could not upgrade MaterialDesc component: {error}")) + }) + .transpose()?; + let legacy_overrides = entity + .components + .iter() + .find(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE) + .map(|component| { + ron::from_str::(&component.ron).map_err(|error| { + format!("could not upgrade MaterialOverride component: {error}") + }) + }) + .transpose()?; + let mut consumed_legacy_material = false; + + if let Some(index) = entity + .components + .iter() + .position(|component| component.type_name == COMPONENT_PRIMITIVE) + { + let mut primitive: Primitive = ron::from_str(&entity.components[index].ron) + .map_err(|error| format!("could not upgrade Primitive component: {error}"))?; + normalize_primitive_surface(&mut primitive); + if let Some(material) = legacy_material.as_ref() { + primitive.surface.material = Some(legacy_materials.reference_for(material)?); + consumed_legacy_material = true; + } + entity.components[index] = + SceneComponentBlob::from_serializable(COMPONENT_PRIMITIVE, &primitive)?; + } + + if let Some(index) = entity + .components + .iter() + .position(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER) + { + let mut renderer: StaticMeshRenderer = ron::from_str(&entity.components[index].ron) + .map_err(|error| { + format!("could not upgrade StaticMeshRenderer component: {error}") + })?; + normalize_static_renderer(&mut renderer); + if let Some(material) = legacy_material.as_ref() { + let reference = legacy_materials.reference_for(material)?; + for slot in &mut renderer.materials.slots { + slot.material = Some(reference.clone()); + } + consumed_legacy_material = true; + } else if let Some(overrides) = legacy_overrides.as_ref() { + for override_slot in &overrides.slots { + let Some(part) = renderer + .slots + .iter() + .find(|part| part.id == override_slot.slot_id) + else { + continue; + }; + let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) else { + continue; + }; + slot.material = Some(legacy_materials.reference_for(&override_slot.material)?); + } + } + entity.components[index] = + SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?; + } + + if let Some(index) = entity + .components + .iter() + .position(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER) + { + let mut renderer: SkinnedMeshRenderer = ron::from_str(&entity.components[index].ron) + .map_err(|error| { + format!("could not upgrade SkinnedMeshRenderer component: {error}") + })?; + if let Some(material) = legacy_material.as_ref() { + let reference = legacy_materials.reference_for(material)?; + for slot in &mut renderer.materials.slots { + slot.material = Some(reference.clone()); + } + consumed_legacy_material = true; + } else if let Some(overrides) = legacy_overrides.as_ref() { + for override_slot in &overrides.slots { + if let Some(slot) = renderer.materials.slot_mut(&override_slot.slot_id) { + slot.material = + Some(legacy_materials.reference_for(&override_slot.material)?); + } + } + } + entity.components[index] = + SceneComponentBlob::from_serializable(COMPONENT_SKINNED_MESH_RENDERER, &renderer)?; + } + + if consumed_legacy_material + || entity.components.iter().any(|component| { + component.type_name == COMPONENT_STATIC_MESH_RENDERER + || component.type_name == COMPONENT_SKINNED_MESH_RENDERER + }) + { + entity.components.retain(|component| { + component.type_name != COMPONENT_MATERIAL_DESC + && component.type_name != COMPONENT_MATERIAL_OVERRIDE + }); + } + let present_types = entity .components .iter() @@ -178,20 +438,6 @@ fn canonical_scene_document(text: &str) -> Result { .map(|component| component.type_name.clone()) .collect::>(); - if let Some(component) = entity - .components - .iter_mut() - .find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER) - { - let mut renderer: StaticMeshRenderer = - ron::from_str(&component.ron).map_err(|error| { - format!("could not upgrade StaticMeshRenderer component: {error}") - })?; - normalize_static_renderer(&mut renderer); - *component = - SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?; - } - let states_index = entity .components .iter() @@ -255,25 +501,151 @@ fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) { if part.material_slot_id.is_empty() { part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0)); } - let legacy_material = part.material.take().map(MaterialRef::new); if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) { - if slot.source_material.is_none() { - slot.source_material = legacy_material; - } if slot.name.trim().is_empty() { slot.name = part.name.clone(); } } else { - renderer.materials.slots.push(RendererMaterialSlot { + renderer.materials.slots.push(MaterialSlot { id: part.material_slot_id.clone(), name: part.name.clone(), - source_material: legacy_material, material: None, }); } } } +fn normalize_primitive_surface(primitive: &mut Primitive) { + if primitive.surface.id.is_empty() { + primitive.surface.id = ComponentInstanceId::new(shared::PRIMITIVE_SURFACE_SLOT_ID); + } + if primitive.surface.name.trim().is_empty() { + primitive.surface.name = "Surface".into(); + } +} + +impl LegacyMaterialMigration<'_> { + fn reference_for(&mut self, material: &MaterialDesc) -> Result { + if let Some(path) = material.material_asset_path.as_deref() { + if let Some(reference) = self.reference_for_existing(path) { + return Ok(reference); + } + } + + let mut canonical = material.clone(); + canonical.material_asset_path = None; + let canonical_ron = ron::ser::to_string(&canonical) + .map_err(|error| format!("could not canonicalize legacy material: {error}"))?; + let hash = blake3::hash(canonical_ron.as_bytes()).to_hex().to_string(); + let short = &hash[..16]; + let relative = format!("assets/materials/migrated/legacy-{short}.material.ron"); + let destination = self.root.join(&relative); + let label = format!("Legacy Material {short}"); + let asset = MaterialAsset { + schema_version: MATERIAL_ASSET_SCHEMA_VERSION, + label: label.clone(), + shader: canonical.shader.clone(), + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + provenance: None, + inputs: shared::MaterialInputSet::from_material_desc(&canonical), + }; + let contents = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize migrated material: {error}"))?; + + if destination.exists() { + let existing = MaterialAsset::load_from_path(destination.to_string_lossy().as_ref()) + .map_err(|_| { + format!( + "legacy material migration collision at {}; choose another migration destination", + destination.display() + ) + })?; + if existing.inputs != shared::MaterialInputSet::from_material_desc(&canonical) { + return Err(format!( + "legacy material migration collision at {}", + destination.display() + )); + } + } else { + self.material_writes.entry(destination).or_insert(contents); + } + + let record_index = if let Some(index) = self + .registry + .records + .iter() + .position(|record| record.path == relative) + { + index + } else { + let digest = blake3::hash(format!("blacksite:{relative}").as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest.as_bytes()[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + self.registry.records.push(AssetRecord { + id: AssetId(uuid::Uuid::from_bytes(bytes)), + path: relative.clone(), + label: label.clone(), + kind: AssetKind::Material, + source_fingerprint: None, + import_settings: shared::AssetImportSettings::None, + dependencies: material_dependencies(&canonical), + }); + self.registry.records.len() - 1 + }; + let record = &self.registry.records[record_index]; + Ok(MaterialRef::new( + shared::EditorAssetRef::new(record.id.as_string(), "material:source", &record.label) + .with_source_path(&record.path), + )) + } + + fn reference_for_existing(&self, path: &str) -> Option { + let record = self + .registry + .records + .iter() + .find(|record| record.path == path)?; + let full = self.root.join(path); + let sub_asset_id = + if MaterialInstanceAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() { + "material:instance" + } else if MaterialAsset::load_from_path(full.to_string_lossy().as_ref()).is_ok() { + "material:source" + } else { + return None; + }; + Some(MaterialRef::new( + shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, &record.label) + .with_source_path(path), + )) + } +} + +fn material_dependencies(material: &MaterialDesc) -> Vec { + let mut dependencies = [ + material.base_color_texture.as_ref(), + material.emissive_texture.as_ref(), + material.normal_map_texture.as_ref(), + material.metallic_roughness_texture.as_ref(), + ] + .into_iter() + .flatten() + .cloned() + .collect::>(); + dependencies.extend(material.textures.iter().filter_map(|texture| { + texture + .texture + .as_ref() + .and_then(|reference| reference.source_path.clone()) + })); + dependencies.sort(); + dependencies.dedup(); + dependencies +} + fn normalize_component_states(states: &mut AuthoringComponentStates) { let old = std::mem::take(&mut states.states); for state in old { @@ -288,60 +660,315 @@ fn normalize_component_states(states: &mut AuthoringComponentStates) { } } -fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> { - if let Ok(asset) = ron::from_str::(text) { - return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) - .ok() - .map(|text| (text, "material-schema")); - } - if let Ok(instance) = ron::from_str::(text) { +#[derive(Deserialize)] +struct LegacyMaterialAssetV1 { + label: String, + #[serde(default)] + shader: Option, + #[serde(default)] + shader_ref: Option, + #[serde(default)] + render_state: MaterialRenderState, + #[serde(default)] + provenance: Option, + material: MaterialDesc, +} + +#[derive(Deserialize)] +struct LegacyMaterialInstanceV1 { + label: String, + base: MaterialRef, + #[serde(default)] + parameters: Vec, + #[serde(default)] + textures: Vec, +} + +#[derive(Deserialize)] +struct LegacyShaderSchemaV1 { + label: String, + kind: shared::MaterialShaderKind, + #[serde(default)] + wgsl_path: Option, + #[serde(default)] + parameters: Vec, + #[serde(default)] + default_values: Vec, + #[serde(default)] + default_textures: Vec, +} + +#[derive(Deserialize)] +struct LegacyShaderInputV1 { + name: String, + display_name: String, + #[serde(default)] + group: String, + property_type: ShaderPropertyType, +} + +fn canonical_material_document( + path: &Path, + text: &str, + registered_kind: Option, + migration: &mut LegacyMaterialMigration<'_>, +) -> Option<(String, &'static str)> { + let is_instance = registered_kind == Some(AssetKind::MaterialInstance) + || path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".material-instance.ron")); + if is_instance { + if let Ok(instance) = ron::from_str::(text) { + if instance.schema_version == shared::MATERIAL_INSTANCE_SCHEMA_VERSION { + return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) + .ok() + .map(|text| (text, "material-instance-schema")); + } + } + let legacy = ron::from_str::(text).ok()?; + let mut overrides = MaterialInputSet { + values: legacy.parameters, + textures: legacy.textures, + }; + resolve_texture_references(&mut overrides, &migration.registry); + let instance = MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: legacy.label, + base: legacy.base, + overrides, + }; + update_material_instance_registry_dependencies(path, &instance, migration); return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()) .ok() - .map(|text| (text, "material-instance-schema")); + .map(|text| (text, "material-instance-schema-v2")); + } + + if let Ok(asset) = ron::from_str::(text) { + if asset.schema_version == shared::MATERIAL_ASSET_SCHEMA_VERSION { + return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) + .ok() + .map(|text| (text, "material-schema")); + } + } + if let Ok(legacy) = ron::from_str::(text) { + let mut inputs = MaterialInputSet::from_material_desc(&legacy.material); + resolve_texture_references(&mut inputs, &migration.registry); + let mut shader = legacy.material.shader; + if shader.schema_path.is_none() { + shader.schema_path = legacy + .shader + .as_deref() + .filter(|path| path.ends_with(".ron")) + .map(str::to_string); + } + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: legacy.label, + shader, + shader_ref: legacy.shader_ref, + render_state: legacy.render_state, + provenance: legacy.provenance, + inputs, + }; + update_material_registry_dependencies(path, &asset.inputs, migration); + return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) + .ok() + .map(|text| (text, "material-schema-v2")); } - let _ = path; None } fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> { - let schema = ron::from_str::(text).ok()?; - ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) - .ok() - .map(|text| (text, "surface-shader-schema")) -} - -fn collect_files(path: &Path, output: &mut Vec) -> Result<(), String> { - if !path.exists() { - return Ok(()); - } - for entry in fs::read_dir(path).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; - let path = entry.path(); - if entry - .file_type() - .map_err(|error| error.to_string())? - .is_dir() - { - collect_files(&path, output)?; - } else { - output.push(path); + if let Ok(schema) = ron::from_str::(text) { + if schema.schema_version == shared::SURFACE_SHADER_SCHEMA_VERSION { + return ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) + .ok() + .map(|text| (text, "surface-shader-schema")); } } - Ok(()) + let legacy = ron::from_str::(text).ok()?; + let schema = if legacy.kind == shared::MaterialShaderKind::StandardLit { + shared::standard_lit_input_schema() + } else { + legacy_shader_input_schema(&legacy) + }; + let schema = ShaderSchemaAsset { + schema_version: shared::SURFACE_SHADER_SCHEMA_VERSION, + label: legacy.label, + kind: legacy.kind, + wgsl_path: legacy.wgsl_path, + schema, + }; + ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default()) + .ok() + .map(|text| (text, "surface-shader-schema-v2")) } -fn normalize_text(text: &str) -> String { - text.trim().replace("\r\n", "\n") +fn resolve_texture_references(inputs: &mut MaterialInputSet, registry: &AssetRegistryDocument) { + for binding in &mut inputs.textures { + let Some(reference) = binding.texture.as_mut() else { + continue; + }; + let Some(path) = reference.source_path.as_deref() else { + continue; + }; + if let Some(record) = registry.records.iter().find(|record| record.path == path) { + reference.asset_id = record.id.as_string(); + reference.sub_asset_id = "texture:source".into(); + reference.label = record.label.clone(); + } + } +} + +fn update_material_registry_dependencies( + path: &Path, + inputs: &MaterialInputSet, + migration: &mut LegacyMaterialMigration<'_>, +) { + let relative = path + .strip_prefix(migration.root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let dependencies = inputs + .textures + .iter() + .filter_map(|binding| binding.texture.as_ref()) + .filter_map(|reference| reference.source_path.clone()) + .collect::>() + .into_iter() + .collect::>(); + if let Some(record) = migration + .registry + .records + .iter_mut() + .find(|record| record.path == relative) + { + record.dependencies = dependencies; + } +} + +fn update_material_instance_registry_dependencies( + path: &Path, + instance: &MaterialInstanceAsset, + migration: &mut LegacyMaterialMigration<'_>, +) { + let relative = path + .strip_prefix(migration.root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let mut dependencies = instance + .overrides + .textures + .iter() + .filter_map(|binding| binding.texture.as_ref()) + .filter_map(|reference| reference.source_path.clone()) + .collect::>(); + if let Some(path) = instance.base.0.source_path.as_ref() { + dependencies.insert(path.clone()); + } + if let Some(record) = migration + .registry + .records + .iter_mut() + .find(|record| record.path == relative) + { + record.dependencies = dependencies.into_iter().collect(); + } +} + +fn legacy_shader_input_schema(legacy: &LegacyShaderSchemaV1) -> MaterialInputSchema { + let mut group_names = Vec::new(); + for input in &legacy.parameters { + if !group_names.contains(&input.group) { + group_names.push(input.group.clone()); + } + } + let groups = group_names + .iter() + .enumerate() + .map(|(index, name)| MaterialInputGroupDesc { + id: legacy_group_id(name), + display_name: if name.trim().is_empty() { + "Properties".into() + } else { + name.clone() + }, + order: index as i32, + advanced: name.to_ascii_lowercase().contains("advanced"), + }) + .collect::>(); + let inputs = legacy + .parameters + .iter() + .enumerate() + .map(|(index, input)| shared::MaterialInputDesc { + name: input.name.clone(), + display_name: input.display_name.clone(), + group: legacy_group_id(&input.group), + order: index as i32, + property_type: input.property_type.clone(), + default_value: legacy + .default_values + .iter() + .find(|value| value.name == input.name) + .map(|value| value.value.clone()), + texture: matches!(input.property_type, ShaderPropertyType::Texture).then(|| { + let default_channel = legacy + .default_textures + .iter() + .find(|binding| binding.name == input.name) + .map(|binding| binding.channel) + .unwrap_or_default(); + shared::MaterialTextureInputDesc { + semantic: shared::TextureSemantic::Mask, + default_channel, + allow_channel_override: true, + } + }), + ..Default::default() + }) + .collect(); + MaterialInputSchema { groups, inputs } +} + +fn legacy_group_id(name: &str) -> String { + let value = name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::(); + let value = value.trim_matches('_'); + if value.is_empty() { + "properties".into() + } else { + value.into() + } } #[cfg(test)] mod tests { use super::*; use shared::{ - EditorAssetRef, InspectorComponentState, MeshRenderSlot, - AUTHORING_COMPONENT_STATIC_MESH_RENDERER, + EditorAssetRef, InspectorComponentState, MaterialSlotOverride, MaterialSlotSet, + MeshRenderSlot, AUTHORING_COMPONENT_STATIC_MESH_RENDERER, }; + fn migration_context(root: &Path) -> LegacyMaterialMigration<'_> { + LegacyMaterialMigration { + root, + registry: AssetRegistryDocument::default(), + material_writes: std::collections::BTreeMap::new(), + } + } + #[test] fn dry_run_does_not_write_and_apply_creates_backup() { let root = std::env::temp_dir().join(format!( @@ -357,7 +984,15 @@ mod tests { let dry_run = upgrade_project(&root, false).unwrap(); assert!(!dry_run.applied); - assert_eq!(dry_run.changes.len(), 1); + assert_eq!(dry_run.changes.len(), 2); + assert!(dry_run + .changes + .iter() + .any(|change| change.path == "assets/levels/main.scn.ron")); + assert!(dry_run + .changes + .iter() + .any(|change| change.path == "assets/.index/registry.ron")); assert!(fs::read_to_string(&level) .unwrap() .contains("schema_version: 3")); @@ -366,7 +1001,7 @@ mod tests { assert!(applied.applied); assert!(fs::read_to_string(&level) .unwrap() - .contains("schema_version: 4")); + .contains("schema_version: 6")); assert!(root.join(applied.backup_path.unwrap()).exists()); fs::remove_dir_all(root).unwrap(); } @@ -378,14 +1013,21 @@ mod tests { id: ComponentInstanceId::new("draw:body"), name: "Body".into(), mesh: EditorAssetRef::new("model", "mesh:body", "Body"), - material: Some(EditorAssetRef::new( - "model", - "material:body", - "Body Material", - )), + material_slot_id: ComponentInstanceId::new("slot:draw:body"), ..Default::default() }], - ..Default::default() + materials: MaterialSlotSet { + slots: vec![MaterialSlot { + id: ComponentInstanceId::new("slot:draw:body"), + name: "Body".into(), + material: Some(MaterialRef::new(EditorAssetRef::new( + "model", + "material:body", + "Body Material", + ))), + }], + orphaned_assignments: Vec::new(), + }, }; let order = InspectorOrder { component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()], @@ -402,7 +1044,15 @@ mod tests { ron::to_string(&order).unwrap(), ); - let upgraded = canonical_scene_document(&text).unwrap(); + let migration_root = std::env::temp_dir().join(format!( + "blacksite-canonical-upgrader-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut migration = migration_context(&migration_root); + let upgraded = canonical_scene_document(&text, &mut migration).unwrap(); let document = SceneDocument::from_ron_text(&upgraded).unwrap(); let components = &document.entities[0].components; let renderer: StaticMeshRenderer = ron::from_str( @@ -413,11 +1063,11 @@ mod tests { .ron, ) .unwrap(); - assert!(renderer.slots[0].material.is_none()); + assert_eq!(renderer.slots[0].material_slot_id.0, "slot:draw:body"); assert_eq!(renderer.materials.slots.len(), 1); assert_eq!( renderer.materials.slots[0] - .source_material + .material .as_ref() .unwrap() .0 @@ -447,4 +1097,168 @@ mod tests { .unwrap(); assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER)); } + + #[test] + fn primitive_legacy_material_becomes_one_deduplicated_project_asset_and_surface_assignment() { + let root = std::env::temp_dir().join(format!( + "blacksite-primitive-material-upgrader-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let primitive = Primitive::default(); + let material = MaterialDesc { + metallic: 0.65, + roughness: 0.2, + ..Default::default() + }; + let entity = format!( + "(components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})", + ron::to_string(&primitive).unwrap(), + ron::to_string(&material).unwrap(), + ); + let text = + format!("(schema_version: 4, resources: {{}}, entities: {{1: {entity}, 2: {entity}}})"); + let mut migration = migration_context(&root); + + let upgraded = canonical_scene_document(&text, &mut migration).unwrap(); + let document = SceneDocument::from_ron_text(&upgraded).unwrap(); + + assert_eq!(migration.material_writes.len(), 1); + assert_eq!(migration.registry.records.len(), 1); + let assigned = document + .entities + .iter() + .map(|entity| { + assert!(!entity + .components + .iter() + .any(|component| component.type_name == COMPONENT_MATERIAL_DESC)); + let primitive: Primitive = ron::from_str( + &entity + .components + .iter() + .find(|component| component.type_name == COMPONENT_PRIMITIVE) + .unwrap() + .ron, + ) + .unwrap(); + assert_eq!(primitive.surface.id.0, shared::PRIMITIVE_SURFACE_SLOT_ID); + primitive.surface.material.unwrap() + }) + .collect::>(); + assert_eq!(assigned[0], assigned[1]); + assert_eq!( + assigned[0].0.asset_id, + migration.registry.records[0].id.as_string() + ); + } + + #[test] + fn skinned_slot_override_migrates_to_the_exact_saved_slot() { + let root = std::env::temp_dir().join(format!( + "blacksite-skinned-material-upgrader-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let slot_id = ComponentInstanceId::new("slot:body"); + let renderer = SkinnedMeshRenderer { + materials: MaterialSlotSet { + slots: vec![MaterialSlot { + id: slot_id.clone(), + name: "Body".into(), + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }; + let overrides = MaterialOverride { + slots: vec![MaterialSlotOverride { + slot_id: slot_id.clone(), + base_material: None, + material: MaterialDesc { + metallic: 1.0, + ..Default::default() + }, + }], + }; + let text = format!( + "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_SKINNED_MESH_RENDERER}\": {}, \"{COMPONENT_MATERIAL_OVERRIDE}\": {}}})}})", + ron::to_string(&renderer).unwrap(), + ron::to_string(&overrides).unwrap(), + ); + let mut migration = migration_context(&root); + + let upgraded = canonical_scene_document(&text, &mut migration).unwrap(); + let document = SceneDocument::from_ron_text(&upgraded).unwrap(); + let components = &document.entities[0].components; + let renderer: SkinnedMeshRenderer = ron::from_str( + &components + .iter() + .find(|component| component.type_name == COMPONENT_SKINNED_MESH_RENDERER) + .unwrap() + .ron, + ) + .unwrap(); + + assert!(renderer + .materials + .slot(&slot_id) + .unwrap() + .material + .is_some()); + assert!(!components + .iter() + .any(|component| component.type_name == COMPONENT_MATERIAL_OVERRIDE)); + assert_eq!(migration.material_writes.len(), 1); + } + + #[test] + fn material_collision_blocks_apply_without_writing_any_project_bytes() { + let root = std::env::temp_dir().join(format!( + "blacksite-material-collision-upgrader-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let material = MaterialDesc { + metallic: 0.42, + ..Default::default() + }; + let scene = root.join("assets/levels/collision.scn.ron"); + fs::create_dir_all(scene.parent().unwrap()).unwrap(); + let scene_bytes = format!( + "(schema_version: 4, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_PRIMITIVE}\": {}, \"{COMPONENT_MATERIAL_DESC}\": {}}})}})", + ron::to_string(&Primitive::default()).unwrap(), + ron::to_string(&material).unwrap(), + ); + fs::write(&scene, &scene_bytes).unwrap(); + let canonical = ron::ser::to_string(&material).unwrap(); + let hash = blake3::hash(canonical.as_bytes()).to_hex().to_string(); + let collision = root.join(format!( + "assets/materials/migrated/legacy-{}.material.ron", + &hash[..16] + )); + fs::create_dir_all(collision.parent().unwrap()).unwrap(); + fs::write(&collision, "owned by another asset").unwrap(); + + let preview = upgrade_project(&root, false).unwrap(); + assert_eq!(preview.warnings.len(), 1); + let error = upgrade_project(&root, true).unwrap_err(); + + assert!(error.contains("blocked")); + assert_eq!(fs::read_to_string(&scene).unwrap(), scene_bytes); + assert_eq!( + fs::read_to_string(&collision).unwrap(), + "owned by another asset" + ); + assert!(!root.join("assets/.index/registry.ron").exists()); + assert!(!root.join(".blacksite").exists()); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/scene/src/upgrade/io.rs b/crates/scene/src/upgrade/io.rs new file mode 100644 index 0000000..91dc4cb --- /dev/null +++ b/crates/scene/src/upgrade/io.rs @@ -0,0 +1,26 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub(super) fn collect_files(path: &Path, output: &mut Vec) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + for entry in fs::read_dir(path).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + { + collect_files(&path, output)?; + } else { + output.push(path); + } + } + Ok(()) +} + +pub(super) fn normalize_text(text: &str) -> String { + text.trim().replace("\r\n", "\n") +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b5862fd..136263d 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -20,8 +20,8 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; pub const DEFAULT_PROJECT_PATH: &str = "assets/project.ron"; -pub const SETTINGS_VERSION: u32 = 1; -pub const PROJECT_TEMPLATE_VERSION: u32 = 1; +pub const SETTINGS_VERSION: u32 = 2; +pub const PROJECT_TEMPLATE_VERSION: u32 = 2; #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Reflect, PartialEq, Eq)] #[reflect(Clone)] @@ -73,13 +73,7 @@ impl Default for ProjectSettings { "audio".into(), ], default_level: "assets/levels/editor_scene.scn.ron".into(), - asset_roots: vec![ - "assets/models".into(), - "assets/textures".into(), - "assets/materials".into(), - "assets/levels".into(), - "assets/audio".into(), - ], + asset_roots: vec!["assets".into()], rendering: RenderingSettings::default(), audio: AudioSettings::default(), physics: PhysicsSettings::default(), @@ -334,14 +328,11 @@ mod tests { } #[test] - fn project_defaults_include_audio_capability_and_asset_root() { + fn project_defaults_include_audio_capability_and_one_content_root() { let settings = ProjectSettings::default(); assert!(settings.capabilities.iter().any(|item| item == "audio")); - assert!(settings - .asset_roots - .iter() - .any(|item| item == "assets/audio")); + assert_eq!(settings.asset_roots, ["assets"]); assert!(settings.audio.validate().is_ok()); } diff --git a/crates/settings/src/plugin.rs b/crates/settings/src/plugin.rs index 9b7a9bb..d75b313 100644 --- a/crates/settings/src/plugin.rs +++ b/crates/settings/src/plugin.rs @@ -78,7 +78,12 @@ pub struct ProjectSettingsPlugin; impl Plugin for ProjectSettingsPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + // Startup systems from hot-loaded crates validate their resource + // parameters before the deferred `PreStartup` load is guaranteed to + // have been applied. Keep a default value present synchronously, then + // replace it with the authored project document in `load_settings`. + app.init_resource::() + .init_resource::() .register_type::() .register_type::() .register_type::() @@ -121,3 +126,16 @@ pub fn save_project_settings( io.dirty = false; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_makes_project_settings_available_before_startup_runs() { + let mut app = App::new(); + app.add_plugins(ProjectSettingsPlugin); + + assert!(app.world().contains_resource::()); + } +} diff --git a/crates/shared/AGENTS.md b/crates/shared/AGENTS.md new file mode 100644 index 0000000..4bbec6c --- /dev/null +++ b/crates/shared/AGENTS.md @@ -0,0 +1,8 @@ +# Shared subtree rules + +- Public schema or type changes require migration analysis and focused compatibility tests. +- Ordinary loading remains read-only unless an accepted architecture decision explicitly says + otherwise. +- Update accepted ADRs and canonical docs at the slice boundary. +- A shared type change invalidates dependents according to the verification matrix; it does not + automatically require packaging in the fast loop. diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 4a49cb9..11ff97c 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -12,3 +12,4 @@ blake3 = "1" ron = "0.8" serde.workspace = true settings.workspace = true +uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/shared/src/animation.rs b/crates/shared/src/animation.rs index 17036e4..0879bfd 100644 --- a/crates/shared/src/animation.rs +++ b/crates/shared/src/animation.rs @@ -94,7 +94,7 @@ pub struct SkinnedMeshRenderer { pub scene_index: usize, /// Stable material slots for every draw binding in the imported animated hierarchy. #[serde(default)] - pub materials: crate::RendererMaterialSet, + pub materials: crate::MaterialSlotSet, } impl SkinnedMeshRenderer { @@ -103,7 +103,7 @@ impl SkinnedMeshRenderer { asset_id: String::new(), path: path.into(), scene_index: 0, - materials: crate::RendererMaterialSet::default(), + materials: crate::MaterialSlotSet::default(), } } diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index d9efe7a..44dd717 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -2,6 +2,8 @@ use avian3d::prelude::ColliderConstructor; use bevy::prelude::*; use serde::{Deserialize, Serialize}; +use crate::{ColorDesc, MaterialDesc}; + /// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only. #[derive( Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, @@ -617,12 +619,24 @@ pub enum PrimitiveShape { Ramp, } +pub const PRIMITIVE_SURFACE_SLOT_ID: &str = "slot:primitive:surface"; + +fn default_primitive_surface() -> crate::MaterialSlot { + crate::MaterialSlot { + id: ComponentInstanceId::new(PRIMITIVE_SURFACE_SLOT_ID), + name: "Surface".into(), + material: None, + } +} + /// Reflectable authoring primitive. Hydration turns this into `Mesh3d`. #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] #[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct Primitive { pub shape: PrimitiveShape, pub size: Vec3, + #[serde(default = "default_primitive_surface")] + pub surface: crate::MaterialSlot, } impl Primitive { @@ -630,6 +644,7 @@ impl Primitive { Self { shape: PrimitiveShape::Box, size, + surface: default_primitive_surface(), } } @@ -637,6 +652,7 @@ impl Primitive { Self { shape: PrimitiveShape::Sphere, size: Vec3::splat(radius * 2.0), + surface: default_primitive_surface(), } } @@ -644,6 +660,7 @@ impl Primitive { Self { shape: PrimitiveShape::Ramp, size, + surface: default_primitive_surface(), } } } @@ -1132,10 +1149,6 @@ pub struct MeshRenderSlot { /// Stable renderer material slot used by this draw part. #[serde(default)] pub material_slot_id: ComponentInstanceId, - /// Legacy inline imported-material reference. Schema-v4 migration moves this into the - /// renderer's shared `RendererMaterialSet`. - #[serde(default)] - pub material: Option, #[serde(default)] pub local_transform: Transform, #[serde(default = "default_true")] @@ -1153,7 +1166,6 @@ impl Default for MeshRenderSlot { name: "Mesh".into(), mesh: EditorAssetRef::default(), material_slot_id: ComponentInstanceId::default(), - material: None, local_transform: Transform::default(), visible: true, cast_shadows: true, @@ -1176,14 +1188,14 @@ pub struct StaticMeshRenderer { #[serde(default, alias = "entries")] pub slots: Vec, #[serde(default)] - pub materials: crate::RendererMaterialSet, + pub materials: crate::MaterialSlotSet, } impl StaticMeshRenderer { pub fn empty() -> Self { Self { slots: Vec::new(), - materials: crate::RendererMaterialSet::default(), + materials: crate::MaterialSlotSet::default(), } } @@ -1191,12 +1203,10 @@ impl StaticMeshRenderer { if slot.material_slot_id.0.trim().is_empty() { slot.material_slot_id = ComponentInstanceId::new(format!("slot:{}", slot.id.0)); } - let source_material = slot.material.clone().map(crate::MaterialRef::new); - let materials = crate::RendererMaterialSet { - slots: vec![crate::RendererMaterialSlot { + let materials = crate::MaterialSlotSet { + slots: vec![crate::MaterialSlot { id: slot.material_slot_id.clone(), name: slot.name.clone(), - source_material, material: None, }], orphaned_assignments: Vec::new(), @@ -1207,10 +1217,7 @@ impl StaticMeshRenderer { } } - pub fn material_slot_for_part( - &self, - part: &MeshRenderSlot, - ) -> Option<&crate::RendererMaterialSlot> { + pub fn material_slot_for_part(&self, part: &MeshRenderSlot) -> Option<&crate::MaterialSlot> { let slot_id = if part.material_slot_id.0.trim().is_empty() { &part.id } else { @@ -1262,148 +1269,6 @@ impl Default for ShaderRefDesc { } /// Typed material parameter value exposed by a shader schema. -#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub enum MaterialParameterValue { - Bool(bool), - Float(f32), - Vec2(Vec2), - Vec3(Vec3), - Color(ColorDesc), - Enum(String), -} - -impl Default for MaterialParameterValue { - fn default() -> Self { - Self::Float(0.0) - } -} - -#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaterialParameter { - pub name: String, - pub value: MaterialParameterValue, -} - -#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaterialTextureBinding { - pub name: String, - pub texture: Option, -} - -/// Per-render-slot material override stored on scene actors. -#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaterialSlotOverride { - pub slot_id: ComponentInstanceId, - #[serde(default)] - pub base_material: Option, - pub material: MaterialDesc, -} - -/// Actor-level material overrides. Shared material assets are edited by asset -/// inspectors, while actor inspectors write only to this component. -#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MaterialOverride { - #[serde(default)] - pub slots: Vec, -} - -/// Serializable color description. This avoids coupling saved scenes to any -/// internal color representation details. -#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, Serialize, Deserialize)] -pub struct ColorDesc { - pub r: f32, - pub g: f32, - pub b: f32, - pub a: f32, -} - -impl ColorDesc { - pub const fn srgb(r: f32, g: f32, b: f32) -> Self { - Self { r, g, b, a: 1.0 } - } - - pub fn to_color(self) -> Color { - Color::srgba(self.r, self.g, self.b, self.a) - } -} - -impl Default for ColorDesc { - fn default() -> Self { - Self::srgb(0.8, 0.8, 0.8) - } -} - -/// Reflectable authoring material. Hydration turns this into `StandardMaterial`. -#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Component, Default, Debug, Serialize, Deserialize)] -pub struct MaterialDesc { - #[serde(default)] - pub shader: ShaderRefDesc, - pub base_color: ColorDesc, - pub metallic: f32, - pub roughness: f32, - /// Emissive radiance tint. Values are multiplied by `emissive_intensity`. - #[serde(default = "default_emissive_color")] - pub emissive_color: ColorDesc, - /// Emissive luminance multiplier in nits. `0.0` means non-emissive. - #[serde(default)] - pub emissive_intensity: f32, - /// Optional base color (albedo) texture, path relative to `assets/`. - pub base_color_texture: Option, - /// Optional emissive texture, path relative to `assets/`. - #[serde(default)] - pub emissive_texture: Option, - /// Optional normal map texture, path relative to `assets/`. - pub normal_map_texture: Option, - /// Optional metallic/roughness texture, path relative to `assets/`. - pub metallic_roughness_texture: Option, - /// When set, references a [`crate::MaterialAsset`] RON under `assets/materials/`. - #[serde(default)] - pub material_asset_path: Option, - /// Shader-schema-driven scalar/vector/color values. - #[serde(default)] - pub parameters: Vec, - /// Shader-schema-driven texture references. - #[serde(default)] - pub textures: Vec, -} - -impl MaterialDesc { - pub fn new(base_color: ColorDesc, metallic: f32, roughness: f32) -> Self { - Self { - shader: ShaderRefDesc::default(), - base_color, - metallic, - roughness, - emissive_color: default_emissive_color(), - emissive_intensity: 0.0, - base_color_texture: None, - emissive_texture: None, - normal_map_texture: None, - metallic_roughness_texture: None, - material_asset_path: None, - parameters: Vec::new(), - textures: Vec::new(), - } - } -} - -fn default_emissive_color() -> ColorDesc { - ColorDesc::srgb(1.0, 1.0, 1.0) -} - -impl Default for MaterialDesc { - fn default() -> Self { - Self::new(ColorDesc::default(), 0.0, 0.65) - } -} - /// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX). /// Hydration turns this into a `WorldAssetRoot` on the entity. #[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)] @@ -1906,8 +1771,8 @@ impl Default for PhysicsBody { #[cfg(test)] mod tests { use super::{ - AudioListenerDesc, AudioRolloff, AudioSourceDesc, EditorAssetRef, ModelRef, - AUDIO_CLIP_SUB_ASSET_ID, + AudioListenerDesc, AudioRolloff, AudioSourceDesc, EditorAssetRef, ModelRef, Primitive, + AUDIO_CLIP_SUB_ASSET_ID, PRIMITIVE_SURFACE_SLOT_ID, }; use bevy::light::light_consts; @@ -1948,6 +1813,15 @@ mod tests { assert_eq!(model.scene_index, 2); } + #[test] + fn legacy_primitive_deserializes_with_stable_unassigned_surface_slot() { + let primitive: Primitive = ron::from_str("(shape:Box,size:(1.0,2.0,3.0))").unwrap(); + + assert_eq!(primitive.surface.id.0, PRIMITIVE_SURFACE_SLOT_ID); + assert_eq!(primitive.surface.name, "Surface"); + assert!(primitive.surface.material.is_none()); + } + #[test] fn audio_authoring_descriptors_round_trip() { let source = AudioSourceDesc { diff --git a/crates/shared/src/content.rs b/crates/shared/src/content.rs new file mode 100644 index 0000000..ecb7ef2 --- /dev/null +++ b/crates/shared/src/content.rs @@ -0,0 +1,693 @@ +//! Stable project-content registry and import contracts shared by editor, runtime, and tools. + +use bevy::prelude::Resource; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialRef}; + +pub const ASSET_REGISTRY_SCHEMA_VERSION: u32 = 3; +pub const RUNTIME_CONTENT_CATALOG_VERSION: u32 = 2; + +/// Stable identity for one top-level project asset. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct AssetId(pub Uuid); + +impl AssetId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn as_string(&self) -> String { + self.0.to_string() + } +} + +impl Default for AssetId { + fn default() -> Self { + Self::new() + } +} + +/// Folder-independent authored/imported asset classification. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum AssetKind { + Model, + Texture, + Material, + MaterialInstance, + AudioClip, + Level, + Prefab, + PostProcessEffect, + RenderingProfile, + ShaderSchema, + #[default] + Unknown, +} + +impl AssetKind { + pub fn from_legacy_tag(tag: &str) -> Self { + match tag { + "Model" => Self::Model, + "Texture" => Self::Texture, + "Material" => Self::Material, + "MaterialInstance" => Self::MaterialInstance, + "AudioClip" => Self::AudioClip, + "Level" => Self::Level, + "Prefab" => Self::Prefab, + "PostProcessEffect" => Self::PostProcessEffect, + "RenderingProfile" => Self::RenderingProfile, + "ShaderSchema" => Self::ShaderSchema, + _ => Self::Unknown, + } + } + + pub const fn is_imported_source(self) -> bool { + matches!(self, Self::Model | Self::Texture | Self::AudioClip) + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum ModelPlacementMode { + #[default] + StaticAsset, + SceneInstance, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum ModelHierarchyMode { + #[default] + SingleActor, + SourceHierarchy, +} + +/// Legacy whole-model policy retained only for registry-v1 migration. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum MaterialImportPolicy { + #[default] + SourceMaterials, + AuthoringOverride, +} + +/// Persisted model-asset default for one stable imported material slot. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum ModelMaterialSelection { + #[default] + Source, + Project(MaterialRef), + Default, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ModelMaterialSlotSelection { + pub slot_id: ComponentInstanceId, + #[serde(default)] + pub selection: ModelMaterialSelection, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct OrphanedModelMaterialSelection { + pub slot_id: ComponentInstanceId, + #[serde(default)] + pub last_known_name: String, + pub material: MaterialRef, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelImportSettings { + pub scale: f32, + pub generate_collider: bool, + pub lod0_only: bool, + #[serde(default)] + pub placement_mode: ModelPlacementMode, + #[serde(default)] + pub hierarchy_mode: ModelHierarchyMode, + /// Registry-v1 compatibility input. Registry-v2 writers keep this at its default and use + /// `material_slots` instead. + #[serde(default, skip_serializing_if = "material_import_policy_is_default")] + pub material_policy: MaterialImportPolicy, + #[serde(default)] + pub material_slots: Vec, + #[serde(default)] + pub orphaned_material_slots: Vec, + #[serde(default)] + pub static_mesh_manifest_path: Option, + #[serde(default)] + pub animation_manifest_path: Option, + #[serde(default)] + pub default_animation_clip_id: Option, +} + +fn material_import_policy_is_default(policy: &MaterialImportPolicy) -> bool { + *policy == MaterialImportPolicy::SourceMaterials +} + +impl Default for ModelImportSettings { + fn default() -> Self { + Self { + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: ModelPlacementMode::default(), + hierarchy_mode: ModelHierarchyMode::default(), + material_policy: MaterialImportPolicy::default(), + material_slots: Vec::new(), + orphaned_material_slots: Vec::new(), + static_mesh_manifest_path: None, + animation_manifest_path: None, + default_animation_clip_id: None, + } + } +} + +/// Source role used to infer safe color-space and compression defaults. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureAssetSemantic { + #[default] + Auto, + Color, + Normal, + MaskData, + Ui, + Hdr, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureColorSpace { + #[default] + Auto, + Srgb, + Linear, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureMipmapMode { + #[default] + Generate, + PreserveSource, + None, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureCompression { + #[default] + Auto, + Uastc, + Uncompressed, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureFilter { + Nearest, + #[default] + Linear, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum TextureWrap { + #[default] + Repeat, + Clamp, + Mirror, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum NormalMapConvention { + #[default] + OpenGl, + DirectX, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct TextureImportSettings { + #[serde(default)] + pub semantic: TextureAssetSemantic, + #[serde(default)] + pub color_space: TextureColorSpace, + #[serde(default)] + pub mipmaps: TextureMipmapMode, + #[serde(default)] + pub compression: TextureCompression, + #[serde(default)] + pub max_dimension: Option, + #[serde(default)] + pub filter: TextureFilter, + #[serde(default)] + pub wrap: TextureWrap, + #[serde(default = "default_texture_anisotropy")] + pub anisotropy: u16, + #[serde(default)] + pub normal_map_convention: NormalMapConvention, +} + +const fn default_texture_anisotropy() -> u16 { + 8 +} + +impl Default for TextureImportSettings { + fn default() -> Self { + Self { + semantic: TextureAssetSemantic::Auto, + color_space: TextureColorSpace::Auto, + mipmaps: TextureMipmapMode::Generate, + compression: TextureCompression::Auto, + max_dimension: None, + filter: TextureFilter::Linear, + wrap: TextureWrap::Repeat, + anisotropy: default_texture_anisotropy(), + normal_map_convention: NormalMapConvention::OpenGl, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub enum AssetImportSettings { + #[default] + None, + Model(ModelImportSettings), + Texture(TextureImportSettings), +} + +impl AssetImportSettings { + pub fn for_kind(kind: AssetKind) -> Self { + match kind { + AssetKind::Model => Self::Model(ModelImportSettings::default()), + AssetKind::Texture => Self::Texture(TextureImportSettings::default()), + _ => Self::None, + } + } + + pub fn model(&self) -> Option<&ModelImportSettings> { + match self { + Self::Model(settings) => Some(settings), + _ => None, + } + } + + pub fn model_mut(&mut self) -> Option<&mut ModelImportSettings> { + match self { + Self::Model(settings) => Some(settings), + _ => None, + } + } + + pub fn texture(&self) -> Option<&TextureImportSettings> { + match self { + Self::Texture(settings) => Some(settings), + _ => None, + } + } + + pub fn texture_mut(&mut self) -> Option<&mut TextureImportSettings> { + match self { + Self::Texture(settings) => Some(settings), + _ => None, + } + } +} + +impl From for AssetImportSettings { + fn from(settings: ModelImportSettings) -> Self { + Self::Model(settings) + } +} + +impl From for AssetImportSettings { + fn from(settings: TextureImportSettings) -> Self { + Self::Texture(settings) + } +} + +/// One-cycle source alias used by model processing APIs while call sites adopt the typed enum. +pub type ImportSettings = ModelImportSettings; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssetRecord { + pub id: AssetId, + pub path: String, + pub label: String, + pub kind: AssetKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_fingerprint: Option, + #[serde(default)] + pub import_settings: AssetImportSettings, + #[serde(default)] + pub dependencies: Vec, +} + +impl AssetRecord { + pub fn model_import(&self) -> &ModelImportSettings { + self.import_settings + .model() + .expect("model asset record must carry model import settings") + } + + pub fn model_import_mut(&mut self) -> &mut ModelImportSettings { + self.import_settings + .model_mut() + .expect("model asset record must carry model import settings") + } + + pub fn texture_import(&self) -> Option<&TextureImportSettings> { + self.import_settings.texture() + } + + pub fn texture_import_mut(&mut self) -> Option<&mut TextureImportSettings> { + self.import_settings.texture_mut() + } +} + +#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectContentDefaults { + #[serde(default)] + pub default_material: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssetRegistryDocument { + #[serde(default = "asset_registry_schema_version")] + pub schema_version: u32, + #[serde(default)] + pub defaults: ProjectContentDefaults, + #[serde(default)] + pub records: Vec, +} + +impl Default for AssetRegistryDocument { + fn default() -> Self { + Self { + schema_version: ASSET_REGISTRY_SCHEMA_VERSION, + defaults: ProjectContentDefaults::default(), + records: Vec::new(), + } + } +} + +const fn asset_registry_schema_version() -> u32 { + ASSET_REGISTRY_SCHEMA_VERSION +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacyAssetRecord { + id: AssetId, + path: String, + label: String, + kind_tag: String, + #[serde(default)] + source_fingerprint: Option, + #[serde(default)] + import_settings: ModelImportSettings, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct AssetRegistryDocumentV2 { + schema_version: u32, + #[serde(default)] + defaults: ProjectContentDefaults, + #[serde(default)] + records: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct AssetRecordV2 { + id: AssetId, + path: String, + label: String, + kind: AssetKind, + #[serde(default)] + source_fingerprint: Option, + #[serde(default)] + import_settings: ModelImportSettings, + #[serde(default)] + dependencies: Vec, +} + +pub struct LoadedAssetRegistry { + pub document: AssetRegistryDocument, + pub migration_required: bool, +} + +pub fn parse_asset_registry(source: &str) -> Result { + if let Ok(document) = ron::from_str::(source) { + if document.schema_version != ASSET_REGISTRY_SCHEMA_VERSION { + return Err(format!( + "unsupported asset registry schema {}; expected {}", + document.schema_version, ASSET_REGISTRY_SCHEMA_VERSION + )); + } + return Ok(LoadedAssetRegistry { + document, + migration_required: false, + }); + } + + if let Ok(document) = ron::from_str::(source) { + if document.schema_version != 2 { + return Err(format!( + "unsupported asset registry schema {}; expected {}", + document.schema_version, ASSET_REGISTRY_SCHEMA_VERSION + )); + } + return Ok(LoadedAssetRegistry { + document: AssetRegistryDocument { + schema_version: ASSET_REGISTRY_SCHEMA_VERSION, + defaults: document.defaults, + records: document + .records + .into_iter() + .map(|record| { + let import_settings = match record.kind { + AssetKind::Model => AssetImportSettings::Model(record.import_settings), + AssetKind::Texture => { + AssetImportSettings::Texture(TextureImportSettings::default()) + } + _ => AssetImportSettings::None, + }; + AssetRecord { + id: record.id, + path: record.path, + label: record.label, + kind: record.kind, + source_fingerprint: record.source_fingerprint, + import_settings, + dependencies: record.dependencies, + } + }) + .collect(), + }, + migration_required: true, + }); + } + + match ron::from_str::>(source) { + Ok(records) => Ok(LoadedAssetRegistry { + document: AssetRegistryDocument { + records: records + .into_iter() + .map(|record| { + let kind = AssetKind::from_legacy_tag(&record.kind_tag); + let import_settings = match kind { + AssetKind::Model => AssetImportSettings::Model(record.import_settings), + AssetKind::Texture => { + AssetImportSettings::Texture(TextureImportSettings::default()) + } + _ => AssetImportSettings::None, + }; + AssetRecord { + id: record.id, + path: record.path, + label: record.label, + kind, + source_fingerprint: record.source_fingerprint, + import_settings, + dependencies: record.dependencies, + } + }) + .collect(), + ..Default::default() + }, + migration_required: true, + }), + Err(error) => Err(format!("invalid asset registry RON: {error}")), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TextureRuntimeData { + pub source_path: String, + #[serde(default)] + pub processed_path: Option, + #[serde(default)] + pub processing_key: Option, + pub settings: TextureImportSettings, + pub is_srgb: bool, +} + +/// Runtime-only description of one project texture load. This keeps Bevy-specific loading out of +/// the content catalog while ensuring every renderer consumes the same authored sampling state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeTextureLoadSpec { + pub path: String, + pub is_srgb: bool, + pub filter: TextureFilter, + pub wrap: TextureWrap, + pub anisotropy: u16, +} + +impl TextureRuntimeData { + pub fn load_spec(&self) -> RuntimeTextureLoadSpec { + RuntimeTextureLoadSpec { + path: self + .processed_path + .clone() + .unwrap_or_else(|| self.source_path.clone()), + is_srgb: self.is_srgb, + filter: self.settings.filter, + wrap: self.settings.wrap, + anisotropy: self.settings.anisotropy, + } + } +} + +impl RuntimeContentCatalog { + pub fn texture_load_spec(&self, reference: &EditorAssetRef) -> Option { + self.records + .iter() + .find(|record| record.id.as_string() == reference.asset_id) + .and_then(|record| record.texture.as_ref()) + .map(TextureRuntimeData::load_spec) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MaterialRuntimeData { + #[serde(default)] + pub packed_arm_path: Option, + #[serde(default)] + pub processing_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeContentRecord { + pub id: AssetId, + pub path: String, + pub label: String, + pub kind: AssetKind, + #[serde(default)] + pub material_slots: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub texture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub material: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeContentCatalog { + pub version: u32, + #[serde(default)] + pub defaults: ProjectContentDefaults, + #[serde(default)] + pub records: Vec, +} + +impl From<&AssetRegistryDocument> for RuntimeContentCatalog { + fn from(document: &AssetRegistryDocument) -> Self { + Self { + version: RUNTIME_CONTENT_CATALOG_VERSION, + defaults: document.defaults.clone(), + records: document + .records + .iter() + .map(|record| RuntimeContentRecord { + id: record.id.clone(), + path: record.path.clone(), + label: record.label.clone(), + kind: record.kind, + material_slots: record + .import_settings + .model() + .map(|settings| settings.material_slots.clone()) + .unwrap_or_default(), + texture: record + .import_settings + .texture() + .map(|settings| TextureRuntimeData { + source_path: record.path.clone(), + processed_path: None, + processing_key: None, + settings: settings.clone(), + is_srgb: resolves_srgb(settings), + }), + material: None, + }) + .collect(), + } + } +} + +pub fn resolves_srgb(settings: &TextureImportSettings) -> bool { + match settings.color_space { + TextureColorSpace::Srgb => true, + TextureColorSpace::Linear => false, + TextureColorSpace::Auto => matches!( + settings.semantic, + TextureAssetSemantic::Auto | TextureAssetSemantic::Color | TextureAssetSemantic::Ui + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_record_lists_require_explicit_migration() { + let source = r#"[(id:("00000000-0000-0000-0000-000000000001"),path:"assets/Props/a.glb",label:"a",kind_tag:"Model")]"#; + let loaded = parse_asset_registry(source).unwrap(); + assert!(loaded.migration_required); + assert_eq!(loaded.document.records[0].kind, AssetKind::Model); + } + + #[test] + fn runtime_catalog_strips_editor_import_state() { + let record = AssetRecord { + id: AssetId::new(), + path: "assets/Props/a.glb".into(), + label: "a".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: AssetImportSettings::Model(ImportSettings::default()), + dependencies: vec!["assets/Props/a.bin".into()], + }; + let document = AssetRegistryDocument { + records: vec![record], + ..Default::default() + }; + let runtime = RuntimeContentCatalog::from(&document); + assert_eq!(runtime.records.len(), 1); + assert_eq!(runtime.records[0].kind, AssetKind::Model); + } + + #[test] + fn registry_v2_writers_omit_the_legacy_whole_model_material_policy() { + let document = AssetRegistryDocument { + records: vec![AssetRecord { + id: AssetId::new(), + path: "assets/model.glb".into(), + label: "Model".into(), + kind: AssetKind::Model, + source_fingerprint: None, + import_settings: AssetImportSettings::Model(ImportSettings::default()), + dependencies: Vec::new(), + }], + ..Default::default() + }; + + let serialized = ron::ser::to_string(&document).unwrap(); + + assert!(!serialized.contains("material_policy")); + } +} diff --git a/crates/shared/src/hydration/brushes.rs b/crates/shared/src/hydration/brushes.rs index 2edb67b..4d6a067 100644 --- a/crates/shared/src/hydration/brushes.rs +++ b/crates/shared/src/hydration/brushes.rs @@ -15,6 +15,7 @@ use crate::{ }; use super::materials::material_from_desc; +use super::{default_grid_emergency_handle, DefaultGridEmergencyMaterial}; /// Runtime marker for generated brush render/collider children. #[derive(Component, Reflect, Default, Debug, Clone, Copy)] @@ -22,11 +23,16 @@ use super::materials::material_from_desc; pub struct HydratedBrushMesh; #[allow(clippy::type_complexity)] +#[expect( + clippy::too_many_arguments, + reason = "Bevy system parameters keep independently change-tracked brush inputs explicit" +)] pub fn hydrate_brushes( mut commands: Commands, asset_server: Res, mut meshes: ResMut>, mut materials: ResMut>, + mut fallback: ResMut, brushes: Query< ( Entity, @@ -61,6 +67,7 @@ pub fn hydrate_brushes( &asset_server, &mut meshes, &mut materials, + &mut fallback, entity, brush, material.filter(|_| { @@ -82,6 +89,7 @@ pub fn spawn_brush_mesh( asset_server: &AssetServer, meshes: &mut Assets, materials: &mut Assets, + fallback: &mut DefaultGridEmergencyMaterial, parent: Entity, brush: &BrushDesc, material: Option<&MaterialDesc>, @@ -103,22 +111,35 @@ pub fn spawn_brush_mesh( continue; }; let mesh = meshes.add(mesh); - let material = materials.add(brush_group_material( - asset_server, - material, - group.material_path.as_deref(), - group.texture_path.as_deref(), - )); + let uses_default_grid = + material.is_none() && group.material_path.is_none() && group.texture_path.is_none(); + let material_handle = if uses_default_grid { + default_grid_emergency_handle(materials, fallback) + } else { + materials.add(brush_group_material( + asset_server, + material, + group.material_path.as_deref(), + group.texture_path.as_deref(), + )) + }; let mut brush_child = commands.spawn(( HydratedBrushMesh, Name::new("Hydrated Brush Mesh"), Mesh3d(mesh), - MeshMaterial3d(material), + MeshMaterial3d(material_handle), Transform::default(), Visibility::Visible, ChildOf(parent), )); + if uses_default_grid { + brush_child.insert(crate::HydratedMaterialSlotBinding { + owner: parent, + slot_id: crate::ComponentInstanceId::new("brush:default"), + selection: crate::HydratedMaterialSelection::Inherit, + }); + } if !brush.cast_shadows { brush_child.insert(NotShadowCaster); } diff --git a/crates/shared/src/hydration/materials.rs b/crates/shared/src/hydration/materials.rs index d76626e..7880c8e 100644 --- a/crates/shared/src/hydration/materials.rs +++ b/crates/shared/src/hydration/materials.rs @@ -1,50 +1,29 @@ //! Standard materials from [`MaterialDesc`] authoring data. -use bevy::prelude::*; - -use crate::{ - asset_server_path, authoring_component_active, AuthoringComponentStates, InspectorOrder, - LevelObject, MaterialDesc, MaterialShaderKind, Primitive, COMPONENT_MATERIAL_DESC, +use bevy::{ + image::{ + ImageAddressMode, ImageFilterMode, ImageLoaderSettings, ImageSampler, + ImageSamplerDescriptor, + }, + prelude::*, }; -#[allow(clippy::type_complexity)] -pub fn hydrate_materials( - mut commands: Commands, - asset_server: Res, - mut materials: ResMut>, - targets: Query< - ( - Entity, - &MaterialDesc, - Option<&AuthoringComponentStates>, - Option<&InspectorOrder>, - ), - ( - With, - With, - Or<( - Changed, - Added, - Changed, - Changed, - Changed, - )>, - ), - >, -) { - for (entity, desc, states, legacy_order) in &targets { - if !authoring_component_active(states, legacy_order, COMPONENT_MATERIAL_DESC) { - commands - .entity(entity) - .remove::>(); - continue; - } - let material = materials.add(material_from_desc(&asset_server, desc)); - commands.entity(entity).insert(MeshMaterial3d(material)); - } -} +use crate::{ + asset_server_path, MaterialDesc, MaterialShaderKind, RuntimeTextureLoadSpec, TextureFilter, + TextureWrap, +}; pub fn material_from_desc(asset_server: &AssetServer, desc: &MaterialDesc) -> StandardMaterial { + material_from_desc_with_texture_loader(asset_server, desc, |path| { + asset_server.load(asset_server_path(path)) + }) +} + +pub fn material_from_desc_with_texture_loader( + _asset_server: &AssetServer, + desc: &MaterialDesc, + mut load_texture: impl FnMut(&str) -> Handle, +) -> StandardMaterial { StandardMaterial { base_color: desc.base_color.to_color(), metallic: desc.metallic, @@ -53,7 +32,7 @@ pub fn material_from_desc(asset_server: &AssetServer, desc: &MaterialDesc) -> St base_color_texture: desc .base_color_texture .as_ref() - .map(|path| asset_server.load(asset_server_path(path))), + .map(|path| load_texture(path)), emissive: LinearRgba::rgb( desc.emissive_color.r * desc.emissive_intensity, desc.emissive_color.g * desc.emissive_intensity, @@ -62,19 +41,70 @@ pub fn material_from_desc(asset_server: &AssetServer, desc: &MaterialDesc) -> St emissive_texture: desc .emissive_texture .as_ref() - .map(|path| asset_server.load(asset_server_path(path))), + .map(|path| load_texture(path)), normal_map_texture: desc .normal_map_texture .as_ref() - .map(|path| asset_server.load(asset_server_path(path))), + .map(|path| load_texture(path)), metallic_roughness_texture: desc .metallic_roughness_texture .as_ref() - .map(|path| asset_server.load(asset_server_path(path))), + .map(|path| load_texture(path)), + occlusion_texture: desc + .occlusion_texture + .as_ref() + .map(|path| load_texture(path)), + uv_transform: bevy::math::Affine2::from_scale_angle_translation( + desc.uv_tiling, + 0.0, + desc.uv_offset, + ), ..default() } } +pub fn load_runtime_texture( + asset_server: &AssetServer, + spec: &RuntimeTextureLoadSpec, +) -> Handle { + let sampler = ImageSampler::Descriptor(runtime_image_sampler_descriptor(spec)); + let is_srgb = spec.is_srgb; + asset_server + .load_builder() + .with_settings(move |settings: &mut ImageLoaderSettings| { + settings.is_srgb = is_srgb; + settings.sampler = sampler.clone(); + }) + .load(asset_server_path(&spec.path)) +} + +pub fn runtime_image_sampler_descriptor(spec: &RuntimeTextureLoadSpec) -> ImageSamplerDescriptor { + let address_mode = match spec.wrap { + TextureWrap::Repeat => ImageAddressMode::Repeat, + TextureWrap::Clamp => ImageAddressMode::ClampToEdge, + TextureWrap::Mirror => ImageAddressMode::MirrorRepeat, + }; + let filter = match spec.filter { + TextureFilter::Nearest => ImageFilterMode::Nearest, + TextureFilter::Linear => ImageFilterMode::Linear, + }; + let anisotropy = if filter == ImageFilterMode::Linear { + spec.anisotropy.clamp(1, 16) + } else { + 1 + }; + ImageSamplerDescriptor { + address_mode_u: address_mode, + address_mode_v: address_mode, + address_mode_w: address_mode, + mag_filter: filter, + min_filter: filter, + mipmap_filter: filter, + anisotropy_clamp: anisotropy, + ..Default::default() + } +} + #[cfg(test)] mod tests { use super::*; @@ -97,4 +127,46 @@ mod tests { assert_eq!(material.emissive.green, 250.0); assert_eq!(material.emissive.blue, 1000.0); } + + #[test] + fn shared_uv_transform_maps_to_standard_material() { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + let asset_server = app.world().resource::(); + let desc = MaterialDesc { + uv_offset: Vec2::new(0.25, -0.5), + uv_tiling: Vec2::new(4.0, 2.0), + ..Default::default() + }; + + let material = material_from_desc(asset_server, &desc); + + assert_eq!(material.uv_transform.translation, desc.uv_offset); + assert_eq!(material.uv_transform.matrix2.x_axis.x, desc.uv_tiling.x); + assert_eq!(material.uv_transform.matrix2.y_axis.y, desc.uv_tiling.y); + } + + #[test] + fn runtime_sampler_maps_authored_wrap_filter_and_anisotropy() { + let spec = RuntimeTextureLoadSpec { + path: "texture.basis".into(), + is_srgb: false, + filter: TextureFilter::Linear, + wrap: TextureWrap::Repeat, + anisotropy: 64, + }; + let sampler = runtime_image_sampler_descriptor(&spec); + assert_eq!(sampler.address_mode_u, ImageAddressMode::Repeat); + assert_eq!(sampler.address_mode_v, ImageAddressMode::Repeat); + assert_eq!(sampler.address_mode_w, ImageAddressMode::Repeat); + assert_eq!(sampler.min_filter, ImageFilterMode::Linear); + assert_eq!(sampler.anisotropy_clamp, 16); + + let mut nearest = spec; + nearest.filter = TextureFilter::Nearest; + nearest.wrap = TextureWrap::Mirror; + let sampler = runtime_image_sampler_descriptor(&nearest); + assert_eq!(sampler.address_mode_u, ImageAddressMode::MirrorRepeat); + assert_eq!(sampler.anisotropy_clamp, 1); + } } diff --git a/crates/shared/src/hydration/mod.rs b/crates/shared/src/hydration/mod.rs index c42fe5a..eeb9190 100644 --- a/crates/shared/src/hydration/mod.rs +++ b/crates/shared/src/hydration/mod.rs @@ -21,8 +21,10 @@ use bevy::prelude::*; use brushes::{hydrate_brushes, spawn_brush_mesh, HydratedBrushMesh}; use lights::{hydrate_lights, reconcile_missing_runtime_lights}; -use materials::hydrate_materials; -pub use materials::material_from_desc; +pub use materials::{ + load_runtime_texture, material_from_desc, material_from_desc_with_texture_loader, + runtime_image_sampler_descriptor, +}; use models::hydrate_models; pub use models::HydratedModelRoot; use physics::hydrate_physics; @@ -34,9 +36,9 @@ use skinned_meshes::{ apply_skinned_renderer_material_changes, cleanup_removed_skinned_mesh_renderers, hydrate_skinned_mesh_renderers, mark_skinned_materials_ready, }; +pub use static_meshes::StaticMeshArtifactCache; use static_meshes::{ hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart, - StaticMeshArtifactCache, }; pub use terrain::HydratedTerrainChunk; use terrain::{cleanup_removed_terrain, hydrate_terrain}; @@ -48,11 +50,32 @@ use visibility::{ use crate::{ authoring_component_active, AuthoringComponentStates, BrushDesc, ColliderDesc, InspectorOrder, - LevelObject, MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer, - COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, - COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER, + LevelObject, MaterialDesc, Primitive, StaticMeshRenderer, COMPONENT_BRUSH_DESC, + COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_PRIMITIVE, + COMPONENT_STATIC_MESH_RENDERER, }; +/// One neutral emergency handle used until the Surface plugin replaces it with embedded DefaultGrid. +#[derive(Resource, Default, Debug, Clone)] +pub struct DefaultGridEmergencyMaterial(pub Option>); + +pub(crate) fn default_grid_emergency_handle( + materials: &mut Assets, + fallback: &mut DefaultGridEmergencyMaterial, +) -> Handle { + fallback + .0 + .get_or_insert_with(|| { + materials.add(StandardMaterial { + base_color: Color::srgb(0.46, 0.46, 0.46), + perceptual_roughness: 0.86, + cull_mode: None, + ..default() + }) + }) + .clone() +} + /// Registers hydration systems in deterministic order. pub struct HydrationPlugin; @@ -65,6 +88,7 @@ enum HydrationSet { impl Plugin for HydrationPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .add_observer(tag_hydrated_prefab_members) .add_observer(mark_skinned_materials_ready) .add_observer(crate::prefab_overrides::apply_prefab_overrides_on_ready) @@ -78,7 +102,6 @@ impl Plugin for HydrationPlugin { hydrate_primitives, hydrate_brushes, hydrate_terrain, - hydrate_materials, hydrate_lights, reconcile_missing_runtime_lights, hydrate_static_mesh_renderers, @@ -115,6 +138,9 @@ pub fn flush_level_object_hydration(world: &mut World) { if !world.contains_resource::() { world.insert_resource(StaticMeshArtifactCache::default()); } + if !world.contains_resource::() { + world.insert_resource(DefaultGridEmergencyMaterial::default()); + } initialize_level_object_visibility_hierarchy(world); @@ -132,20 +158,6 @@ pub fn flush_level_object_hydration(world: &mut World) { .map(|(entity, primitive, _, _)| (entity, primitive.clone())) .collect(); - let material_targets: Vec<(Entity, MaterialDesc)> = world - .query_filtered::<( - Entity, - &MaterialDesc, - Option<&AuthoringComponentStates>, - Option<&InspectorOrder>, - ), With>() - .iter(world) - .filter(|(_, _, states, order)| { - authoring_component_active(*states, *order, COMPONENT_MATERIAL_DESC) - }) - .map(|(entity, desc, _, _)| (entity, desc.clone())) - .collect(); - let brush_entities_for_cleanup: Vec = world .query_filtered::<(Entity, &BrushDesc), With>() .iter(world) @@ -190,45 +202,27 @@ pub fn flush_level_object_hydration(world: &mut World) { .map(|(entity, _)| entity) .collect(); - let static_mesh_renderers: Vec<( - Entity, - StaticMeshRenderer, - Option, - Option, - Option, - )> = world + let static_mesh_renderers: Vec<(Entity, StaticMeshRenderer, Option)> = world .query_filtered::<( Entity, &StaticMeshRenderer, - Option<&MaterialDesc>, - Option<&MaterialOverride>, Option<&ColliderDesc>, Option<&AuthoringComponentStates>, Option<&InspectorOrder>, ), With>() .iter(world) - .filter(|(_, _, _, _, _, states, order)| { + .filter(|(_, _, _, states, order)| { authoring_component_active(*states, *order, COMPONENT_STATIC_MESH_RENDERER) }) - .map( - |(entity, renderer, material, material_override, collider, states, order)| { - ( - entity, - renderer.clone(), - material - .filter(|_| { - authoring_component_active(states, order, COMPONENT_MATERIAL_DESC) - }) - .cloned(), - material_override.cloned(), - collider - .filter(|_| { - authoring_component_active(states, order, COMPONENT_COLLIDER_DESC) - }) - .cloned(), - ) - }, - ) + .map(|(entity, renderer, collider, states, order)| { + ( + entity, + renderer.clone(), + collider + .filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)) + .cloned(), + ) + }) .collect(); let mut generated_static_mesh_parts = Vec::new(); @@ -285,21 +279,33 @@ pub fn flush_level_object_hydration(world: &mut World) { ResMut>, ResMut>, ResMut, + ResMut, )> = SystemState::new(world); { - let (mut commands, asset_server, mut meshes, mut materials, mut artifact_cache) = state + let ( + mut commands, + asset_server, + mut meshes, + mut materials, + mut artifact_cache, + mut fallback, + ) = state .get_mut(world) .expect("hydrate_level_objects system params should be valid"); for (entity, primitive) in primitives { let mesh = meshes.add(primitive_mesh(&primitive)); - commands.entity(entity).insert(Mesh3d(mesh)); - } - - for (entity, desc) in material_targets { - let material = materials.add(material_from_desc(&asset_server, &desc)); - commands.entity(entity).insert(MeshMaterial3d(material)); + let material = default_grid_emergency_handle(&mut materials, &mut fallback); + commands.entity(entity).insert(( + Mesh3d(mesh), + MeshMaterial3d(material), + crate::HydratedMaterialSlotBinding { + owner: entity, + slot_id: primitive.surface.id.clone(), + selection: primitive.surface.hydrated_selection(), + }, + )); } for child in generated_brush_meshes { @@ -312,6 +318,7 @@ pub fn flush_level_object_hydration(world: &mut World) { &asset_server, &mut meshes, &mut materials, + &mut fallback, *entity, brush, material.as_ref(), @@ -323,16 +330,15 @@ pub fn flush_level_object_hydration(world: &mut World) { commands.entity(child).despawn(); } - for (entity, renderer, material, material_override, collider) in &static_mesh_renderers { + for (entity, renderer, collider) in &static_mesh_renderers { spawn_static_mesh_parts( &mut commands, &asset_server, &mut materials, &mut artifact_cache, + &mut fallback, *entity, renderer, - material.as_ref(), - material_override.as_ref(), collider.as_ref(), ); } @@ -510,7 +516,7 @@ mod tests { } #[test] - fn flush_hydration_spawns_mesh_material_from_primitive_and_desc() { + fn primitive_legacy_descriptors_do_not_create_actor_local_materials() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_plugins(AssetPlugin::default()); @@ -518,7 +524,7 @@ mod tests { app.init_asset::(); let material = MaterialDesc::new(ColorDesc::srgb(0.85, 0.85, 0.88), 0.0, 0.3); - let entity = app + let first = app .world_mut() .spawn(( LevelObject, @@ -526,13 +532,31 @@ mod tests { material, )) .id(); + let second = app + .world_mut() + .spawn(( + LevelObject, + Primitive::sphere(1.0), + MaterialDesc::new(ColorDesc::srgb(0.1, 0.8, 0.2), 1.0, 0.05), + )) + .id(); flush_level_object_hydration(app.world_mut()); - assert!(app.world().get::(entity).is_some()); + let first_handle = app + .world() + .get::>(first) + .unwrap(); + let second_handle = app + .world() + .get::>(second) + .unwrap(); + assert_eq!(first_handle.0, second_handle.0); assert!(app .world() - .get::>(entity) - .is_some()); + .get::(first) + .is_some_and(|binding| { + matches!(binding.selection, crate::HydratedMaterialSelection::Inherit) + })); } } diff --git a/crates/shared/src/hydration/primitives.rs b/crates/shared/src/hydration/primitives.rs index 982a23c..1175192 100644 --- a/crates/shared/src/hydration/primitives.rs +++ b/crates/shared/src/hydration/primitives.rs @@ -2,6 +2,7 @@ use bevy::prelude::*; +use super::{default_grid_emergency_handle, DefaultGridEmergencyMaterial}; use crate::{ authoring_component_active, AuthoringComponentStates, InspectorOrder, LevelObject, Primitive, PrimitiveShape, COMPONENT_PRIMITIVE, @@ -49,6 +50,8 @@ pub fn hydrate_primitives( ), >, mut meshes: ResMut>, + mut materials: ResMut>, + mut fallback: ResMut, ) { for (entity, primitive, states, legacy_order) in &primitives { if !authoring_component_active(states, legacy_order, COMPONENT_PRIMITIVE) { @@ -59,8 +62,17 @@ pub fn hydrate_primitives( continue; } let mesh = meshes.add(primitive_mesh(primitive)); + let material = default_grid_emergency_handle(&mut materials, &mut fallback); - commands.entity(entity).insert(Mesh3d(mesh)); + commands.entity(entity).insert(( + Mesh3d(mesh), + MeshMaterial3d(material), + crate::HydratedMaterialSlotBinding { + owner: entity, + slot_id: primitive.surface.id.clone(), + selection: primitive.surface.hydrated_selection(), + }, + )); } } diff --git a/crates/shared/src/hydration/skinned_meshes.rs b/crates/shared/src/hydration/skinned_meshes.rs index 749dd7f..46aa8a8 100644 --- a/crates/shared/src/hydration/skinned_meshes.rs +++ b/crates/shared/src/hydration/skinned_meshes.rs @@ -10,13 +10,14 @@ use serde::Deserialize; use crate::{ asset_server_path, authoring_component_active, AuthoringComponentStates, - HydratedRendererMaterialBinding, InspectorOrder, LevelObject, SkinnedMeshRenderer, + HydratedMaterialSlotBinding, InspectorOrder, LevelObject, SkinnedMeshRenderer, ANIMATION_ARTIFACT_DIR, COMPONENT_SKINNED_MESH_RENDERER, }; use super::static_meshes::{ renderer_error_material, resolve_renderer_material_handle, StaticMeshArtifactCache, }; +use super::DefaultGridEmergencyMaterial; /// Runtime hierarchy root generated for one authored [`SkinnedMeshRenderer`]. /// @@ -176,6 +177,7 @@ pub(super) fn apply_skinned_renderer_material_changes( asset_server: Res, mut materials: ResMut>, mut artifact_cache: ResMut, + mut fallback: ResMut, dirty_roots: Query<(Entity, &HydratedSkinnedMeshRoot), With>, renderers: Query<&SkinnedMeshRenderer>, children: Query<&Children>, @@ -238,30 +240,27 @@ pub(super) fn apply_skinned_renderer_material_changes( continue; }; used.insert(*entity); - let effective = slot.effective_material().cloned(); - let Some(reference) = effective.as_ref() else { - commands - .entity(*entity) - .insert(HydratedRendererMaterialBinding { - owner: root.owner, - slot_id: slot.id.clone(), - effective_material: None, - }); - continue; + let mut selection = slot.hydrated_selection(); + if matches!(selection, crate::HydratedMaterialSelection::Inherit) { + selection = + artifact_cache.resolve_model_material_selection(&renderer.asset_id, &slot.id); + } + let handle = match &selection { + crate::HydratedMaterialSelection::ImportedSource { reference } => { + resolve_renderer_material_handle(&asset_server, &mut artifact_cache, reference) + .unwrap_or_else(|| renderer_error_material(&mut materials, &mut fallback)) + } + crate::HydratedMaterialSelection::Project { .. } + | crate::HydratedMaterialSelection::Inherit => { + renderer_error_material(&mut materials, &mut fallback) + } }; - let handle = resolve_renderer_material_handle( - &asset_server, - &mut materials, - &mut artifact_cache, - reference, - ) - .unwrap_or_else(|| renderer_error_material(&mut materials)); commands.entity(*entity).insert(( MeshMaterial3d(handle), - HydratedRendererMaterialBinding { + HydratedMaterialSlotBinding { owner: root.owner, slot_id: slot.id.clone(), - effective_material: effective, + selection, }, )); } diff --git a/crates/shared/src/hydration/static_meshes.rs b/crates/shared/src/hydration/static_meshes.rs index 9b65166..2fef156 100644 --- a/crates/shared/src/hydration/static_meshes.rs +++ b/crates/shared/src/hydration/static_meshes.rs @@ -3,19 +3,19 @@ use avian3d::prelude::ColliderConstructor; use bevy::light::{NotShadowCaster, NotShadowReceiver}; use bevy::prelude::*; -use bevy::render::render_resource::Face; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use crate::{ asset_server_path, authoring_component_active, standard_material_asset_path, AuthoringComponentStates, ColliderDesc, ColliderShapeDesc, EditorAssetRef, - HydratedRendererMaterialBinding, InspectorOrder, LevelObject, MaterialAlphaMode, MaterialDesc, - MaterialOverride, MaterialRef, StaticMeshRenderer, StaticMeshRendererEntry, - COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_STATIC_MESH_RENDERER, + HydratedMaterialLayer, HydratedMaterialSelection, HydratedMaterialSlotBinding, InspectorOrder, + LevelObject, MaterialRef, ModelMaterialSelection, ModelMaterialSlotSelection, + StaticMeshRenderer, StaticMeshRendererEntry, COMPONENT_COLLIDER_DESC, + COMPONENT_STATIC_MESH_RENDERER, }; -use super::materials::material_from_desc; +use super::{default_grid_emergency_handle, DefaultGridEmergencyMaterial}; pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated"; @@ -28,7 +28,6 @@ pub struct HydratedStaticMeshPart; struct ResolvedStaticMeshPart { source_path: String, mesh_label: String, - material_label: Option, } #[derive(Debug, Clone)] @@ -48,10 +47,10 @@ pub(crate) struct ResolvedRendererDraw { pub struct StaticMeshArtifactCache { parts: HashMap<(String, String), ResolvedStaticMeshPart>, materials: HashMap<(String, String), ResolvedStaticMeshMaterial>, + model_materials: HashMap<(String, String), HydratedMaterialSelection>, draw_bindings: HashMap<(String, String), ResolvedRendererDraw>, loaded_assets: HashSet, failed_assets: HashSet, - project_materials: HashMap>, } impl StaticMeshArtifactCache { @@ -70,6 +69,18 @@ impl StaticMeshArtifactCache { self.draw_bindings.get(&key).cloned() } + pub(crate) fn resolve_model_material_selection( + &mut self, + asset_id: &str, + slot_id: &crate::ComponentInstanceId, + ) -> HydratedMaterialSelection { + let key = (asset_id.to_string(), slot_id.0.clone()); + if !self.loaded_assets.contains(asset_id) && !self.failed_assets.contains(asset_id) { + self.load_artifact(asset_id); + } + self.model_materials.get(&key).cloned().unwrap_or_default() + } + fn resolve(&mut self, mesh: &EditorAssetRef) -> Option { if !mesh.is_resolved() { return None; @@ -122,7 +133,7 @@ impl StaticMeshArtifactCache { }; let slot_id = format!("slot:{part_id}"); self.draw_bindings.insert( - (asset_id.to_string(), slot_id), + (asset_id.to_string(), slot_id.clone()), ResolvedRendererDraw { mesh_label: part.mesh_label.clone(), source_node: part.source_node.clone(), @@ -141,6 +152,39 @@ impl StaticMeshArtifactCache { material_label, }); } + let model_selection = manifest + .import + .material_slots + .iter() + .find(|selection| selection.slot_id.0 == slot_id) + .map(|selection| selection.selection.clone()) + .unwrap_or(ModelMaterialSelection::Source); + let hydrated_selection = match model_selection { + ModelMaterialSelection::Project(reference) => { + HydratedMaterialSelection::Project { + reference, + layer: HydratedMaterialLayer::Model, + } + } + ModelMaterialSelection::Default => HydratedMaterialSelection::Inherit, + ModelMaterialSelection::Source => part + .material_id + .clone() + .filter(|id| !id.trim().is_empty()) + .or_else(|| part.material_label.as_deref().map(material_id_from_label)) + .map(|material_id| HydratedMaterialSelection::ImportedSource { + reference: MaterialRef::new(EditorAssetRef::new( + asset_id, + material_id, + part.material_label + .clone() + .unwrap_or_else(|| "Imported Material".into()), + )), + }) + .unwrap_or_default(), + }; + self.model_materials + .insert((asset_id.to_string(), slot_id), hydrated_selection); if part.skinned || manifest.metadata.animation_count > 0 || manifest.metadata.skin_count > 0 @@ -156,7 +200,6 @@ impl StaticMeshArtifactCache { ResolvedStaticMeshPart { source_path: manifest.source.path.clone(), mesh_label: part.mesh_label.clone(), - material_label: part.material_label.clone(), }, ); } @@ -174,11 +217,19 @@ impl StaticMeshArtifactCache { struct StaticMeshResolveManifest { source: StaticMeshResolveSource, #[serde(default)] + import: StaticMeshResolveImport, + #[serde(default)] metadata: StaticMeshResolveMetadata, #[serde(default)] parts: Vec, } +#[derive(Debug, Default, Deserialize)] +struct StaticMeshResolveImport { + #[serde(default)] + material_slots: Vec, +} + #[derive(Debug, Default, Deserialize)] struct StaticMeshResolveMetadata { #[serde(default)] @@ -208,17 +259,20 @@ struct StaticMeshResolvePart { } #[allow(clippy::type_complexity)] +#[expect( + clippy::too_many_arguments, + reason = "Bevy system parameters keep independently change-tracked renderer inputs explicit" +)] pub fn hydrate_static_mesh_renderers( mut commands: Commands, asset_server: Res, mut materials: ResMut>, mut artifact_cache: ResMut, + mut fallback: ResMut, renderers: Query< ( Entity, &StaticMeshRenderer, - Option<&MaterialDesc>, - Option<&MaterialOverride>, Option<&ColliderDesc>, Option<&AuthoringComponentStates>, Option<&InspectorOrder>, @@ -228,8 +282,6 @@ pub fn hydrate_static_mesh_renderers( Or<( Added, Changed, - Changed, - Changed, Changed, Changed, Changed, @@ -239,15 +291,11 @@ pub fn hydrate_static_mesh_renderers( children: Query<&Children>, generated_parts: Query<(), With>, ) { - for (entity, renderer, parent_material, material_override, collider, states, order) in - &renderers - { + for (entity, renderer, collider, states, order) in &renderers { despawn_static_mesh_parts(&mut commands, entity, &children, &generated_parts); if !authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER) { continue; } - let parent_material = parent_material - .filter(|_| authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)); let collider = collider.filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)); spawn_static_mesh_parts( @@ -255,10 +303,9 @@ pub fn hydrate_static_mesh_renderers( &asset_server, &mut materials, &mut artifact_cache, + &mut fallback, entity, renderer, - parent_material, - material_override, collider, ); } @@ -273,10 +320,9 @@ pub fn spawn_static_mesh_parts( asset_server: &AssetServer, materials: &mut Assets, artifact_cache: &mut StaticMeshArtifactCache, + fallback: &mut DefaultGridEmergencyMaterial, parent: Entity, renderer: &StaticMeshRenderer, - parent_material: Option<&MaterialDesc>, - material_override: Option<&MaterialOverride>, collider: Option<&ColliderDesc>, ) { let mut spawned = 0usize; @@ -295,15 +341,22 @@ pub fn spawn_static_mesh_parts( let source_path = asset_server_path(&resolved.source_path); let mesh_handle: Handle = asset_server.load(labeled_asset_path(&source_path, &resolved.mesh_label)); - let material_handle = material_for_entry( + let material_slot_id = effective_material_slot_id(entry); + let selection = renderer + .materials + .slot(&material_slot_id) + .map(crate::MaterialSlot::hydrated_selection) + .filter(|selection| !matches!(selection, HydratedMaterialSelection::Inherit)) + .unwrap_or_else(|| { + artifact_cache + .resolve_model_material_selection(&entry.mesh.asset_id, &material_slot_id) + }); + let material_handle = material_for_selection( asset_server, materials, artifact_cache, - renderer, - entry, - &resolved, - parent_material, - material_override, + fallback, + &selection, ); let name = if entry.name.trim().is_empty() { format!("Static Mesh Part {index}") @@ -316,10 +369,10 @@ pub fn spawn_static_mesh_parts( Name::new(name), Mesh3d(mesh_handle), MeshMaterial3d(material_handle), - HydratedRendererMaterialBinding { + HydratedMaterialSlotBinding { owner: parent, - slot_id: effective_material_slot_id(entry), - effective_material: effective_material_ref(renderer, entry).cloned(), + slot_id: material_slot_id, + selection, }, entry.local_transform, Visibility::Visible, @@ -361,37 +414,15 @@ pub fn despawn_static_mesh_parts( } #[allow(clippy::too_many_arguments)] -fn material_for_entry( +fn material_for_selection( asset_server: &AssetServer, materials: &mut Assets, artifact_cache: &mut StaticMeshArtifactCache, - renderer: &StaticMeshRenderer, - entry: &StaticMeshRendererEntry, - resolved: &ResolvedStaticMeshPart, - parent_material: Option<&MaterialDesc>, - material_override: Option<&MaterialOverride>, + fallback: &mut DefaultGridEmergencyMaterial, + selection: &HydratedMaterialSelection, ) -> Handle { - if let Some(parent_material) = parent_material { - return materials.add(material_from_desc(asset_server, parent_material)); - } - - if let Some(override_material) = material_override.and_then(|overrides| { - overrides - .slots - .iter() - .find(|slot| slot.slot_id == entry.id) - .map(|slot| &slot.material) - }) { - return materials.add(material_from_desc(asset_server, override_material)); - } - - if let Some(material_ref) = effective_material_ref(renderer, entry) { - if let Some(handle) = - resolve_shared_material(asset_server, materials, artifact_cache, material_ref) - { - return handle; - } - if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) { + if let HydratedMaterialSelection::ImportedSource { reference } = selection { + if let Some(resolved_material) = artifact_cache.resolve_material(&reference.0) { let source_path = asset_server_path(&resolved_material.source_path); return asset_server.load(standard_material_asset_path( &source_path, @@ -400,34 +431,10 @@ fn material_for_entry( } debug!( "Static mesh material ref unresolved: asset_id='{}' material='{}' label='{}'", - material_ref.0.asset_id, material_ref.0.sub_asset_id, material_ref.0.label + reference.0.asset_id, reference.0.sub_asset_id, reference.0.label ); } - - // Read-only compatibility path for schema-v1/v2 scenes. The explicit project upgrader moves - // this reference into `RendererMaterialSet` and clears the legacy draw-part field. - if let Some(material_ref) = entry.material.as_ref() { - let material_ref = MaterialRef::new(material_ref.clone()); - if let Some(handle) = - resolve_shared_material(asset_server, materials, artifact_cache, &material_ref) - { - return handle; - } - if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) { - let source_path = asset_server_path(&resolved_material.source_path); - return asset_server.load(standard_material_asset_path( - &source_path, - &resolved_material.material_label, - )); - } - } - - if let Some(label) = resolved.material_label.as_deref() { - let source_path = asset_server_path(&resolved.source_path); - return asset_server.load(standard_material_asset_path(&source_path, label)); - } - - materials.add(error_material()) + default_grid_emergency_handle(materials, fallback) } fn effective_material_slot_id(entry: &StaticMeshRendererEntry) -> crate::ComponentInstanceId { @@ -438,47 +445,11 @@ fn effective_material_slot_id(entry: &StaticMeshRendererEntry) -> crate::Compone } } -fn effective_material_ref<'a>( - renderer: &'a StaticMeshRenderer, - entry: &'a StaticMeshRendererEntry, -) -> Option<&'a MaterialRef> { - let slot_id = effective_material_slot_id(entry); - renderer.materials.effective_material(&slot_id) -} - -fn resolve_shared_material( - asset_server: &AssetServer, - materials: &mut Assets, - cache: &mut StaticMeshArtifactCache, - reference: &MaterialRef, -) -> Option> { - if let Some(handle) = cache.project_materials.get(reference) { - return Some(handle.clone()); - } - let path = reference.0.source_path.as_deref()?; - let desc_and_state = crate::load_resolved_material_from_path(path).ok()?; - let mut material = material_from_desc(asset_server, &desc_and_state.0); - material.alpha_mode = match desc_and_state.1.alpha_mode { - MaterialAlphaMode::Opaque => AlphaMode::Opaque, - MaterialAlphaMode::Cutout => AlphaMode::Mask(desc_and_state.1.alpha_cutoff), - }; - material.cull_mode = (!desc_and_state.1.double_sided).then_some(Face::Back); - let handle = materials.add(material); - cache - .project_materials - .insert(reference.clone(), handle.clone()); - Some(handle) -} - pub(crate) fn resolve_renderer_material_handle( asset_server: &AssetServer, - materials: &mut Assets, cache: &mut StaticMeshArtifactCache, reference: &MaterialRef, ) -> Option> { - if let Some(handle) = resolve_shared_material(asset_server, materials, cache, reference) { - return Some(handle); - } let resolved = cache.resolve_material(&reference.0)?; let source_path = asset_server_path(&resolved.source_path); Some(asset_server.load(standard_material_asset_path( @@ -489,17 +460,9 @@ pub(crate) fn resolve_renderer_material_handle( pub(crate) fn renderer_error_material( materials: &mut Assets, + fallback: &mut DefaultGridEmergencyMaterial, ) -> Handle { - materials.add(error_material()) -} - -fn error_material() -> StandardMaterial { - StandardMaterial { - base_color: Color::srgb(1.0, 0.0, 1.0), - perceptual_roughness: 0.35, - metallic: 0.0, - ..default() - } + default_grid_emergency_handle(materials, fallback) } fn labeled_asset_path(path: &str, label: &str) -> String { diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 360d6fe..345f4e2 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -9,8 +9,10 @@ mod animation; mod asset_fingerprint; pub mod brush_math; mod components; +pub mod content; mod hydration; mod material_asset; +mod material_desc; mod navigation; mod post_process_effect_asset; mod prefab_overrides; @@ -21,17 +23,24 @@ pub use actor::{infer_actor_kind, validate_actor, ActorValidationError}; pub use animation::*; pub use asset_fingerprint::AssetSourceFingerprint; pub use components::*; +pub use content::*; pub use hydration::{ cascade_config_from_rendering, flush_level_object_hydration, - initialize_level_object_visibility_hierarchy, material_from_desc, strip_hydrated, + initialize_level_object_visibility_hierarchy, load_runtime_texture, material_from_desc, + material_from_desc_with_texture_loader, runtime_image_sampler_descriptor, strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember, HydratedPrefabReady, HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin, PrefabHydrationBlocked, + StaticMeshArtifactCache, }; pub use material_asset::{ - load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, - MaterialRenderState, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, - MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION, + load_resolved_material_from_path, standard_lit_input_schema, MaterialAlphaMode, MaterialAsset, + MaterialInputDesc, MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, + MaterialInputSet, MaterialInstanceAsset, MaterialProvenance, MaterialRenderState, + MaterialTextureInputDesc, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, + TextureSemantic, MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, + SURFACE_SHADER_SCHEMA_VERSION, }; +pub use material_desc::*; pub use navigation::*; pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind}; pub use prefab_overrides::{ @@ -69,7 +78,7 @@ pub fn standard_material_asset_path(asset_path: &str, material_label: &str) -> S format!("{asset_path}#{label}") } -use bevy::prelude::*; +use bevy::prelude::{App, Commands, Entity, Plugin, Query, Update, With, Without}; /// Registers all reflectable authoring types and installs hydration systems. pub struct SharedTypesPlugin; @@ -88,8 +97,8 @@ impl Plugin for SharedTypesPlugin { .register_type::() .register_type::() .register_type::() - .register_type::() - .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() @@ -131,6 +140,8 @@ impl Plugin for SharedTypesPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() @@ -183,6 +194,27 @@ fn migrate_legacy_authoring_component_states( #[cfg(test)] mod tests { use super::*; + use bevy::{ecs::reflect::AppTypeRegistry, reflect::TypeInfo}; + + #[test] + fn runtime_material_property_blocks_are_registered_for_editor_tooling() { + let mut app = App::new(); + app.add_plugins(SharedTypesPlugin); + + let registry = app.world().resource::().read(); + let block = registry + .get(std::any::TypeId::of::()) + .expect("MaterialPropertyBlock must be registered"); + let TypeInfo::Struct(block_info) = block.type_info() else { + panic!("MaterialPropertyBlock must reflect as a struct"); + }; + assert!(block_info.field("parameters").is_some()); + assert!(block_info.field("textures").is_some()); + + assert!(registry + .get(std::any::TypeId::of::()) + .is_some()); + } #[test] fn gltf_standard_material_paths_use_pbr_conversion_label() { diff --git a/crates/shared/src/material_asset.rs b/crates/shared/src/material_asset.rs index 13bad01..04c5000 100644 --- a/crates/shared/src/material_asset.rs +++ b/crates/shared/src/material_asset.rs @@ -4,13 +4,13 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ - EditorAssetRef, MaterialDesc, MaterialParameter, MaterialRef, MaterialShaderKind, - MaterialTextureBinding, + ColorDesc, EditorAssetRef, MaterialDesc, MaterialParameter, MaterialParameterValue, + MaterialRef, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel, }; -pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 1; -pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 1; -pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 1; +pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2; +pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 2; +pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 2; const fn default_material_schema_version() -> u32 { MATERIAL_ASSET_SCHEMA_VERSION @@ -55,20 +55,230 @@ impl Default for MaterialRenderState { } } -/// RON material asset: label plus authoring [`MaterialDesc`]. -#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize)] +/// Canonical values and texture bindings consumed by both built-in and custom material schemas. +/// +/// Values are keyed by stable input names. Validation rejects duplicates before publication; +/// vectors are retained instead of maps so shader-defined presentation order remains serializable. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct MaterialInputSet { + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub textures: Vec, +} + +impl MaterialInputSet { + pub fn from_material_desc(material: &MaterialDesc) -> Self { + let mut values = vec![ + MaterialParameter { + name: "base_color".into(), + value: MaterialParameterValue::Color(material.base_color), + }, + MaterialParameter { + name: "metallic".into(), + value: MaterialParameterValue::Float(material.metallic), + }, + MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(material.roughness), + }, + MaterialParameter { + name: "emissive_color".into(), + value: MaterialParameterValue::Color(material.emissive_color), + }, + MaterialParameter { + name: "emissive_intensity".into(), + value: MaterialParameterValue::Float(material.emissive_intensity), + }, + MaterialParameter { + name: "uv_offset".into(), + value: MaterialParameterValue::Vec2(material.uv_offset), + }, + MaterialParameter { + name: "uv_tiling".into(), + value: MaterialParameterValue::Vec2(material.uv_tiling), + }, + ]; + for parameter in &material.parameters { + if !is_standard_value_name(¶meter.name) { + upsert_parameter(&mut values, parameter.clone()); + } + } + + let mut textures = Vec::new(); + if let Some(path) = material.base_color_texture.as_deref() { + textures.push(path_texture_binding( + "base_color", + path, + TextureChannel::Rgba, + )); + } + if let Some(path) = material.normal_map_texture.as_deref() { + textures.push(path_texture_binding("normal", path, TextureChannel::Rgb)); + } + if let Some(path) = material.metallic_roughness_texture.as_deref() { + textures.push(path_texture_binding("occlusion", path, TextureChannel::R)); + textures.push(path_texture_binding("roughness", path, TextureChannel::G)); + textures.push(path_texture_binding("metallic", path, TextureChannel::B)); + } + if let Some(path) = material.occlusion_texture.as_deref() { + upsert_texture( + &mut textures, + path_texture_binding("occlusion", path, TextureChannel::R), + ); + } + if let Some(path) = material.emissive_texture.as_deref() { + textures.push(path_texture_binding("emissive", path, TextureChannel::Rgb)); + } + for texture in &material.textures { + if !is_standard_texture_name(&texture.name) { + upsert_texture(&mut textures, texture.clone()); + } + } + Self { values, textures } + } + + pub fn apply_to_material_desc(&self, material: &mut MaterialDesc) { + material.parameters.clear(); + material.textures.clear(); + for parameter in &self.values { + match (parameter.name.as_str(), ¶meter.value) { + ("base_color", MaterialParameterValue::Color(value)) => { + material.base_color = *value + } + ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, + ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value, + ("emissive_color", MaterialParameterValue::Color(value)) => { + material.emissive_color = *value + } + ("emissive_intensity", MaterialParameterValue::Float(value)) => { + material.emissive_intensity = *value + } + ("uv_offset", MaterialParameterValue::Vec2(value)) => material.uv_offset = *value, + ("uv_tiling", MaterialParameterValue::Vec2(value)) => material.uv_tiling = *value, + _ => upsert_parameter(&mut material.parameters, parameter.clone()), + } + } + material.base_color_texture = binding_path(self.texture("base_color")); + material.normal_map_texture = binding_path(self.texture("normal")); + material.emissive_texture = binding_path(self.texture("emissive")); + let roughness = binding_path(self.texture("roughness")); + let metallic = binding_path(self.texture("metallic")); + material.metallic_roughness_texture = match (roughness, metallic) { + (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness), + (Some(path), None) | (None, Some(path)) => Some(path), + _ => None, + }; + material.occlusion_texture = binding_path(self.texture("occlusion")); + for texture in &self.textures { + if !is_standard_texture_name(&texture.name) { + upsert_texture(&mut material.textures, texture.clone()); + } + } + } + + pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> { + self.textures.iter().find(|binding| binding.name == name) + } + + pub fn validate_unique(&self) -> Result<(), String> { + let mut names = std::collections::HashSet::new(); + for value in &self.values { + if !names.insert(("value", value.name.as_str())) { + return Err(format!( + "material value `{}` is assigned more than once", + value.name + )); + } + } + for texture in &self.textures { + if !names.insert(("texture", texture.name.as_str())) { + return Err(format!( + "material texture `{}` is assigned more than once", + texture.name + )); + } + } + Ok(()) + } +} + +fn path_texture_binding(name: &str, path: &str, channel: TextureChannel) -> MaterialTextureBinding { + let label = std::path::Path::new(path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(path); + MaterialTextureBinding { + name: name.into(), + texture: Some( + EditorAssetRef::new(String::new(), format!("texture:{name}"), label) + .with_source_path(path), + ), + channel, + } +} + +fn binding_path(binding: Option<&MaterialTextureBinding>) -> Option { + binding + .and_then(|binding| binding.texture.as_ref()) + .and_then(|reference| reference.source_path.clone()) +} + +fn is_standard_value_name(name: &str) -> bool { + matches!( + name, + "base_color" + | "metallic" + | "roughness" + | "emissive_color" + | "emissive_intensity" + | "uv_offset" + | "uv_tiling" + ) +} + +fn is_standard_texture_name(name: &str) -> bool { + matches!( + name, + "base_color" + | "base_color_texture" + | "normal" + | "normal_map_texture" + | "occlusion" + | "roughness" + | "metallic" + | "metallic_roughness" + | "metallic_roughness_texture" + | "emissive" + | "emissive_texture" + ) +} + +/// RON material asset with schema-driven authoring inputs. +#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct MaterialAsset { #[serde(default = "default_material_schema_version")] pub schema_version: u32, pub label: String, - /// Legacy path/label retained for v0 compatibility. New assets use `shader_ref`. #[serde(default)] - pub shader: Option, + pub shader: ShaderRefDesc, #[serde(default)] pub shader_ref: Option, #[serde(default)] pub render_state: MaterialRenderState, - pub material: MaterialDesc, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, + #[serde(default)] + pub inputs: MaterialInputSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MaterialProvenance { + pub source_path: String, + pub source_fingerprint: String, + pub source_sub_asset_id: String, + pub source_label: String, } impl MaterialAsset { @@ -87,7 +297,11 @@ pub fn load_resolved_material_from_path( catalog_path: &str, ) -> Result<(MaterialDesc, MaterialRenderState), String> { if let Ok(asset) = MaterialAsset::load_from_path(catalog_path) { - let mut material = asset.material; + let mut material = MaterialDesc { + shader: asset.shader, + ..Default::default() + }; + asset.inputs.apply_to_material_desc(&mut material); material.material_asset_path = Some(catalog_path.to_string()); return Ok((material, asset.render_state)); } @@ -100,7 +314,11 @@ pub fn load_resolved_material_from_path( ) })?; let base = MaterialAsset::load_from_path(base_path)?; - let mut material = base.material; + let mut material = MaterialDesc { + shader: base.shader, + ..Default::default() + }; + base.inputs.apply_to_material_desc(&mut material); instance.apply_to(&mut material); material.material_asset_path = Some(catalog_path.to_string()); Ok((material, base.render_state)) @@ -108,15 +326,14 @@ pub fn load_resolved_material_from_path( /// Explicit reusable overrides over one project Material asset. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct MaterialInstanceAsset { #[serde(default = "default_material_instance_schema_version")] pub schema_version: u32, pub label: String, pub base: MaterialRef, #[serde(default)] - pub parameters: Vec, - #[serde(default)] - pub textures: Vec, + pub overrides: MaterialInputSet, } impl MaterialInstanceAsset { @@ -129,7 +346,7 @@ impl MaterialInstanceAsset { /// Applies this instance's sparse overrides without mutating the shared base asset. pub fn apply_to(&self, material: &mut MaterialDesc) { - for parameter in &self.parameters { + for parameter in &self.overrides.values { match (parameter.name.as_str(), ¶meter.value) { ("base_color", crate::MaterialParameterValue::Color(value)) => { material.base_color = *value; @@ -146,23 +363,45 @@ impl MaterialInstanceAsset { ("emissive_intensity", crate::MaterialParameterValue::Float(value)) => { material.emissive_intensity = *value; } + ("uv_offset", crate::MaterialParameterValue::Vec2(value)) => { + material.uv_offset = *value; + } + ("uv_tiling", crate::MaterialParameterValue::Vec2(value)) => { + material.uv_tiling = *value; + } _ => upsert_parameter(&mut material.parameters, parameter.clone()), } } - for texture in &self.textures { + for texture in &self.overrides.textures { let path = texture .texture .as_ref() .and_then(|reference| reference.source_path.clone()); match texture.name.as_str() { - "base_color_texture" => material.base_color_texture = path, - "emissive_texture" => material.emissive_texture = path, - "normal_map_texture" => material.normal_map_texture = path, - "metallic_roughness_texture" => material.metallic_roughness_texture = path, + "base_color" | "base_color_texture" => material.base_color_texture = path, + "emissive" | "emissive_texture" => material.emissive_texture = path, + "normal" | "normal_map_texture" => material.normal_map_texture = path, + "roughness" | "metallic" | "metallic_roughness_texture" => { + material.metallic_roughness_texture = path + } _ => upsert_texture(&mut material.textures, texture.clone()), } } } + + /// Overlays sparse runtime/property-block values onto this direct-base instance. + pub fn merge_overrides( + &mut self, + parameters: impl IntoIterator, + textures: impl IntoIterator, + ) { + for parameter in parameters { + upsert_parameter(&mut self.overrides.values, parameter); + } + for texture in textures { + upsert_texture(&mut self.overrides.textures, texture); + } + } } fn upsert_parameter(values: &mut Vec, value: MaterialParameter) { @@ -192,17 +431,107 @@ pub enum ShaderPropertyType { Texture, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MaterialInputGroupDesc { + pub id: String, + pub display_name: String, + #[serde(default)] + pub order: i32, + #[serde(default)] + pub advanced: bool, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum TextureSemantic { + #[default] + Auto, + Color, + Normal, + Scalar, + Mask, + Hdr, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MaterialTextureInputDesc { + #[serde(default)] + pub semantic: TextureSemantic, + #[serde(default)] + pub default_channel: TextureChannel, + #[serde(default)] + pub allow_channel_override: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ShaderPropertyDesc { +pub struct MaterialInputDesc { pub name: String, pub display_name: String, #[serde(default)] pub group: String, + #[serde(default)] + pub order: i32, pub property_type: ShaderPropertyType, + #[serde(default)] + pub default_value: Option, + #[serde(default)] + pub texture: Option, + #[serde(default)] + pub tooltip: String, + #[serde(default)] + pub advanced: bool, + /// Authoring presentation only. Runtime evaluation continues to address inputs by `name`. + #[serde(default)] + pub presentation: MaterialInputPresentation, +} + +impl Default for MaterialInputDesc { + fn default() -> Self { + Self { + name: String::new(), + display_name: String::new(), + group: String::new(), + order: 0, + property_type: ShaderPropertyType::Float { + min: None, + max: None, + }, + default_value: None, + texture: None, + tooltip: String::new(), + advanced: false, + presentation: MaterialInputPresentation::Row, + } + } +} + +/// Declares whether an input owns a visible row or is edited through another input's row. +/// +/// Companion values remain independent in [`MaterialInputSet`]. This keeps the schema suitable for +/// generated/custom shaders while allowing related controls such as emissive color and intensity +/// to share one coherent Inspector row. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum MaterialInputPresentation { + #[default] + Row, + Companion { + owner: String, + }, +} + +/// Compatibility source alias for code that has not yet adopted the material-input terminology. +pub type ShaderPropertyDesc = MaterialInputDesc; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct MaterialInputSchema { + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub inputs: Vec, } /// RON shader schema describing material inspector parameters. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct ShaderSchemaAsset { #[serde(default = "default_surface_shader_schema_version")] pub schema_version: u32, @@ -211,11 +540,7 @@ pub struct ShaderSchemaAsset { #[serde(default)] pub wgsl_path: Option, #[serde(default)] - pub parameters: Vec, - #[serde(default)] - pub default_values: Vec, - #[serde(default)] - pub default_textures: Vec, + pub schema: MaterialInputSchema, } impl ShaderSchemaAsset { @@ -227,18 +552,194 @@ impl ShaderSchemaAsset { } } +/// Engine-owned Standard Lit authoring schema. It is consumed by the same renderer used for +/// project Surface schemas, so a future graph compiler only needs to emit this descriptor shape. +pub fn standard_lit_input_schema() -> MaterialInputSchema { + let groups = vec![ + MaterialInputGroupDesc { + id: "surface_inputs".into(), + display_name: "Surface Inputs".into(), + order: 0, + advanced: false, + }, + MaterialInputGroupDesc { + id: "advanced_inputs".into(), + display_name: "Advanced Inputs".into(), + order: 100, + advanced: true, + }, + MaterialInputGroupDesc { + id: "uv_transform".into(), + display_name: "UV Transform".into(), + order: 90, + advanced: false, + }, + ]; + let scalar_texture = |channel| MaterialTextureInputDesc { + semantic: TextureSemantic::Scalar, + default_channel: channel, + allow_channel_override: true, + }; + let inputs = vec![ + MaterialInputDesc { + name: "base_color".into(), + display_name: "Base Color".into(), + group: "surface_inputs".into(), + order: 0, + property_type: ShaderPropertyType::Color, + default_value: Some(MaterialParameterValue::Color(ColorDesc::default())), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Color, + default_channel: TextureChannel::Rgba, + allow_channel_override: false, + }), + tooltip: "Color multiplier applied to the base-color texture.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "metallic".into(), + display_name: "Metallic".into(), + group: "surface_inputs".into(), + order: 10, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.0)), + texture: Some(scalar_texture(TextureChannel::B)), + tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "roughness".into(), + display_name: "Roughness".into(), + group: "surface_inputs".into(), + order: 20, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.65)), + texture: Some(scalar_texture(TextureChannel::G)), + tooltip: "Scalar multiplier; ARM/ORM uses the green channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "occlusion".into(), + display_name: "Occlusion".into(), + group: "surface_inputs".into(), + order: 30, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(1.0)), + texture: Some(scalar_texture(TextureChannel::R)), + tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "normal".into(), + display_name: "Normal".into(), + group: "surface_inputs".into(), + order: 40, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.85)), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Normal, + default_channel: TextureChannel::Rgb, + allow_channel_override: false, + }), + tooltip: String::new(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "emissive_color".into(), + display_name: "Emissive".into(), + group: "surface_inputs".into(), + order: 50, + property_type: ShaderPropertyType::Color, + default_value: Some(MaterialParameterValue::Color(ColorDesc::srgb( + 1.0, 1.0, 1.0, + ))), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Color, + default_channel: TextureChannel::Rgb, + allow_channel_override: false, + }), + tooltip: "Emissive color multiplied by the texture and intensity.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "emissive_intensity".into(), + display_name: "Emissive Intensity".into(), + group: "surface_inputs".into(), + order: 60, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(50_000.0), + }, + default_value: Some(MaterialParameterValue::Float(0.0)), + texture: None, + tooltip: "Emissive luminance multiplier in nits.".into(), + advanced: false, + presentation: MaterialInputPresentation::Companion { + owner: "emissive_color".into(), + }, + }, + MaterialInputDesc { + name: "uv_offset".into(), + display_name: "Offset".into(), + group: "uv_transform".into(), + order: 0, + property_type: ShaderPropertyType::Vec2, + default_value: Some(MaterialParameterValue::Vec2(Vec2::ZERO)), + texture: None, + tooltip: "Shared UV translation for every texture on this material.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "uv_tiling".into(), + display_name: "Tiling".into(), + group: "uv_transform".into(), + order: 10, + property_type: ShaderPropertyType::Vec2, + default_value: Some(MaterialParameterValue::Vec2(Vec2::ONE)), + texture: None, + tooltip: "Shared UV scale for every texture on this material.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + ]; + MaterialInputSchema { groups, inputs } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn legacy_material_defaults_to_current_schema_and_opaque() { - let parsed: MaterialAsset = ron::from_str( - r#"(label: "Legacy", material: (base_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), metallic: 0.0, roughness: 0.5, base_color_texture: None, normal_map_texture: None, metallic_roughness_texture: None))"#, - ) - .expect("legacy material should remain readable"); + fn material_v2_defaults_to_opaque() { + let parsed = MaterialAsset { + schema_version: MATERIAL_ASSET_SCHEMA_VERSION, + label: "Material".into(), + shader: ShaderRefDesc::default(), + shader_ref: None, + render_state: MaterialRenderState::default(), + provenance: None, + inputs: MaterialInputSet::default(), + }; - assert_eq!(parsed.schema_version, MATERIAL_ASSET_SCHEMA_VERSION); assert_eq!(parsed.render_state.alpha_mode, MaterialAlphaMode::Opaque); } @@ -252,8 +753,7 @@ mod tests { "material:source", "Base", )), - parameters: Vec::new(), - textures: Vec::new(), + overrides: MaterialInputSet::default(), }; assert!(instance.base.is_resolved()); @@ -266,23 +766,26 @@ mod tests { schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, label: "Variant".into(), base: MaterialRef::default(), - parameters: vec![ - MaterialParameter { - name: "roughness".into(), - value: crate::MaterialParameterValue::Float(0.2), - }, - MaterialParameter { - name: "edge_width".into(), - value: crate::MaterialParameterValue::Float(3.0), - }, - ], - textures: vec![MaterialTextureBinding { - name: "base_color_texture".into(), - texture: Some( - EditorAssetRef::new("texture", "texture:source", "Grid") - .with_source_path("assets/textures/grid.png"), - ), - }], + overrides: MaterialInputSet { + values: vec![ + MaterialParameter { + name: "roughness".into(), + value: crate::MaterialParameterValue::Float(0.2), + }, + MaterialParameter { + name: "edge_width".into(), + value: crate::MaterialParameterValue::Float(3.0), + }, + ], + textures: vec![MaterialTextureBinding { + name: "base_color_texture".into(), + texture: Some( + EditorAssetRef::new("texture", "texture:source", "Grid") + .with_source_path("assets/textures/grid.png"), + ), + channel: TextureChannel::Rgba, + }], + }, }; instance.apply_to(&mut material); @@ -298,6 +801,88 @@ mod tests { .any(|value| value.name == "edge_width")); } + #[test] + fn material_inputs_round_trip_shared_uv_transform() { + let source = MaterialDesc { + uv_offset: Vec2::new(0.25, -0.5), + uv_tiling: Vec2::new(4.0, 2.0), + ..Default::default() + }; + let inputs = MaterialInputSet::from_material_desc(&source); + let mut resolved = MaterialDesc::default(); + + inputs.apply_to_material_desc(&mut resolved); + + assert_eq!(resolved.uv_offset, source.uv_offset); + assert_eq!(resolved.uv_tiling, source.uv_tiling); + } + + #[test] + fn standard_lit_surface_inputs_match_penpot_bindings() { + let schema = standard_lit_input_schema(); + let surface = schema + .inputs + .iter() + .filter(|input| input.group == "surface_inputs") + .collect::>(); + let rows = surface + .iter() + .filter(|input| matches!(input.presentation, MaterialInputPresentation::Row)) + .count(); + assert_eq!(surface.len(), 7); + assert_eq!(rows, 6); + assert_eq!(surface[0].name, "base_color"); + assert_eq!(surface[4].name, "normal"); + assert!(matches!( + surface[4].property_type, + ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0) + } + )); + assert_eq!(surface[6].name, "emissive_intensity"); + assert_eq!( + surface[6].presentation, + MaterialInputPresentation::Companion { + owner: "emissive_color".into() + } + ); + } + + #[test] + fn promotion_merge_replaces_existing_values_without_duplicates() { + let mut instance = MaterialInstanceAsset { + schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Variant".into(), + base: MaterialRef::default(), + overrides: MaterialInputSet { + values: vec![MaterialParameter { + name: "roughness".into(), + value: crate::MaterialParameterValue::Float(0.8), + }], + textures: Vec::new(), + }, + }; + instance.merge_overrides( + [MaterialParameter { + name: "roughness".into(), + value: crate::MaterialParameterValue::Float(0.2), + }], + [MaterialTextureBinding { + name: "normal_map_texture".into(), + texture: None, + channel: TextureChannel::Rgb, + }], + ); + + assert_eq!(instance.overrides.values.len(), 1); + assert_eq!( + instance.overrides.values[0].value, + crate::MaterialParameterValue::Float(0.2) + ); + assert_eq!(instance.overrides.textures.len(), 1); + } + #[test] fn resolved_material_instance_applies_sparse_overrides_and_keeps_instance_path() { let root = std::env::temp_dir().join(format!( @@ -313,16 +898,17 @@ mod tests { let base = MaterialAsset { schema_version: MATERIAL_ASSET_SCHEMA_VERSION, label: "Base".into(), - shader: None, + shader: ShaderRefDesc::default(), shader_ref: None, render_state: MaterialRenderState { double_sided: true, ..Default::default() }, - material: MaterialDesc { + provenance: None, + inputs: MaterialInputSet::from_material_desc(&MaterialDesc { roughness: 0.8, ..Default::default() - }, + }), }; std::fs::write( &base_path, @@ -336,11 +922,13 @@ mod tests { EditorAssetRef::new("base", "material:source", "Base") .with_source_path(base_path.to_string_lossy().into_owned()), ), - parameters: vec![MaterialParameter { - name: "roughness".into(), - value: crate::MaterialParameterValue::Float(0.25), - }], - textures: Vec::new(), + overrides: MaterialInputSet { + values: vec![MaterialParameter { + name: "roughness".into(), + value: crate::MaterialParameterValue::Float(0.25), + }], + textures: Vec::new(), + }, }; std::fs::write( &instance_path, diff --git a/crates/shared/src/material_desc.rs b/crates/shared/src/material_desc.rs new file mode 100644 index 0000000..8e3e889 --- /dev/null +++ b/crates/shared/src/material_desc.rs @@ -0,0 +1,176 @@ +//! Runtime and legacy-compatible material descriptor types. + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ComponentInstanceId, EditorAssetRef, ShaderRefDesc}; + +#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub enum MaterialParameterValue { + Bool(bool), + Float(f32), + Vec2(Vec2), + Vec3(Vec3), + Color(ColorDesc), + Enum(String), +} + +impl Default for MaterialParameterValue { + fn default() -> Self { + Self::Float(0.0) + } +} + +#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialParameter { + pub name: String, + pub value: MaterialParameterValue, +} + +/// Channels sampled from a texture binding. Scalar material inputs use one channel while +/// color/normal inputs use RGB or RGBA. The default preserves the pre-v2 whole-texture behavior. +#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub enum TextureChannel { + R, + G, + B, + A, + Rgb, + #[default] + Rgba, +} + +#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialTextureBinding { + pub name: String, + pub texture: Option, + #[serde(default)] + pub channel: TextureChannel, +} + +/// Per-render-slot material override stored on scene actors. +#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialSlotOverride { + pub slot_id: ComponentInstanceId, + #[serde(default)] + pub base_material: Option, + pub material: MaterialDesc, +} + +/// Actor-level material overrides retained for legacy scene upgrade only. +#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialOverride { + #[serde(default)] + pub slots: Vec, +} + +/// Serializable color description. This avoids coupling saved scenes to any internal color +/// representation details. +#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct ColorDesc { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl ColorDesc { + pub const fn srgb(r: f32, g: f32, b: f32) -> Self { + Self { r, g, b, a: 1.0 } + } + + pub fn to_color(self) -> Color { + Color::srgba(self.r, self.g, self.b, self.a) + } +} + +impl Default for ColorDesc { + fn default() -> Self { + Self::srgb(0.8, 0.8, 0.8) + } +} + +/// Resolved runtime and legacy-compatible material representation. Project authoring uses +/// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials. +#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Component, Default, Debug, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialDesc { + #[serde(default)] + pub shader: ShaderRefDesc, + pub base_color: ColorDesc, + pub metallic: f32, + pub roughness: f32, + #[serde(default = "default_emissive_color")] + pub emissive_color: ColorDesc, + #[serde(default)] + pub emissive_intensity: f32, + pub base_color_texture: Option, + #[serde(default)] + pub emissive_texture: Option, + pub normal_map_texture: Option, + pub metallic_roughness_texture: Option, + #[serde(default)] + pub occlusion_texture: Option, + #[serde(default)] + pub material_asset_path: Option, + #[serde(default)] + pub parameters: Vec, + #[serde(default)] + pub textures: Vec, + #[serde(default)] + pub uv_offset: Vec2, + #[serde(default = "default_material_uv_tiling")] + pub uv_tiling: Vec2, +} + +impl MaterialDesc { + pub fn new(base_color: ColorDesc, metallic: f32, roughness: f32) -> Self { + Self { + shader: ShaderRefDesc::default(), + base_color, + metallic, + roughness, + emissive_color: default_emissive_color(), + emissive_intensity: 0.0, + base_color_texture: None, + emissive_texture: None, + normal_map_texture: None, + metallic_roughness_texture: None, + occlusion_texture: None, + material_asset_path: None, + parameters: Vec::new(), + textures: Vec::new(), + uv_offset: Vec2::ZERO, + uv_tiling: Vec2::ONE, + } + } +} + +fn default_material_uv_tiling() -> Vec2 { + Vec2::ONE +} + +fn default_emissive_color() -> ColorDesc { + ColorDesc::srgb(1.0, 1.0, 1.0) +} + +impl Default for MaterialDesc { + fn default() -> Self { + Self::new(ColorDesc::default(), 0.0, 0.65) + } +} diff --git a/crates/shared/src/prefab_overrides.rs b/crates/shared/src/prefab_overrides.rs index a3a59cf..ba4f196 100644 --- a/crates/shared/src/prefab_overrides.rs +++ b/crates/shared/src/prefab_overrides.rs @@ -10,7 +10,10 @@ use bevy::world_serialization::{WorldInstanceReady, WorldInstanceSpawner}; use serde::de::{DeserializeOwned, DeserializeSeed}; use serde::{Deserialize, Serialize}; -use crate::{ActorId, EditorVisibility, HydratedPrefabMember, MaterialDesc, PrefabInstance}; +use crate::{ + ActorId, EditorVisibility, HydratedPrefabMember, MaterialDesc, PrefabInstance, + COMPONENT_MATERIAL_DESC, +}; pub const PREFAB_OVERRIDE_FORMAT_VERSION: u32 = 2; @@ -399,7 +402,7 @@ fn apply_component_override( world.resource_scope(|world, registry: Mut| { let registry = registry.read(); let registration = registry - .get_with_type_path(&component.component_type) + .get_with_type_path(reflected_component_type_path(&component.component_type)) .ok_or_else(|| { format!( "component type `{}` is not registered", @@ -449,7 +452,7 @@ fn apply_property_override( world.resource_scope(|world, registry: Mut| { let registry = registry.read(); let registration = registry - .get_with_type_path(&property.component_type) + .get_with_type_path(reflected_component_type_path(&property.component_type)) .ok_or_else(|| { format!( "component type `{}` is not registered", @@ -489,6 +492,17 @@ fn apply_property_override( }) } +/// Rust's concrete type name follows the owning module after a domain extraction, while saved +/// reflected scene data deliberately keeps its stable public component path. Accept the concrete +/// name emitted by editor authoring without duplicating the type registration. +fn reflected_component_type_path(component_type: &str) -> &str { + if component_type == std::any::type_name::() { + COMPONENT_MATERIAL_DESC + } else { + component_type + } +} + fn deserialize_component( text: &str, registration: &bevy::reflect::TypeRegistration, @@ -633,6 +647,7 @@ mod tests { let value = crate::Primitive { shape: crate::PrimitiveShape::Sphere, size: Vec3::splat(99.0), + ..Default::default() }; let merged = apply_serialized_navigation_property_override( std::any::type_name::(), diff --git a/crates/shared/src/renderer_material.rs b/crates/shared/src/renderer_material.rs index 8acb173..b709a8e 100644 --- a/crates/shared/src/renderer_material.rs +++ b/crates/shared/src/renderer_material.rs @@ -32,23 +32,31 @@ impl From for MaterialRef { } } -/// One stable, named material assignment exposed by a renderer. +/// One stable, named material assignment exposed by any renderable surface. #[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct RendererMaterialSlot { +pub struct MaterialSlot { pub id: ComponentInstanceId, pub name: String, - /// Read-only imported default. Clearing `material` returns to this reference. - #[serde(default)] - pub source_material: Option, - /// Explicit shared Material or Material Instance assignment. + /// Actor-owned shared Material or Material Instance assignment. + /// Model defaults and imported source materials remain asset-owned. #[serde(default)] pub material: Option, } -impl RendererMaterialSlot { +impl MaterialSlot { pub fn effective_material(&self) -> Option<&MaterialRef> { - self.material.as_ref().or(self.source_material.as_ref()) + self.material.as_ref() + } + + pub fn hydrated_selection(&self) -> HydratedMaterialSelection { + self.material + .clone() + .map(|reference| HydratedMaterialSelection::Project { + reference, + layer: HydratedMaterialLayer::Actor, + }) + .unwrap_or_default() } } @@ -62,31 +70,36 @@ pub struct OrphanedMaterialAssignment { pub material: MaterialRef, } -/// Material slots shared by static and skinned renderer authoring components. +/// Material slots shared by primitive, static, and skinned authoring components. #[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct RendererMaterialSet { +pub struct MaterialSlotSet { #[serde(default)] - pub slots: Vec, + pub slots: Vec, #[serde(default)] pub orphaned_assignments: Vec, } -impl RendererMaterialSet { - pub fn slot(&self, id: &ComponentInstanceId) -> Option<&RendererMaterialSlot> { +impl MaterialSlotSet { + pub fn slot(&self, id: &ComponentInstanceId) -> Option<&MaterialSlot> { self.slots.iter().find(|slot| &slot.id == id) } - pub fn slot_mut(&mut self, id: &ComponentInstanceId) -> Option<&mut RendererMaterialSlot> { + pub fn slot_mut(&mut self, id: &ComponentInstanceId) -> Option<&mut MaterialSlot> { self.slots.iter_mut().find(|slot| &slot.id == id) } pub fn effective_material(&self, id: &ComponentInstanceId) -> Option<&MaterialRef> { - self.slot(id) - .and_then(RendererMaterialSlot::effective_material) + self.slot(id).and_then(MaterialSlot::effective_material) } } +/// Source-compatible name retained for the schema-v4 transition. +pub type RendererMaterialSlot = MaterialSlot; + +/// Source-compatible name retained for the schema-v4 transition. +pub type RendererMaterialSet = MaterialSlotSet; + /// Runtime-only property overrides analogous to a material property block. /// /// Blocks never alter shared assets and are deliberately excluded from scene/prefab persistence. @@ -94,9 +107,7 @@ impl RendererMaterialSet { #[reflect(Default, Debug, PartialEq)] pub struct MaterialPropertyBlock { pub slot_id: ComponentInstanceId, - #[reflect(ignore)] pub parameters: Vec, - #[reflect(ignore)] pub textures: Vec, } @@ -107,14 +118,47 @@ pub struct MaterialPropertyBlocks { } /// Runtime ownership/binding marker placed on every hydrated renderer draw entity. +#[derive(Reflect, Debug, Clone, Copy, PartialEq, Eq)] +#[reflect(Debug, PartialEq)] +pub enum HydratedMaterialLayer { + Actor, + Model, +} + +#[derive(Reflect, Debug, Clone, PartialEq, Eq, Default)] +#[reflect(Debug, PartialEq, Default)] +pub enum HydratedMaterialSelection { + Project { + reference: MaterialRef, + layer: HydratedMaterialLayer, + }, + ImportedSource { + reference: MaterialRef, + }, + #[default] + Inherit, +} + +impl HydratedMaterialSelection { + pub fn project_reference(&self) -> Option<&MaterialRef> { + match self { + Self::Project { reference, .. } => Some(reference), + Self::ImportedSource { .. } | Self::Inherit => None, + } + } +} + #[derive(Component, Reflect, Debug, Clone, PartialEq, Eq)] #[reflect(Component, Debug, PartialEq)] -pub struct HydratedRendererMaterialBinding { +pub struct HydratedMaterialSlotBinding { pub owner: Entity, pub slot_id: ComponentInstanceId, - pub effective_material: Option, + pub selection: HydratedMaterialSelection, } +/// Source-compatible name retained for the schema-v4 transition. +pub type HydratedRendererMaterialBinding = HydratedMaterialSlotBinding; + /// Runtime ownership marker consumed by the project terrain-layer renderer. #[derive(Component, Reflect, Debug, Clone, PartialEq)] #[reflect(Component, Default, Debug, PartialEq)] @@ -141,19 +185,28 @@ mod tests { } #[test] - fn explicit_assignment_precedes_imported_source() { - let source = material_ref("source"); + fn slot_contains_only_the_actor_assignment() { let assigned = material_ref("assigned"); let mut slot = RendererMaterialSlot { id: ComponentInstanceId::new("slot:body"), name: "Body".into(), - source_material: Some(source.clone()), material: Some(assigned.clone()), }; assert_eq!(slot.effective_material(), Some(&assigned)); + assert!(matches!( + slot.hydrated_selection(), + HydratedMaterialSelection::Project { + layer: HydratedMaterialLayer::Actor, + .. + } + )); slot.material = None; - assert_eq!(slot.effective_material(), Some(&source)); + assert_eq!(slot.effective_material(), None); + assert_eq!( + slot.hydrated_selection(), + HydratedMaterialSelection::Inherit + ); } #[test] @@ -162,7 +215,6 @@ mod tests { slots: vec![RendererMaterialSlot { id: ComponentInstanceId::new("slot:body"), name: "Body".into(), - source_material: Some(material_ref("source")), material: None, }], orphaned_assignments: vec![], diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..c1fd551 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,9 @@ +# Documentation subtree rules + +- Read `docs/authority.toml` before treating a document as current. +- Canonical docs describe current behavior; active plans describe incomplete work; accepted ADRs + describe decisions and constraints; evaluations describe dated evidence. +- Historical and superseded documents are not current requirements. +- Do not duplicate detailed contracts across README, guides, plans, ADRs, and tracker bodies. +- Update the smallest canonical surface that owns the changed behavior. +- Run `scripts/codex/docs_audit.py` through the documentation-integrity workflow. diff --git a/docs/README.md b/docs/README.md index 6d94d55..a3a3328 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,14 +1,27 @@ # Project Documentation -Central index for design decisions, mission, and editor framework docs. Agents and contributors should keep this map current—see [`.cursor/rules/documentation.mdc`](../.cursor/rules/documentation.mdc). +Central index for design decisions, mission, editor framework docs, and repository workflow. +Before treating any document as current guidance, read [`authority.toml`](authority.toml). ## Core | Document | Purpose | |----------|---------| +| [authority.toml](authority.toml) | Machine-readable current/plan/evidence/historical/superseded classification | | [mission.md](mission.md) | Why the editor framework exists, principles, audience, non-goals | | [../README.md](../README.md) | Run/build instructions, controls, implementation checklist | +## Repository workflow + +| Document | Purpose | +|----------|---------| +| [workflow/codex-workflow.md](workflow/codex-workflow.md) | Task lifecycle, scope deltas, and completion states | +| [workflow/documentation-policy.md](workflow/documentation-policy.md) | Authority hierarchy, lifecycle classes, and integrity audit | +| [workflow/verification-policy.md](workflow/verification-policy.md) | Fast, slice, and candidate verification tiers | +| [workflow/build-storage-policy.md](workflow/build-storage-policy.md) | Managed Cargo lanes, budgets, and safe pruning | +| [workflow/gitea-tracking-policy.md](workflow/gitea-tracking-policy.md) | Tracker state and mutation boundaries | +| [archive/](archive/) | Preserved non-current documentation records | + ## Architecture Decision Records (ADRs) Immutable-style log of significant decisions. Add a new numbered ADR when changing boundaries, formats, or policies. @@ -59,15 +72,21 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints | | [0043](adr/0043-content-addressed-import-fingerprints.md) | Content-addressed imported-source identity and byte-preserving artifact publication | | [0044](adr/0044-sandboxed-fbx-external-texture-dependencies.md) | Sandboxed FBX texture bundles, validation policy, and deduplicated loading | +| [0045](adr/0045-content-workspace-and-material-fallback-contract.md) | Path-agnostic content workspace, registry v3, model material defaults, and DefaultGrid fallback | +| [0046](adr/0046-schema-driven-material-inputs-and-processed-textures.md) | Schema-driven Material inputs, registry v3 Texture settings, ARM packing, and deterministic runtime textures | +| [0047](adr/0047-editor-authored-asset-documents.md) | Editor-dirty asset documents, explicit saves, recovery, and background derived publication | +| [0048](adr/0048-modular-editor-composition-and-debt-ratchet.md) | Thin editor composition, registry-only Inspector dispatch, and automated architecture debt ratchet | +| [0049](adr/0049-penpot-led-editor-visual-system.md) | Penpot-led visual tokens, responsive editor components, Source Sans Pro typography, and picker interaction | ## Editor framework | Document | Purpose | |----------|---------| -| [editor/release-notes.md](editor/release-notes.md) | Editor framework 1.0 baseline | -| [editor/evaluations/](editor/evaluations/) | Acceptance records, screenshots, and Gitea evidence-publishing policy | +| [editor/release-notes.md](editor/release-notes.md) | Historical editor framework 1.0 snapshot | +| [editor/evaluations/](editor/evaluations/) | Dated acceptance evidence and Gitea evidence-publishing policy; not product specification | | [editor/architecture.md](editor/architecture.md) | Viewport / PIE / settings data flow | | [editor/visual-language.md](editor/visual-language.md) | Editor chrome, viewport, selection, gizmo, and visualizer language | +| [editor/design-system.md](editor/design-system.md) | Penpot-led visual tokens, reusable controls, responsive geometry, and material-inspector presentation | | [editor/roadmap.md](editor/roadmap.md) | Phased editor roadmap and status | | [editor/brp.md](editor/brp.md) | BRP automation and authoring-only policy | | [editor/debt-audit.md](editor/debt-audit.md) | Zero-debt phase gates | @@ -83,6 +102,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries | | [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract | | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | +| [editor/content-workspace.md](editor/content-workspace.md) | File-manager content organization, destination-first import, stable IDs, and model material authoring | | [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers | | [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application | | [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries | @@ -101,33 +121,20 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering | | [editor/evaluations/deterministic-asset-fingerprints/](editor/evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints | | [editor/evaluations/fbx-external-texture-dependencies/](editor/evaluations/fbx-external-texture-dependencies/) | Source, validation, and native acceptance evidence for sandboxed FBX texture dependencies and override states | +| [editor/evaluations/content-workspace-m2/](editor/evaluations/content-workspace-m2/) | Native M2 evidence for Content Browser file-manager selection, context menus, managed-folder visibility, and Details resizing | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | -## Working plans (not canonical long-term) +## Plans -Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans/). Promote stable outcomes into ADRs or `docs/` when shipped. +Plans describe desired scope and acceptance, never current implementation truth. Their authoritative +classification is in [`authority.toml`](authority.toml); completed plans are frozen as historical +records instead of being rewritten as status diaries. -| Plan | Topic | -|------|-------| -| `project_roadmap_*.plan.md` | M0–M8 program index | -| `editor_framework_mission_roadmap_*.plan.md` | Editor mission + phased roadmap | -| `project_settings_and_pie_eject_*.plan.md` | M1.5: settings, rendering parity, PIE eject | -| `rendering_unification_*.plan.md` | Unified rendering stack, Hybrid Auto GI, emissive materials | -| `static_mesh_asset_refactor_*.plan.md` | Static mesh renderer, normalized model artifacts, inspector redesign | -| `component_system_refactor_*.plan.md` | Component registry, imported asset refs, collider split, material overrides | -| `jackdaw_feature_roadmap_*.plan.md` | Jackdaw-inspired production roadmap; tracked in Gitea as `BS-JD-*` issues | -| `blacksite_production_readiness_*.plan.md` | M6-M7 reliability, content production, shipping, and acceptance gates | -| `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance | -| `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API | -| `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation | -| `source_control_collaboration_safety_*.plan.md` | Exact authored-file guards, observational Git status, conflict recovery, and provider contract | -| `production_readiness_acceptance_*.plan.md` | Release-candidate evidence matrix, blocker sequence, clean-checkout checks, soak, budgets, and independent sign-off | -| `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history | -| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting | -| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance | -| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance | -| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points | -| `fbx_external_texture_dependencies_*.plan.md` | Sandboxed FBX sidecar discovery, import transactions, validation, loader behavior, and Asset Browser states | +Current active plans: + +- [Content workspace and import authoring](../.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md) +- [Production-readiness program](../.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md) +- [Production-readiness acceptance gate](../.cursor/plans/production_readiness_acceptance_2026-07-12.plan.md) ## Crate responsibilities (quick reference) @@ -135,6 +142,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans crates/protocol/ Tick rate, input intent, message shapes crates/sim/ Fixed-step gameplay; no rendering crates/shared/ Authoring components + hydration +crates/content_pipeline/ UI-independent content catalog, transaction, import, and processing engine crates/game/ Client presentation, GamePlugin, rendering crates/editor/ In-process egui editor, BRP, PIE crates/settings/ Project settings, active/effective render profile contracts diff --git a/docs/adr/0014-unified-viewport-model.md b/docs/adr/0014-unified-viewport-model.md index 079cdb6..c20d6e2 100644 --- a/docs/adr/0014-unified-viewport-model.md +++ b/docs/adr/0014-unified-viewport-model.md @@ -23,6 +23,12 @@ The editor has one primary **Viewport** tab backed by one HDR `ViewportRenderTar transform gizmos, visualizers, and selectable proxies without changing camera ownership. - `Ctrl+G` toggles the grid. - Saved dock layouts containing the old Game View tab are migrated to one Viewport tab. +- Ordinary dock and Inspector resizing mutates the existing HDR Image asset's extent while keeping + its stable asset ID. Egui owns exactly one strong registration for that ID; an exceptional target + replacement or absent/collapsed target explicitly unregisters the previous image. +- The editor host window prefers a non-FIFO presentation path (`AutoNoVsync`) so interactive + NVIDIA/Wayland window resizing cannot wedge the UI behind repeated one-second swapchain-acquire + timeouts. Packaged game windows retain `AutoVsync`. - Multi-viewport support is represented as future-facing state/API shape only; secondary visible viewports remain future work. @@ -32,6 +38,10 @@ The editor has one primary **Viewport** tab backed by one HDR `ViewportRenderTar - Camera ownership is explicit: F8 possession selects player camera vs editor camera; G only hides editor overlays. - The HDR/swapchain invariant remains centralized around one offscreen viewport target. +- Repeated splitter movement has bounded render-target ownership instead of retaining one + full-resolution HDR image per intermediate panel width. +- Repeated native-window resizing remains interactive even when the compositor cannot immediately + supply a FIFO swapchain image; this editor-specific latency choice does not change game VSync. - Future secondary viewports should add viewport-id keyed rects, targets, cameras, and overlay state rather than reintroducing a Scene View/Game View split. - Post-FX and GI on the active viewport camera are owned by `game_hot::rendering::viewport_camera` diff --git a/docs/adr/0035-shared-material-assets-and-renderer-slots.md b/docs/adr/0035-shared-material-assets-and-renderer-slots.md index cf895b7..88460f8 100644 --- a/docs/adr/0035-shared-material-assets-and-renderer-slots.md +++ b/docs/adr/0035-shared-material-assets-and-renderer-slots.md @@ -12,51 +12,81 @@ copies per actor, and left rigged geometry without the same assignment model as Matching imported materials by a display name was also unsafe: names are neither unique nor stable when a model is reimported. -Static and skinned renderers need the same Unity-like authoring contract without sharing their mesh -or hydration components. A project material must be reusable, an instance must express explicit -overrides over one base material, and each renderer must expose stable material slots independently -from its draw or skeleton representation. +Primitives, static renderers, and skinned renderers need the same Unity-like authoring contract +without sharing their geometry or hydration components. A project material must be reusable, an +instance must express explicit overrides over one base material, and every renderable surface must +expose stable material slots independently from its draw or skeleton representation. ## Decision - Project materials use versioned `MaterialAsset` documents. A `MaterialInstanceAsset` references one direct base `MaterialAsset` and stores parameter and texture overrides. Nested material - instances are invalid. + instances are invalid. Schema-driven input storage and processed Texture bindings are defined by + [ADR 0046](0046-schema-driven-material-inputs-and-processed-textures.md). - `MaterialRef` is the assignment type. Its registry UUID and subasset ID are authoritative; `source_path` is a cached loading hint that validation or migration may repair after a move. -- `StaticMeshRenderer` and `SkinnedMeshRenderer` each own a `RendererMaterialSet`. They do not share - a mesh component or hydration path. Each `RendererMaterialSlot` has a stable ID, a presentation - name, a read-only imported `source_material`, and an optional explicit `material` assignment. - Explicit assignment wins; clearing it returns to the imported source material. +- `Primitive`, `StaticMeshRenderer`, and `SkinnedMeshRenderer` own `MaterialSlot` values. Mesh + renderers use a `MaterialSlotSet`; a primitive owns one stable `surface` slot with ID + `slot:primitive:surface`. They do not share a geometry component or hydration path. Each saved + slot has a stable ID, a presentation name, and only an optional actor/prefab `material` + assignment. Model defaults and imported source materials remain asset-owned in model import + metadata and runtime manifests. Clearing an actor assignment returns resolution to those weaker + model-owned layers. - Static draw parts and imported skinned-hierarchy draws bind to material slots by stable IDs. Reimport reconciles by ID only. An explicit assignment whose source slot disappears is retained as an `OrphanedMaterialAssignment`; the editor never silently reconnects it by display name. -- Asset Browser **Apply Material** assigns a shared material or material instance to every material - slot on each selected static or skinned renderer. Renderer inspectors provide per-slot - Browse/Select/Locate/Clear controls and expose orphan warnings. -- `HydratedRendererMaterialBinding` identifies each runtime draw's owner, slot, and effective - material. A skinned renderer material-only edit patches the instantiated hierarchy without - reloading its source hierarchy or joints. +- Asset Browser **Apply Material** assigns a shared material or material instance to a selected + primitive surface or every material slot on each selected static/skinned renderer. One inspector + widget provides drag/drop, Browse/Locate/Clear, inherited status, expandable shared parameters, + exact-slot extraction, and orphan diagnostics. +- `HydratedMaterialSlotBinding` identifies each runtime draw's owner and exact slot. + `HydratedMaterialSelection` carries Project Actor, Project Model, Imported Source, or Inherit + ownership without copying model defaults into the scene. A skinned renderer material-only edit + patches the instantiated hierarchy without reloading its source hierarchy or joints. - `MaterialPropertyBlocks` are runtime-only, per-slot overrides. They do not mutate a shared asset and are excluded from scene and prefab persistence. Persisting a reusable variation requires an explicit material instance. -- Scene schema v4 migrates legacy renderer assignments into `RendererMaterialSet`. Project material, - material-instance, shader-schema, and scene rewrites are performed by the explicit transactional - `cargo upgrade-project` command; ordinary loading remains read-only. +- Model assets may provide a project Material/Instance default between their imported source and a + scene/prefab assignment. The terminal fallback chain and immutable DefaultGrid contract are owned + by [ADR 0045](0045-content-workspace-and-material-fallback-contract.md). +- Effective precedence is runtime MaterialPropertyBlock, scene/prefab assignment, model-asset + default, imported source, project default, then built-in DefaultGrid. A configured but broken + authored reference uses the fallback with diagnostics instead of silently exposing another + authored layer. +- Repository scenes use schema v6, where renderable slots persist actor/prefab assignments only. + This pre-production project updates its fixtures directly. The explicit project upgrader remains + available for legacy primitive `MaterialDesc`, mesh-wide descriptors, and per-slot + `MaterialOverride` values: it deduplicates canonical descriptors as deterministic project + Materials under `assets/materials/migrated/` and rewrites project references transactionally. + Ordinary loading remains non-writing. ## Consequences -- Static and skinned renderers have a consistent material-slot workflow while retaining distinct - geometry ownership and hydration behavior. +- Primitives, static renderers, and skinned renderers have one material-slot workflow while + retaining distinct geometry ownership and hydration behavior. - An assignment remains a reference rather than a copied material value, so editing or replacing a shared asset does not create actor-local material copies. Material/base/schema/evaluator dependency revisions update the shared runtime handle in place across all assigned slots. - Source reimport cannot silently move an override to the wrong draw. Orphans require an explicit user decision to reassign or discard. -- Legacy primitive/actor `MaterialDesc` paths remain compatibility paths until their consumers move - to renderer slots; new mesh-renderer work uses shared references. -- `MaterialPropertyBlocks` currently define the runtime-only schema and persistence exclusion, but - renderer application and the editor promote-to-instance transaction are not implemented. Gitea - #53 owns that complete workflow; callers must not treat the component as visibly applied yet. -- Material graph authoring, nested instances, blended/transmissive materials, and a generalized - per-renderer property-block inspector are outside this decision. +- Component-form `MaterialDesc` is restricted to the brush fallback compatibility path. It is not + addable to primitives or mesh renderers, does not override their slots, and never causes a + primitive-local runtime material allocation. Unmigrated actor material components produce an + upgrade diagnostic and visible fallback. +- `MaterialPropertyBlocks` define the runtime-only schema and persistence exclusion. Runtime + application clones and caches the resolved Standard or Surface material per owner/slot, above + direct-base Material Instance values. An invalid block leaves the resolved base visible and emits + a deduplicated diagnostic instead of applying a partial override. Promotion to a reusable + instance uses an explicit target-path review and source/target fingerprint guards, assigns one + exact slot through history, and clears the block only after the file, registry, and scene + transaction succeeds. It may never use DefaultGrid as its base. The editor registers the + component with reflection for trusted local BRP runtime injection and native promotion testing; + that registration does not add it to authoring persistence or the Inspector component registry. +- Material graph authoring, nested instances, and blended/transmissive materials are outside this + decision. + +Scene schema v6 stores only the actor/prefab assignment in a renderable slot. Model defaults and +imported source selections remain model-owned, and hydration carries their origin through +`HydratedMaterialSelection`. Live project resolution is owned solely by the overlay/cache boundary +in [ADR 0047](0047-editor-authored-asset-documents.md); shared slot presentation follows +[ADR 0048](0048-modular-editor-composition-and-debt-ratchet.md). diff --git a/docs/adr/0036-surface-abi-and-solari-parity.md b/docs/adr/0036-surface-abi-and-solari-parity.md index fa4926e..a90a222 100644 --- a/docs/adr/0036-surface-abi-and-solari-parity.md +++ b/docs/adr/0036-surface-abi-and-solari-parity.md @@ -31,6 +31,8 @@ lighting, fog, shadows, and render-pipeline bindings under engine ownership. `SurfaceInput` provides UV0, world position, and world normal. `Surface` returns base color, tangent-space normal, emissive, metallic, perceptual roughness, reflectance, occlusion, alpha, and lit/unlit model selection. +- The schema-driven authoring inputs and canonical processed Texture/ARM artifacts feeding this ABI + are defined by [ADR 0046](0046-schema-driven-material-inputs-and-processed-textures.md). - The ABI has 16 `vec4` parameter lanes, eight sampled 2D texture slots with UV transforms, and a 400-byte `SurfaceUniform`. Shader schemas map typed Bool/Float/Vec2/Vec3/Color/Enum properties and texture properties into that fixed layout. Exceeding either limit is a validation error. diff --git a/docs/adr/0037-collaborative-authored-file-safety.md b/docs/adr/0037-collaborative-authored-file-safety.md index b0c7b24..131ac73 100644 --- a/docs/adr/0037-collaborative-authored-file-safety.md +++ b/docs/adr/0037-collaborative-authored-file-safety.md @@ -51,3 +51,5 @@ provider. - Native filesystem replacement cannot coordinate with unrelated writers that ignore advisory conventions after Blacksite's final revision check; the narrow check-to-rename interval is the platform boundary, and deterministic race hooks cover the editor-controlled interval. +- [ADR 0047](0047-editor-authored-asset-documents.md) applies these guards at explicit asset Save + boundaries; interactive parameter edits never enter the authored-file publication path. diff --git a/docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md b/docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md index 15a7a8b..74bbce9 100644 --- a/docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md +++ b/docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md @@ -55,3 +55,5 @@ tabs switch, while saves may establish a clean point in the middle of a timeline direct non-history repairs, entity respawns, and tab switches. - Operating-system process kill and power loss cannot be confirmed; recovery and abnormal-session handling remain the safety boundary for those cases. +- [ADR 0047](0047-editor-authored-asset-documents.md) extends the guarded decision to dirty asset + documents and project settings while derived processing remains resumable after source save. diff --git a/docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md b/docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md index 76fab6f..703f454 100644 --- a/docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md +++ b/docs/adr/0044-sandboxed-fbx-external-texture-dependencies.md @@ -16,8 +16,8 @@ paths more than once. Import, manifest generation, project validation, editor previews, and the runtime loader must agree on one dependency graph. That graph also crosses a security boundary: an imported source must not read or copy a parent-traversing path or an arbitrary absolute path outside its bundle. A missing -source texture is required when Source Materials is active, but it can be an intentional authoring -condition when an asset explicitly uses Authoring Override. +source texture is required when any stable model slot selects Source, but it can be an intentional +authoring condition when every slot selects a project Material/Instance or Default. ## Decision @@ -36,8 +36,10 @@ loader warning instead of deferred asset-server requests. Static-mesh manifests are the authoritative model import dependency graph. Their existing `source.dependencies` list records every parsed FBX external texture, including unavailable files. Project validation checks that list without loading native assets. Missing FBX textures are a -blocking consolidated finding under Source Materials and an informational consolidated finding -under Authoring Override. Unsafe paths remain blocking under either policy. Model registry records +blocking consolidated finding while any slot selects Source and an informational consolidated +finding when every slot selects Project/Default. The former whole-model Authoring Override value is +accepted only as registry-v1 migration input and expands to explicit Default selections. Unsafe +paths remain blocking under either selection state. Model registry records mirror the list for editor details, but validation does not duplicate it when a generated static manifest exists. @@ -50,16 +52,17 @@ Unreferenced `.fbm` contents are not copied. The Asset Browser renders FBX model and mesh thumbnails through the neutral direct mesh path. Source-material thumbnails preflight the authoritative dependency resolver first and cache one non-retryable actionable failure when required textures are unavailable. Dependency rows expose -present/missing state. The committed painted-chair fixture uses Authoring Override deliberately and -remains untextured; its three source paths stay visible in manifests, validation, and asset details. +present/missing state. The committed painted-chair fixture's legacy Authoring Override migrates to +per-slot Default selections and remains untextured; its three source paths stay visible in +manifests, validation, and asset details. ## Consequences - FBX sibling `textures/` and `.fbm/` bundles import and validate with identical normalized paths. - Missing texture files are visible before native loading and cannot create repeated Bevy asset-server errors. -- Source Materials fails release validation when its FBX textures are absent. Authoring Override - remains release-valid but retains an informational dependency finding. +- Any Source slot fails release validation when its FBX textures are absent. An all-Project/Default + model remains release-valid but retains an informational dependency finding. - Absolute exporter paths, traversal attempts, and destination symlink redirects cannot read or copy arbitrary host files. - FBX files with external textures are parsed twice during loading so no non-`Send` ufbx scene diff --git a/docs/adr/0045-content-workspace-and-material-fallback-contract.md b/docs/adr/0045-content-workspace-and-material-fallback-contract.md new file mode 100644 index 0000000..883282c --- /dev/null +++ b/docs/adr/0045-content-workspace-and-material-fallback-contract.md @@ -0,0 +1,90 @@ +# ADR 0045: Content Workspace and Material Fallback Contract + +## Status + +Accepted + +## Context + +Blacksite's Content Browser scans the project `assets/` tree, but authored RON type detection and +import destinations still depend on conventional folders. The asset registry is editor-owned and +serialized as an unversioned record list, so validation, packaging, runtime hydration, and future +headless processing duplicate parts of its contract. Imported model materials are either read-only +source defaults or scene-level assignments; a model asset cannot own editable project defaults. + +Missing renderer materials currently create separate magenta `StandardMaterial` values, while +skinned, primitive, brush, terrain, thumbnail, raster, and Solari paths use different fallback +rules. A missing or broken material therefore does not have one predictable, production-safe +result. + +## Decision + +- User-authored content may live in any normal nested folder beneath the project `assets/` root. + Folder names are organizational only. External absolute linked assets are not supported. +- `AssetId` is authoritative identity. Project-relative paths and labels are repairable cached + metadata. Moves and reimports reconcile by stable IDs and never by display name. +- Registry contracts move to `shared::content`: typed asset kinds, versioned registry documents, + project content defaults, import settings, model material policies, and runtime catalog records. + UI-independent filesystem scanning, classification, transaction planning, import/reimport, and + processing live in a `content_pipeline` crate used by the editor, validator, watcher, and xtask. +- Registry schema v3 is `AssetRegistryDocument { schema_version, defaults, records }` with typed + Model/Texture import settings as extended by [ADR 0046](0046-schema-driven-material-inputs-and-processed-textures.md). The source + registry remains editor-managed under `assets/.index/`; packages receive a stripped runtime + catalog without editor import provenance or source-only dependencies. +- Project content roots normalize to `assets/`. `.index`, `.trash`, thumbnails, recovery data, and + implementation-owned derived artifacts are hidden and protected from normal file operations. +- Fixed-folder consumers are retired or explicitly constrained as follows: + + | Consumer | Current contract / migration | + |----------|------------------------------| + | Project settings and templates | `asset_roots` upgrades to exactly `["assets"]`; the explicit project upgrader performs the write. | + | Content Browser and registry scanner | Walk all user-managed descendants and classify by extension plus document schema, never by parent folder. | + | Material, shader, model, audio, level, and prefab lookup | Resolve the registry's stable ID to its current path; a cached path is a repair hint only. | + | Scene/prefab validation and hydration | Consume registry/runtime-catalog resolution rather than conventional authoring directories. | + | Packaging and headless processing | Scan the same content root and publish the same stripped catalog and normalized model manifests. | + | Material creation UX | The current browser folder is authoritative; `assets/materials/` remains only a Material Library convenience default. | + | Generated mesh, animation, navigation, thumbnail, trash, and index data | Their fixed implementation-owned locations remain hidden managed storage, excluded from user classification and ordinary file operations. | + +- Model material selections are per stable slot: Source, Project Material/Instance, or Default. + Extracting a source material creates an ordinary project Material and then stores a Project + selection. Re-extraction may replace a provenance-matched Material only after an explicit diff + and fingerprint-guarded Apply decision; Create New remains available and unrelated provenance is + never overwritten. Reimport retains removed project assignments as explicit orphans. +- Effective material precedence, strongest first, is: runtime MaterialPropertyBlock, scene/prefab + assignment, model-asset default, imported source, project default, built-in DefaultGrid. Missing + layers inherit downward. A configured but broken authored reference renders the fallback and + reports the broken layer rather than silently revealing a different authored layer. +- Primitive surfaces participate in that same chain through their saved + `slot:primitive:surface`; they no longer hydrate from a separate actor-local descriptor. Static + and skinned draws bind their exact saved slot IDs through the same runtime binding contract. +- DefaultGrid is immutable engine content compiled into the renderer. It is a shared, + UV-independent world-space checker with raster/Solari parity and a neutral StandardMaterial + emergency path. A project may choose a Material/Instance fallback; clearing or breaking it + returns to DefaultGrid. Terrain retains its specialized layer fallback. +- Built-in DefaultGrid is not editable, instanceable, or a valid MaterialPropertyBlock promotion + base. Promotion requires a resolved project or imported Material base. +- Ordinary editor/runtime loading is read-only. Registry, project-settings, and scene migrations + run only through the explicit transactional project upgrader. + +## Consequences + +- Editor, runtime, validation, packaging, watcher, and headless processing share one content model + and one stable-ID resolver. +- Arbitrary organization no longer changes asset type, runtime behavior, or package inclusion. +- The content pipeline becomes a reusable crate boundary instead of an editor implementation + detail; this adds one workspace crate and moves registry types out of `editor`. +- Older registries, conventional asset roots, global model material policy, and legacy mesh material + overrides require the explicit transactional upgrader; normal loading accepts current schemas. +- Broken assignments stay visible and diagnosable, but may look different from their previous + imported source because failure uses the project/engine fallback deliberately. +- One cached DefaultGrid/emergency handle covers repeated primitive and mesh hydration, so missing + assignments do not grow the material asset stores. +- Thumbnail cache ownership includes the backing Egui image registration. Replacement, retry, and + catalog invalidation explicitly release the last registration before a new studio render owns + the key, preventing refresh-driven GPU resource growth. +- File operations and imports must be staged transactions that update authored references, + registry state, derived manifests, and runtime catalogs together. +- Ordinary asset-property edits use the explicit dirty-document and two-phase publication boundary + defined by [ADR 0047](0047-editor-authored-asset-documents.md); topology transactions remain immediate. +- Scene schema v6 carries actor/model/imported/inherit origin explicitly at hydration time, so a + broken authored layer uses DefaultGrid instead of accidentally revealing a weaker layer. diff --git a/docs/adr/0046-schema-driven-material-inputs-and-processed-textures.md b/docs/adr/0046-schema-driven-material-inputs-and-processed-textures.md new file mode 100644 index 0000000..a4ad1b1 --- /dev/null +++ b/docs/adr/0046-schema-driven-material-inputs-and-processed-textures.md @@ -0,0 +1,55 @@ +# ADR 0046: Schema-driven material inputs and processed textures + +## Status + +Accepted + +## Context + +The first Material asset format stored a resolved `MaterialDesc` plus a second generic parameter +list. The editor consequently rendered Standard Lit twice, could not pair a scalar or color with its +texture, and had no stable place to describe future shader-graph inputs. Texture files also carried +no import intent, so color maps, normals, and packed mask data could be sampled interchangeably. + +## Decision + +Material, direct-base Material Instance, and Shader Schema documents use schema v2. +`MaterialInputSchema` is the presentation and validation contract for built-in Standard Lit, +custom Surface evaluators, and future generated shaders. `MaterialInputSet` is the only authored +value/texture store; instance sets remain sparse. Scalar texture bindings select R, G, B, or A, +and scalar values multiply the sampled value. Standard Lit pairs Base Color with albedo, Metallic +and Roughness with scalar maps, Occlusion with a scalar map, and Emissive Color/Intensity with an +emissive map. + +The registry uses v3 `AssetImportSettings`, with typed Model and Texture settings. Texture settings +own semantic, color space, mip policy, compression, size limit, filtering, wrapping, anisotropy, +and normal convention. One GPU-free processor is called by editor publication, imports, the +watcher refresh path, and `cargo process-assets`. It writes content-addressed UASTC Basis or +uncompressed KTX2 artifacts below `assets/.import-cache/runtime/`, records sampler/color-space data +in runtime catalog v2, and canonicalizes authored AO/Roughness/Metallic selections to R/G/B ARM. +Source images remain editable and unchanged. Processing computes the normalized content key before +image decode or compression. An existing artifact at that key is reused directly, so validation and +catalog repair do not recompress unchanged Texture or packed-Material inputs. + +The actor inspector and asset editors consume the same schema-driven controls. A renderable owns +one compact material-slot header; expanding it edits the resolved shared Material or Instance. +Slot provenance and diagnostics live outside parameter rows. Valid authoring changes refresh the +shared runtime handle immediately but only mark the asset document dirty. Explicit save uses the +two-phase source/derived boundary from [ADR 0047](0047-editor-authored-asset-documents.md), without +recompiling an unchanged Surface evaluator or scheduling a project-wide watcher pass. + +This is an intentional foundation break. Normal loading accepts current documents only; explicit +`cargo upgrade-project --project --apply` converts the repository's v1/v2 documents and +backs up every replaced file transactionally. + +## Consequences + +- Shader additions no longer require bespoke inspector layout code when their schema can describe + the input. +- Color-space and packed-channel choices are deterministic in editor, CI, and packages. +- Cached content-addressed artifacts keep `process-assets --check` and unchanged dependency refreshes + on the source-hash/catalog path instead of the image-compression path. +- Runtime and Solari receive the same canonical input mapping. +- Changing a Texture setting can invalidate dependent packed Material artifacts. +- The future node-graph compiler must emit this schema and Surface WGSL; graph authoring itself is + outside this decision. diff --git a/docs/adr/0047-editor-authored-asset-documents.md b/docs/adr/0047-editor-authored-asset-documents.md new file mode 100644 index 0000000..e685725 --- /dev/null +++ b/docs/adr/0047-editor-authored-asset-documents.md @@ -0,0 +1,71 @@ +# ADR 0047: Editor Authored-Asset Documents and Two-Phase Publication + +## Status + +Accepted + +## Context + +Material controls previously updated the viewport immediately but also wrote source when a pointer +interaction ended. That publication could trigger file guards, Git refresh, watcher work, +thumbnail invalidation, and whole-project processing on the editor frame thread. A slider release +therefore behaved like an implicit save and could freeze the editor. + +Project RON files and registry metadata are already suitable authoritative source documents. The +editor needs a fast, durable edit boundary without adding an opaque asset database or conflating +editor-dirty documents with source-control status. + +## Decision + +The editor owns an `AuthoredAssetDocumentStore` keyed by stable asset ID and document kind. It +holds clean and current values, source path, loaded file revision, editor revision, publication +error, recovery state, and derived-processing state for Materials, Material Instances, model import +settings, Texture import settings, and project content defaults. + +Interactive controls mutate the current document and `blacksite_surface`'s stable-ID-keyed +`LiveMaterialDocumentOverlay` in the same tick. `SurfaceMaterialCache` is the sole owner of project +Material and Material Instance handles; resolution is overlay-first and disk-second. Controls +perform no source write, watcher refresh, Git query, thumbnail invalidation, or derived processing, +including when the pointer is released. Dirtiness is derived from `current != clean` and remains +distinct from Git `MODIFIED`, `UNTRACKED`, and `CONFLICT` state. + +The cache never polls file metadata or reparses a project Material for every bound entity. Explicit +Save and accepted watcher events invalidate the disk generation once; interactive overlay +revisions mutate the existing cached handle in place. Imported glTF/FBX source materials stay on +their AssetServer-owned path and do not enter the project-material resolver. + +`Ctrl+S` saves the last edited context. `Ctrl+Shift+S` and **File > Save All** save every dirty +scene, authored asset document, and project setting. Source publication remains guarded and atomic. +Registry-backed documents use a clean registry baseline plus per-document overlays so saving one +setting cannot publish another unsaved setting. + +Successful source publication advances the clean value, then classifies its +`DerivedProcessingImpact`. Scalar, color, emissive, shader-value, label, and render-state Material +changes queue no derived work. ARM/ORM texture or channel changes queue material packing; Texture +settings, model settings, and project defaults queue only their exact dependency closure. Packing +signatures exclude scalar multipliers and are checked before image decoding so an existing +content-addressed artifact can be reused. The previous valid runtime artifact remains authoritative until replacement +publication succeeds; a processing failure is reported separately and does not make saved source +dirty again. Stale job completions are ignored. + +Dirty asset documents receive editor-local recovery snapshots after two seconds of inactivity, +with five generations retained outside `assets/`. Startup restoration never overwrites source: a +changed source fingerprint restores the document as an external conflict. Guarded shutdown and +project switching count scenes, assets, and project settings in the same Save All / Discard / +Cancel decision. Returning a document exactly to its clean value retires any older recovery +generation on the next editor tick. Startup also discards a recovery envelope whose value already +matches authoritative source, covering a crash between the clean edit and that cleanup tick. + +## Consequences + +- Parameter editing and pointer release stay frame-local and responsive. +- Material and direct-base Instance users share one stable live handle authority instead of + competing editor and renderer caches. +- Saving source and generating derived artifacts are observable, independent phases. +- The project remains plain, reviewable RON plus source assets; no asset binary database is added. +- Unsaved editor state survives selection changes and can be recovered after a crash without + affecting Git until the user saves. +- Packaging and read-only processing validation must reject required pending or failed derived + work rather than silently packaging stale content. +- Import, extract, create, rename, move, and delete remain explicit immediate transactions because + they create or remove project identity. diff --git a/docs/adr/0048-modular-editor-composition-and-debt-ratchet.md b/docs/adr/0048-modular-editor-composition-and-debt-ratchet.md new file mode 100644 index 0000000..a7a6fda --- /dev/null +++ b/docs/adr/0048-modular-editor-composition-and-debt-ratchet.md @@ -0,0 +1,52 @@ +# ADR 0048: Modular Editor Composition and Architecture-Debt Ratchet + +## Status + +Accepted + +## Context + +The Content Browser and Inspector accumulated unrelated navigation, rendering, import, material, +transaction, and component-card behavior in files thousands of lines long. That made small changes +high-risk, encouraged duplicate UI, and obscured ownership. ADR 0012 states a zero-debt intent but +did not provide an enforceable point-of-change budget. + +## Decision + +Editor panels are composed from thin shells, domain modules, and UI-independent services. The +Inspector delegates every visible built-in component through `EditorComponentRegistry`; a +hard-coded type dispatcher is not a second authority. Material slot presentation uses a shared +view-model/action contract, and Content Browser and Material Library cards use one bounded asset +card/status model. + +The machine-readable policy is `.codex/architecture.toml`. Its audit enforces 500 nonblank lines +for UI shells, 800 for other UI modules, and 1,200 for other production Rust modules. Existing +over-limit modules have frozen ceilings. A temporary exception requires a tracker issue, rationale, +hard maximum, extraction target, and expiry milestone. Selective verification and candidate CI run +the audit. + +The M2 extraction is tracked by Gitea #68. The Content Browser and Inspector shells are now below +the 500-line budget, their domain behavior lives in bounded submodules, and no temporary #68 +exception is active. Closing the issue still requires the selective and native acceptance evidence +defined by the milestone. + +The Penpot-led material-inspector refinement remains inside #68's shared-UI scope. The reusable +visual primitives live in `ui/design_system`, while `ui/materials` owns the action-returning domain +panel. This keeps authored visual policy separate from Inspector world access and prevents the +first design-system migration from rebuilding a panel monolith. ADR 0049 owns the visual contract. + +## Consequences + +- New behavior is added through a sustainable extension seam instead of enlarging a monolith. +- Shrinking an over-limit module is always accepted; regrowth above its frozen baseline fails. +- Passing the audit is a no-regression statement; issue closure additionally requires functional + and native evidence for the extracted seams. +- Architecture work remains visible in the same tracker and milestone as the feature that exposes + it. +- ADR 0034 owns registry-driven component semantics; this ADR owns editor composition and the + mechanical debt guard. + +Related decisions: [ADR 0012](0012-zero-tech-debt-editor.md), +[ADR 0034](0034-registry-driven-authoring-components.md), and +[ADR 0047](0047-editor-authored-asset-documents.md), and +[ADR 0049](0049-penpot-led-editor-visual-system.md). diff --git a/docs/adr/0049-penpot-led-editor-visual-system.md b/docs/adr/0049-penpot-led-editor-visual-system.md new file mode 100644 index 0000000..72a6c62 --- /dev/null +++ b/docs/adr/0049-penpot-led-editor-visual-system.md @@ -0,0 +1,119 @@ +# ADR 0049: Penpot-Led Editor Visual System + +## Status + +Accepted + +## Context + +Blacksite's editor controls evolved inside individual panels. Even after the material data and +Inspector composition became shared, local egui styling still produced inconsistent spacing, +typography, interaction states, and narrow-panel behavior. The Penpot **Inspector Material Slot** +provides an authored reference for the visual language rather than another implementation-specific +mock-up. + +## Decision + +The Penpot **Inspector Material Slot** is the source of truth for geometry, type roles, control +heights, radii, spacing, property-column geometry, and interaction states. Color is semantic rather +than panel-owned: Penpot's Assets / Colors library and `editor::ui::theme::EditorVisualPalette` +name the same roles, with Blacksite's existing palette as the default. A later Editor Settings slice +may select or override that palette without changing widgets or material behavior. At the 620 px +reference width, implementations match the documented geometry. Below that width, controls deliberately +reflow instead of shrinking, clipping, scrolling horizontally, or hiding required state. + +Fixed Penpot sections use deterministic geometry models rather than nested egui flow layouts. +Surface, parameter rows, texture fields, UV groups, and Advanced each receive an owning rectangle; +every child rectangle is derived from it and clipped to it. The 569 px and 369 px section widths +encode the exported wide and compact references. A bounded transient mode below 372 px keeps all +rectangles finite, stacks the row, and collapses low-frequency texture actions into overflow while +the Inspector restores its 420 px minimum. + +The reusable implementation lives under `editor::ui::design_system`. It consumes the editor-owned +semantic palette and owns spacing tokens, Source Sans Pro Regular/Bold typography roles, section chrome, property grids, +asset and texture fields, segmented controls, switches, status presentation, and the Blacksite +color picker. Domain widgets consume these primitives instead of copying raw colors or absolute +Penpot coordinates. + +Material authoring is the first migrated domain. One action-returning `MaterialsSection` is used by +primitive, static-mesh, and skinned-mesh inspectors. It owns an ordered list of collapsible +`MaterialSlotPanel` models, and every returned action carries its stable slot ID. The widgets do not +query or mutate arbitrary world state. +Supported material behavior only is shown. Asset identity, health, inheritance, dirty state, and +read-only state share one geometry, while stable IDs and fingerprints remain in secondary +diagnostics. + +The final Penpot component supersedes the earlier v2.2/v2.3 geometry. A 32 px **Materials** heading +precedes repeated slots. Expanded slot headers are 82 px with a 64 px preview; collapsed headers +are 52 px with a 32 px preview. A cyan disclosure spine and **PARAMETERS** ownership label inset the +Surface, Inputs, UV, and Advanced body. The asset identity block owns the single Shader selector. Penpot's former duplicate Surface +"Shading Model" selector was removed from the component and every state reference in handoff +v2.3.1. Surface contains blend mode, Separate/ORM selection, and Double Sided. UV +precedes Advanced with +an explicit 8 px section gap. Advanced render controls +that do not yet have renderer contracts are shown only as a clearly disabled preview: they emit no +actions and create no authored or runtime state. + +The Material slot header is one assignment interaction, not an identity block beside a second drop +box. Its whole 596 x 82 px wide reference area accepts valid Material/Instance drops. Preview and +identity clicks plus the first-class Browse action open the compact current/recent Penpot menu; +Shader, Locate, Clear, +and overflow actions are isolated and cannot accidentally open it. The picker returns a stable +asset reference to the owning Inspector instead of mutating scene or world state. + +The custom 420 x 350 color popup is a fixed modal centered over its owning Inspector clip, with an +Inspector-local dim layer rather than application- or viewport-relative placement. Its header, +mode tabs, wheel and values regions, RGBA byte fields, HSV degree/percentage fields, HEX copy, +checkerboard alpha control, recents, and footer use the exported Penpot coordinates. The exported +eyedropper lane remains a tooltip-labelled disabled preview until screen sampling has an owned +runtime contract. Apply keeps the in-memory dirty value and closes; Cancel, Escape, Close, or +outside dismissal restores the exact pre-open value. Neither path saves source or schedules derived +processing. + +Standard Lit declares seven independently stored inputs but six primary presentation rows. +`emissive_intensity` is a schema companion of `emissive_color`; the Emissive popup previews and +restores both values as one interaction without merging their authored/runtime identities. Custom +shader binding counts likewise count primary rows while rendering companions through their owner. + +Bounded scalar material inputs use one 132 px control composed from a 78 px track, 6 px gap, and +48 x 22 numeric field. Expanding the 178 px Advanced preview requests a bounded +Inspector scroll reveal so its note and lower boundary are visible. The same no-implicit-persistence +rule applies to sliders, texture assignments, and shader controls. + +Inspector overflow is part of the shared visual contract. A floating scrollbar keeps component +width stable as content crosses the vertical overflow threshold, and every nested component clip is +an intersection with the owning Inspector body. Ready texture fields use the typed thumbnail as +their sole leading identity; generic image glyphs are reserved for empty states. + +Standard Lit exposes one shared UV Offset/Tiling transform through the same schema-driven input +document. The renderer maps it to Bevy StandardMaterial UV transforms and to every Surface ABI +texture lane. Material Instances may override it sparsely; resetting an instance restores its base. + +Penpot's `CODEGEN MAP` and handoff annotations are design documentation. Runtime egui layout remains +responsive and semantic; generated SVG or code is not copied into production. Reviewed numeric +geometry is represented by small testable layout models instead of ad hoc child flow or generated +widget trees. +The same release slice applies the active Inspector Header, Array Header, renderer-panel, asset +field, vector, selection, and overlay components across the Inspector. Built-in component cards +dispatch through the registry and share one clipped actor-body scroll owner; domain behavior stays +outside the visual primitives. + +## Consequences + +- New editor UI has one reviewed visual vocabulary rather than panel-local approximations. +- Theme colors have one runtime owner and one design-side asset vocabulary; the material panel does + not carry a private blue/teal palette. +- The 620 px and 420 px geometries are deterministic and unit-testable. The Inspector has a 420 px + floor and one actor-body scroll region; material slots never introduce nested scrolling. +- Unsupported prototype controls are omitted until their runtime contracts exist. +- The explicitly approved Advanced preview is the sole exception: it is disabled, muted, and + labelled as planned work rather than functioning authoring state. +- Native acceptance compares rendered geometry, typography, states, and interaction behavior with + the Penpot reference; compile-only evidence is insufficient. +- Source Sans Pro and its SIL Open Font License are bundled with the editor. +- ADR 0048 continues to own composition and module-size policy. This ADR owns visual tokens, + responsive component geometry, and interaction presentation. + +Related decisions: [ADR 0035](0035-shared-material-assets-and-renderer-slots.md), +[ADR 0047](0047-editor-authored-asset-documents.md), and +[ADR 0048](0048-modular-editor-composition-and-debt-ratchet.md). diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..2d118ab --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,9 @@ +# Documentation Archive + +This directory preserves documents that retain provenance or evidentiary value but no longer belong +in a live topic tree. Archived documents are historical, not current guidance, and must retain an +obvious historical banner linking the relevant canonical document. + +Moving a record here does not change architecture or product behavior. Classify it in +`docs/authority.toml`, preserve useful dates and commit references, and update the live index rather +than copying its detailed contract into multiple places. diff --git a/docs/authority.toml b/docs/authority.toml new file mode 100644 index 0000000..67f30b2 --- /dev/null +++ b/docs/authority.toml @@ -0,0 +1,307 @@ +version = 1 +classifications = ["current", "active-plan", "evidence", "historical", "superseded"] +roles = ["overview", "canonical", "architecture", "active-plan", "evidence", "historical", "superseded"] + +[discovery] +paths = ["README.md"] +trees = ["docs", ".cursor/plans"] + +# Rules are deterministic: the highest priority matching rule wins. Equal-priority +# matches must agree. Repository-root paths are used throughout. + +[[rules]] +id = "root-readme" +path = "README.md" +classification = "current" +role = "overview" +topics = ["project-overview", "user-workflow"] +priority = 100 + +[[rules]] +id = "documentation-default" +prefix = "docs/" +classification = "current" +role = "canonical" +priority = 10 + +[[rules]] +id = "accepted-adrs" +prefix = "docs/adr/" +classification = "current" +role = "architecture" +priority = 20 + +[[rules]] +id = "superseded-render-timeout-adr" +path = "docs/adr/0019-local-bevy-render-timeout-patch.md" +classification = "superseded" +role = "superseded" +replacement = "docs/adr/0022-bevy-0-19-upgrade.md" +priority = 100 + +[[rules]] +id = "superseded-component-spec" +path = "docs/editor/Bevy_Editor_Component_System_Spec.md" +classification = "superseded" +role = "superseded" +replacement = "docs/adr/0034-registry-driven-authoring-components.md" +priority = 100 + +[[rules]] +id = "historical-release-notes" +path = "docs/editor/release-notes.md" +classification = "historical" +role = "historical" +replacement = "docs/README.md" +priority = 100 + +[[rules]] +id = "active-editor-debt-gate" +path = "docs/editor/debt-audit.md" +classification = "active-plan" +role = "active-plan" +topics = ["editor-debt", "production-readiness"] +priority = 100 + +[[rules]] +id = "evaluation-records" +prefix = "docs/editor/evaluations/" +classification = "evidence" +role = "evidence" +replacement = "docs/README.md" +priority = 30 + +[[rules]] +id = "evaluation-index" +path = "docs/editor/evaluations/README.md" +classification = "current" +role = "overview" +topics = ["acceptance-evidence"] +priority = 100 + +[[rules]] +id = "historical-h1" +path = "docs/editor/evaluations/h1-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "historical-h2" +path = "docs/editor/evaluations/h2-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "historical-h3" +path = "docs/editor/evaluations/h3-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "historical-h4" +path = "docs/editor/evaluations/h4-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "historical-h5" +path = "docs/editor/evaluations/h5-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "historical-h6" +path = "docs/editor/evaluations/h6-signoff.md" +classification = "historical" +role = "historical" +replacement = "docs/editor/evaluations/production-readiness/README.md" +priority = 100 + +[[rules]] +id = "archive-records" +prefix = "docs/archive/" +classification = "historical" +role = "historical" +replacement = "docs/README.md" +priority = 30 + +[[rules]] +id = "archive-index" +path = "docs/archive/README.md" +classification = "current" +role = "overview" +priority = 100 + +[[rules]] +id = "plans-default" +prefix = ".cursor/plans/" +classification = "historical" +role = "historical" +replacement = "docs/README.md" +priority = 10 + +[[rules]] +id = "superseded-project-roadmap" +path = ".cursor/plans/project_roadmap_8a452d43.plan.md" +classification = "superseded" +role = "superseded" +replacement = "docs/README.md" +priority = 100 + +[[rules]] +id = "active-production-program" +path = ".cursor/plans/blacksite_production_readiness_2026-07-10.plan.md" +classification = "active-plan" +role = "active-plan" +topics = ["production-readiness"] +priority = 100 + +[[rules]] +id = "active-production-gate" +path = ".cursor/plans/production_readiness_acceptance_2026-07-12.plan.md" +classification = "active-plan" +role = "active-plan" +topics = ["production-readiness"] +priority = 100 + +[[rules]] +id = "active-content-workspace" +path = ".cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md" +classification = "active-plan" +role = "active-plan" +topics = ["content-workspace", "asset-import", "content-browser", "materials"] +priority = 100 + +[[rules]] +id = "canonical-content-workspace" +path = "docs/editor/content-workspace.md" +classification = "current" +role = "canonical" +topics = ["content-workspace", "asset-import", "content-browser"] +priority = 100 + +[[rules]] +id = "canonical-material-system" +path = "docs/editor/material-system.md" +classification = "current" +role = "canonical" +topics = ["materials", "material-slots", "default-grid", "texture-processing"] +priority = 100 + +[[rules]] +id = "workflow-overview" +path = "docs/workflow/codex-workflow.md" +classification = "current" +role = "overview" +topics = ["codex-workflow"] +priority = 100 + +[[rules]] +id = "canonical-documentation-policy" +path = "docs/workflow/documentation-policy.md" +classification = "current" +role = "canonical" +topics = ["documentation-authority"] +priority = 100 + +[[rules]] +id = "canonical-verification-policy" +path = "docs/workflow/verification-policy.md" +classification = "current" +role = "canonical" +topics = ["selective-verification"] +priority = 100 + +[[rules]] +id = "canonical-build-storage-policy" +path = "docs/workflow/build-storage-policy.md" +classification = "current" +role = "canonical" +topics = ["build-storage"] +priority = 100 + +[[rules]] +id = "canonical-gitea-policy" +path = "docs/workflow/gitea-tracking-policy.md" +classification = "current" +role = "canonical" +topics = ["gitea-tracking"] +priority = 100 + +[[rules]] +id = "content-workspace-architecture" +path = "docs/adr/0045-content-workspace-and-material-fallback-contract.md" +classification = "current" +role = "architecture" +topics = ["content-workspace", "materials", "default-grid"] +priority = 100 + +[[rules]] +id = "material-input-architecture" +path = "docs/adr/0046-schema-driven-material-inputs-and-processed-textures.md" +classification = "current" +role = "architecture" +topics = ["materials", "texture-processing"] +priority = 100 + +[[rules]] +id = "authored-asset-document-architecture" +path = "docs/adr/0047-editor-authored-asset-documents.md" +classification = "current" +role = "architecture" +topics = ["content-workspace", "materials", "recovery", "collaboration"] +priority = 100 + +[[rules]] +id = "penpot-editor-visual-architecture" +path = "docs/adr/0049-penpot-led-editor-visual-system.md" +classification = "current" +role = "architecture" +topics = ["editor-design-system", "materials", "responsive-ui"] +priority = 100 + +[[rules]] +id = "canonical-editor-design-system" +path = "docs/editor/design-system.md" +classification = "current" +role = "canonical" +topics = ["editor-design-system", "responsive-ui"] +priority = 100 + +[[rules]] +id = "m2-content-evidence" +path = "docs/editor/evaluations/content-workspace-m2/README.md" +classification = "evidence" +role = "evidence" +topics = ["content-workspace", "content-browser"] +replacement = "docs/README.md" +priority = 100 + +[[rules]] +id = "production-readiness-evidence" +path = "docs/editor/evaluations/production-readiness/README.md" +classification = "evidence" +role = "evidence" +topics = ["production-readiness"] +replacement = "docs/README.md" +priority = 100 + +[[stale_terms]] +pattern = "registry v2" +replacement = "registry v3 or explicit migration wording" +allowed_paths = ["docs/editor/evaluations/**", "docs/archive/**"] + +[[stale_terms]] +pattern = "Authoring Material" +replacement = "material slot" +allowed_paths = ["README.md", "docs/adr/**", "docs/archive/**", "docs/editor/evaluations/**"] diff --git a/docs/editor/Bevy_Editor_Component_System_Spec.md b/docs/editor/Bevy_Editor_Component_System_Spec.md index a4c6c4f..9393891 100644 --- a/docs/editor/Bevy_Editor_Component_System_Spec.md +++ b/docs/editor/Bevy_Editor_Component_System_Spec.md @@ -1,5 +1,7 @@ # Tech Spec: Unity-style Component List for a Bevy Level Editor +> **Superseded specification — not current implementation guidance.** Component ownership is defined by [ADR 0034](../adr/0034-registry-driven-authoring-components.md); material-slot ownership is defined by [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md). + ## Purpose The component list is the main authoring UI for a selected **Actor** in the level. In this editor, an **Actor** is a Bevy `Entity` plus editor-facing metadata: stable ID, display name, hierarchy position, prefab/link info, and a curated list of inspectable components. diff --git a/docs/editor/README.md b/docs/editor/README.md index afea590..4871ad7 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -1,13 +1,15 @@ # Editor Framework Documentation Docs for the in-process egui editor (`crates/editor/`). Update this index when adding editor subsystems. +Consult [`docs/authority.toml`](../authority.toml) before using a page as current guidance. Evaluation +records below are dated evidence, not product specifications. ## Documents | Document | Contents | |----------|----------| | [roadmap.md](roadmap.md) | Phased editor work and status | -| [architecture.md](architecture.md) | Plugins, cameras, PIE, settings split | +| [architecture.md](architecture.md) | Plugins, cameras, PIE, settings split, and modular panel composition | | [brp.md](brp.md) | Remote protocol, authoring-only policy, commands | | [debt-audit.md](debt-audit.md) | Zero-debt phase gates and remaining items | | [rendering.md](rendering.md) | GI modes, post-process volumes, presets, post FX assets | @@ -22,14 +24,16 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [animation-authoring.md](animation-authoring.md) | glTF rig/clip import, stable controller states, preview, runtime API, and validation | | [navigation-authoring.md](navigation-authoring.md) | Bounds, source geometry, deterministic bake, viewport path preview, and runtime query workflow | | [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history | -| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | +| [design-system.md](design-system.md) | Penpot-led tokens, typography, responsive property grids, material controls, and picker behavior | +| [material-system.md](material-system.md) | Unified primitive/static/skinned slots, schema-driven inputs, processed textures, Surface ABI, and migration | +| [content-workspace.md](content-workspace.md) | File-manager content organization, destination-first import, stable identity, and model material mapping | | [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers | | [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application | | [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures | | [physics-placement.md](physics-placement.md) | Transactional gravity placement, prerequisites, isolation, commit/cancel, and undo | | [collider-authoring.md](collider-authoring.md) | Collider shape editing, shared health diagnostics, overlays, hydration status, and placement reuse | | [sample-regression-pack.md](sample-regression-pack.md) | Five-area sample manifest, native workflow, visual composition, validation, and maintenance contract | -| [evaluations/](evaluations/) | Acceptance evidence records and native Gitea attachment publishing policy | +| [evaluations/](evaluations/) | Dated acceptance evidence and native Gitea attachment publishing policy; not current behavior guidance | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | | [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery | @@ -46,6 +50,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering | | [evaluations/deterministic-asset-fingerprints/](evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints | | [evaluations/fbx-external-texture-dependencies/](evaluations/fbx-external-texture-dependencies/) | Source, validation, and native acceptance evidence for sandboxed FBX sidecar dependencies and override states | +| [evaluations/content-workspace-m2/](evaluations/content-workspace-m2/) | Native M2 evidence for file-manager selection, item/empty-space context menus, managed-folder visibility, and resizable Content Browser Details | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | ## Subsystems (code → doc) @@ -63,10 +68,10 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | `play/audio_preview.rs` / `game::audio` | Non-dirty editor audition and authored runtime playback/bus adapter | audio-authoring.md, ADR 0030 | | `assets/animation.rs` / `ui/animation_inspector.rs` / `game::animation` | Generated animation manifests, controller authoring/preview, and runtime state adapter | animation-authoring.md, ADR 0031 | | `ui/navigation_inspector.rs` / `scene::navigation` / `game::navigation` | Navigation authoring, deterministic bake artifacts, viewport preview, and runtime path queries | navigation-authoring.md, ADR 0032 | -| `assets/materials.rs` / `ui/material_library.rs` / `viewport/material_drop.rs` / `shared::renderer_material` | Shared material discovery, docked catalog/usage UI, exact reversible surface drops, stable renderer slots, and draw binding | material-system.md, ADR 0035 | +| `ui/design_system/` / `ui/materials/` / `ui/asset_card.rs` / `assets/materials.rs` / `viewport/material_drop.rs` / `shared::renderer_material` | Penpot-led editor primitives, shared material view models, bounded asset-card status, unified slot authoring, exact reversible surface drops, and draw binding | design-system.md, material-system.md, ADR 0035, ADR 0046, ADR 0048, ADR 0049 | | `blacksite_surface` / `game_hot::rendering::solari` / `third_party/bevy_solari` | Surface ABI packing, raster composition, Solari evaluator dispatch, and deformation eligibility | material-system.md, rendering.md, ADR 0036 | | `assets/fingerprint.rs` / `shared::AssetSourceFingerprint` | Content-addressed imported-source identity and byte-preserving registry/manifest publication | ADR 0043, evaluations/deterministic-asset-fingerprints/ | -| `assets/` | Catalog, transactional import bundles, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027, ADR 0044 | +| `assets/` / `content_pipeline` | Catalog, typed model/Texture import settings, deterministic runtime artifacts, transactional imports, thumbnails, and prefab overrides | this file (below), content-workspace.md, ADR 0017, ADR 0027, ADR 0044, ADR 0046 | | `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 | | `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 | | `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 | @@ -93,7 +98,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic). - **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping. - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. -- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips under `assets/audio/`, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references that retain a runtime source path. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. Before thumbnail loading, glTF sources preflight local external buffers/images. FBX source-material previews use the same sandboxed resolver as import and validation, expose one cached hover-visible dependency failure, and never enqueue known-missing texture paths; neutral model/mesh previews remain available. Asset details mark present/missing dependencies and identify missing textures intentionally ignored by Authoring Override. FBX bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). +- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions. It uses file-manager selection semantics across files and folders (click, Ctrl/Cmd toggle, Shift range, Ctrl/Cmd+A), batch clipboard/duplicate/trash/drag operations, keyboard shortcuts, and empty-space workspace menus. The details divider is draggable so asset and import settings can use more horizontal space, and double-clicking it restores the default width. Narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. Materials and shaders are classified by document schema anywhere under `assets/`; the browser renders material thumbnails on a sphere and exposes shader-schema-driven parameters/textures in Details. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references. glTF/GLB/FBX rows expand into normalized embedded mesh, material, and texture subassets. Model and Texture Details edit asset-keyed **UNSAVED** documents; `Ctrl+S` saves the active context and `Ctrl+Shift+S` saves all before affected-only background processing. FBX previews use the same sandboxed dependency resolver as import and validation; missing textures are required when any slot uses Source and informational when every slot uses Project/Default. Bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [content-workspace.md](content-workspace.md) and [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). - **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/). - **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release. - **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md). @@ -103,7 +108,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Draw Brush** — `B`, toolbar pencil, or command `brush.draw` enters a floor-polygon draw mode. LMB places snapped points, Backspace removes the last point, Enter locks the outline for height editing, mouse up/down adjusts height, and Enter/LMB creates additive prism brushes through history. Esc/right-click cancels. Simple concave outlines decompose into convex brush parts; self-intersections remain blocked. - **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Vertex/edge/face selections use the standard `W`/`E`/`R` gizmo at the element pivot and commit undoable `SetBrush` edits. Clip previews a bounds-based half-brush and commits with Enter; command-palette intersect, convex merge, and subtract operations use the same preview/commit lifecycle for conservative cuboid/prism blockout. - **Collider split and health** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references. Inspector, viewport, Diagnostics, and physics placement share one authored/runtime health result; see [collider-authoring.md](collider-authoring.md). -- **Material assets and renderer slots** — Material and direct-base Material Instance documents are shared assets. `StaticMeshRenderer` and `SkinnedMeshRenderer` each own stable material slots with imported source defaults and explicit shared assignments; Clear returns to the source default, while reimported missing slots retain overrides as orphans instead of matching by name. The property-block schema is runtime-only and persistence-excluded, while renderer application/promotion remains #53. Custom Surface evaluators use the same constrained ABI for raster and Solari-eligible non-deformed geometry; skinned/morph geometry is deliberately excluded from Solari until #54 adds deformed BLAS updates. See [material-system.md](material-system.md), [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md), and [ADR 0036](../adr/0036-surface-abi-and-solari-parity.md). +- **Material assets and renderer slots** — Material and direct-base Material Instance documents are shared assets. `StaticMeshRenderer` and `SkinnedMeshRenderer` each own stable material slots with scene, model, source, project-default, and DefaultGrid resolution. Reimported missing project mappings remain visible orphans. Runtime-only property blocks clone and cache the resolved Standard or Surface base per owner/slot, leaving invalid overrides non-destructive. Custom Surface evaluators use the same constrained ABI for raster and Solari-eligible non-deformed geometry; skinned/morph geometry is deliberately excluded from Solari until #54 adds deformed BLAS updates. See [material-system.md](material-system.md), [content-workspace.md](content-workspace.md), [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md), and [ADR 0036](../adr/0036-surface-abi-and-solari-parity.md). - **Prefab authoring** — shared `PrefabOverrides` targets generated actors by nested anchor chain plus stable `ActorId`, and applies reflected property/component plus same-layer structural operations in editor and game hydration. The editor supports scoped Revert and transactional **Apply to source** with three-way conflict checks, exact-file undo guards, and recoverable changed/broken/conflict states. `HydratedPrefabMember` keeps generated content out of owner serialization; linked-root saves retain local structure as variants. **Unpack Layer** preserves nested links, while **Convert to Local** recursively removes them. See [prefab-authoring.md](prefab-authoring.md) and [ADR 0027](../adr/0027-stable-prefab-ownership-and-variants.md). - **Visual language** uses a near-black industrial shell, a compact Blacksite identity mark, restrained amber actions/primary selection, cyan secondary selection, semantic status colors, stable grouped controls, and shared elevated viewport chips. Primary/secondary selection roles remain consistent across hierarchy rows, viewport x-ray shells/corner brackets, the selection HUD, and transform interactions. See [visual-language.md](visual-language.md). - **Hierarchy** shows authored objects plus useful runtime context (`Player`, `PlayerCamera`, Project Sun). Ordinary runtime rows remain read-only; generated prefab rows expose override-aware inspection and same-layer remove/reparent actions while rejecting cross-instance/layer and composition-boundary drops. Locks prevent selection, gizmos, drag participation, structural targets, and mutating context actions. Authored reparenting preserves world placement and records parent/local-transform/manual-order changes as one undoable command. diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index 51f7b0e..098c1b1 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -129,7 +129,12 @@ Three camera roles coexist: **WYSIWYG rendering:** `render_view.rs` is the only editor system that mutates viewport post-FX. It calls `game_hot::sync_viewport_camera_stack` on the active camera (editor fly or possessed player), driven by `ActiveCameraRenderProfile` from project settings and volumes plus `EffectiveRenderStack` for requested/effective GI fallback. `scene_view` and `play/session` set HDR render targets and `is_active` only—they do not strip FX. Only one camera carries the stack at a time (mesh-view bind group limits). See [ADR 0015](../adr/0015-viewport-camera-stack-ownership.md) and [ADR 0016](../adr/0016-unified-rendering-contract.md). -The viewport uses **render-to-texture**: the active camera renders to an offscreen HDR target sized to the dock panel (`panel_physical_size()`), then egui displays that texture. Cameras use `viewport = None` on image targets so the scissor always matches the texture; panel size only drives texture allocation, not a sub-viewport on the image. +The viewport uses **render-to-texture**: the active camera renders to an offscreen HDR target sized +to the dock panel (`panel_physical_size()`), then egui displays that texture. Cameras use +`viewport = None` on image targets so the scissor always matches the texture; panel size only drives +the image extent, not a sub-viewport on the image. Resizing mutates the existing Image asset so its +ID and Egui texture registration remain stable. The presentation layer owns at most one strong Egui +registration and removes it when the target is replaced or absent. **HDR invariant:** Atmosphere, authored HDR, and effective Solari all require an `Hdr` camera target; they must never render to the primary window swapchain @@ -220,12 +225,15 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See ## Model import (glTF + FBX) -- **Import:** File -> Import Assets copies glTF/GLB/FBX into `assets/models/`. An FBX import +- **Import:** **Import Here** and **Import To...** copy glTF/GLB/FBX into the chosen normal folder + beneath `assets/` without type-directory routing. An FBX import parses every safe external texture reference, including sibling `textures/` and `.fbm/` layouts, then stages and publishes the complete referenced bundle transactionally. Absolute non-sidecar paths, parent traversal, and dependencies that canonically escape the source folder are rejected before project files change; unreferenced sidecar contents are not copied. -- **Processing:** the asset registry generates normalized model manifests under +- **Processing:** `content_pipeline` owns one scan/reconciliation path used by editor startup, + manual refresh, the debounced watcher, validation, packaging, and the headless command. Browser + selection changes never trigger registry scans. The processor generates normalized model manifests under `assets/meshes/generated/`. The manifests store stable part IDs, glTF/FBX mesh/material subasset labels, whether each part is skin-bound, source metadata, dependencies, and import settings. Model, texture, and audio registry records use byte length plus BLAKE3 source identity; model @@ -236,8 +244,8 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See - **FBX dependencies:** the local `bevy_ufbx` resolver owns separator normalization, safe `.fbm/` rebasing, deduplication, and sandbox rejection. Static-mesh manifests record every declared external texture even when it is absent. Project validation reports one blocking finding for - missing Source Materials textures, or one informational finding when Authoring Override - deliberately leaves the model untextured. The runtime loader reads unique dependencies through + missing textures used by any per-slot Source selection, or one informational finding when every + slot deliberately uses Project/Default. The runtime loader reads unique dependencies through `LoadContext` and creates labeled images directly, so missing paths cannot fan out into repeated asset-server errors. See [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). - **Browser subassets:** model rows can expand into a content shelf backed by the generated @@ -300,6 +308,26 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad | `history/` | Undo commands + plugin | | `ui/` | egui dock shell | +### Panel composition and debt budgets + +`ui/inspector.rs` and `ui/asset_browser/panel.rs` are thin composition shells and are not homes for +new domain behavior. Inspector component cards register callbacks through +`EditorComponentRegistry`; the registry is the only visible built-in dispatcher. Shared Material +presentation lives under `ui/materials/`, while bounded thumbnail/footer/status presentation lives +in `ui/asset_card.rs`. Content transactions, material resolution, authored-document publication, +and derived-processing decisions remain outside egui rendering. + +The actor Inspector owns one fixed identity header and one actor-keyed component scroll region. +Its scrollbar is floating with stable width reservation, so vertical overflow never changes the +width supplied to responsive component cards. Nested component UIs intersect their local bounds +with the inherited clip and must not expand painting or input above the fixed actor header. + +The enforceable budgets and frozen legacy ceilings live in `.codex/architecture.toml`; +`scripts/codex/architecture_audit.py` runs in selective verification and candidate CI. Both UI +shells satisfy the 500-nonblank-line limit. Any future exception requires a tracker, rationale, +maximum, extraction target, and expiry milestone. +See [ADR 0048](../adr/0048-modular-editor-composition-and-debt-ratchet.md). + Shared scene schema lives in the Bevy-free `crates/scene` crate (stamp/migrate/validate on save, load, and CI). `game::schema_world_loader` is the runtime adapter that validates and unwraps the schema envelope before Bevy deserializes versioned prefab assets; both game and editor install it @@ -307,7 +335,21 @@ through `GamePlugin`. ## HDR / swapchain invariant -The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force `Hdr` on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR targets. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment and reapplies the project FX stack whenever the active panel target handle changes (startup, resize), not only on settings Apply. Project Settings uses a **draft buffer**; **Apply** commits after render targets resync. +The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel +target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the +swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force `Hdr` +on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR +targets. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment +and reapplies the project FX stack whenever the active panel target handle changes (startup or +exceptional replacement), not only on settings Apply. Ordinary resize preserves the handle and +updates the Image asset in place. Project Settings uses a **draft buffer**; **Apply** commits after +render targets resync. + +The editor host and packaged game intentionally use different presentation priorities. The editor +uses `AutoNoVsync`, preferring Immediate or Mailbox where supported, because FIFO acquisition on +NVIDIA Wayland can block for about one second per frame throughout an interactive native-window +resize. Packaged game windows retain `AutoVsync`. This complements the stable offscreen-target +ownership rule; it does not replace it. Bevy 0.19 removed the workspace's prior local `bevy_render` swapchain-timeout patch. The only active Bevy-adjacent patches are compatibility shims for ecosystem crates: `transform-gizmo-bevy` and `bevy_ufbx` under `third_party/`. diff --git a/docs/editor/brp.md b/docs/editor/brp.md index bbf3596..1449d3f 100644 --- a/docs/editor/brp.md +++ b/docs/editor/brp.md @@ -19,23 +19,32 @@ Default HTTP endpoint follows Bevy 0.19 remote defaults (port **15702**, routes ## Authoring-only mutation policy -**BRP and automation must mutate authoring components only** — the same allowlist used on scene save in `scene_io.rs`. Hydration rebuilds runtime ECS (`Mesh3d`, Bevy lights, physics, `WorldAssetRoot`, etc.) on the next frame. +**BRP and durable automation must mutate authoring components only** — the same allowlist used on +scene save in `scene_io.rs`. Hydration rebuilds runtime ECS (`Mesh3d`, Bevy lights, physics, +`WorldAssetRoot`, etc.) on the next frame. One deliberate editor-only exception exists for +`MaterialPropertyBlocks`: trusted local tooling may inject this explicitly runtime-only component +to preview or promote a transient override. It is still excluded from scene/prefab persistence and +the Add Component registry. | Allowed (examples) | Forbidden on level objects | |--------------------|----------------------------| | `Transform`, `Name`, `ActorKind` | `PointLight`, `SpotLight`, `DirectionalLight` | -| `Primitive`, `StaticMeshRenderer`, `SkinnedMeshRenderer`, `MaterialDesc`, `MaterialOverride`, `LightDesc` | `Mesh3d`, `MeshMaterial3d`, `RigidBody`, `Collider` | +| `Primitive`, `StaticMeshRenderer`, `SkinnedMeshRenderer`, brush-only legacy `MaterialDesc`, `LightDesc` | `Mesh3d`, `MeshMaterial3d`, `RigidBody`, `Collider` | | `ModelRef`, `RigidBodyDesc`, `ColliderDesc`, legacy `PhysicsBody`, gameplay markers | `WorldAssetRoot`, generated static/skinned roots, internal visibility types | | `PostProcessVolumeDesc` | Runtime post-process components on cameras | +`shared::renderer_material::MaterialPropertyBlocks` is a runtime diagnostic/tooling exception, not +a durable authoring component. Use **Promote to Material Instance** in the exact Inspector slot when +the variation should become project content. + Prefer: 1. Editing `LightDesc` instead of `SpotLight` / `DirectionalLight` -2. Editing `MaterialDesc` instead of `StandardMaterial` handles +2. Editing the owning `Primitive.surface` or renderer `MaterialSlotSet` instead of `StandardMaterial` handles; `MaterialDesc` is limited to the brush/legacy fallback path 3. Editing `StaticMeshRenderer` instead of child `Mesh3d` entities 4. Editing `SkinnedMeshRenderer` instead of its hydrated joint/mesh hierarchy 5. Editing `ColliderDesc` / `RigidBodyDesc` instead of runtime Avian components -5. Version-controlled `assets/levels/*.scn.ron` for durable changes +6. Version-controlled `assets/levels/*.scn.ron` for durable changes See [ADR 0009 — Authoring vs hydrated](../adr/0009-authoring-vs-hydrated.md). diff --git a/docs/editor/content-workspace.md b/docs/editor/content-workspace.md new file mode 100644 index 0000000..274c8ca --- /dev/null +++ b/docs/editor/content-workspace.md @@ -0,0 +1,200 @@ +# Content Workspace + +Blacksite treats the project `assets/` tree as a user-organized content workspace. Folders are for +organization and do not determine asset type. The stable asset registry ID is authoritative; a path +is mutable metadata that moves, validation, and packaging repair through the shared content catalog. + +## Content boundaries + +- User assets may live in any normal nested folder beneath `assets/`. +- External absolute linked assets are unsupported so projects remain portable and packageable. +- `.index`, `.trash`, thumbnails, recovery data, and derived artifacts are hidden and protected. +- Authored document schemas and typed registry records identify RON assets. Ambiguous or invalid + documents report diagnostics instead of being guessed from their folder. + +## Content Browser workflow + +The Content Browser toolbar provides **New Folder**, **Rename**, **Duplicate**, **Cut**, **Copy**, +and **Paste**. The selected folder is the destination for paste and **File > Import Assets**. These +operations use the shared content transaction engine: it previews collisions, rejects managed or +symlink paths, stages filesystem and cached-reference changes together, preserves IDs on moves, +assigns new IDs to copies, and rolls back on failure. Successful move, rename, create-folder, copy, +duplicate, and authored-file creation transactions add a content-specific **Undo Content** entry. +Move/rename undo applies the inverse reference-safe transaction. Undoing newly created content +moves the unchanged result into transactional Trash, so it remains restorable instead of being +permanently deleted. Every undo is blocked if any affected file or folder has changed externally +since commit. Registry and stripped runtime-catalog publication are part of the same rollback +boundary. + +Before create-folder, rename, move, copy, duplicate, paste, or drag-to-folder commits, **Review +Content Operation** lists every source/destination, affected registered-asset count, and each cached +RON document whose path hint will be rewritten. Collisions are shown in the same window and disable +commit. Read-only files, provider locks, and source-control conflicts are visible and block commit; +modified/untracked state remains visible but does not. Source and rewrite-document fingerprints are +captured for the review, so any external edit before confirmation also blocks the stale transaction. +Cancel restores the pre-review folder, selection, anchor, status, clipboard, and drag state; a +cancelled drag review clears only the stale drag gesture. Folder-scoped **Paste Into** and +**Import To** pin their destinations without navigating the browser. + +Delete remains trash-first. **Trash** in the toolbar, **Open Trash** in a folder/empty-space context +menu, and the restore window expose complete deletion batches. A new batch records the original +paths and registry records before moving bytes under `assets/.trash/`. Model batches also carry +their generated static/animation manifests so deletion or guarded undo cannot leave orphan runtime +artifacts; restoring the batch restores those exact derived bytes with the source. Restore preserves +stable IDs and is blocked if a destination path, derived path, or registry ID is now occupied. +Legacy trash batches are shown when their original paths can be inferred, but have no historical +registry IDs to recover. A normal Move to Trash action also records the exact batch in Content Undo, +so Ctrl/Cmd+Z restores it when the batch fingerprint and destinations remain valid. +Before confirmation, the trash review enumerates external authored RON documents, model mappings, +project-default selection, and dependency records that will become unresolved; references inside +the same selected tree are excluded from that warning. + +Selection follows desktop file-manager conventions in both grid and list views: click selects one, +Ctrl/Cmd-click toggles an item, Shift-click selects the visible range from the anchor, and +Ctrl/Cmd+A selects every visible result. Folders are first-class selectable and draggable items; +double-click opens them and clears the previous folder's selection so hidden content cannot be +changed accidentally. Starting a drag on an unselected item selects that item first, while dragging +one member of an existing multi-selection preserves the complete batch. Batch Cut, Copy, Duplicate, +drag-to-folder, and Move to Trash operate on the complete selection while removing nested duplicates +such as a selected folder and one of its children; the menu reports those children as covered by +the parent rather than mislabeling them as ignored. Changing search text, recursive scope, or the +kind filter clears the selection so a now-hidden item cannot receive a keyboard operation. Sorting +and switching grid/list view preserve selection because they do not hide results. Content shortcuts only take ownership while the +pointer is over the content pane: Ctrl/Cmd+C, X, V, D, A and Z, Ctrl/Cmd+Shift+N, F2, and Delete. +Ctrl/Cmd+Z invokes the most recent guarded Content Browser operation; it does not consume scene +history while the pointer is outside the content pane. The browser surface and its shortcuts are +disabled while any Content Browser review/modal or popup menu is open, so a confirmation click or +keystroke cannot also mutate the selection behind it. Cut/Paste keeps the exact requested basename; an occupied destination is +shown as a collision in **Review Content Operation** instead of being silently renamed. +Embedded mesh, material, texture, skeleton, and animation rows are authoring subassets rather than +filesystem entries; generic rename/clipboard/drag-to-folder/trash commands never reinterpret one as +its parent model file. + +Right-clicking an unselected file, folder, or embedded model subasset makes it the primary selection +before opening its menu. Right-clicking one member of an existing multi-selection preserves the +complete batch. File menus expose type-specific actions plus Rename, Cut, Copy, Duplicate, and Move +to Trash. Folder menus add destination-scoped import, paste, and creation actions. Right-clicking +unused content space clears any old selection and opens workspace actions for **Import Here**, +**Import To**, **Create Material Here**, **Create Materials From Folder**, **New Folder**, **Paste**, +**Select All**, **Refresh**, and grid/list view selection. New Materials are authored directly in the +current folder; the Material Library's create button retains `assets/materials/` as its convenient +default. +Double-clicking a scene opens it, a model toggles its embedded contents, and other asset types reveal +their editable Details. The footer and Details pane summarize multi-selections; single-selection +Details remains the authoritative editor for import and material settings. Drag the Details divider +to resize it for long slot/shader forms; double-click the divider to restore its default width. +Texture Details uses the same pane for semantic/color-space intent, mip generation, UASTC or +uncompressed KTX2 output, resize limits, sampler settings, and normal-Y conversion. Changes become +an **UNSAVED** Texture document immediately; explicit Save publishes the settings and queues +affected-only derived processing without rewriting the editable source image. + +Thumbnail cache keys are typed by asset category. Texture cards may use direct image loads, while +model cards always queue the offscreen geometry studio—even when a glTF has a base-color texture. +Expanded mesh rows request their exact mesh-subasset render, and source materials use a separate +material-sphere key. Missing dependencies produce an explicit failed thumbnail; a model or mesh +card never substitutes an albedo texture. Replacing, retrying, or invalidating a thumbnail also +unregisters its last Egui image handle, so repeated catalog refreshes release the prior GPU image +instead of accumulating hidden render targets. + +**Import Here** pins the current/selected folder before the native picker opens. **Import To...** +first opens a project-folder chooser without navigating away from the current browser location. +After files are chosen, a second review lists every source dependency and exact final project path; +collisions disable commit and cancel publishes nothing. Source/dependency fingerprints and target +absence are checked again at commit so external changes after review cannot overwrite project data. + +External FBX and textual glTF imports preserve relative texture/buffer layouts as one staged bundle. +The complete multi-source picker batch is preflighted before mutation: existing targets and two +sources mapping to one target are reported as collisions, and any publication failure removes all +new files and directories from that batch. Missing dependencies, traversal, and symlink escapes +also fail before the destination changes. GLB is self-contained. Files already under `assets/` are +shown as **Adopt in place** in the review and processed without copying or type-folder sorting. + +Folder/file Move preserves registry IDs and rewrites cached paths in project documents, model +defaults, the runtime catalog, and generated manifests. Copy/Duplicate assigns new IDs, remaps only +references internal to the copied tree, and leaves the originals plus external references +untouched. Copied models discard inherited generated-manifest paths so normal processing publishes +fresh artifacts under the copied model ID. +Out-of-editor moves reconcile imported assets only when one missing record has the matching kind +and source fingerprint. If the watcher finds one new file matching multiple missing records, it +opens **Resolve Ambiguous External Moves** and leaves registry/manifests unchanged. Each row must +either preserve exactly one candidate ID or intentionally **Register as New Asset**; one old ID +cannot be assigned to two new paths. **Review Later** leaves a **Resolve Moves** toolbar action, and +Apply rechecks the registry, files, and candidate set before publishing. Headless processing fails +with the same candidate paths until the choice is completed in the editor; neither path silently +replaces stable identity. + +Imported static and skinned model Details expose stable material slots. Each slot selects its source +material, a project Material/Instance, or **Default**; bulk Source and Default actions are +available. Default intentionally skips the imported source and resolves through the project +fallback, then immutable DefaultGrid. Reimport reconciles by stable slot ID and retains removed project assignments as explicit +orphans. The displayed source health makes a missing imported material distinct from an intentional +Default selection. **Locate** reveals the referenced project Material/Instance, while **Clear** +returns an active model slot to Source. Orphan rows provide **Locate** and **Clear Orphan** so a +removed source slot can be reviewed and resolved explicitly before applying the import settings. + +The former whole-model Source Materials/Authoring Override switch is migration input only. Explicit +processing expands legacy Authoring Override into a Default selection for each stable slot, resets +the legacy field, and publishes only per-slot selections to current registry/manifests. Validation +requires an FBX texture bundle while any slot uses Source; an all-Project/Default model keeps missing +source textures visible as informational provenance instead of a release blocker. + +For glTF, GLB, and FBX, **Extract Editable Materials Here** opens a conversion review containing +every source material, converted metallic/roughness and render state, detected external texture +channels, and the editable final project paths. Embedded texture payloads remain source-owned; the +review still converts supported scalar PBR values. Confirmed entries become normal +`*.material.ron` assets and every model draw slot using that source material is changed to a +`Project(...)` selection in the same rollback-capable content transaction. The generated model +manifest is refreshed immediately so editor and packaged runtime bindings agree. A first +extraction uses a collision-free path. On a later extraction into the same folder, matching source +path and stable source-slot provenance opens an explicit diff: choose **Apply Reviewed Update** to +replace the edited Material, or **Create New Copy** to preserve it as a separate revision. Apply is +guarded by the reviewed file fingerprint, participates in transaction rollback, and is blocked if +the file changes before publication; mismatched provenance can never overwrite an unrelated +Material. Each extracted Material records read-only source path, stable source-slot identity, and +source-content fingerprint provenance in Details. + +For loose texture sets, right-click their folder or unused space inside it and choose **Create +Materials From Folder**. The review window detects common PBR suffixes such as `albedo`, +`basecolor`, `diff`, `normal`, `nor_gl`, `roughness`, `metallic`, `ao`, `orm`/`arm`, `height`, and +`displacement`. Every row shows its confidence and stays editable. Change a row's **Target** to +merge it into another material or split it into a new material; change **Role** to correct a guess. +Unresolved images remain visible and start unchecked. Creation publishes every selected group as +one atomic content transaction and chooses a suffixed filename instead of overwriting an existing +Material. Packed ORM/ARM maps use R=occlusion, G=roughness, and B=metallic. + +Project Settings > Content selects an optional project fallback Material/Instance. Clear restores +the immutable **Default Grid — Engine Built-in**. DefaultGrid is world-space and UV-independent, so +broken or unassigned meshes stay visible. + +Material, Material Instance, model-import, Texture-import, and project-default edits live in an +editor-owned authored-document store until explicitly saved. **UNSAVED** is editor state and is +shown separately from Git state. `Ctrl+S` saves the last edited context; `Ctrl+Shift+S` and +**File > Save All** save every dirty scene, asset document, and project setting. Selection changes +never discard a dirty asset. Source publication is guarded and atomic, then affected-only derived +processing runs in the background while the previous valid artifact remains active. +Material scalar/color/render-state saves do not show `PROCESSING`; only packed-map binding changes +do. Content Browser and Material Library cards share clipped two-line labels, a top-right grid-card +status rail, and fixed-width list markers, so state never paints over or shifts an asset name. + +## Headless processing + +Run `cargo process-assets --project ` to deterministically refresh registry v3, normalized +static/animation model manifests, processed Texture artifacts, canonical Material ARM maps, and +`assets/content.catalog.ron` without a GPU. All planned artifacts and both catalogs publish under +one rollback boundary. Use `--check` in CI for a strictly read-only comparison that fails on any +stale artifact, and `--json` for machine-readable diagnostics. Texture and packed-Material keys are +resolved before image decode; an existing content-addressed artifact is reused, so an unchanged +check validates source hashes and catalog references without recompressing images. +Editor startup, the toolbar/empty-space **Refresh** action, and the shared watcher all use the same +processor. Saved authored documents enqueue revisioned, coalesced jobs rather than running the +whole processor on the UI frame. The watcher debounces and deduplicates user-content paths, ignores +managed/generated publication noise, incrementally rescans the same registry/catalog model, +refreshes selection-safe browser state, and invalidates thumbnail caches. Selection-only UI changes +never fingerprint or republish content. Editor transactions suppress their filesystem events until commit. Failures remain visible +in the editor status line. When an external deletion leaves authored references unresolved, the +status includes their count and affected document paths. Thumbnail rendering remains editor-owned. +Ambiguous move repair is rediscovered on startup and manual refresh, so choosing **Review Later** +cannot lose the repair entry across an editor restart. + +The permanent storage, material precedence, and fallback decisions are in +[ADR 0045](../adr/0045-content-workspace-and-material-fallback-contract.md). diff --git a/docs/editor/debt-audit.md b/docs/editor/debt-audit.md index c1696d2..95289dc 100644 --- a/docs/editor/debt-audit.md +++ b/docs/editor/debt-audit.md @@ -1,5 +1,7 @@ # Editor zero-debt audit checklist +> **Active plan — describes incomplete release/debt gate work.** Current shipped behavior is indexed in the [canonical documentation](../README.md). + Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-tech-debt-editor.md)). Update when closing a phase or removing debt. ## Must not ship (gate before phase sign-off) @@ -17,6 +19,10 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero- | Checkout metadata or filesystem enumeration can rewrite imported-asset artifacts | Done | Shared content fingerprints, semantic byte-preserving publication, normalized registry order, clean-checkout validators, and live mtime-only drift pass at `cbd380a`; see the [evaluation](evaluations/deterministic-asset-fingerprints/). | | Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor | | `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` | +| Inspector hard-coded component dispatcher | Done | `EditorComponentRegistry` is the sole visible built-in dispatcher; startup rejects missing callbacks | +| `ui/inspector.rs` monolith | Done — P0 #68 | 363 nonblank-line registry-dispatch shell; component cards and complex domains are separate modules | +| `ui/asset_browser/panel.rs` monolith | Done — P0 #68 | 450 nonblank-line shell; navigation, cards, Details, import, extraction, transactions, trash, and undo are separate modules | +| Architecture debt can grow silently | Done | `.codex/architecture.toml` plus `scripts/codex/architecture_audit.py`; exact frozen baselines ratchet every remaining legacy module | ## Phase completion gates diff --git a/docs/editor/design-system.md b/docs/editor/design-system.md new file mode 100644 index 0000000..1d154ec --- /dev/null +++ b/docs/editor/design-system.md @@ -0,0 +1,153 @@ +# Editor Design System + +Blacksite's editor uses the Penpot **Inspector Material Slot** as its visual source of truth. This +guide defines the reusable visual and interaction language; domain behavior remains in its owning +editor subsystem. See [ADR 0049](../adr/0049-penpot-led-editor-visual-system.md). + +## Semantic palette + +The default theme mirrors Blacksite's established palette. These values are published under +Penpot Assets / Colors and represented by `EditorVisualPalette`; domain widgets consume semantic +roles rather than literals. Theme selection and persistence are intentionally deferred to Editor +Settings, but the renderer-facing UI seam already accepts a different palette. + +| Role | Default | +|------|-------| +| Canvas / panel / recessed | `#07080A` / `#0D0F12` / `#090B0D` | +| Control / elevated | `#15181C` / `#1F2328` | +| Brand accent / hover | `#E5A433` / `#F9C14F` | +| Selection and valid drop | `#41C6CF` | +| Border / strong border | `#262B31` / `#40474F` | +| Primary / secondary / muted text | `#DCE1E5` / `#9BA3AB` / `#606972` | +| Healthy / warning / error | `#54BE7F` / `#EBB448` / `#E25E5E` | + +Source Sans Pro Regular and Bold are bundled in `crates/editor/assets/fonts/source-sans/`. Type +roles use the Penpot 9–16 px scale; weight is selected through separate egui font families rather +than simulated styling. Spacing follows 4/8/12 px steps. Common controls are 22–31 px tall with +4/5/6/7/8 px radii. + +Use semantic tokens from `ui/design_system`; do not copy literal colors into a domain card. The +selection color is reserved for focus, valid drag targets, selection, and active values. Health, +unsaved, processing, failed, read-only, inherited, and built-in states keep the same geometry. + +## Composition contract + +Reusable controls render a supplied view model and return actions. They do not query unrelated +Bevy resources, save files, schedule processing, or directly mutate scene/world state. The owning +domain resolves actions after rendering. This keeps the visual system reusable in the Inspector, +Content Browser, Material Library, and future editor panels. + +The material implementation is split as follows: + +- `ui/design_system` owns tokens, typography, control chrome, property-grid geometry, and the color + popup. +- `ui/materials/panel.rs` owns the material-slot view model and action contract, while + `ui/materials/pickers.rs` owns the compact current/recent Material menu and the richer Texture + asset-picker presentation. +- `ui/materials/inputs.rs` renders schema-declared material controls. +- Inspector and asset editors provide state and apply returned actions through authored documents. + +## Reference layout + +At the current 620 px wide reference, 12 px outer padding produces a 596 px slot and a 569 px parameter +body. A 32 px **Materials** heading precedes repeated slots. Expanded headers are 82 px with a +64 px preview; collapsed headers are 52 px with a 32 px preview. Surface is 46 px, Inputs is +216 px (24 px header plus six 32 px rows), UV is 54 px, and Advanced is 30 px collapsed or 178 px +expanded. An expanded wide row keeps its label/value/channel geometry fixed while only the texture +field grows above the reference width. + +| Column | Width | +|--------|-------| +| Parameter label | 82 px | +| Value | 132 px | +| Value/channel gap | 6 px | +| Channel | 48 px | +| Channel/texture gap | 16.5 px | +| Texture field | 198.5 px at the reference width | +| Locate / Clear | 24 px each, separated by 6 px | + +At the 420 px Inspector floor, the slot is 396 px and parameter sections are 369 px. Surface remains +46 px and Inputs is 372 px: each of the six rows uses a 58 px two-line layout with parameter, +value, and channel above a 345 px asset picker. UV reflows from 54 px to 94 px. The layout must not introduce nested or horizontal +scrolling, clipped buttons, or height-dependent width changes. + +These reference dimensions are implemented as owning rectangles, not nested `horizontal` flow. +At 569 px, each input row begins its value at x96, channel at x234, and texture group at x298.5. +At 369 px, the parameter line is `x12/y5/w345` and the texture line is +`x12/y31/w345`. Texture names are clipped and ellipsized inside the field, so content cannot move +the fixed Locate/Clear anchors. A transient width below 372 px retains finite clipped rectangles +and replaces the two low-frequency texture actions with one overflow control until the Inspector +minimum is restored. + +## Material controls + +Primitive, static-mesh, and skinned-mesh surfaces use the same ordered `MaterialsSection` and +slot-keyed `MaterialSlotPanel` actions. Static/skinned geometry cards do not embed duplicate +material presentations. The slot header and +asset zone include the material sphere, bounded name and project-relative path, shader, health, +assignment interaction, and responsive actions. The entire zone is the drop target; there is no +second “Drop material asset” box. Preview/name/path and Browse open the picker, while Shader, +Locate, Clear, and overflow remain isolated. Direct assignments do not show an “Explicit” badge. +Inherited identities are limited to Model Source, Model Default, Project Default, and Default Grid. + +Expanded content shows supported controls only: + +- Surface: Opaque/Cutout, cutoff when Cutout, Separate/ORM, and Double Sided. +- Inputs: six Standard Lit rows. Emissive color owns the independently stored intensity companion + and edits/restores both through the Emissive popup. +- UV: two equal Offset/Tiling groups with fixed X/Y controls and independent resets; Material + Instances reset to inheritance. +- Advanced Inputs: only evaluator-supported schema declarations. + +Imported source content is read-only with Extract Editable in overflow. DefaultGrid is immutable. +Broken explicit references keep their failed identity and diagnostics rather than exposing a weaker +layer. Stable IDs, fingerprints, orphan state, and provenance live in overflow diagnostics, not in +the parameter grid. + +## Pickers and direct manipulation + +The compact Material menu shows the current assignment and editor-local recent choices, then offers +Browse Asset Library and Clear. The richer Texture picker uses bounded thumbnail rows for current, +recent, and project textures. A popup dismissal or invalid choice does not assign anything. Material and +Instance drops use the active theme's selection highlight; Texture drops show an explicit invalid state. + +Handoff v2.3.1 has one Shader selector in the asset identity block. Surface contains the blend mode +and Double Sided without a visible `Blend` label; it does not mirror Shader as a second "Shading +Model" control. UV is followed by a +collapsed Advanced row. Its culling, render queue, depth-write, and receive-shadows mock controls are +explicitly painted muted previews until those renderer contracts exist, and therefore never return +actions. Standard egui disabled opacity is not compounded over these controls. + +The Inspector actor identity remains fixed. The remaining dock-tab body is one actor-salted +component scroll region whose clip is intersected with the dock body, so scrolled Transform or +component cards cannot paint or receive input beneath the actor header. Component-local children +must preserve that inherited clip rather than replacing it with their own maximum rectangle. The +vertical scrollbar floats over a stable four-pixel interaction rail, so crossing the height overflow +threshold cannot change the content width or trigger a responsive-layout flip. + +Texture fields open their picker when the field itself is clicked. They show a thumbnail or typed +fallback icon plus Locate and Clear actions. A ready typed thumbnail replaces the generic Texture +glyph rather than appearing beside a redundant icon; a redundant folder button is not used. + +The color popup is a fixed 420 x 350 px modal centered over the owning Inspector region, with a +dimmed Inspector-local scrim. Its exported geometry includes a 38 px header, 250 x 26 mode tabs, +178 x 202 wheel editor, 198 x 186 values region, byte RGBA fields, degree/percentage HSV fields, +functional HEX copy, a 156 x 14 checkerboard alpha control, 396 x 28 recents row, and 396 x 26 +footer. Wheel, Sliders, and Presets share that fixed frame. The exported eyedropper lane is a muted, +non-interactive preview labelled `Planned — screen sampling pending`. + +Every preview updates the live material document. Apply keeps the dirty in-memory value; Cancel, +Escape, Close, and outside dismissal restore the exact value captured on open. No picker action +writes source or queues derived processing. Material scalar inputs use the reusable 130 x 22 +Penpot control: a 70 x 14 track, 10 px thumb, 8 px separation, and 48 x 22 numeric field. Expanding +Advanced scrolls its complete 178 px disabled-preview body into view rather than revealing a clipped +partial section. + +## Review checklist + +- Verify the 620 px geometry numerically and in a native capture. +- Verify the narrow reflow, long and Unicode names, invalid drops, read-only/imported/built-in + states, and popup cancellation. +- Verify handle identity and dirty-document behavior during live interaction. +- Run `python scripts/codex/architecture_audit.py check`; new design-system work does not earn an + exception from the module-size ratchet. diff --git a/docs/editor/evaluations/README.md b/docs/editor/evaluations/README.md index c1d508d..ca7c0ee 100644 --- a/docs/editor/evaluations/README.md +++ b/docs/editor/evaluations/README.md @@ -1,8 +1,11 @@ # Editor Evaluation Evidence -Each subdirectory keeps the canonical, versioned acceptance record for an editor feature. Record -visual evidence with its attachment ID, dimensions, and checksum. A repository image copy is -optional; the committed Markdown record, not a raw image URL, is the source of truth. +Each subdirectory keeps dated acceptance evidence for an editor feature. Evidence records describe +what was observed against a named implementation; they are not current product specifications. +Resolve current behavior through [`docs/authority.toml`](../../authority.toml) and the linked +canonical guide or ADR. Record visual evidence with its attachment ID, dimensions, and checksum. A +repository image copy is optional; the committed Markdown record, not a raw image URL, is the +durable evidence source. ## Publishing To Gitea @@ -15,3 +18,9 @@ attachment so the evidence remains traceable to its tested source and exact comm Native attachments are the presentation copies used by issues and pull requests. Do not commit a duplicate image solely to publish it; retain verification notes and attachment metadata in the evaluation record. + +## Records + +The [editor documentation index](../README.md) lists feature evidence records. The +[production-readiness matrix](production-readiness/) is the active candidate-evidence ledger; +H1–H6 sign-offs are historical snapshots. diff --git a/docs/editor/evaluations/collaborative-file-safety/README.md b/docs/editor/evaluations/collaborative-file-safety/README.md index e24893c..b9032a7 100644 --- a/docs/editor/evaluations/collaborative-file-safety/README.md +++ b/docs/editor/evaluations/collaborative-file-safety/README.md @@ -1,5 +1,7 @@ # Collaborative File Safety Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Branch: `codex/collaboration-live-acceptance` Implementation commits: `5a82a9e`, `f2ad88f` diff --git a/docs/editor/evaluations/collider-diagnostics/README.md b/docs/editor/evaluations/collider-diagnostics/README.md index ec62bba..dd2e9d5 100644 --- a/docs/editor/evaluations/collider-diagnostics/README.md +++ b/docs/editor/evaluations/collider-diagnostics/README.md @@ -1,5 +1,7 @@ # Collider Diagnostics Acceptance +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Issue: Gitea #26 (`BS-JD-305`) ## Evidence diff --git a/docs/editor/evaluations/content-workspace-m2/README.md b/docs/editor/evaluations/content-workspace-m2/README.md new file mode 100644 index 0000000..cd42d6e --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/README.md @@ -0,0 +1,205 @@ +# M2 Content Workspace Native Evaluation + +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + +**Date:** 2026-07-14 + +**Milestone:** [M2 - Content workspace and asset pipeline](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/milestone/12) + +**Epic:** [Gitea #59](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/59) + +**Base commit:** `931f561e7ff1489f897e22ebb3cc7545fcfef702` + +**Implementation revision:** Candidate tree passed all gates; exact commit assigned at publication + +**Release-candidate commit:** Pending publication commit + +**Native interaction acceptance:** **Pass for the workflows recorded below** + +**Pinned Penpot acceptance targets:** [export index](penpot/README.md) + +This feature-level record covers the Content Browser interaction pass added during M2 completion. +It does not close the milestone or replace the exact-commit, clean-checkout, import/reimport, +forward/Solari, packaging, and independent signoff gates in the M2 plan. + +## Native Protocol + +The normal debug editor was rebuilt with `cargo build -p editor --bin editor`, launched as +`LD_LIBRARY_PATH=target/debug/deps target/debug/editor --project .`, and exercised in native +Wayland windows at 3426x1384 and 1440x3440. The project stayed live throughout each workflow and +was stopped without saving the temporary placement scene. + +| Workflow | Result | Evidence | +|----------|--------|----------| +| Managed storage visibility | Pass | `assets/.index`, `.trash`, `.thumbnails`, `.import-cache`, and generated artifact directories were absent from the tree and content grid; ordinary content folders remained visible. | +| Empty workspace context | Pass | Right-clicking visible unused content space cleared the selected model and opened Import Here, PBR grouping, Create Material Here, New Folder, Open Trash, Paste, Undo, Select All, Refresh, and view actions. | +| Multi-selection | Pass | Ctrl-click selected `assets/build_profiles` and `assets/levels` together; both tiles, the two-item footer, and the multi-selection Details summary stayed synchronized. | +| Batch context menu | Pass | Right-clicking one selected folder preserved both selections and exposed Cut, Copy, Duplicate, and Move Selection To Trash. | +| Item context menu | Pass | Right-clicking the Poly Haven desk model selected it and exposed model actions plus Rename, Cut, Copy, Duplicate, and Move To Trash. | +| Details resizing | Pass | Dragging the Content Browser divider expanded the Details pane; its hover affordance described drag resize and double-click reset. | +| Background/item priority | Pass | The workspace background menu remained available without intercepting folder/model clicks or Ctrl multi-selection. | +| Destination-first bundled import | Pass | Created `assets/Props/Office/` through reviewed folder transactions, chose **Import Here** from genuinely empty space, and imported the Poly Haven `metal_office_desk_2k.gltf` directly there with its `.bin` and three relative textures. No type-specific routing occurred. | +| Import To and final-path review | Pass | Chose `assets/Furniture/Office` without navigating away from the root browser view, opened the native picker, selected `/tmp/m2-live.png`, and confirmed the second review showed the pinned destination plus exact `assets/Furniture/Office/m2-live.png` target. Cancel closed the review and a filesystem assertion confirmed that no target was published. | +| Destination-first skeletal import and placement | Pass | Chose **Import Here** in `assets/Furniture/Office/`, imported `blacksite-m2-robot-expressive.glb` directly there, inspected its stable draw-slot IDs, and placed it as a live `SkinnedMesh` actor with its skeleton hierarchy intact. Registry ID `5b4640dd-fc17-4703-b9ea-e4eb49a8c7fc` and both generated manifests survived the final headless refresh. | +| Editable material extraction | Pass | The review exposed the converted metallic/roughness values, base/normal/packed texture channels, double-sided state, destination path, and stable source provenance before one transactional publication. | +| Re-extraction diff/apply safety | Pass after corrective implementation | Native review found the provenance-matched Material, exposed a source-revision diff, and kept Extract disabled until **Apply Reviewed Update** or **Create New Copy** was chosen. Apply preserved Material ID `647a63d4-64c6-4431-ba46-9efdd515cd8f`, mapped all eight active slots, and **Undo Content** restored the exact prior Material bytes. Pipeline tests additionally prove external-edit rejection and exact-byte rollback when dependent publication fails; editor tests prove undecided and stale reviews cannot publish. | +| Extract-and-map contract | Pass after corrective implementation | Native QA found that extraction initially created the Material without changing model defaults. The transaction now registers the Material and maps all nine draw-slot IDs using source material 0 before publishing either registry document; the generated static-mesh manifest contains the same nine `Project(...)` selections. | +| Guarded create/write undo | Pass | **Undo Content** moved the first extracted Material into one Trash batch while leaving the model bundle untouched. The corrected extraction was then rerun against the same fixture. | +| Forward/Solari DefaultGrid visibility | Pass | The scene's UV-independent neutral grid remained visible in `Lit / Forward` and after applying `GiMode::Solari` in memory; `assets/project.ron` remained configured as `Forward`. | +| Broken project fallback safety | Pass | Selected `new_material_2` as the project fallback, moved that Material to Trash through the reviewed transaction, and confirmed affected primitives and the placed skinned model remained visible with DefaultGrid. Undo restored the Material; the project fallback was then cleared back to the built-in default. | +| Skinned scene-slot override | Pass | The Inspector exposed every imported draw slot and its stable ID. Assigning `new_material_2` to the first slot changed only that scene slot to an explicit project Material while the remaining slots continued to report imported-source fallback. | +| Restart persistence and deterministic refresh | Pass | After a clean close/restart, the registry, runtime catalog, generated static-mesh manifest, and extracted Material retained identical SHA-256 hashes. Registry and manifest each retained nine project slot selections. | +| Editor/headless classification parity | Pass after corrective implementation | `process-assets --check` found that browser sync had collapsed a schema-classified Material Instance back to Material. Browser registry sync now reclassifies the authored file while retaining the unified visible Material family. A steady-state editor restart preserved both document hashes and the following headless check reported zero changes. | +| Reimport and orphan reconciliation | Pass after corrective implementation | Removing source node 8 from the QA glTF and choosing **Reimport** retained eight active `Project(...)` mappings by stable slot ID and exposed the removed mapping as one explicit orphan. **Locate** selected its extracted Material; **Clear Orphan** plus **Apply** published eight active mappings and zero orphans to registry and runtime manifests. The source bundle under `source_assets/` was not modified. | +| Move/rename stable identity | Pass after corrective implementation | A reviewed Cut/Paste moved `assets/Props/Office/` to `assets/Furniture/Office/` while preserving model ID `36c51ff3-29ae-4ab1-aaca-f90f57e27966` and Material ID `647a63d4-64c6-4431-ba46-9efdd515cd8f`. Native QA found stale nested `MaterialRef.source_path` caches on the first attempt; guarded Undo restored the prior bytes, the transaction rewrite was corrected, and the repeated move updated registry, catalog, both model manifests, Material provenance, and texture paths. Reimport at the destination retained all eight mappings. | +| Duplicate identity and internal references | Pass after corrective implementation | Native **Duplicate** assigned fresh IDs to the copied model, Material, and textures; every copied model slot referenced the copied Material and folder, while the original records remained unchanged. Copied models discarded inherited manifest paths and the watcher regenerated static/animation manifests under the copied model ID. | +| Duplicate guarded undo and derived cleanup | Pass after corrective implementation | Native Undo initially exposed orphan generated manifests after removing the copied tree. Trash manifest schema v2 now includes model static/animation artifacts. Rebuilt native Duplicate -> Undo moved the copy plus both generated manifests into one restorable Trash batch, removed copied registry/catalog records, and preserved the original tree and artifacts. | + +## Automated and Packaged Acceptance + +The same local implementation passed the complete workspace/all-feature test suite, strict +all-target/all-feature Clippy with warnings denied, formatting, both project validators, and a +steady-state headless content check over 78 discovered assets. `validate-levels` audited 152 +dependencies with zero blockers; `validate-samples` audited all five samples and 157 dependencies +with zero blockers. Both validators reported the expected eight non-blocking import/platform +findings. The final `process-assets --check` reported zero registry, catalog, model, texture, or +material-artifact changes. The QA package published 125 runtime files (87 written and 38 reused) +with no stale packaged files. + +Focused Content Browser regressions also cover guarded undo for created folders and copied content: +an unchanged created result is targeted as one restorable Trash batch, while any external tree +change blocks the undo and preserves the workspace. Ordinary Move to Trash records its exact batch +for guarded Ctrl/Cmd+Z restoration as well. Model trash/restore additionally verifies byte-exact +static/animation manifest removal and restoration, and copy coverage verifies fresh IDs plus +internal-only reference remapping. + +The final transaction audit adds focused coverage for exact browser/scene status restoration when +a content review is cancelled, stale-drag cleanup, registry/runtime-catalog drift refusal during an +import review, and reviewed-import rollback that removes newly published bytes without deleting an +in-project adopted asset. Destructive reference discovery reports external authored documents, +project defaults, model mappings, and dependency records while excluding references removed in the +same selected tree. Ambiguous out-of-editor fingerprint moves now leave registry/manifests +unchanged and open an explicit identity-repair review in the editor; the user can preserve one +candidate ID or intentionally register the file as new. Headless processing reports both candidate +paths until that choice is published instead of silently allocating a replacement ID. +Focused repair coverage proves preserved-ID continuity through the next shared scan, intentional +new-ID behavior, duplicate-ID rejection, and one commit that publishes the world registry, registry +file, and stripped runtime catalog together. Content shortcuts are also suppressed while a browser +review or popup owns input, and Cut/Paste keeps the requested name so collisions reach review. +The follow-up QoL audit made right-click targeting consistent for files, folders, and embedded +subassets; distinguishes nested selections covered by a selected parent from genuinely unaffected +built-ins/subassets; and clears selection when search/scope/type filters would hide it. Browser +controls are disabled behind reviews. Startup, manual Refresh, and watcher processing now share the +same pipeline, rediscover unresolved move repairs after restart, and selection-only changes no +longer trigger whole-catalog fingerprint or registry work. +A rebuilt native editor then mapped successfully on the live Wayland session with the shared +startup refresh active. The Details drag strip now uses one stable explicit egui ID across layout +passes; a final bounded launch produced no resize-handle ID warning, and the following headless +check again reported zero content changes. + +The architecture audit also moved destination-first import planning/publication, source +fingerprints, static-mesh normalization, animation normalization, and combined model-artifact +publication into `content_pipeline`. The `process-assets` feature's dependency tree no longer +contains the editor crate; the editor retains compatibility re-exports for its UI consumers. + +The extraction correction adds focused coverage for dependent registry edits that can see newly +registered asset IDs, byte-restoring rollback when that dependent edit fails, and source-index +mapping across every draw slot that uses an extracted source material. Re-extraction coverage also +requires an explicit decision for provenance matches, rejects stale external edits, preserves the +existing asset ID on Apply, and restores exact prior bytes if dependent publication fails. + +Property-block coverage verifies owner/slot-local Standard handles, stable handle counts across +repeated application, strongest precedence above an existing Material Instance on the Surface +path, and whole-block rejection for invalid Standard/schema/type/range/texture input. Invalid +blocks leave the resolved base handle visible and emit one deduplicated diagnostic. + +The reviewed promotion transaction has focused coverage for a direct Material and an existing +Material Instance base, exact-slot assignment, one scene-history edit, cancel without mutation, +source/slot/schema/block/target conflicts after review, invalid runtime input, and restoration of +the exact prior file, registry, and runtime-catalog bytes when the scene edit fails. The import +review likewise has focused coverage for non-mutating planning, collision refusal, post-review +source/registry/runtime-catalog changes, complete textual-glTF dependency fingerprints, artifact +processing failure with whole-bundle rollback, adopted-file preservation, and an explicitly pinned +Import To destination that does not change the visible browser location. + +The final native promotion scenario used the editor-only BRP reflection boundary to inject a +two-parameter `MaterialPropertyBlock` into Ground's exact `slot:primitive:surface`. The Inspector +reviewed and published one sparse direct-base Material Instance, assigned it to only that primitive +slot through one history edit, and removed the runtime block only after publication succeeded. +BRP readback proved the exact Material Instance reference and absence of the runtime component; +Undo restored the original Pebble Bricks slot. The temporary instance, registry, catalog, scene, +and Pebble fixture were then restored to their pre-scenario bytes. Runtime blocks remain absent +from Add Component and scene/prefab persistence; no QA-only authoring component was added. + +The final moved/reimported QA fixture hashes were: + +- registry: `e1eb425c2d52be06d7db840df1f14f1d3a96daf2c1876dfc025d861bf0d32b74` +- runtime catalog: `cc296049b656d52e3159cca2e11f52bc4bdef0c922fb0dc00fa5d33f4e3748e7` +- desk static-mesh manifest: `6c69a9f9502fce36cb88e82a7e2e2074e6279ca6da4d49e3e36c1ede35c0577f` +- desk animation manifest: `8a0632bd8b6ea4a5d117214ea168f4020f9399c92bb9da3d7cba1ad371b9e4e8` +- extracted desk Material: `70e498f596d37da5735f5e0d0b1f3834442cd1aefa698232a640fec9d9e422f3` +- skeletal static-mesh manifest: `ee9168b123d89ff57b312a35e54a3a8d5bf5b9dae3ed98e79c7bc24dcb6d6454` +- skeletal animation manifest: `7c14b6ad016db5c09af7f56f50492a05f9bdd89b1bb672e9886bc9a3447e2130` + +The latest 2026-07-14 rerun of `cargo package-project --profile development` wrote zero changed +files and reused 74 unchanged files. The packaged +`assets/content.catalog.ron` was byte-identical to the source runtime catalog with SHA-256 +`cc296049b656d52e3159cca2e11f52bc4bdef0c922fb0dc00fa5d33f4e3748e7`. A bounded native package +launch hydrated the authored scene and visibly rendered the world-space DefaultGrid across UV-less +primitives and the floor. The corrective binary also mapped a live 3426x1384 Hyprland window as +class `bevy-fps-foundation`, title `Bevy FPS Foundation`, before the bounded QA process stopped. + +## Screenshot Captures + +The native PNGs are retained locally pending upload as Gitea issue attachments. Repository image +copies are intentionally not required by the evaluation policy. + +| Capture | Dimensions | SHA-256 | +|---------|------------|---------| +| `blacksite-m2-managed-hidden.png` | 3426x1384 RGB PNG | `97bb710d569039b54218a556865f366cff3bda2ae6ce3afe4a1933db8e58c36b` | +| `blacksite-m2-empty-clears-selection.png` | 3426x1384 RGB PNG | `e162c865202717cf36eec7a37c3f95a4cc4c3c55dcc5d48243b00fe47be0d136` | +| `blacksite-m2-multiselect-fixed-2.png` | 3426x1384 RGB PNG | `729ae5d2b10ca2d8acdd249f19c35f5e6f3a57d9dbb9550f80c371b703336868` | +| `blacksite-m2-multiselect-context.png` | 3426x1384 RGB PNG | `88232360d592a1e4436e86a17fe1356ea10d7766cfbdb98eb6323767cbc16fe6` | +| `blacksite-m2-item-context.png` | 3426x1384 RGB PNG | `6f01f23ea7ad65dffecd0d0e51e57b6a1fcf26cf06cfe6f536f8376557a6649b` | +| `blacksite-m2-details-resized.png` | 3426x1384 RGB PNG | `407ee7779367f83ca2a9c5d6f4a5803584609e319d881265f9544b194fbd0296` | +| `blacksite-m2-office-empty-context.png` | 3426x1384 RGB PNG | `a4004292f70b8a07f6c0d24e8bc47c1716e7de33b4b627a67c4198afe76a00a3` | +| `blacksite-m2-imported-model-details.png` | 3426x1384 RGB PNG | `cf68b41293b823945fdd17e9752d3733f8d38144504f5fdbfd9be7f793ce0205` | +| `blacksite-m2-extraction-review.png` | 3426x1384 RGB PNG | `f4d567d3353536ca1492d48fe59f72ff5b3e4b37c095ef27eaed51347c6a67ec` | +| `blacksite-m2-extraction-transaction.png` | 3426x1384 RGB PNG | `99944392580a88ec14868dbf5a422bacc89ca3b8d8d0750311f429e65250aef6` | +| `blacksite-m2-details-wide-mapped.png` | 3426x1384 RGB PNG | `6aee075b4006909cde1625d5afd42a8be93b9323de93156bd8a55d27ef07e678` | +| `blacksite-m2-defaultgrid-solari.png` | 3426x1384 RGB PNG | `9f23685f6354029ce772878a3f080c7efb045ccc73fce6c19f8fe7802e378ee8` | +| `blacksite-m2-orphan-controls.png` | 3426x1384 RGB PNG | `afe8f07d92c00ac1294b021682a66ea623b3c22158598119d2617f2e0b1dae72` | +| `blacksite-m2-orphan-locate.png` | 3426x1384 RGB PNG | `d7c841747e1a712da80537846699baa8b2f83f91a65ddc298d1d3e665f2e74b7` | +| `blacksite-m2-orphan-applied.png` | 3426x1384 RGB PNG | `c979981fba5b30692c3f858fd349b582bb204b5720702bd684f3721ed5a76350` | +| `blacksite-m2-root-empty-context.png` | 3426x1384 RGB PNG | `960231dc8661f7b012abf7ddf0cc97ce42548e789e911895e12b8b800b29153d` | +| `blacksite-m2-office-move-review-fixed.png` | 3426x1384 RGB PNG | `aaa06af27049858f2c691b9c251197a19a5330919048b59b2c313d06d2bf4471` | +| `blacksite-m2-office-moved-fixed.png` | 3426x1384 RGB PNG | `0cfdd6206d56355583c55e483959c8c154223acf5142015f9f2329da54baf5ca` | +| `blacksite-m2-moved-model-reimported.png` | 3426x1384 RGB PNG | `a6420434c25f72af94f1f4bdb22c8686bd840aad5fe114c192bdefc6db020aec` | +| `blacksite-m2-duplicate-review.png` | 3426x1384 RGB PNG | `f0fae051ca8c807540779de662a5381b3e9f1ac3faaf1cd5412bb7f1cb2835b0` | +| `blacksite-m2-duplicate-committed.png` | 3426x1384 RGB PNG | `3e3b0fd38f0374c47598307d93f5238028d269753742f4b296536f0fe886c1a5` | +| `blacksite-m2-duplicate-undo-clean-editor.png` | 3426x1384 RGB PNG | `45aaa55e2069de66d9eb86f4157fd143d92afa2de07aaefb0bb2cc960958401a` | +| `blacksite-m2-packaged-live-window.png` | 3426x1384 RGB PNG | `de8c19b2dbc2b488e290b5e4d1bd3e1c279388374a81e25a5684dd2454f58ee2` | +| `blacksite-m2-reextract-modal.png` | 1440x3440 RGB PNG | `b7139a3b6da88755be1c53ef9921450449f207a3082248efd8199bd8abdea515` | +| `blacksite-m2-reextract-selected.png` | 1440x3440 RGB PNG | `bd22912ff3d2e4bfdab6469909344a0b0e94f8309665ad876e98e43fe7c80563` | +| `blacksite-m2-reextract-applied.png` | 1440x3440 RGB PNG | `007164ae995c64f048aac638e20664fc173769945691afc3e6a0a6fbf4704380` | +| `blacksite-m2-reextract-undo.png` | 1440x3440 RGB PNG | `3f95eec702d4df6a55d88132c82282311d96d3bca15e2a8ebe11d06ee408461b` | +| `m2-skeletal-import-review.png` | 1440x3440 RGB PNG | `a08dc1d17848d3089d601c8b31c697c5f22b36dda625baf5fa8f52ee7dad2ea2` | +| `m2-skeletal-selected.png` | 1440x3440 RGB PNG | `2867517356b63a1b7627d5d069e7b45150325065ef247ff20a06c00b53c79581` | +| `m2-skeletal-placed.png` | 1440x3440 RGB PNG | `1936dfcd84d9a6e6cf8f293f71d8a4cb89d1761ad9ee982954ad25682cfd999d` | +| `m2-project-default-selected.png` | 1440x3440 RGB PNG | `ee3d5385ccd7af1d099a042fb02400e1e193374ca475a28b3d7f55d483d64547` | +| `m2-project-default-broken-fallback.png` | 1440x3440 RGB PNG | `5efb715a619456afcbee808d13f7b4c2cf24bb975f6b69671401bba1fed07ba0` | +| `m2-inspector-open.png` | 1440x3440 RGB PNG | `7ae9d456d0924ab50012ecbefb8e42847ccb141793ce89487c0fd2fbd2b8307e` | +| `m2-skeletal-scene-override.png` | 1440x3440 RGB PNG | `3c7ec6fce6a09f9e46402aa64dda77c5508703a11149b30297cee80d9e7907af` | +| `m2-import-to-selected.png` | 1440x3440 RGB PNG | `d9cb38de6fca185dab6887324163698b2ac036e8f5eb19f16fb65415172f9605` | +| `m2-import-review.png` | 1440x3440 RGB PNG | `631e1ac1d3c63d72d7c930c3b4789584e15a178f03425fd972df82da51dcfa05` | +| `property-block-promotion-20260717T130300Z.png` | 3426x1384 RGB PNG | `69cd668618206b1cee25142521127882cc785031e8e7cd21684c1eab7c97befa` | + +## Remaining Release Use + +The complete deterministic candidate gate passes on the local candidate tree. After assigning its +exact commit, confirm the clean-tree evidence digest, publish the native/candidate evidence on +Gitea, and finish with issue/milestone readback. The local candidate tree covers static and skeletal +import, edit/reimport/orphan resolution, broken project fallback, scene-slot precedence, reviewed +Import To cancellation, runtime-injected property-block promotion, responsive Penpot material UI, +dirty/save/restart behavior, and packaged runtime behavior. Publication readback—not another +destructive native content-authoring repetition—is the remaining release use of this record. diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/README.md b/docs/editor/evaluations/content-workspace-m2/penpot/README.md new file mode 100644 index 0000000..420add8 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/README.md @@ -0,0 +1,40 @@ +# M2 Penpot Design Exports + +> **Evidence record — not current product guidance.** Use the [documentation index](../../../../README.md) for current behavior and architecture. These exports pin the visual acceptance targets used to close M2 issues #67 and #68. The source document is `Blacksite Editor UI` (`cf1804c7-b801-80c5-8008-57ad3313e5c6`). + +The current Penpot README makes pages 01–30 authoritative, pages 90–91 implementation masters only, and page 99 forbidden as current guidance. The `CODEGEN MAP` block is handoff documentation and never renders in Blacksite. + +## Responsive Inspector targets + +| Export | Penpot page and shape | Required match | +|---|---|---| +| [Materials — wide](materials-wide-620.svg) | Page 01, `ad983554-05bb-80cb-8008-5698dfab667e` | 620 px Inspector reference; 596 px material slot; 569 px parameter body; deterministic wide rows and actions. | +| [Materials — narrow](materials-narrow-420.svg) | Page 01, `ad983554-05bb-80cb-8008-5698e08d425a` | 420 px Inspector floor; 396 px material slot; 369 px parameter body; two-line input rows with no clipping or horizontal scroll. | +| [Static Mesh Renderer — wide](static-mesh-renderer-wide-760.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5315abf0a` | Full 760 px renderer composition, stable slot ordering, shared component chrome, and repeated material slots. | +| [Static Mesh Renderer — narrow](static-mesh-renderer-narrow-480.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5419afad2` | Full 480 px renderer composition and bounded narrow reflow. | + +## Shared component targets + +| Export | Penpot page and shape | Required match | +|---|---|---| +| [Foundation components catalog](foundation-components-catalog.svg) | Page 11, `6cb78075-fbd0-8045-8008-5801d6740577` | Authoritative controls, field sizing, selection behavior, asset/drop states, vector layouts, buttons, disclosure, and usage rules shared by Inspector components. | +| [Inspector component header](component-header-50.svg) | Page 12, `83c3baf0-0e37-808a-8008-57cd2388f9b9` | One reusable 50 px component header and action/status geometry. | +| [Array header](array-header-40.svg) | Page 12, `83c3baf0-0e37-808a-8008-57cd23947466` | One reusable 40 px collection header. | +| [Composition rule](component-composition-rule.svg) | Page 12, `83c3baf0-0e37-808a-8008-57cd23a58de0` | Component containment and section ownership rule. | +| [Material components and states](material-components-states.svg) | Page 13, `83c3baf0-0e37-808a-8008-57d8c2a3305d` | Shared slot anatomy and direct, inherited, dirty, processing, failed, read-only, imported-source, and built-in states. | + +## Color-picker targets + +| Export | Penpot page and shape | Required match | +|---|---|---| +| [Wheel](color-picker-wheel.svg) | Page 22, `83c3baf0-0e37-808a-8008-57cd9b7b0667` | Wheel mode layout, current/previous values, alpha, recents, and footer behavior. | +| [Adjusted wheel state](color-picker-adjusted.svg) | Page 22, `83c3baf0-0e37-808a-8008-57cd9bbd9c3b` | Dirty preview state and exact value presentation. | +| [Sliders](color-picker-sliders.svg) | Page 23, `83c3baf0-0e37-808a-8008-57cda8a2c70f` | Sliders mode layout and conversion controls. | +| [Presets](color-picker-presets.svg) | Page 23, `83c3baf0-0e37-808a-8008-57cda8c84257` | Presets mode, recent/user palette presentation, and bounded footer. | + +## Issue ownership + +- **#68** owns exact Inspector/component/material/color-picker geometry and responsive behavior against these exports. +- **#67** owns the live authored-document and save-state behavior represented by the material-state and adjusted-color exports: interaction remains memory-only until explicit save, with distinct unsaved, processing, failed, and conflict state. +- **#65** owns consolidated native comparison evidence across wide, narrow, short-height, multi-slot, overlay, save, restart, and failure scenarios. +- **#59** indexes the design authority and requires the relevant child acceptance targets to be satisfied before epic closure. diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.svg b/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.svg new file mode 100644 index 0000000..ead7a87 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.svg @@ -0,0 +1,14 @@ +Mesh renderers8Each mesh owns its material slot array+Add renderer diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.svg b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.svg new file mode 100644 index 0000000..8d01e4d --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.svg @@ -0,0 +1,14 @@ +Base ColorsRGB · RGBAWheelSlidersPresetsDrag ring for hue · square for S/VCURRENTPREVIOUSHEX#53D3E6RGBAR83G211B230A255HSVH187°S64%V90%ALPHARECENTEnter apply · Esc cancelCancelApplyAdvancedPlanned — renderer support pending diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.svg b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.svg new file mode 100644 index 0000000..6b4f633 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.svg @@ -0,0 +1,14 @@ +Base ColorsRGB · RGBAWheelSlidersPresetsRECENTEnter apply · Esc cancelCancelApplyCURATED MATERIAL COLORSSELECTED#E7D4B4Save preset diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.svg b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.svg new file mode 100644 index 0000000..1320c31 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.svg @@ -0,0 +1,14 @@ +Base ColorsRGB · RGBAWheelSlidersPresetsRECENTEnter apply · Esc cancelCancelApplyRGBA CHANNELSR231G212B180A255HSV CHANNELSH38°S22%V91%#E7D4B4Drag a channel or edit its numeric field diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.svg b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.svg new file mode 100644 index 0000000..b2c4a7f --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.svg @@ -0,0 +1,14 @@ +Base ColorsRGB · RGBAWheelSlidersPresetsDrag ring for hue · square for S/VCURRENTPREVIOUSHEX#E7D4B4RGBAR231G212B180A255HSVH38°S22%V91%ALPHARECENTEnter apply · Esc cancelCancelApply diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.svg b/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.svg new file mode 100644 index 0000000..fa66529 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.svg @@ -0,0 +1,14 @@ +Composition ruleInspector-specific pages should compose these shared headers with domain primitives rather than duplicating their internals. diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.svg b/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.svg new file mode 100644 index 0000000..0601958 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.svg @@ -0,0 +1,14 @@ +Static Mesh Renderer8 renderers · 12 material slotsActive diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.svg b/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.svg new file mode 100644 index 0000000..e2bf924 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.svg @@ -0,0 +1,14 @@ +Blacksite FoundationsAuthoritative reusable controls organized by interaction semantics. Use these components instead of rebuilding look-alikes inside feature inspectors.SELECTIONExclusive, multi-select, toggle, checkbox, and segmented patterns. Choose by data semantics, not by whichever widget looks convenient.CHECKBOXESVisibleVisibleTOGGLEDouble SidedSEGMENTEDSeparateORMSINGLE SELECTGMULTI SELECT — CLOSEDRGB3 selectedMULTI SELECT — OPENRGB3 selectedRGBASingle Select: exactly one value. Multi Select: zero or more values, summarized in the trigger and edited through a checklist. Reuse for enums, modes, tags, layers, flags, filters, bitmasks, channels, platforms, and feature sets.TEXT & GENERAL FIELDSUser-authored strings, search, general enum fields, and scalar input primitives.SINGLE-LINE TEXTEnter value…SEARCHSearch…MULTILINE TEXTMultiline text…LABELED COMBOSurfaceOpaqueNUMERIC & PARAMETER CONTROLSScalar numbers, units, ranges, booleans, sliders, and color values.NUMERIC0.72WITH UNIT1.00m/sRANGE0.001.00BOOLEANVisibleEnabledSLIDERCOLORVECTOR PARAMETERSSame fixed dimension, two presentation variants. Horizontal for wide inspectors; vertical for narrow layouts. Preserve axis order and colors.VECTOR2 · HORIZONTALX0.00Y0.00VECTOR2 · VERTICALX0.00Y0.00VECTOR3 · HORIZONTALX0.00Y0.00Z0.00VECTOR3 · VERTICALX0.00Y0.00Z0.00VECTOR4 · HORIZONTALX0.00Y0.00Z0.00W0.00VECTOR4 · VERTICALX0.00Y0.00Z0.00W0.00ASSET FIELDS & DROP STATESAssigned fields and drag-target states. Valid targets use cyan focus treatment; invalid targets retain the dark surface with an error border.FULL ASSET FIELDmetal_office_desk / Primitive 0assets/Furniture/Office/metal_office_desk.meshCOMPACT ASSET PICKERT_Wood_ORMDROP VALIDDROP INVALIDDrop to assign assetCompatible asset · release to assignIncompatible assetThis field does not accept that asset typeBUTTONS & DISCLOSURESmall action primitives and collapsible structural controls.ICON BUTTONDISCLOSURE · COLLAPSEDAdvancedPlanned — renderer support pendingDISCLOSURE · EXPANDEDAdvancedPlanned — renderer support pending diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.svg b/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.svg new file mode 100644 index 0000000..1e35cd2 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.svg @@ -0,0 +1,14 @@ +Materials ComponentsAuthoritative material editor components shared by the Materials inspector and mesh material-slot workflows.MATERIAL SLOT · EXPANDEDReusable inline material editor. This is the source used by both Inspector Materials and Static Mesh Renderer slot editing.M_Wood_Planksmaterials/wood/M_Wood_Planks.matStandard LitCompiledPARAMETERSSurfaceOpaqueSeparateORMDouble SidedInputs6 bindingsBase Color#E7D4B4T_Wood_ORMMetallic0.72GT_Wood_ORMRoughness0.72GT_Wood_ORMOcclusion0.72GT_Wood_ORMNormal0.72GT_Wood_ORMEmissive#E7D4B4T_Wood_ORMUVOffsetX0.00Y0.00TilingX1.00Y1.00AdvancedPlanned — renderer support pendingCODEGEN DOCUMENTATIONReusable implementation notes for mapping visual component composition into Bevy egui.CODEGEN MAPMaterials list repeated collapsible slot framesSlot header is the disclosure target; its body is nested beneath itIndented spine + PARAMETERS body communicate expanded ownershipResponsive rows preserve alignment without redundant column labels diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.svg b/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.svg new file mode 100644 index 0000000..c5cd57d --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.svg @@ -0,0 +1,14 @@ +MaterialsM_Wood_Planksmaterials/wood/M_Wood_Planks.matStandard LitCompiledPARAMETERSSurfaceOpaqueSeparateORMDouble SidedInputs6 bindingsBase Color#E7D4B4T_Wood_ORMMetallic0.72GT_Wood_ORMRoughness0.72GT_Wood_ORMOcclusion0.72GT_Wood_ORMNormal0.72GT_Wood_ORMEmissive#E7D4B4T_Wood_ORMUVOffsetX0.00Y0.00TilingX1.00Y1.00AdvancedPlanned — renderer support pendingCODEGEN MAPMaterials list repeated collapsible slot framesSlot header is the disclosure target; its body is nested beneath itIndented spine + PARAMETERS body communicate expanded ownershipResponsive rows preserve alignment without redundant column labels diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.svg b/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.svg new file mode 100644 index 0000000..dc3d7dd --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.svg @@ -0,0 +1,14 @@ +MaterialsM_Wood_Planksmaterials/wood/M_Wood_Planks.matStandard LitCompiledPARAMETERSSurfaceOpaqueSeparateORMDouble SidedInputs6 bindingsBase Color#E7D4B4T_Wood_ORMMetallic0.72GT_Wood_ORMRoughness0.72GT_Wood_ORMOcclusion0.72GT_Wood_ORMNormal0.72GT_Wood_ORMEmissive#E7D4B4T_Wood_ORMUVOffsetX0.00Y0.00TilingX1.00Y1.00AdvancedPlanned — renderer support pendingCODEGEN MAPMaterials list repeated collapsible slot framesSlot header is the disclosure target; its body is nested beneath itIndented spine + PARAMETERS body communicate expanded ownershipResponsive rows preserve alignment without redundant column labels diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.svg b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.svg new file mode 100644 index 0000000..a47bde3 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.svg @@ -0,0 +1,14 @@ +Static Mesh Renderer3 renderers · 5 material slotsActiveMesh renderers3Each renderer owns the slots exposed by its mesh+Add rendererRENDERER 0metal_office_desk / Primitive 0draw:scene0:node2:mesh2:primitive03 slotsRenderer propertiesMesh + render flagsNamemetal_office_desk / Primitive 0Meshmetal_office_desk / Primitive 0assets/Furniture/Office/metal_office_desk.meshFlagsVisibleCast shadowsReceive shadowsMaterial slots3 exposed by meshIndex mappedSLOT0M_Wood_Planksmaterials/wood/M_Wood_Planks.matStandard LitCompiledSLOT1M_Painted_Metalmaterials/office/M_Painted_Metal.matCompiledSLOT2No material assignedDrop a material here or choose from the browserChoose materialmetal_office_desk_drawer_06 / Primitive 0Renderer 1 · 1 material slot · Visible · Shadows on1 slotmetal_office_desk_tray_01 / Primitive 0Renderer 2 · 1 material slot · Hidden · Shadows on1 slot diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.svg b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.svg new file mode 100644 index 0000000..d9da0a3 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.svg @@ -0,0 +1,14 @@ +Static Mesh Renderer3 renderers · 5 material slotsActiveMesh renderers3Each renderer owns the slots exposed by its mesh+Add rendererRENDERER 0metal_office_desk / Primitive 0draw:scene0:node2:mesh2:primitive03 slotsRenderer propertiesMesh + render flagsNamemetal_office_desk / Primitive 0Meshmetal_office_desk / Primitive 0assets/Furniture/Office/metal_office_desk.meshFlagsVisibleCast shadowsReceive shadowsMaterial slots3 exposed by meshIndex mappedSLOT0M_Wood_Planksmaterials/wood/M_Wood_Planks.matStandard LitCompiledPARAMETERSSurfaceOpaqueSeparateORMDouble SidedInputs6 bindingsBase Color#E7D4B4T_Wood_ORMMetallic0.72GT_Wood_ORMRoughness0.72GT_Wood_ORMOcclusion0.72GT_Wood_ORMNormal0.72GT_Wood_ORMEmissive#E7D4B4T_Wood_ORMUVOffsetX0.00Y0.00TilingX1.00Y1.00AdvancedPlanned — renderer support pendingSLOT1M_Painted_Metalmaterials/office/M_Painted_Metal.matCompiledSLOT2No material assignedDrop a material here or choose from the browserChoose materialmetal_office_desk_drawer_06 / Primitive 0Renderer 1 · 1 material slot · Visible · Shadows on1 slotmetal_office_desk_tray_01 / Primitive 0Renderer 2 · 1 material slot · Hidden · Shadows on1 slot diff --git a/docs/editor/evaluations/deterministic-asset-fingerprints/README.md b/docs/editor/evaluations/deterministic-asset-fingerprints/README.md index e3a9201..d9c3e64 100644 --- a/docs/editor/evaluations/deterministic-asset-fingerprints/README.md +++ b/docs/editor/evaluations/deterministic-asset-fingerprints/README.md @@ -1,5 +1,7 @@ # Deterministic Imported-Asset Fingerprints Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + **Date:** 2026-07-13 **Issue:** [Gitea #56](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/56) diff --git a/docs/editor/evaluations/fbx-external-texture-dependencies/README.md b/docs/editor/evaluations/fbx-external-texture-dependencies/README.md index a2f3759..c8d0fda 100644 --- a/docs/editor/evaluations/fbx-external-texture-dependencies/README.md +++ b/docs/editor/evaluations/fbx-external-texture-dependencies/README.md @@ -1,5 +1,7 @@ # FBX External Texture Dependency Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + **Date:** 2026-07-13 **Issue:** [Gitea #58](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/58) diff --git a/docs/editor/evaluations/guarded-shutdown-savepoints/README.md b/docs/editor/evaluations/guarded-shutdown-savepoints/README.md index 2e9c1c4..fa163be 100644 --- a/docs/editor/evaluations/guarded-shutdown-savepoints/README.md +++ b/docs/editor/evaluations/guarded-shutdown-savepoints/README.md @@ -1,5 +1,7 @@ # Guarded Shutdown And Clean Savepoints Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + **Date:** 2026-07-13 **Issue:** [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55) diff --git a/docs/editor/evaluations/h1-signoff.md b/docs/editor/evaluations/h1-signoff.md index 3c1c543..0803435 100644 --- a/docs/editor/evaluations/h1-signoff.md +++ b/docs/editor/evaluations/h1-signoff.md @@ -1,6 +1,6 @@ # H1 Gate Sign-Off — Game Development Ready -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Evaluator:** implementation pass diff --git a/docs/editor/evaluations/h2-signoff.md b/docs/editor/evaluations/h2-signoff.md index 0984984..26038a6 100644 --- a/docs/editor/evaluations/h2-signoff.md +++ b/docs/editor/evaluations/h2-signoff.md @@ -1,6 +1,6 @@ # H2 Gate Sign-Off — Content Pipeline Maturity -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Result:** Pass with waivers diff --git a/docs/editor/evaluations/h3-signoff.md b/docs/editor/evaluations/h3-signoff.md index 8a49722..17a91be 100644 --- a/docs/editor/evaluations/h3-signoff.md +++ b/docs/editor/evaluations/h3-signoff.md @@ -1,6 +1,6 @@ # H3 Gate Sign-Off — Gameplay Authoring -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Result:** Pass diff --git a/docs/editor/evaluations/h4-signoff.md b/docs/editor/evaluations/h4-signoff.md index 9f9cdae..9ad0446 100644 --- a/docs/editor/evaluations/h4-signoff.md +++ b/docs/editor/evaluations/h4-signoff.md @@ -1,6 +1,6 @@ # H4 Gate Sign-Off — Multiplayer Tooling -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Result:** Pass with waivers diff --git a/docs/editor/evaluations/h5-signoff.md b/docs/editor/evaluations/h5-signoff.md index ebc027a..a712203 100644 --- a/docs/editor/evaluations/h5-signoff.md +++ b/docs/editor/evaluations/h5-signoff.md @@ -1,6 +1,6 @@ # H5 Gate Sign-Off — Extensibility -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Result:** Pass diff --git a/docs/editor/evaluations/h6-signoff.md b/docs/editor/evaluations/h6-signoff.md index cb45bf6..fd6a224 100644 --- a/docs/editor/evaluations/h6-signoff.md +++ b/docs/editor/evaluations/h6-signoff.md @@ -1,6 +1,6 @@ # H6 Gate Sign-Off — Production / Framework 1.0 -> Historical baseline only. Current production authority: [production-readiness matrix](production-readiness/). +> **Historical record — not current implementation guidance.** Current production authority: [production-readiness matrix](production-readiness/README.md). **Date:** 2026-05-30 **Result:** Pass with waivers diff --git a/docs/editor/evaluations/material-library-targeted-drop/README.md b/docs/editor/evaluations/material-library-targeted-drop/README.md index e4c67ad..c3b490d 100644 --- a/docs/editor/evaluations/material-library-targeted-drop/README.md +++ b/docs/editor/evaluations/material-library-targeted-drop/README.md @@ -1,5 +1,7 @@ # Material Library And Targeted Drop Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Branch: `codex/material-library-targeted-drop` Implementation commit: `02247f0` diff --git a/docs/editor/evaluations/material-renderer-foundation/README.md b/docs/editor/evaluations/material-renderer-foundation/README.md index 7c1a785..ef2752e 100644 --- a/docs/editor/evaluations/material-renderer-foundation/README.md +++ b/docs/editor/evaluations/material-renderer-foundation/README.md @@ -1,5 +1,7 @@ # Renderer, Material, and Component Foundation Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-11 Branch: `codex/renderer-material-component-foundation` diff --git a/docs/editor/evaluations/native-dialog-responsiveness/README.md b/docs/editor/evaluations/native-dialog-responsiveness/README.md index ab0f446..f91d0f7 100644 --- a/docs/editor/evaluations/native-dialog-responsiveness/README.md +++ b/docs/editor/evaluations/native-dialog-responsiveness/README.md @@ -1,5 +1,7 @@ # Native Dialog Responsiveness Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Branch: `codex/non-blocking-native-dialogs` Implementation commits: `d038cf3` and the closing commit for Gitea #52 diff --git a/docs/editor/evaluations/navigation-authoring/README.md b/docs/editor/evaluations/navigation-authoring/README.md index c066fd3..f04dee2 100644 --- a/docs/editor/evaluations/navigation-authoring/README.md +++ b/docs/editor/evaluations/navigation-authoring/README.md @@ -1,5 +1,7 @@ # Navigation Authoring Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Branch: `codex/renderer-material-component-foundation` diff --git a/docs/editor/evaluations/operator-invariants/README.md b/docs/editor/evaluations/operator-invariants/README.md index 1899283..03c6d36 100644 --- a/docs/editor/evaluations/operator-invariants/README.md +++ b/docs/editor/evaluations/operator-invariants/README.md @@ -1,5 +1,7 @@ # Operator Invariants Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Gitea issue: diff --git a/docs/editor/evaluations/physics-placement/README.md b/docs/editor/evaluations/physics-placement/README.md index 5f7d1c5..ac84c63 100644 --- a/docs/editor/evaluations/physics-placement/README.md +++ b/docs/editor/evaluations/physics-placement/README.md @@ -1,5 +1,7 @@ # Physics Placement Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 ![Three selected props settled on the committed placement floor](physics-placement-settled.png) diff --git a/docs/editor/evaluations/production-readiness/README.md b/docs/editor/evaluations/production-readiness/README.md index dac63fa..3854642 100644 --- a/docs/editor/evaluations/production-readiness/README.md +++ b/docs/editor/evaluations/production-readiness/README.md @@ -1,8 +1,10 @@ # Production-Readiness Acceptance Matrix -**Matrix version:** 0.7 +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. -**Last audit:** 2026-07-13 +**Matrix version:** 0.8 + +**Last audit:** 2026-07-14 **Release-candidate commit:** Not nominated @@ -35,15 +37,15 @@ another commit, a dirty worktree, or an older package do not transfer to the can | G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. | | G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. | | G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. | -| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS, runs both project/sample content gates, and asserts a pristine checkout. The [#56 feature run](../deterministic-asset-fingerprints/) passed native startup and both validators from a fresh LFS-hydrated worktree at `cbd380a`; [#58](../fbx-external-texture-dependencies/) passed both validators and clean native startup at `3e30c61`. No release candidate is nominated and no candidate CI run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. | +| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS, runs both project/sample content gates, and asserts a pristine checkout. The [#56 feature run](../deterministic-asset-fingerprints/) passed native startup and both validators from a fresh LFS-hydrated worktree at `cbd380a`; [#58](../fbx-external-texture-dependencies/) passed both validators and clean native startup at `3e30c61`. The local M2 content-workspace run passed `process-assets --check`, both validators, full all-feature tests, and strict all-feature lint over 48 assets, but its dirty worktree is not candidate evidence. No release candidate is nominated and no candidate CI run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. | | G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. | -| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, deterministic imported-asset fingerprints `#56`, and external FBX dependency handling `#58` have complete feature-level acceptance; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Property-block application `#53` remains P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. | +| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, deterministic imported-asset fingerprints `#56`, external FBX dependency handling `#58`, and property-block application/promotion `#53` have complete feature-level implementations; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. | ## Deliverables | ID | Deliverable | State | Evidence or next action | |----|-------------|-------|-------------------------| -| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.6. | +| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.8. | | D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/), commands, exact-implementation native evidence, and [fresh-checkout fingerprint protocol](../deterministic-asset-fingerprints/) exist. Nominate a release candidate and rerun the complete set from a clean LFS-hydrated checkout. | | D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. | @@ -57,7 +59,7 @@ another commit, a dirty worktree, or an older package do not transfer to the can | Multi-scene composition | Implemented | Not rerun | | Five-area editor sample pack | Implemented with manifest, native menu, typed scene gate, and headless validation | Exact-implementation source/native acceptance passed at `d52cc2e`; not rerun as one release candidate | | Brush blockout/edit/CSG | Implemented foundation; `#37` open | Not signed off | -| Material catalog and assignment | Renderer/material foundation `#51`, Material Library, and exact targeted drops accepted; optional property-block promotion is `#53` | Not rerun as one candidate | +| Material catalog and assignment | Renderer/material foundation `#51`, Material Library, exact targeted drops, and MaterialPropertyBlock application/promotion `#53` are implemented | Not rerun as one candidate | | Rendering volumes and look development | Deterministic visible rendering fixture is in the sample pack | Not rerun as one candidate | | Terrain authoring | `#22`-`#24` source/live acceptance complete; M3 closed | Feature-level live acceptance exists; not rerun as one candidate. Packaged-runtime acceptance owner-deferred. | | Physics placement | `#25` source/live acceptance complete | Feature-level live acceptance exists; not rerun as one candidate. Packaged-runtime acceptance owner-deferred. | @@ -73,6 +75,7 @@ another commit, a dirty worktree, or an older package do not transfer to the can | Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate | | Imported asset identity and generated artifacts | Implemented; `#56` uses shared BLAKE3 fingerprints, byte-preserving semantic publication, and normalized registry order | Fresh LFS checkout, native startup, mtime-only drift, validators, and artifact hashes passed at `cbd380a`; not rerun as one candidate | | FBX external texture dependencies | Implemented; `#58` uses one sandboxed resolver, transactional bundle import, labeled loader images, authoritative manifests, and explicit Source Materials/Authoring Override semantics | Sibling/`.fbm`/missing/traversal/symlink tests, both validators, exact-implementation native chair QA, and a controlled broken fixture passed at `3e30c61`; not rerun as one candidate | +| Content workspace and import authoring | Registry v3, shared content pipeline, managed-path hiding, reviewed destination-first Import Here/Import To, file-manager multi-selection/context actions, transactional extract-and-map model defaults, stable-ID move/copy semantics, derived-aware Trash/restore, DefaultGrid, and reviewed exact-slot property-block promotion are implemented locally | [M2 native evaluation](../content-workspace-m2/) records the Poly Haven import/extraction, reimport orphan Locate/Clear, reference-safe folder move, fresh-ID Duplicate, guarded duplicate undo including generated manifests, Forward/Solari fallback, deterministic 48-asset headless processing, and a packaged DefaultGrid launch through 2026-07-14. Focused tests cover Import To/final-path review and transactional property-block promotion. Full all-feature tests/lint and validators pass with zero blockers. Exact committed-candidate rerun, clean-worktree assertion, Gitea attachments, and independent signoff remain pending. | ## Candidate Validation Commands @@ -88,6 +91,7 @@ cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings cargo test --workspace cargo validate-levels --project . cargo validate-samples --project . +cargo process-assets --project . --check --json cargo bake-navigation --project . --check ``` diff --git a/docs/editor/evaluations/sample-regression-pack/README.md b/docs/editor/evaluations/sample-regression-pack/README.md index ac0f9d2..c0ddb90 100644 --- a/docs/editor/evaluations/sample-regression-pack/README.md +++ b/docs/editor/evaluations/sample-regression-pack/README.md @@ -1,5 +1,7 @@ # Sample Regression Pack Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + **Date:** 2026-07-13 **Issue:** [Gitea #32](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/32) diff --git a/docs/editor/evaluations/scoped-ui-actions/README.md b/docs/editor/evaluations/scoped-ui-actions/README.md index 98ddabd..1246f0c 100644 --- a/docs/editor/evaluations/scoped-ui-actions/README.md +++ b/docs/editor/evaluations/scoped-ui-actions/README.md @@ -1,5 +1,7 @@ # Scoped UI Action Safety Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + **Date:** 2026-07-13 **Issue:** [Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57) diff --git a/docs/editor/evaluations/terrain-foundation/README.md b/docs/editor/evaluations/terrain-foundation/README.md index 45de66c..562ce23 100644 --- a/docs/editor/evaluations/terrain-foundation/README.md +++ b/docs/editor/evaluations/terrain-foundation/README.md @@ -1,5 +1,7 @@ # Terrain Foundation Evaluation +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Date: 2026-07-12 Branch: `codex/terrain-foundation` Gitea issue: `#22` diff --git a/docs/editor/evaluations/terrain-material-layers/README.md b/docs/editor/evaluations/terrain-material-layers/README.md index 120a45d..c8e2ae3 100644 --- a/docs/editor/evaluations/terrain-material-layers/README.md +++ b/docs/editor/evaluations/terrain-material-layers/README.md @@ -1,5 +1,7 @@ # Terrain Material Layers Acceptance +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + ## Evidence ![Terrain layer paint stroke](terrain-material-paint-stroke.png) diff --git a/docs/editor/evaluations/terrain-sculpt-tools/README.md b/docs/editor/evaluations/terrain-sculpt-tools/README.md index a209d65..1a89bcc 100644 --- a/docs/editor/evaluations/terrain-sculpt-tools/README.md +++ b/docs/editor/evaluations/terrain-sculpt-tools/README.md @@ -1,5 +1,7 @@ # Terrain Sculpt Tools Acceptance +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) for current behavior and architecture. + Issue: Gitea `#23` Scope: native Wayland debug editor; packaged acceptance remains deferred by project-owner direction. diff --git a/docs/editor/material-system.md b/docs/editor/material-system.md index ffa8823..39b6b0b 100644 --- a/docs/editor/material-system.md +++ b/docs/editor/material-system.md @@ -1,28 +1,65 @@ # Material System -Blacksite uses shared project materials and renderer-owned material slots. Static and skinned mesh -renderers expose the same assignment workflow, but remain separate components with separate mesh -and hydration ownership. The permanent contracts are [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md) -and [ADR 0036](../adr/0036-surface-abi-and-solari-parity.md). +Blacksite uses shared project materials and renderable-owned material slots. Primitives, static +meshes, and skinned meshes expose the same assignment workflow, but retain separate geometry and +hydration ownership. The permanent contracts are [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md), +[ADR 0036](../adr/0036-surface-abi-and-solari-parity.md), +[ADR 0046](../adr/0046-schema-driven-material-inputs-and-processed-textures.md), and +[ADR 0047](../adr/0047-editor-authored-asset-documents.md). Material authoring presentation follows +the shared [editor design system](design-system.md) and +[ADR 0049](../adr/0049-penpot-led-editor-visual-system.md). ## Mental model | Concept | Owns | Saved? | |---------|------|--------| -| Material asset | Reusable shader choice, render state, parameters, and textures | Yes, under `assets/materials/` | -| Material instance | Overrides over one direct base material | Yes, under `assets/materials/` | -| Renderer material slot | A stable reference on one static or skinned renderer | Yes, in scenes/prefabs | +| Material asset | Reusable shader choice, render state, parameters, and textures | Yes, anywhere under `assets/` | +| Material instance | Overrides over one direct base material | Yes, anywhere under `assets/` | +| Material slot assignment | Only the actor/prefab override for a stable primitive or renderer slot | Yes, in scenes/prefabs | | Imported source material | Read-only default supplied by model import | Yes, as the slot fallback | -| Material property block | Reserved runtime-only per-renderer/per-slot override schema; application/promotion tracked by Gitea #53 | No | +| Model material selection | Source, project Material/Instance, or Default for one stable imported slot | Yes, in registry import metadata | +| Property block | Per-owner/per-slot runtime parameter and texture overrides | No | +| DefaultGrid | Immutable engine fallback for missing or unassigned surfaces | Engine built-in | -A renderer slot stores a `MaterialRef`, not a copy of the material. The registry UUID and subasset +A material slot assignment stores a `MaterialRef`, not a copy of the material. Model defaults and +imported source selections stay asset-owned in import metadata and runtime manifests instead of +being copied into actor overrides. Every primitive has one stable +`slot:primitive:surface`; mesh renderers use imported stable slot IDs. The registry UUID and subasset ID identify the target; the cached path is only a loading hint. A material instance may reference a material, but may not reference another instance. -`MaterialPropertyBlocks` are excluded from authored persistence, but they are not yet consumed by -renderer binding. Runtime application and the transactional **Promote to Material Instance** -workflow are tracked by Gitea #53. Use an ordinary direct-base Material Instance for reusable -variations until that issue closes. +Resolution is strongest-first: property block, scene/prefab slot, model default, imported source, +project default, then engine DefaultGrid. A broken explicit reference reports a diagnostic and uses +DefaultGrid; it does not silently expose a weaker source assignment. Clearing an assignment is the +explicit way to return to the next layer. + +Property blocks are applied after base resolution and cached per owner/slot for both Standard and +Surface materials, including above any sparse values already present on a selected Material +Instance. They never mutate a shared Material asset and remain excluded from scene/prefab +persistence. Unknown properties, schema/type/range mismatches, duplicate names, and broken texture +references reject the complete block with a diagnostic while the resolved base stays visible. +DefaultGrid cannot be edited, instanced, or used as a promotion base. + +The editor registers this runtime-only component with Bevy reflection so trusted local BRP tooling +can inject a block for debugging, gameplay integration, and promotion acceptance. Reflection does +not make the component an authoring component: it remains absent from Add Component, excluded from +scene/prefab serialization, and is never published by an asset save. + +Surface evaluators specialize forward and deferred material fragments only. Bevy's standard shader +remains authoritative for depth, normal, and motion-vector-only prepasses, so those passes never +try to write a deferred G-buffer attachment that is not present. + +When a selected primitive, static renderer, or skinned renderer has a runtime block, its exact material-slot card shows +the override counts and **Promote to Material Instance**. Promotion requires an explicit project +Material or direct-base Material Instance; imported source and DefaultGrid are intentionally +rejected. **Promote to Material Instance** opens a review showing the selected/direct base, exact +slot, sparse override counts, and collision-safe target path. The editor fingerprints the selected +asset, direct base, schema, runtime block, slot assignment, and absent target while the review is +open. Commit merges an existing instance's sparse values with the runtime block, creates one +direct-base Material Instance in the current Content Browser folder, assigns it to that exact scene +slot through one history transaction, and clears the runtime block only after file, registry, and +assignment publication succeed. Cancel or any conflict leaves the block, registry, scene, and +history unchanged. ## Assign materials @@ -36,32 +73,74 @@ Assign through either exact workflow: 1. Drag a Material or Material Instance from the Material Library or Asset Browser onto the desired viewport surface. A hydrated static/skinned draw targets its exact stable renderer slot, a - primitive targets its actor material, and a brush targets the exact face under the pointer. + primitive targets its stable surface slot, and a brush targets the exact face under the pointer. 2. Check the pointer card and target visual. Green names a valid actor/slot/face; red gives the blocking reason. Release commits one undoable assignment. Move away, leave the viewport, press Escape, or right-click to restore the pre-drag state without history or dirtying the scene. -3. Alternatively, select a static- or skinned-mesh actor and use **Browse** or **Select** on the - named slot in its renderer inspector. **Locate** reveals the current asset in the Asset Browser; - **Clear** removes only the explicit assignment and restores the read-only imported source. +3. Alternatively, select a primitive, static-mesh, or skinned-mesh actor and use its ordered + **Materials** section. Each 82 px expanded or 52 px collapsed slot header is an assignment + target; there is no adjacent drop + box. A valid Material/Instance drag uses the active theme's + selection highlight. A Texture drag + highlights the target as invalid and explains that textures must be authored inside a Material. + **Locate** reveals the current asset; **Clear** removes only the explicit assignment and restores + the inherited model source, project default, or DefaultGrid. -From the Asset Browser, **Apply Material** assigns the selected material to every material slot on -each selected static or skinned renderer. This is the explicit bulk path. A viewport hit never -silently broadens to all slots. +From the Asset Browser, **Apply Material** assigns the selected material to a primitive surface or +every material slot on each selected static/skinned renderer. This is the explicit bulk path. A +viewport hit never silently broadens to all slots. -Texture drops target a primitive's base-color texture or one brush face. Direct Texture drops on a -renderer slot are rejected because a renderer slot stores a Material reference; create or edit a -Material Instance and assign the texture there. +Direct Texture drops on primitive or mesh slots are rejected because slots store Material +references. Brush faces retain their specialized direct texture path. Create or edit a Material or +Material Instance, assign the texture there, then assign that asset to the slot. + +Expand any shared slot to inspect the actual effective material. At the current 620 px wide +reference the slot/body are 596/569 px; at the hard 420 px Inspector floor they are 396/369 px and +six parameter rows reflow to 58 px two-line rows. Project Materials and Instances open the +same asset-keyed editor used by the Content Browser. Valid parameter changes +update `blacksite_surface`'s live document overlay on every interaction frame; its single cache +mutates the shared Standard or Surface handle in place, and there are no Apply/Revert buttons. +The asset-keyed authored document becomes **UNSAVED**, survives selection changes, and performs no +source write, Git refresh, watcher work, thumbnail invalidation, or processing when the pointer is +released. `Ctrl+S` publishes the last edited context; `Ctrl+Shift+S` publishes every dirty editor +document. Scalar, color, label, shader-value, emissive, and render-state saves queue no derived +work; ARM/ORM binding changes queue one coalesced packing job. **Create Instance and Assign** transactionally publishes a +direct-base instance and assigns only the current actor slot for local variation. Imported source +parameters remain read-only; **Extract Editable…** opens extraction for that single source and +assigns the resulting project Material only to the selected actor slot. The project default is +expandable as inherited shared content. DefaultGrid is visible but read-only. + +The final panel shows only Shader, Surface, Inputs, shared UV, and supported Advanced Inputs. +Standard Lit stores seven inputs but reports six visible bindings: Emissive Intensity is a +companion of Emissive color and is edited/restored through the same popup. Offset/Tiling changes affect every texture sampler on the Material in +both Standard and Surface rendering; Material Instances may override them sparsely and Reset +restores the base values. Texture fields open their picker on field click and expose Locate and +Clear without a redundant folder action. Direct actor assignments do not receive an “Explicit” label. Source paths, fingerprints, +stable IDs, orphan state, and provenance are secondary diagnostics in overflow rather than material +parameters. The sphere preview and bounded 229.5 px reference identity live in the 82 px assignment block; preview, +identity, and Browse open the compact current/recent Material menu without making Shader or +action clicks assign anything. No second preview, drop box, or material-slot list is rendered. + +Project textures are loaded from runtime-catalog sampling data rather than Bevy's default sampler. +Repeat, Clamp, and Mirror map to Repeat, Clamp-to-edge, and Mirror-repeat for U/V/W; authored +filtering and anisotropy apply to Standard, Surface/Solari, property-block bases, and project +material previews. Packed AO/roughness/metallic sources must agree on filter, wrap, and anisotropy +because their canonical ARM artifact has one sampler. A mismatch retains the previous valid +material and reports a processing diagnostic. Imported glTF/FBX materials keep their loader-owned +samplers. See the [Material Library and targeted-drop evaluation](evaluations/material-library-targeted-drop/) for the live preview, commit/undo, cancel, and source-verification record. Static draw slots and material slots are separate. Removing a static draw retains its explicit material assignment as an orphan rather than guessing a replacement. Reimport also reconciles -slots by stable ID, never by display name. Resolve an orphan explicitly in the renderer inspector. +slots by stable ID, never by display name. Model Details can **Locate** the preserved project +Material/Instance or **Clear Orphan** before applying the import settings; active project/default +model selections likewise expose **Locate** and **Clear**, with Clear restoring Source. ## Author shared assets -Material files and material-instance files are RON documents under `assets/materials/`. The +Material files and material-instance files are typed RON documents anywhere under `assets/`. The Material Library and Asset Browser identify the document kind, expose the same guarded schema-driven editor and texture bindings, and use stable subasset IDs (`material:source` or `material:instance`). Material Instance thumbnails resolve the direct base plus sparse overrides. @@ -73,6 +152,18 @@ unchecked values continue to inherit. Material instances cannot inherit from oth Material render state exposes Opaque/Cutout, alpha cutoff, and Double Sided in the base Material inspector. +Materials extracted from textual glTF remain ordinary editable project assets. The extraction +review previews and can exclude each source material, shows converted PBR/render-state/texture +coverage, and permits collision-checked project-path edits before one atomic publication. The same +transaction assigns each affected stable model slot to the newly registered project Material; this +is an explicit model default, not a live source link. Their +Details card also shows read-only provenance: source asset path, source material label/stable index, +and a BLAKE3 source fingerprint. A later extraction detects that stable provenance, shows whether +the source revision, shader selection, render state, or converted PBR values differ, and requires +**Apply Reviewed Update** or **Create New Copy**. Apply replaces the file only while its reviewed +fingerprint still matches and rolls back exact prior bytes if the dependent model mapping cannot +publish. Provenance is not a live link and never authorizes an automatic overwrite. + Every standard and shader-schema texture slot uses the project texture picker. **Browse** lists texture assets across the project, including imported model texture subassets, and a texture can be dragged from the Asset Browser directly onto a slot. Non-texture drops are ignored. **Clear** removes @@ -80,6 +171,39 @@ the base Material value; on a Material Instance it removes the sparse override s from its base again. Dropping or browsing a texture onto an unchecked instance slot creates the override automatically. +## Schema-driven inputs and packed maps + +Material schema v2 stores one canonical `MaterialInputSet`. Standard Lit and custom Surface +shaders both describe their controls with `MaterialInputSchema`, so primitive, static-mesh, +skinned-mesh, Content Browser, and Material Library editors render the same responsive rows. The +exported 569 px section uses one fixed label/value/channel line beside its texture field; the 369 px +section stacks the texture field beneath that line. A bounded transient fallback below 369 px moves +Locate/Clear into overflow instead of allowing negative or out-of-clip geometry. Slot IDs, source +paths, fingerprints, and usage counts are diagnostics rather than material parameters. + +Base Color is a multiplier over its albedo texture. Metallic and Roughness are scalar multipliers +over their selected channel; Occlusion is a scalar texture input. The **ARM / glTF ORM** preset uses +R=ambient occlusion, G=roughness, and B=metallic. **Separate Maps** samples each map's R channel, +while the adjacent channel selectors support custom packed layouts. Publication repacks those +choices into one canonical linear R/G/B ARM artifact used by Standard and Surface/Solari paths. + +Select a Texture asset to edit its semantic, color space, mipmaps, compression, maximum size, +filtering, wrapping, anisotropy, and normal convention. Auto treats color/emissive maps as sRGB and +normal/mask/ARM data as Linear. DirectX normals are converted to OpenGL +Y during processing. +Texture property changes use the same **UNSAVED** document state. Saving preserves the source image +and queues a content-addressed UASTC Basis or uncompressed KTX2 artifact below managed +`.import-cache/runtime/` storage. The processor derives the output key from source bytes and +normalized settings before decoding; if that exact artifact already exists, editor refresh, +packaging, and `process-assets --check` reuse it without recompression. + +Loose PBR image sets can be turned into editable Material assets from the Content Browser with +**Create Materials From Folder**. The preview groups filename suffixes, reports confidence, and lets +authors correct roles or edit a target name to merge/split sets before publication. Base-color, +normal, and packed ORM/ARM maps populate the Standard material fields; occlusion and separately +authored roughness, metallic, or height maps are also retained as named Surface texture bindings. +The batch is transactional and never overwrites an existing Material file. See the +[content-workspace guide](content-workspace.md) for the full workflow. + The supported render states are: - **Opaque** — no alpha candidate test. @@ -91,7 +215,10 @@ Editing a shared asset never writes copies into renderer components. The runtime Material, direct base, shader schema, and evaluator dependencies and updates the shared hydrated handle in place. Standard and custom Surface changes therefore propagate to every assigned static or skinned renderer slot without reloading geometry, joints, or the current animation pose. Invalid -Surface edits retain the last-good evaluator generation and emit diagnostics. +Surface edits retain the last-good evaluator generation and emit diagnostics. The neutral +`StandardMaterial` attached during hydration is only a singleton emergency visibility handle. It +is never reused for an assigned asset: the resolver caches one live handle per `MaterialRef`, so +users of the same Material share updates while unrelated Materials cannot overwrite one another. Renderer and terrain material bindings target generated draw entities. Scene switches may remove those entities between binding discovery and deferred command application, so binding replacement @@ -154,8 +281,11 @@ cargo upgrade-project --project . cargo upgrade-project --project . --apply ``` -Apply mode stages the complete rewrite, creates a timestamped backup under -`.blacksite/backups/material-component-v4-*`, and rolls back on failure. Commit or otherwise back up +Repository scenes use schema v6 for actor-only slot ownership. This pre-production project updates +fixtures directly rather than carrying a generalized v5 compatibility migrator. The explicit +upgrade path converts legacy primitive/mesh descriptors and per-slot overrides into generalized saved slots, and deduplicates canonical descriptors into project +Materials under `assets/materials/migrated/`. It stages the complete rewrite, creates a timestamped +backup under `.blacksite/backups/material-slot-v5-*`, and rolls back on failure. Commit or otherwise back up the project before applying, inspect the diff afterward, then run: ```bash diff --git a/docs/editor/prefab-authoring.md b/docs/editor/prefab-authoring.md index 5ea6319..d96a6cb 100644 --- a/docs/editor/prefab-authoring.md +++ b/docs/editor/prefab-authoring.md @@ -35,10 +35,11 @@ builds apply the same data without loading the editor plugin. Select a generated prefab member to edit its override layer. The current inspector exposes: -- **Property overrides** for Transform position, rotation, and scale, plus Material base color, - metallic, and roughness. -- **Component overrides** for adding, replacing, or removing `MaterialDesc`, plus visibility on - direct or nested generated actors. +- **Property overrides** for reflected authoring-component fields, including Transform fields and + brush-only legacy `MaterialDesc` parameters. +- **Component overrides** for adding, replacing, or removing registered authoring components, plus + visibility on direct or nested generated actors. Primitive and mesh material assignments live in + their owning `MaterialSlot` data; `MaterialDesc` is not a general mesh/primitive material path. - **Structural overrides** for removing a source actor or reparenting generated actors within the same instance layer. Hierarchy **Unparent override** writes the same stable structural operation. diff --git a/docs/editor/project-launcher.md b/docs/editor/project-launcher.md index 9e25388..90ec8b8 100644 --- a/docs/editor/project-launcher.md +++ b/docs/editor/project-launcher.md @@ -51,9 +51,11 @@ the browser exits only after a new editor process has been accepted by the user' This keeps renderer, asset server, import registry, and project settings rooted consistently. On the supported Linux workstation, the **Blacksite Editor** desktop entry provides an **Open -Project Browser** action. Its wrapper builds both editor binaries into the shared target directory, -shows the normal startup splash, avoids duplicate browser processes, and preserves the Wayland, -session-bus, graphics, and XDG environment required by the detached process. +Project Browser** action. Its wrapper launches the current managed development binary directly, +shows the normal startup splash, avoids duplicate processes, and preserves the Wayland, session-bus, +graphics, and XDG environment required by the detached process. A desktop click never invokes Cargo. +An explicit `~/.local/bin/blacksite-editor --build-only` rebuild uses the repository's managed Cargo +lane; a missing binary produces an actionable splash error instead of starting an implicit build. The browser's **New Sandbox** workflow requires a missing or empty folder and a non-empty name. Creation failures remain in the browser as status text; a successful scaffold is validated and diff --git a/docs/editor/release-notes.md b/docs/editor/release-notes.md index 4b0cf0c..d9466c1 100644 --- a/docs/editor/release-notes.md +++ b/docs/editor/release-notes.md @@ -1,5 +1,7 @@ # Editor Framework Release Notes (1.0 baseline) +> **Historical release snapshot — not current implementation guidance.** Use the [documentation index](../README.md) for current behavior and architecture. + ## Shipped - Stable asset registry (`assets/.index/registry.ron`) with import settings and content-addressed diff --git a/docs/editor/rendering.md b/docs/editor/rendering.md index 0a28b75..5700de4 100644 --- a/docs/editor/rendering.md +++ b/docs/editor/rendering.md @@ -74,9 +74,11 @@ Material authoring includes emissive color, emissive intensity (nits), and an op |------|---------| | `assets/rendering_profiles/*.ron` | Named override bundles (`RenderingProfileAsset`); Apply / Revert in volume inspector | | `assets/post_fx/*.ron` + `.wgsl` | Fullscreen effects (`PostProcessEffectAsset`); picker in volume inspector | -| `assets/materials/*.ron` | Material presets, including emissive fields for Solari-compatible surfaces | +| Material/Instance documents anywhere under `assets/` | Schema-classified shared materials, including emissive inputs for Solari-compatible surfaces | -Shipped examples: `post_fx/vignette.ron`, `post_fx/chromatic_aberration.ron`, `rendering_profiles/cave_dark.ron`, `rendering_profiles/outdoor_haze.ron`, `materials/emissive_panel.ron`. +Shipped examples use the conventional `post_fx/`, `rendering_profiles/`, and `materials/` folders, +but project Material/Instance location is organizational rather than type authority. See the +[Content Workspace guide](content-workspace.md). ## Game team checklist diff --git a/docs/editor/roadmap.md b/docs/editor/roadmap.md index fb0668b..bf783ad 100644 --- a/docs/editor/roadmap.md +++ b/docs/editor/roadmap.md @@ -4,7 +4,10 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur ## Production editor program (P5 cleanup remaining) -**Goal:** Zero-debt authoring loop — hydration modules, mandatory `ActorKind`, actor inspector only, schema v2 migrations. See [ADR 0009–0012](../adr/0009-authoring-vs-hydrated.md) and `.cursor/plans/editor_rendering_and_ux_*.plan.md`. +**Goal:** Zero-debt authoring loop — hydration modules, derived `ActorKind`, a registered actor +inspector, and explicit schema migrations. See [ADR 0009](../adr/0009-authoring-vs-hydrated.md), +[ADR 0034](../adr/0034-registry-driven-authoring-components.md), and the +[documentation authority map](../authority.toml). | Phase | Status | Notes | |-------|--------|-------| @@ -13,7 +16,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur | P2 Actor inspector | **Done** | `ui/actor_inspector/`; removed `DefaultInspectorConfigPlugin` + ECS dump on Inspector tab | | P3 Hierarchy tree + sibling order | **Done** | `HierarchySiblingIndex`, tree outliner, full-row DnD attach/reorder/root, world-transform preservation, cycle guards, visibility/lock | | P4 Viewport modes | **Done** | Lit / Unlit / Collider toolbar, light gizmo overlays, Tab pick cycle | -| P5 Assets + prefabs | **Partial** | Prefab v2 is production-accepted with stable nested identity, scoped/runtime overrides, committed base/nested/variant fixtures, source Apply/conflict recovery, and local conversion; legacy `fbx_preview` cleanup remains | +| P5 Assets + prefabs | **Done** | Prefab v2 is production-accepted with stable nested identity, scoped/runtime overrides, committed base/nested/variant fixtures, source Apply/conflict recovery, and local conversion; the legacy `fbx_preview` crate/path is absent from the current workspace | | P6 Extensibility + CI | **Done** | `ActorInspectorSection`, palette commands, BRP policy, hydration/scene CI | | P7 Docs + debt audit | **Done** | ADRs 0009–0012, [debt-audit.md](debt-audit.md), CI matrix | @@ -109,7 +112,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur | Pause in PIE | Done | `PlayPaused` freezes sim while staying in Play (`F6`) | | Dock layout persistence | Done | `ui/layout.rs` + `UserPreferences.dock_layout` | | Hierarchy tree + type icons | Done | Authored + generated/runtime rows, persistent expansion, full-row attach targets, subtree-safe multi-drag, transform-preserving undo | -| Actor inspector (authoring-only) | Done | Transform + `LightDesc`/`MaterialDesc`/…; no bevy_inspector dump | +| Actor inspector (authoring-only) | Done | Transform plus registered authoring components; primitives/static/skinned renderers use stable `MaterialSlot` ownership; no bevy_inspector dump | | Inspector component headers | Done | Header + Transform + component cards + Add Component footer | | Asset browser project/file views | Done | Folder tree, breadcrumb, search/filter/sort, grid/list, details pane, texture + model thumbnails | @@ -121,7 +124,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur |------|--------|-------| | `asset_db.rs` registry | Done | Stable UUIDs by path/content, deterministic model/texture/audio BLAKE3 fingerprints, normalized publication order, and import settings in details | | Model import formats | Done | glTF/GLB + binary FBX; sandboxed `textures/`/`.fbm/` discovery and transactional referenced-bundle copy | -| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; direct neutral FBX geometry; preflighted source-material dependency state | +| Thumbnails | Done | Typed texture/model/mesh/material cache keys; textures via asset load; all model and mesh cards via offscreen geometry studio; preflighted dependency failures | | Import settings per asset | Done | Scale/collider/LOD in registry + details pane | | Prefab workflow | Done | Shared stable nested property/component/structural overrides, recursive validation, committed variant fixtures, transactional source Apply, conflict recovery, and undoable outer unpack/recursive local conversion passed headless, packaged-runtime, and live editor acceptance in Gitea #43 | | Curated editor regression samples | Done | Versioned five-area manifest, dedicated brush/material labs, strengthened terrain/physics/rendering fixtures, **File > Open Sample**, typed scene coverage, and `validate-samples` release gate in Gitea #32 | diff --git a/docs/editor/session-recovery.md b/docs/editor/session-recovery.md index ced819d..45c4bcd 100644 --- a/docs/editor/session-recovery.md +++ b/docs/editor/session-recovery.md @@ -17,12 +17,20 @@ or credentials. Discard confirmation, the last authored scene and non-destructive UI state are restored. - **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps - the startup scene. Modal tools and dirty preview state are never restored. + the startup scene. The prompt blocks interaction with the safe scene until either choice is made. + Modal tools and dirty preview state are never restored. - Native window close, **File > Quit**, project switching, and programmatic editor exit use one non-blocking Save All / Discard / Cancel workflow. Cancel and failed saves keep the session open. Direct `AppExit`, process kill, and crashes are not recorded as clean shutdowns. - Dirty scene data remains governed by the independent scene recovery workflow in [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md). +- Dirty Materials, Material Instances, import settings, and project content defaults receive + editor-local recovery snapshots after two seconds of inactivity. Five generations are retained + outside project `assets/`. Startup restoration never overwrites source: a changed fingerprint + restores the document as a conflict. Returning the document exactly to its source value clears + older recovery generations; startup defensively ignores any recovery value that already matches + source. See + [ADR 0047](../adr/0047-editor-authored-asset-documents.md). An unknown newer session schema is ignored safely. Legacy unversioned session documents migrate to v1 after finite camera bookmark validation. diff --git a/docs/mission.md b/docs/mission.md index 7c380c0..48d22f5 100644 --- a/docs/mission.md +++ b/docs/mission.md @@ -15,12 +15,18 @@ An **in-process Bevy editor and game foundation** for a server-authoritative mul 7. **Composable actor model** — Every saved level object carries a derived `ActorKind` display hint, while registered authoring components own behavior, requirements, conflicts, persistence, and hydration ([ADR 0034](adr/0034-registry-driven-authoring-components.md)). +8. **Sustainable extension seams** — Pay architecture debt at the point of change: thin panel + shells dispatch into bounded domain modules and shared services, with an automated no-growth + ratchet for legacy modules ([ADR 0048](adr/0048-modular-editor-composition-and-debt-ratchet.md)). +9. **Authored visual language** — Build editor panels from semantic, responsive design-system + primitives whose native output is accepted against the Penpot source of truth; unsupported mock + controls do not ship ([ADR 0049](adr/0049-penpot-led-editor-visual-system.md)). ## Audience - **Level authors** — Iterate on scenes, tuning, and feel via PIE and project settings. - **Engine extenders** — Reuse or embed `editor` patterns (viewport, history, PIE) in other Bevy projects. -- **Automation / CI** — BRP and headless validation (planned). +- **Automation / CI** — BRP, headless validation, and deterministic asset processing. - **Multiplayer development** — Same editor shell exercises deterministic sim and protocol types. ## Non-goals diff --git a/docs/workflow/build-storage-policy.md b/docs/workflow/build-storage-policy.md new file mode 100644 index 0000000..22c578c --- /dev/null +++ b/docs/workflow/build-storage-policy.md @@ -0,0 +1,32 @@ +# Build Storage Policy + +Blacksite uses one persistent ordinary-development lane and creates exceptional candidate, +hot-reload, full-debug, cross-target, or package lanes only when their distinct signature is needed. +Lane identity includes the canonical workspace, toolchain, target, profile, features, linker and +compiler flags. + +On the current checkout, intermediates live in a canonical-workspace partition beneath +`/.blacksite-build-cache`; this keeps the cache on the workspace volume because +Cargo home does not satisfy the configured free-space floor. Set `BLACKSITE_BUILD_CACHE_ROOT` to an +approved cache volume when the checkout moves. Final ordinary artifacts retain predictable paths +under repository `target/`, while exceptional final artifacts live under `target/lanes/`. + +Use `scripts/codex/build_storage.py` for measurement, dry-run pruning, and policy enforcement. Under +budget or free-space pressure, pre/post enforcement may automatically remove only expired disposable +lanes after printing and flushing their complete byte-counted safety plan. A managed lane must carry +a sentinel identifying its workspace and signature before it is eligible for +deletion. Never run an unscoped `cargo clean` as a troubleshooting reflex, and never delete individual +files from Cargo's `deps`, `.fingerprint`, `build`, or `incremental` layouts by age. Pruning removes +only a complete verified disposable lane. + +Before a heavy gate, enforce the configured total budget and free-space floor; remaining hard-limit +or free-space violations block the gate. Afterward, record lane size and last use. Every automatic or +explicit prune/reset prints and flushes its complete byte-counted, checked plan before deletion starts. +Before removing a candidate lane, copy the nominated binaries or +packages and evidence outside the lane, then run: + +`python scripts/codex/build_storage.py mark-candidate-preserved --evidence --artifact ` + +The generation-bound marker becomes stale if that candidate lane is used again. The persistent +development lane is reset only at a safe slice boundary when signature drift or the hard budget makes +that necessary. diff --git a/docs/workflow/codex-workflow.md b/docs/workflow/codex-workflow.md new file mode 100644 index 0000000..0eac982 --- /dev/null +++ b/docs/workflow/codex-workflow.md @@ -0,0 +1,30 @@ +# Codex Workflow + +Blacksite uses a repository-local workflow to keep implementation fast without weakening acceptance. +The compact operating rules live in `AGENTS.md`; procedural details live in repository Skills and the +policies indexed here. + +## Task lifecycle + +1. Confirm the canonical repository path, branch, HEAD, dirty state, and active processes. +2. Resume `.codex/session/STATE.md` when valid instead of rediscovering the repository. +3. Read `docs/authority.toml`, the smallest relevant current docs, and exact linked tracker items. +4. Define one bounded slice with acceptance criteria, non-goals, and a verification tier. +5. Record material user steering as a scope delta, including which evidence remains valid. +6. Run selective verification, then update canonical docs at the slice boundary. +7. Treat native acceptance and release-candidate gates as separate states from engineering checks. + +Ignored `.codex/session/`, `.codex/logs/`, `.codex/cache/`, and `.codex/evidence/` data is working +state, not documentation. It exists to make reconnects and compaction cheap and must not become a +second source of product truth. + +## Policies + +- [Documentation authority](documentation-policy.md) +- [Selective verification](verification-policy.md) +- [Build storage](build-storage-policy.md) +- [Gitea tracking](gitea-tracking-policy.md) + +The workflow distinguishes Implementing, Engineering-complete, Acceptance-in-progress, +Candidate-ready, and Accepted/closed. “Complete” is reserved for the acceptance state actually +proven; a dirty worktree or source-only check is not closure evidence. diff --git a/docs/workflow/documentation-policy.md b/docs/workflow/documentation-policy.md new file mode 100644 index 0000000..72d074e --- /dev/null +++ b/docs/workflow/documentation-policy.md @@ -0,0 +1,52 @@ +# Documentation Authority Policy + +`docs/authority.toml` classifies every root README, repository documentation page, and Cursor plan +before it may be used as guidance. Run `python scripts/codex/docs_audit.py` after changing behavior, +schemas, commands, component names, workflows, plans, or evidence records. + +## Authority order + +For desired scope and acceptance, use the latest user direction, exact current Gitea scope, and then +an active plan. For implemented behavior, use source and tests, then current native evidence and +canonical current documentation. Accepted ADRs own architecture decisions. Do not merge conflicting +sources silently; report and reconcile the conflict at the smallest owning surface. + +## Classifications + +Each authority rule has a lifecycle classification and a role. Lifecycle controls whether a page +may guide current work; roles distinguish overview, canonical behavior, architecture, active plan, +evidence, historical record, and superseded record. Accepted ADRs use the architecture role and are +authoritative only for their surviving decision scope. + +| Class | Meaning | May define current behavior? | +|-------|---------|------------------------------| +| `current` | Canonical workflow, architecture, intent, or user guidance | Yes, within its stated role | +| `active-plan` | Unaccepted desired scope and acceptance work | No; source and current docs describe what exists | +| `evidence` | Dated automated or native observations | No | +| `historical` | Preserved context from completed work | No | +| `superseded` | Replaced contract retained for provenance | No | + +Historical and superseded documents carry an obvious banner linking current guidance. Evidence +records carry an evidence-only banner. Active plans identify themselves and must become historical +when their acceptance target is complete. + +## One home per fact + +- Root `README.md`: user commands, controls, troubleshooting, and shipped checklist. +- `docs/mission.md`: product intent and non-goals. +- `docs/adr/`: accepted decisions and durable constraints. +- `docs/editor/`: current editor workflows and subsystem contracts. +- `.cursor/plans/`: active or historical implementation roadmaps, never implementation truth. +- `docs/editor/evaluations/`: dated evidence, never product requirements. +- `docs/archive/`: preserved records that no longer belong in a live topic tree. + +Update canonical documentation when behavior stabilizes at a slice boundary. Tiny intermediate edits +do not require repeated narrative churn, but the slice cannot become engineering-complete while its +canonical docs contradict the implementation. + +## Audit contract + +The audit discovers `README.md`, every `docs/**/*.md`, and every `.cursor/plans/**/*.md`. The +highest-priority matching authority rule wins; equal-priority disagreement fails. It also verifies +lifecycle banners and configured replacement links. New documents fail until a deterministic rule +classifies them. diff --git a/docs/workflow/gitea-tracking-policy.md b/docs/workflow/gitea-tracking-policy.md new file mode 100644 index 0000000..176b4b2 --- /dev/null +++ b/docs/workflow/gitea-tracking-policy.md @@ -0,0 +1,17 @@ +# Gitea Tracking Policy + +Read exact issue, epic, and milestone records rather than broad tracker dumps. Current tracker scope +is desired behavior; source and accepted evidence determine implementation state. + +Use these states precisely: Implementing, Engineering-complete, Acceptance-in-progress, +Candidate-ready, and Accepted/closed. Do not check an acceptance criterion without corresponding +code, automated, native, or candidate evidence. A dirty working tree cannot close an issue, and +closure requires the nominated commit plus configured release evidence. + +Post at most one useful status update per completed slice or material scope delta. Keep epic +checklists, child issues, and milestone scope aligned, but do not create, close, reopen, delete, or +rewrite tracker scope without direct authority. Workflow installation and audits produce dry-run +recommendations only. + +Tracker comments summarize outcomes and link durable repository evidence; they do not receive raw +build logs, entire issue listings, or speculative completion claims. diff --git a/docs/workflow/verification-policy.md b/docs/workflow/verification-policy.md new file mode 100644 index 0000000..b31a1f3 --- /dev/null +++ b/docs/workflow/verification-policy.md @@ -0,0 +1,25 @@ +# Selective Verification Policy + +Verification is proportional to the changed dependency surface and is reused while its inputs, +toolchain, features, environment, and command remain unchanged. + +| Tier | Use | Typical scope | +|------|-----|---------------| +| Fast loop | Ordinary implementation edits | Formatting as needed, affected package check, focused tests | +| Slice gate | A bounded behavior slice is stable | Affected package tests and lint plus relevant domain validators | +| Candidate gate | An exact release candidate is nominated | Full workspace/all-feature gates, deterministic content checks, packaging, and named native scenarios | + +Codex Cargo commands run through `scripts/codex/verify.py` or `scripts/codex/cargo_lane.py`; complete +output belongs in `.codex/logs/`. Do not run a candidate gate during ordinary iteration, duplicate a +workspace check immediately before a compiling workspace test without a distinct target reason, or +rerun a valid gate after unrelated documentation changes. + +User steering invalidates only gates whose inputs or acceptance claim changed. Record that boundary +in session state. Automated tests are not native editor acceptance, and screenshots alone do not +prove an interaction. + +When native acceptance is explicitly delegated, `scripts/codex/native_qa.sh plan ` prints +the named steps and assertions before anything launches. The runner snapshots a declared fixture, +records each manual assertion and target-window capture, closes only its recorded PID, and exposes a +hash-checked `restore --dry-run` / `restore --apply` pair. Planning a scenario never launches the +editor, and Codex does not take over user-owned visual QA merely because a scenario exists. diff --git a/scripts/codex/architecture_audit.py b/scripts/codex/architecture_audit.py new file mode 100755 index 0000000..08d0bd1 --- /dev/null +++ b/scripts/codex/architecture_audit.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Enforce Blacksite's module-size debt ratchet.""" + +from __future__ import annotations + +import argparse +import tempfile +import tomllib +from pathlib import Path + + +def nonblank_lines(path: Path) -> int: + return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip()) + + +def module_limit(relative: str, policy: dict) -> int: + if relative in policy.get("shells", {}): + return int(policy["limits"]["ui_shell"]) + if relative.startswith("crates/editor/src/ui/"): + return int(policy["limits"]["ui_module"]) + return int(policy["limits"]["production_rust_module"]) + + +def validate_exception(entry: dict) -> list[str]: + required = ("path", "issue", "rationale", "maximum", "extraction_target", "expiry_milestone") + missing = [name for name in required if not entry.get(name)] + errors = [f"invalid exception for {entry.get('path', '')}: missing {name}" for name in missing] + if entry.get("issue") and not str(entry["issue"]).startswith("#"): + errors.append(f"invalid exception for {entry['path']}: issue must be a tracker reference") + return errors + + +def audit(root: Path, policy: dict) -> tuple[list[str], list[str]]: + errors: list[str] = [] + notes: list[str] = [] + baselines = {str(path): int(value) for path, value in policy.get("baselines", {}).items()} + exceptions = {entry.get("path"): entry for entry in policy.get("exceptions", [])} + for entry in policy.get("exceptions", []): + errors.extend(validate_exception(entry)) + + for path in sorted((root / "crates").glob("**/*.rs")): + relative = path.relative_to(root).as_posix() + count = nonblank_lines(path) + limit = module_limit(relative, policy) + baseline = baselines.get(relative) + exception = exceptions.get(relative) + + if baseline is not None and count > baseline: + errors.append(f"{relative}: {count} nonblank lines exceeds frozen baseline {baseline}") + continue + if baseline is not None and count < baseline: + notes.append(f"{relative}: shrank from baseline {baseline} to {count}") + if count <= limit: + continue + if baseline is not None: + continue + if exception is not None and count <= int(exception["maximum"]): + notes.append(f"{relative}: temporary {exception['issue']} exception ({count}/{exception['maximum']})") + continue + errors.append(f"{relative}: {count} nonblank lines exceeds module budget {limit}") + return errors, notes + + +def load_policy(path: Path) -> dict: + with path.open("rb") as handle: + policy = tomllib.load(handle) + if policy.get("version") != 1: + raise SystemExit(f"unsupported architecture policy version in {path}") + return policy + + +def self_test() -> int: + with tempfile.TemporaryDirectory(prefix="blacksite-architecture-audit-") as directory: + root = Path(directory) + (root / "crates/editor/src/ui").mkdir(parents=True) + policy = { + "limits": {"ui_shell": 2, "ui_module": 3, "production_rust_module": 4}, + "shells": {"crates/editor/src/ui/shell.rs": 2}, + "baselines": {"crates/editor/src/ui/legacy.rs": 5}, + "exceptions": [], + } + (root / "crates/editor/src/ui/new.rs").write_text("a\nb\nc\nd\n", encoding="utf-8") + errors, _ = audit(root, policy) + assert any("new.rs" in error for error in errors) + (root / "crates/editor/src/ui/new.rs").write_text("a\nb\n", encoding="utf-8") + (root / "crates/editor/src/ui/legacy.rs").write_text("a\nb\nc\nd\ne\nf\n", encoding="utf-8") + errors, _ = audit(root, policy) + assert any("frozen baseline" in error for error in errors) + (root / "crates/editor/src/ui/legacy.rs").write_text("a\nb\nc\nd\n", encoding="utf-8") + errors, notes = audit(root, policy) + assert not errors and any("shrank" in note for note in notes) + policy["exceptions"] = [{ + "path": "crates/editor/src/ui/new.rs", "issue": "#1", "rationale": "test", + "maximum": 4, "extraction_target": "test module", "expiry_milestone": "M2", + }] + (root / "crates/editor/src/ui/new.rs").write_text("a\nb\nc\nd\n", encoding="utf-8") + errors, _ = audit(root, policy) + assert not errors + policy["exceptions"][0]["issue"] = "missing-prefix" + errors, _ = audit(root, policy) + assert any("tracker reference" in error for error in errors) + print("PASS architecture-audit self-test") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("check", "self-test"), nargs="?", default="check") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + arguments = parser.parse_args() + if arguments.command == "self-test": + return self_test() + root = arguments.root.resolve() + policy = load_policy(root / ".codex/architecture.toml") + errors, notes = audit(root, policy) + for note in notes: + print(f"NOTE {note}") + if errors: + for error in errors: + print(f"FAIL {error}") + return 1 + print("PASS architecture-audit") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/build_storage.py b/scripts/codex/build_storage.py new file mode 100755 index 0000000..53c9b2b --- /dev/null +++ b/scripts/codex/build_storage.py @@ -0,0 +1,1745 @@ +#!/usr/bin/env python3 +"""Measure and safely enforce Blacksite's bounded Cargo build storage policy.""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +try: + import tomllib +except ModuleNotFoundError as error: # pragma: no cover - Python < 3.11 + raise SystemExit("build_storage.py requires Python 3.11 or newer") from error + +from cargo_lane import ( + SENTINEL_NAME, + LaneError, + cache_root, + canonical_path, + cargo_home, + load_workflow, + read_sentinel, + sentinel_path, + toolchain_info, + utc_now, + validate_lane_name, + validate_sentinel, + workspace_hash, + workspace_root, +) + + +GIB = 1024**3 +LEDGER_SCHEMA_VERSION = 1 +CANDIDATE_PRESERVATION_SCHEMA_VERSION = 1 +CANDIDATE_PRESERVATION_PATH = Path(".codex/session/candidate-prune-safe.json") +DEFAULT_POLICY: dict[str, Any] = { + "enabled": True, + "soft_total_gib": 55, + "hard_total_gib": 80, + "min_free_gib": 40, + "min_free_percent": 15, + "persistent_lane_max_gib": 40, + "disposable_lane_max_gib": 35, + "candidate_retention_hours": 24, + "hot_reload_retention_hours": 24, + "full_debug_retention_hours": 12, + "failed_lane_retention_hours": 24, + "auto_prune_disposable": True, + "delete_only_marked_lanes": True, +} + + +class StorageError(RuntimeError): + """Storage state cannot be measured or changed safely.""" + + +def _policy(workflow: Mapping[str, Any]) -> dict[str, Any]: + result = dict(DEFAULT_POLICY) + configured = workflow.get("build_storage", {}) + if isinstance(configured, Mapping): + result.update(configured) + return result + + +def human_bytes(value: int) -> str: + amount = float(max(0, value)) + for suffix in ("B", "KiB", "MiB", "GiB", "TiB"): + if amount < 1024 or suffix == "TiB": + return f"{amount:.1f} {suffix}" + amount /= 1024 + return f"{amount:.1f} TiB" + + +def _allocated_bytes(stat_result: os.stat_result) -> int: + blocks = getattr(stat_result, "st_blocks", 0) + return blocks * 512 if blocks else stat_result.st_size + + +@dataclass +class PathUsage: + path: str + allocated_bytes: int = 0 + apparent_bytes: int = 0 + files: int = 0 + directories: int = 0 + symlinks: int = 0 + top_level: dict[str, int] | None = None + error: str | None = None + + +def measure_tree(path: Path) -> PathUsage: + path = canonical_path(path) + usage = PathUsage(path=str(path), top_level={}) + if not path.exists(): + return usage + if path.is_symlink(): + usage.symlinks = 1 + usage.error = "root is a symlink" + return usage + + seen: set[tuple[int, int]] = set() + try: + root_stat = path.lstat() + root_device = root_stat.st_dev + usage.allocated_bytes += _allocated_bytes(root_stat) + usage.apparent_bytes += root_stat.st_size + usage.directories += 1 + seen.add((root_stat.st_dev, root_stat.st_ino)) + except OSError as error: + usage.error = str(error) + return usage + + for directory, names, filenames in os.walk(path, topdown=True, followlinks=False): + current = Path(directory) + relative = current.relative_to(path) + current_bucket = relative.parts[0] if relative.parts else None + + kept_names: list[str] = [] + for name in names: + child = current / name + try: + stat_result = child.lstat() + except OSError: + continue + if child.is_symlink(): + usage.symlinks += 1 + usage.allocated_bytes += _allocated_bytes(stat_result) + usage.apparent_bytes += stat_result.st_size + continue + if stat_result.st_dev != root_device: + continue + kept_names.append(name) + inode = (stat_result.st_dev, stat_result.st_ino) + if inode in seen: + continue + seen.add(inode) + allocated = _allocated_bytes(stat_result) + usage.allocated_bytes += allocated + usage.apparent_bytes += stat_result.st_size + usage.directories += 1 + bucket = current_bucket or name + usage.top_level[bucket] = usage.top_level.get(bucket, 0) + allocated + names[:] = kept_names + + for name in filenames: + child = current / name + try: + stat_result = child.lstat() + except OSError: + continue + if child.is_symlink(): + usage.symlinks += 1 + inode = (stat_result.st_dev, stat_result.st_ino) + if inode in seen: + continue + seen.add(inode) + allocated = _allocated_bytes(stat_result) + usage.allocated_bytes += allocated + usage.apparent_bytes += stat_result.st_size + usage.files += 1 + bucket = current_bucket or name + usage.top_level[bucket] = usage.top_level.get(bucket, 0) + allocated + usage.top_level = dict( + sorted(usage.top_level.items(), key=lambda item: item[1], reverse=True) + ) + return usage + + +def _nearest_existing(path: Path) -> Path: + current = canonical_path(path) + while not current.exists() and current != current.parent: + current = current.parent + return current + + +def _mount_point(path: Path) -> Path: + current = _nearest_existing(path) + while current != current.parent and not os.path.ismount(current): + current = current.parent + return current + + +def _filesystem_status(paths: Iterable[Path], policy: Mapping[str, Any]) -> list[dict[str, Any]]: + by_device: dict[int, dict[str, Any]] = {} + for requested in paths: + probe = _nearest_existing(requested) + try: + device = probe.stat().st_dev + usage = shutil.disk_usage(probe) + except OSError: + continue + if device in by_device: + by_device[device]["paths"].append(str(canonical_path(requested))) + continue + percentage_floor = int(usage.total * float(policy["min_free_percent"]) / 100) + fixed_floor = int(float(policy["min_free_gib"]) * GIB) + floor = max(fixed_floor, percentage_floor) + by_device[device] = { + "device": device, + "mount": str(_mount_point(probe)), + "paths": [str(canonical_path(requested))], + "total_bytes": usage.total, + "used_bytes": usage.used, + "free_bytes": usage.free, + "minimum_free_bytes": floor, + } + return list(by_device.values()) + + +def _path_within(path: Path, parent: Path) -> bool: + try: + canonical_path(path).relative_to(canonical_path(parent)) + return True + except ValueError: + return False + + +def _read_proc_value(path: Path, *, binary: bool = False) -> str: + try: + if binary: + return path.read_bytes().replace(b"\0", b" ").decode(errors="replace").strip() + return path.read_text(encoding="utf-8", errors="replace").strip() + except (OSError, PermissionError): + return "" + + +BUILD_TOOL_NAMES = { + "cargo", + "rustc", + "rustdoc", + "clang", + "clang++", + "cc", + "c++", + "gcc", + "g++", + "ld", + "ld.lld", + "lld", + "mold", +} + + +def active_processes(root: Path, watched_paths: Sequence[Path]) -> list[dict[str, Any]]: + proc = Path("/proc") + if not proc.is_dir(): + return [] + root = canonical_path(root) + watched = [canonical_path(path) for path in watched_paths] + results: list[dict[str, Any]] = [] + own_pid = os.getpid() + + for entry in proc.iterdir(): + if not entry.name.isdigit() or int(entry.name) == own_pid: + continue + pid = int(entry.name) + comm = _read_proc_value(entry / "comm") + command = _read_proc_value(entry / "cmdline", binary=True) + executable = "" + cwd = "" + try: + executable = os.readlink(entry / "exe") + except OSError: + pass + try: + cwd = os.readlink(entry / "cwd") + except OSError: + pass + + command_name = Path(command.split(" ", 1)[0]).name if command else comm + is_build_tool = comm in BUILD_TOOL_NAMES or command_name in BUILD_TOOL_NAMES + is_packager = any( + token in command + for token in ("package-project", "process-assets", "cargo package") + ) + is_editor = comm in {"editor", "project_launcher", "game"} or command_name in { + "editor", + "project_launcher", + "game", + } + + environment = _read_proc_value(entry / "environ", binary=True) + references = " ".join((command, executable, cwd, environment)) + used = [str(path) for path in watched if str(path) in references] + workspace_related = bool(cwd and _path_within(Path(cwd), root)) + + if is_editor and not used: + maps = _read_proc_value(entry / "maps") + used = [str(path) for path in watched if str(path) in maps] + + if not used and not ((is_build_tool or is_packager) and workspace_related): + continue + results.append( + { + "pid": pid, + "comm": comm, + "command": command, + "cwd": cwd, + "exe": executable, + "build_tool": is_build_tool, + "packager": is_packager, + "editor": is_editor, + "workspace_related": workspace_related, + "uses": used, + } + ) + return sorted(results, key=lambda item: item["pid"]) + + +def _sccache_status(root: Path) -> dict[str, Any]: + wrapper = os.environ.get("RUSTC_WRAPPER", "") + config_path = root / ".cargo" / "config.toml" + if not wrapper and config_path.is_file(): + try: + with config_path.open("rb") as handle: + config = tomllib.load(handle) + build = config.get("build", {}) + if isinstance(build, Mapping): + wrapper = str(build.get("rustc-wrapper", "")) + except (OSError, tomllib.TOMLDecodeError): + pass + enabled = Path(wrapper).name == "sccache" if wrapper else False + result: dict[str, Any] = { + "enabled": enabled, + "wrapper": wrapper or None, + "executable": shutil.which("sccache"), + "allocated_bytes": 0, + } + if not enabled: + return result + + configured_dir = os.environ.get("SCCACHE_DIR") + if configured_dir: + usage = measure_tree(Path(configured_dir)) + result["cache_dir"] = usage.path + result["allocated_bytes"] = usage.allocated_bytes + executable = result["executable"] + if executable: + completed = subprocess.run( + [executable, "--show-stats", "--stats-format=json"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if completed.returncode == 0: + try: + result["stats"] = json.loads(completed.stdout) + except json.JSONDecodeError: + result["stats_text"] = completed.stdout.strip() + return result + + +def _marker_record(path: Path, root: Path, role: str) -> dict[str, Any]: + document = read_sentinel(path) + lane = path.name + errors: list[str] = [] + if document is None: + errors.append(f"missing or malformed {SENTINEL_NAME}") + else: + lane = str(document.get("lane", lane)) + errors.extend( + validate_sentinel(path, document, root=root, lane=lane, role=role) + ) + usage = measure_tree(path) + if usage.error: + errors.append(usage.error) + return { + "path": str(canonical_path(path)), + "role": role, + "lane": lane, + "valid_sentinel": not errors, + "sentinel_errors": errors, + "sentinel": document, + "usage": asdict(usage), + } + + +def _discover_lanes(root: Path, workflow: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Path, Path]: + storage = workflow["build_storage"] + external_partition = cache_root(root, workflow) / workspace_hash(root) + exceptional_root = canonical_path( + root / str(storage.get("exceptional_target_root", "target/lanes")) + ) + records: list[dict[str, Any]] = [] + if external_partition.is_dir() and not external_partition.is_symlink(): + for child in sorted(external_partition.iterdir()): + if child.is_dir() and not child.is_symlink(): + records.append(_marker_record(child, root, "build")) + if exceptional_root.is_dir() and not exceptional_root.is_symlink(): + for child in sorted(exceptional_root.iterdir()): + if child.is_dir() and not child.is_symlink(): + records.append(_marker_record(child, root, "target")) + return records, external_partition, exceptional_root + + +def _group_lanes(records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in records: + grouped[str(record["lane"])].append(record) + result: list[dict[str, Any]] = [] + for lane, artifacts in sorted(grouped.items()): + total = sum(item["usage"]["allocated_bytes"] for item in artifacts) + last_use_values = [ + item["sentinel"].get("last_use") + for item in artifacts + if isinstance(item.get("sentinel"), Mapping) + and item["sentinel"].get("last_use") + ] + result.append( + { + "lane": lane, + "allocated_bytes": total, + "last_use": max(last_use_values) if last_use_values else None, + "valid": all(item["valid_sentinel"] for item in artifacts), + "artifacts": artifacts, + } + ) + return result + + +def storage_snapshot(root: Path, workflow: Mapping[str, Any]) -> dict[str, Any]: + root = canonical_path(root) + policy = _policy(workflow) + repository_target = canonical_path( + root / str(policy.get("dev_target_dir", "target")) + ) + target_usage = measure_tree(repository_target) + records, external_partition, exceptional_root = _discover_lanes(root, workflow) + external_usage = measure_tree(external_partition) + lanes = _group_lanes(records) + sccache = _sccache_status(root) + + total = ( + target_usage.allocated_bytes + + external_usage.allocated_bytes + + int(sccache.get("allocated_bytes", 0)) + ) + watched = [repository_target, external_partition, exceptional_root] + filesystems = _filesystem_status( + [repository_target, cache_root(root, workflow)], policy + ) + + debug = repository_target / "debug" + legacy_paths = { + "deps": debug / "deps", + "incremental": debug / "incremental", + "build": debug / "build", + "fingerprint": debug / ".fingerprint", + } + legacy_intermediates = { + name: asdict(measure_tree(path)) + for name, path in legacy_paths.items() + if path.exists() + } + + return { + "schema_version": 1, + "timestamp": utc_now(), + "workspace": str(root), + "workspace_hash": workspace_hash(root), + "toolchain": toolchain_info(), + "policy": policy, + "repository_target": asdict(target_usage), + "external_partition": asdict(external_usage), + "exceptional_target_root": str(exceptional_root), + "lanes": lanes, + "legacy_intermediates": legacy_intermediates, + "sccache": sccache, + "filesystems": filesystems, + "active_processes": active_processes(root, watched), + "total_allocated_bytes": total, + } + + +def _parse_simulated_lanes(values: Sequence[str]) -> dict[str, int]: + result: dict[str, int] = {} + for value in values: + lane, separator, amount = value.partition("=") + if not separator: + raise StorageError("--simulate-lane-gib expects LANE=GIB") + validate_lane_name(lane) + result[lane] = int(float(amount) * GIB) + return result + + +def apply_simulation(snapshot: dict[str, Any], arguments: argparse.Namespace) -> bool: + simulated = False + if arguments.simulate_total_gib is not None: + snapshot["total_allocated_bytes"] = int(arguments.simulate_total_gib * GIB) + simulated = True + if arguments.simulate_free_gib is not None: + for filesystem in snapshot["filesystems"]: + filesystem["free_bytes"] = int(arguments.simulate_free_gib * GIB) + simulated = True + lane_sizes = _parse_simulated_lanes(arguments.simulate_lane_gib or []) + if lane_sizes: + simulated = True + existing = {lane["lane"]: lane for lane in snapshot["lanes"]} + for lane, size in lane_sizes.items(): + if lane in existing: + existing[lane]["allocated_bytes"] = size + else: + snapshot["lanes"].append( + { + "lane": lane, + "allocated_bytes": size, + "last_use": None, + "valid": True, + "artifacts": [], + "simulated": True, + } + ) + snapshot["simulated"] = simulated + return simulated + + +def evaluate_budget(snapshot: Mapping[str, Any]) -> dict[str, list[str]]: + policy = snapshot["policy"] + total = int(snapshot["total_allocated_bytes"]) + warnings: list[str] = [] + violations: list[str] = [] + soft = int(float(policy["soft_total_gib"]) * GIB) + hard = int(float(policy["hard_total_gib"]) * GIB) + if total > hard: + violations.append( + f"total build artifacts {human_bytes(total)} exceed hard budget {human_bytes(hard)}" + ) + elif total > soft: + warnings.append( + f"total build artifacts {human_bytes(total)} exceed soft budget {human_bytes(soft)}" + ) + + persistent = str(policy.get("persistent_lane", "dev")) + for lane in snapshot["lanes"]: + maximum_gib = ( + policy["persistent_lane_max_gib"] + if lane["lane"] == persistent + else policy["disposable_lane_max_gib"] + ) + maximum = int(float(maximum_gib) * GIB) + if int(lane["allocated_bytes"]) > maximum: + warnings.append( + f"lane {lane['lane']} uses {human_bytes(lane['allocated_bytes'])}, " + f"above {human_bytes(maximum)}" + ) + + for filesystem in snapshot["filesystems"]: + free = int(filesystem["free_bytes"]) + floor = int(filesystem["minimum_free_bytes"]) + if free < floor: + violations.append( + f"filesystem {filesystem['mount']} has {human_bytes(free)} free; " + f"minimum is {human_bytes(floor)}" + ) + return {"warnings": warnings, "violations": violations} + + +def _parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _retention_hours(lane: str, policy: Mapping[str, Any]) -> float: + if lane == str(policy.get("candidate_lane", "candidate")): + return float(policy["candidate_retention_hours"]) + if lane == str(policy.get("hot_reload_lane", "hot-reload")): + return float(policy["hot_reload_retention_hours"]) + if lane == str(policy.get("full_debug_lane", "full-debug")): + return float(policy["full_debug_retention_hours"]) + return float(policy["failed_lane_retention_hours"]) + + +def prune_plan(snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + policy = snapshot["policy"] + persistent = str(policy.get("persistent_lane", "dev")) + now = datetime.now(timezone.utc) + plans: list[dict[str, Any]] = [] + for lane in snapshot["lanes"]: + if lane["lane"] == persistent: + continue + last_use = _parse_timestamp(lane.get("last_use")) + age_hours = ( + (now - last_use).total_seconds() / 3600 if last_use is not None else None + ) + retention = _retention_hours(lane["lane"], policy) + eligible = bool(lane["valid"] and age_hours is not None and age_hours >= retention) + plans.append( + { + "lane": lane["lane"], + "allocated_bytes": lane["allocated_bytes"], + "age_hours": age_hours, + "retention_hours": retention, + "eligible": eligible, + "reason": ( + "expired disposable lane" + if eligible + else "invalid sentinel" + if not lane["valid"] + else "missing last-use timestamp" + if age_hours is None + else "retention window active" + ), + "artifacts": [artifact["path"] for artifact in lane["artifacts"]], + } + ) + return plans + + +def _contains_symlink(path: Path) -> bool: + if path.is_symlink(): + return True + for directory, names, filenames in os.walk(path, topdown=True, followlinks=False): + current = Path(directory) + for name in [*names, *filenames]: + try: + if (current / name).is_symlink(): + return True + except OSError: + return True + return False + + +def _tracked_files(root: Path, path: Path) -> list[str]: + if not _path_within(path, root): + return [] + relative = canonical_path(path).relative_to(root) + completed = subprocess.run( + ["git", "ls-files", "--", str(relative)], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + return [line for line in completed.stdout.splitlines() if line] + + +def _candidate_lane_name(workflow: Mapping[str, Any]) -> str: + verification = workflow.get("verification", {}) + if isinstance(verification, Mapping): + value = verification.get("candidate_lane", "candidate") + if isinstance(value, str) and value: + return value + return "candidate" + + +def _candidate_preservation_path(root: Path) -> Path: + return canonical_path(root) / CANDIDATE_PRESERVATION_PATH + + +def _lane_generation(lane: Mapping[str, Any]) -> str | None: + """Bind preservation evidence to the exact last-used lane generation.""" + + sentinels: list[dict[str, Any]] = [] + artifacts = sorted( + lane.get("artifacts", []), + key=lambda item: (str(item.get("role", "")), str(item.get("path", ""))), + ) + for artifact in artifacts: + marker = read_sentinel(Path(str(artifact["path"]))) + if marker is None: + return None + sentinels.append(marker) + if not sentinels: + return None + encoded = json.dumps( + sentinels, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _preserved_path(root: Path, raw: object) -> Path | None: + if not isinstance(raw, str) or not raw: + return None + path = Path(os.path.expandvars(os.path.expanduser(raw))) + if not path.is_absolute(): + path = root / path + return path + + +def _preserved_record_errors( + record: object, + *, + label: str, + root: Path, + lane_paths: Sequence[Path], +) -> list[str]: + if not isinstance(record, Mapping): + return [f"{label} must be an object with path, bytes, and sha256"] + raw_path = _preserved_path(root, record.get("path")) + if raw_path is None: + return [f"{label} has no valid path"] + if raw_path.is_symlink(): + return [f"{label} is a symlink: {raw_path}"] + path = canonical_path(raw_path) + errors: list[str] = [] + if any(_path_within(path, lane_path) for lane_path in lane_paths): + errors.append(f"{label} remains inside the disposable candidate lane: {path}") + if not path.is_file(): + errors.append(f"{label} is not a preserved file: {path}") + return errors + + expected_bytes = record.get("bytes") + if not isinstance(expected_bytes, int) or expected_bytes < 0: + errors.append(f"{label} has an invalid byte count") + else: + actual_bytes = path.stat().st_size + if actual_bytes != expected_bytes: + errors.append( + f"{label} byte count changed: expected {expected_bytes}, found {actual_bytes}" + ) + + expected_digest = record.get("sha256") + if not isinstance(expected_digest, str) or not re.fullmatch( + r"[0-9a-f]{64}", expected_digest + ): + errors.append(f"{label} has an invalid sha256") + else: + try: + actual_digest = _sha256_file(path) + except OSError as error: + errors.append(f"{label} cannot be hashed: {error}") + else: + if actual_digest != expected_digest: + errors.append(f"{label} sha256 no longer matches: {path}") + return errors + + +def _candidate_preservation_errors( + lane: Mapping[str, Any], *, root: Path, workflow: Mapping[str, Any] +) -> list[str]: + if str(lane.get("lane")) != _candidate_lane_name(workflow): + return [] + + marker_path = _candidate_preservation_path(root) + if marker_path.is_symlink(): + return [f"candidate preservation marker is a symlink: {marker_path}"] + if not marker_path.is_file(): + return [ + "candidate preservation marker is missing; preserve the candidate evidence " + "and artifacts, then run mark-candidate-preserved" + ] + try: + marker = json.loads(marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + return [f"candidate preservation marker is unreadable: {error}"] + if not isinstance(marker, Mapping): + return ["candidate preservation marker must be a JSON object"] + + root = canonical_path(root) + generation = _lane_generation(lane) + expected = { + "schema_version": CANDIDATE_PRESERVATION_SCHEMA_VERSION, + "workspace": str(root), + "workspace_hash": workspace_hash(root), + "lane": _candidate_lane_name(workflow), + "lane_generation": generation, + "prune_safe": True, + } + errors: list[str] = [] + for key, value in expected.items(): + if marker.get(key) != value: + errors.append( + f"candidate preservation {key}: expected {value!r}, found {marker.get(key)!r}" + ) + + lane_paths = [canonical_path(Path(str(item["path"]))) for item in lane["artifacts"]] + evidence = marker.get("evidence") + errors.extend( + _preserved_record_errors( + evidence, label="candidate evidence", root=root, lane_paths=lane_paths + ) + ) + artifacts = marker.get("preserved_artifacts") + if not isinstance(artifacts, list) or not artifacts: + errors.append("candidate preservation must name at least one preserved artifact") + else: + for index, record in enumerate(artifacts): + errors.extend( + _preserved_record_errors( + record, + label=f"preserved candidate artifact {index}", + root=root, + lane_paths=lane_paths, + ) + ) + evidence_path = ( + canonical_path(_preserved_path(root, evidence.get("path"))) + if isinstance(evidence, Mapping) + and _preserved_path(root, evidence.get("path")) is not None + else None + ) + artifact_paths = { + canonical_path(path) + for record in artifacts + if isinstance(record, Mapping) + and (path := _preserved_path(root, record.get("path"))) is not None + } + if evidence_path is not None and artifact_paths == {evidence_path}: + errors.append("candidate evidence alone is not a preserved candidate artifact") + return errors + + +def deletion_safety( + artifact: Mapping[str, Any], + *, + root: Path, + workflow: Mapping[str, Any], + processes: Sequence[Mapping[str, Any]], +) -> list[str]: + path = Path(str(artifact["path"])) + lane = str(artifact["lane"]) + role = str(artifact["role"]) + errors: list[str] = [] + if not path.exists(): + return ["path no longer exists"] + if path.is_symlink(): + errors.append("lane root is a symlink") + + path = canonical_path(path) + root = canonical_path(root) + policy = _policy(workflow) + external_parent = canonical_path(cache_root(root, workflow) / workspace_hash(root)) + exceptional_parent = canonical_path( + root / str(policy.get("exceptional_target_root", "target/lanes")) + ) + expected_parent = external_parent if role == "build" else exceptional_parent + if path.parent != expected_parent: + errors.append(f"path is not a direct child of managed {role} root") + + protected = [Path("/"), Path.home(), cargo_home(), root, root / ".git"] + for protected_path in protected: + protected_path = canonical_path(protected_path) + if path == protected_path or _path_within(protected_path, path): + errors.append(f"path is or contains protected path {protected_path}") + + # Never recursively inspect a path that already failed containment or + # protected-root checks. This keeps even a malicious dry run bounded. + if errors: + return errors + + marker = read_sentinel(path) + if marker is None: + errors.append(f"missing or malformed {SENTINEL_NAME}") + else: + errors.extend( + validate_sentinel(path, marker, root=root, lane=lane, role=role) + ) + if _contains_symlink(path): + errors.append("lane contains a symlink") + tracked = _tracked_files(root, path) + if tracked: + errors.append(f"lane contains tracked files: {', '.join(tracked[:5])}") + + for process in processes: + build_activity = bool( + process.get("build_tool") + or process.get("packager") + or process.get("workspace_related") + ) + direct_use = any( + _path_within(Path(used), path) or _path_within(path, Path(used)) + for used in process.get("uses", []) + ) + if direct_use or (build_activity and process.get("workspace_related")): + errors.append( + f"active process {process.get('pid')} ({process.get('comm')}) may use the lane" + ) + return errors + + +def _lane_by_name(snapshot: Mapping[str, Any], lane: str) -> dict[str, Any] | None: + return next((item for item in snapshot["lanes"] if item["lane"] == lane), None) + + +def _delete_lane( + lane: Mapping[str, Any], + *, + root: Path, + workflow: Mapping[str, Any], + apply: bool, +) -> dict[str, Any]: + paths = [Path(item["path"]) for item in lane["artifacts"]] + processes = active_processes(root, paths) + checks: list[dict[str, Any]] = [] + for artifact in lane["artifacts"]: + errors = deletion_safety( + artifact, root=root, workflow=workflow, processes=processes + ) + checks.append({"path": artifact["path"], "errors": errors}) + if str(lane.get("lane")) == _candidate_lane_name(workflow): + checks.append( + { + "path": str(_candidate_preservation_path(root)), + "kind": "candidate-preservation", + "errors": _candidate_preservation_errors( + lane, root=root, workflow=workflow + ), + } + ) + blockers = [error for check in checks for error in check["errors"]] + result = { + "lane": lane["lane"], + "apply": apply, + "allocated_bytes": lane["allocated_bytes"], + "checks": checks, + "deleted": [], + "blocked": blockers, + } + if blockers or not apply: + return result + + # Delete only complete, independently marked lane roots. rmtree does not + # follow directory symlinks, and the preflight above rejects every symlink. + for artifact in sorted(lane["artifacts"], key=lambda item: item["role"] == "build"): + path = Path(artifact["path"]) + shutil.rmtree(path) + result["deleted"].append(str(path)) + return result + + +def _execute_checked_deletions( + lanes: Sequence[Mapping[str, Any]], + *, + root: Path, + workflow: Mapping[str, Any], + action: str, + apply: bool, + output: Any | None = None, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Print the complete checked plan before any requested deletion occurs.""" + + checked = [ + _delete_lane(lane, root=root, workflow=workflow, apply=False) + for lane in lanes + ] + plan = { + "timestamp": utc_now(), + "action": f"{action}-checked-plan", + "apply_requested": apply, + "total_allocated_bytes": sum( + int(result["allocated_bytes"]) for result in checked + ), + "total": human_bytes( + sum(int(result["allocated_bytes"]) for result in checked) + ), + "lanes": checked, + } + if not apply: + return plan, checked + + # `flush=True` is part of the safety contract: the complete byte-counted, + # sentinel/process/candidate-evidence checked plan is externally visible + # before the first recursive deletion can begin. + print( + json.dumps(plan, indent=2, sort_keys=True), + file=output if output is not None else sys.stdout, + flush=True, + ) + results = [ + _delete_lane(lane, root=root, workflow=workflow, apply=True) + for lane in lanes + ] + return plan, results + + +def _ledger_path(root: Path) -> Path: + return root / ".codex" / "session" / "build-storage.json" + + +def _atomic_json(path: Path, document: Mapping[str, Any]) -> None: + if path.is_symlink(): + raise StorageError(f"refusing to replace symlinked JSON state: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def write_ledger(root: Path, event: Mapping[str, Any]) -> None: + path = _ledger_path(root) + path.parent.mkdir(parents=True, exist_ok=True) + document: dict[str, Any] = {"schema_version": LEDGER_SCHEMA_VERSION, "events": []} + if path.is_file() and not path.is_symlink(): + try: + with path.open("r", encoding="utf-8") as handle: + existing = json.load(handle) + if isinstance(existing, dict) and isinstance(existing.get("events"), list): + document = existing + except (OSError, json.JSONDecodeError): + pass + events = list(document.get("events", []))[-499:] + events.append(dict(event)) + document["schema_version"] = LEDGER_SCHEMA_VERSION + document["events"] = events + document["updated_at"] = utc_now() + + _atomic_json(path, document) + + +def _summary(snapshot: Mapping[str, Any], evaluation: Mapping[str, Any]) -> dict[str, Any]: + return { + "timestamp": snapshot["timestamp"], + "workspace": snapshot["workspace"], + "total_allocated_bytes": snapshot["total_allocated_bytes"], + "total": human_bytes(snapshot["total_allocated_bytes"]), + "repository_target": { + "path": snapshot["repository_target"]["path"], + "allocated_bytes": snapshot["repository_target"]["allocated_bytes"], + "human": human_bytes(snapshot["repository_target"]["allocated_bytes"]), + "largest": [ + {"name": name, "bytes": size, "human": human_bytes(size)} + for name, size in list( + (snapshot["repository_target"].get("top_level") or {}).items() + )[:10] + ], + }, + "external_partition": { + "path": snapshot["external_partition"]["path"], + "allocated_bytes": snapshot["external_partition"]["allocated_bytes"], + "human": human_bytes(snapshot["external_partition"]["allocated_bytes"]), + }, + "lanes": [ + { + "lane": lane["lane"], + "bytes": lane["allocated_bytes"], + "human": human_bytes(lane["allocated_bytes"]), + "last_use": lane["last_use"], + "valid": lane["valid"], + } + for lane in snapshot["lanes"] + ], + "legacy_intermediates": { + name: { + "path": usage["path"], + "bytes": usage["allocated_bytes"], + "human": human_bytes(usage["allocated_bytes"]), + } + for name, usage in snapshot["legacy_intermediates"].items() + }, + "filesystems": [ + { + **filesystem, + "free": human_bytes(filesystem["free_bytes"]), + "minimum_free": human_bytes(filesystem["minimum_free_bytes"]), + } + for filesystem in snapshot["filesystems"] + ], + "active_processes": snapshot["active_processes"], + "sccache": snapshot["sccache"], + "warnings": evaluation["warnings"], + "violations": evaluation["violations"], + "simulated": snapshot.get("simulated", False), + } + + +def _print_human(summary: Mapping[str, Any]) -> None: + print(f"Build artifacts: {summary['total']}") + repository = summary["repository_target"] + print(f"Repository target: {repository['human']} ({repository['path']})") + for item in repository["largest"][:6]: + print(f" {item['name']}: {item['human']}") + external = summary["external_partition"] + print(f"Managed intermediates: {external['human']} ({external['path']})") + if summary["lanes"]: + print("Lanes:") + for lane in summary["lanes"]: + print( + f" {lane['lane']}: {lane['human']} " + f"({'valid' if lane['valid'] else 'INVALID SENTINEL'})" + ) + if summary["legacy_intermediates"]: + print("Legacy intermediates still in repository target:") + for name, usage in summary["legacy_intermediates"].items(): + print(f" {name}: {usage['human']}") + for filesystem in summary["filesystems"]: + print( + f"Free on {filesystem['mount']}: {filesystem['free']} " + f"(floor {filesystem['minimum_free']})" + ) + if summary["active_processes"]: + print("Active build/artifact users:") + for process in summary["active_processes"]: + print(f" PID {process['pid']} {process['comm']}: {process['command']}") + for warning in summary["warnings"]: + print(f"WARNING: {warning}") + for violation in summary["violations"]: + print(f"BLOCK: {violation}") + + +def _snapshot_with_simulation( + root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace +) -> tuple[dict[str, Any], dict[str, list[str]]]: + snapshot = storage_snapshot(root, workflow) + apply_simulation(snapshot, arguments) + return snapshot, evaluate_budget(snapshot) + + +def _make_preserved_record( + path: Path, *, root: Path, lane_paths: Sequence[Path], label: str +) -> dict[str, Any]: + original = path.expanduser() + if not original.is_absolute(): + original = root / original + if original.is_symlink(): + raise StorageError(f"{label} must not be a symlink: {original}") + resolved = canonical_path(original) + if any(_path_within(resolved, lane_path) for lane_path in lane_paths): + raise StorageError( + f"{label} must be copied outside the disposable candidate lane: {resolved}" + ) + if not resolved.is_file(): + raise StorageError(f"{label} is not a file: {resolved}") + return { + "path": str(resolved), + "bytes": resolved.stat().st_size, + "sha256": _sha256_file(resolved), + } + + +def _mark_candidate_preserved( + root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace +) -> int: + snapshot = storage_snapshot(root, workflow) + lane_name = _candidate_lane_name(workflow) + lane = _lane_by_name(snapshot, lane_name) + if lane is None: + raise StorageError(f"managed candidate lane does not exist: {lane_name}") + if not lane.get("valid"): + raise StorageError("candidate lane has an invalid sentinel") + generation = _lane_generation(lane) + if generation is None: + raise StorageError("candidate lane generation cannot be established") + lane_paths = [canonical_path(Path(item["path"])) for item in lane["artifacts"]] + evidence = _make_preserved_record( + arguments.evidence, + root=root, + lane_paths=lane_paths, + label="candidate evidence manifest", + ) + artifacts = [ + _make_preserved_record( + path, + root=root, + lane_paths=lane_paths, + label=f"preserved candidate artifact {index}", + ) + for index, path in enumerate(arguments.artifact) + ] + evidence_path = evidence["path"] + if all(record["path"] == evidence_path for record in artifacts): + raise StorageError( + "candidate evidence alone is insufficient; preserve at least one distinct artifact" + ) + + document = { + "schema_version": CANDIDATE_PRESERVATION_SCHEMA_VERSION, + "workspace": str(canonical_path(root)), + "workspace_hash": workspace_hash(root), + "lane": lane_name, + "lane_generation": generation, + "prune_safe": True, + "preserved_at": utc_now(), + "evidence": evidence, + "preserved_artifacts": artifacts, + } + marker_path = _candidate_preservation_path(root) + _atomic_json(marker_path, document) + errors = _candidate_preservation_errors(lane, root=root, workflow=workflow) + if errors: + raise StorageError("candidate preservation marker failed validation: " + "; ".join(errors)) + write_ledger( + root, + { + "timestamp": utc_now(), + "action": "mark-candidate-preserved", + "lane": lane_name, + "lane_generation": generation, + "marker": str(marker_path), + "evidence": evidence, + "preserved_artifacts": artifacts, + }, + ) + print(json.dumps(document, indent=2, sort_keys=True)) + return 0 + + +def _status(root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace) -> int: + snapshot, evaluation = _snapshot_with_simulation(root, workflow, arguments) + summary = _summary(snapshot, evaluation) + if arguments.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + else: + _print_human(summary) + return 0 + + +def _plan(root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace) -> int: + snapshot, evaluation = _snapshot_with_simulation(root, workflow, arguments) + document = { + "summary": _summary(snapshot, evaluation), + "prune": prune_plan(snapshot), + "legacy_migration": { + "required": bool(snapshot["legacy_intermediates"]), + "automatic": False, + "reason": ( + "unmanaged repository intermediates require the separately authorized " + "one-time migration; they are never removed by prune" + ), + }, + } + if arguments.json: + print(json.dumps(document, indent=2, sort_keys=True)) + else: + _print_human(document["summary"]) + print("Prune plan:") + for item in document["prune"]: + disposition = "eligible" if item["eligible"] else "keep" + print(f" {item['lane']}: {disposition} ({item['reason']})") + if document["legacy_migration"]["required"]: + print("Legacy target migration is required and will not be auto-pruned.") + return 0 + + +def _prune(root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace) -> int: + snapshot, evaluation = _snapshot_with_simulation(root, workflow, arguments) + if snapshot.get("simulated") and arguments.apply: + raise StorageError("--apply is forbidden with simulated inputs") + candidates = {item["lane"]: item for item in prune_plan(snapshot) if item["eligible"]} + lanes: list[dict[str, Any]] = [] + for lane_name in sorted(candidates): + lane = _lane_by_name(snapshot, lane_name) + if lane is not None: + lanes.append(lane) + checked_plan, results = _execute_checked_deletions( + lanes, + root=root, + workflow=workflow, + action="prune", + apply=bool(arguments.apply), + ) + event = { + "timestamp": utc_now(), + "action": "prune", + "apply": bool(arguments.apply), + "before_bytes": snapshot["total_allocated_bytes"], + "checked_plan": checked_plan, + "results": results, + "violations": evaluation["violations"], + } + if arguments.apply: + write_ledger(root, event) + print(json.dumps(event, indent=2, sort_keys=True)) + return 2 if any(result["blocked"] for result in results) else 0 + + +def _reset_lane(root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace) -> int: + lane_name = validate_lane_name(arguments.lane) + snapshot, _ = _snapshot_with_simulation(root, workflow, arguments) + if snapshot.get("simulated") and arguments.apply: + raise StorageError("--apply is forbidden with simulated inputs") + lane = _lane_by_name(snapshot, lane_name) + if lane is None: + raise StorageError(f"managed lane does not exist: {lane_name}") + checked_plan, results = _execute_checked_deletions( + [lane], + root=root, + workflow=workflow, + action="reset-lane", + apply=bool(arguments.apply), + ) + result = results[0] + event = { + "timestamp": utc_now(), + "action": "reset-lane", + "apply": bool(arguments.apply), + "checked_plan": checked_plan, + "result": result, + } + if arguments.apply: + write_ledger(root, event) + print(json.dumps(event, indent=2, sort_keys=True)) + return 2 if result["blocked"] else 0 + + +def _enforce(root: Path, workflow: Mapping[str, Any], arguments: argparse.Namespace) -> int: + snapshot, evaluation = _snapshot_with_simulation(root, workflow, arguments) + before_bytes = int(snapshot["total_allocated_bytes"]) + before_warnings = list(evaluation["warnings"]) + before_violations = list(evaluation["violations"]) + pressure = bool(evaluation["violations"] or evaluation["warnings"]) + eligible = [item for item in prune_plan(snapshot) if item["eligible"]] + checked_plan: dict[str, Any] | None = None + prune_results: list[dict[str, Any]] = [] + policy = _policy(workflow) + auto_prune = bool(policy.get("auto_prune_disposable", True)) + + if pressure and eligible and auto_prune and not snapshot.get("simulated"): + lanes = [ + lane + for item in eligible + if (lane := _lane_by_name(snapshot, str(item["lane"]))) is not None + ] + checked_plan, prune_results = _execute_checked_deletions( + lanes, + root=root, + workflow=workflow, + action=f"enforce-{arguments.phase}", + apply=True, + # Keep --json stdout machine-readable while still making the + # mandatory pre-delete plan visible before recursive deletion. + output=sys.stderr if arguments.json else sys.stdout, + ) + snapshot = storage_snapshot(root, workflow) + evaluation = evaluate_budget(snapshot) + + event = { + "timestamp": utc_now(), + "action": "enforce", + "phase": arguments.phase, + "before_bytes": before_bytes, + "total_allocated_bytes": snapshot["total_allocated_bytes"], + "reclaimed_bytes": max( + 0, before_bytes - int(snapshot["total_allocated_bytes"]) + ), + "warnings_before_prune": before_warnings, + "violations_before_prune": before_violations, + "warnings": evaluation["warnings"], + "violations": evaluation["violations"], + "auto_prune_enabled": auto_prune, + "auto_prune_would_apply": bool( + pressure and eligible and auto_prune and snapshot.get("simulated") + ), + "destructive": any(result["deleted"] for result in prune_results), + "checked_plan": checked_plan, + "prune_results": prune_results, + "eligible_prune_bytes": sum( + int(item["allocated_bytes"]) for item in eligible + ), + "eligible_prune_lanes": [item["lane"] for item in eligible], + "prune_recommended": pressure and bool(eligible), + "simulated": snapshot.get("simulated", False), + } + if not snapshot.get("simulated"): + write_ledger(root, event) + if arguments.json: + print(json.dumps(event, indent=2, sort_keys=True)) + else: + _print_human(_summary(snapshot, evaluation)) + if pressure and eligible: + if auto_prune and snapshot.get("simulated"): + print("Simulation only: expired disposable lanes would be pruned.") + elif not auto_prune: + print( + "Automatic pruning is disabled. Review `build_storage.py prune " + "--dry-run` before any explicit apply." + ) + return 2 if evaluation["violations"] else 0 + + +def _write_test_marker( + path: Path, + root: Path, + lane: str, + role: str, + *, + last_use: str | None = None, +) -> None: + path.mkdir(parents=True, exist_ok=True) + document = { + "schema_version": 1, + "workspace": str(canonical_path(root)), + "workspace_hash": workspace_hash(root), + "lane": lane, + "role": role, + "path": str(canonical_path(path)), + "created_at": utc_now(), + "last_use": last_use or utc_now(), + } + sentinel_path(path).write_text(json.dumps(document), encoding="utf-8") + + +def _self_test() -> dict[str, Any]: + checks: list[str] = [] + fake = { + "policy": dict(DEFAULT_POLICY), + "total_allocated_bytes": 81 * GIB, + "lanes": [], + "filesystems": [ + {"mount": "/test", "free_bytes": 100 * GIB, "minimum_free_bytes": 40 * GIB} + ], + } + assert evaluate_budget(fake)["violations"] + checks.append("simulated-hard-budget-crossing") + fake["total_allocated_bytes"] = 56 * GIB + soft = evaluate_budget(fake) + assert not soft["violations"] and soft["warnings"] + checks.append("simulated-soft-budget-crossing") + fake["total_allocated_bytes"] = 1 * GIB + fake["filesystems"][0]["free_bytes"] = 39 * GIB + assert evaluate_budget(fake)["violations"] + checks.append("simulated-free-space-floor") + + with tempfile.TemporaryDirectory(prefix="blacksite-storage-self-test-") as temporary: + base = Path(temporary) + root = base / "workspace" + (root / ".git").mkdir(parents=True) + (root / "Cargo.toml").write_text("[workspace]\nmembers=[]\n", encoding="utf-8") + cache = base / "cache" + workflow = { + "build_storage": { + **DEFAULT_POLICY, + "cache_root": str(cache), + "workspace_partition": "canonical-path-hash", + "exceptional_target_root": "target/lanes", + } + } + lane_path = cache / workspace_hash(root) / "candidate" + _write_test_marker(lane_path, root, "candidate", "build") + artifact = { + "path": str(lane_path), + "lane": "candidate", + "role": "build", + } + assert not deletion_safety( + artifact, root=root, workflow=workflow, processes=[] + ) + checks.append("valid-sentinel-path-accepted") + + link = lane_path / "escape" + link.symlink_to(root) + errors = deletion_safety( + artifact, root=root, workflow=workflow, processes=[] + ) + assert any("symlink" in error for error in errors) + checks.append("internal-symlink-rejected") + link.unlink() + + errors = deletion_safety( + artifact, + root=root, + workflow=workflow, + processes=[ + { + "pid": 42, + "comm": "cargo", + "uses": [str(lane_path)], + "build_tool": True, + "packager": False, + "workspace_related": True, + } + ], + ) + assert any("active process" in error for error in errors) + checks.append("active-process-rejected") + + unmarked = cache / workspace_hash(root) / "unmarked" + unmarked.mkdir(parents=True) + errors = deletion_safety( + {"path": str(unmarked), "lane": "unmarked", "role": "build"}, + root=root, + workflow=workflow, + processes=[], + ) + assert any("missing or malformed" in error for error in errors) + checks.append("unmarked-lane-rejected") + + for protected_path, label in ( + (root, "workspace-root"), + (root.parent, "workspace-parent"), + (Path.home(), "home"), + (cargo_home(), "cargo-home"), + ): + errors = deletion_safety( + { + "path": str(protected_path), + "lane": "candidate", + "role": "build", + }, + root=root, + workflow=workflow, + processes=[], + ) + assert errors + checks.append(f"{label}-rejected") + + # Candidate deletion requires a generation-bound marker whose evidence + # and preserved artifacts still exist outside the disposable lane. + dev_path = cache / workspace_hash(root) / "dev" + _write_test_marker(dev_path, root, "dev", "build") + evidence = base / "evidence" / "candidate-manifest.json" + evidence.parent.mkdir(parents=True) + evidence.write_text('{"candidate":"test"}\n', encoding="utf-8") + preserved_artifact = base / "preserved" / "editor-package.tar.zst" + preserved_artifact.parent.mkdir(parents=True) + preserved_artifact.write_bytes(b"preserved candidate package") + lane = { + "lane": "candidate", + "allocated_bytes": measure_tree(lane_path).allocated_bytes, + "artifacts": [artifact], + } + + blocked_output = io.StringIO() + _, blocked_results = _execute_checked_deletions( + [lane], + root=root, + workflow=workflow, + action="self-test-candidate", + apply=True, + output=blocked_output, + ) + assert blocked_results[0]["blocked"] and lane_path.exists() + assert any( + "preservation marker is missing" in error + for error in blocked_results[0]["blocked"] + ) + checks.append("candidate-without-preservation-marker-rejected") + + mark_arguments = argparse.Namespace( + evidence=evidence, artifact=[preserved_artifact] + ) + with contextlib.redirect_stdout(io.StringIO()): + assert _mark_candidate_preserved(root, workflow, mark_arguments) == 0 + assert not _candidate_preservation_errors( + lane, root=root, workflow=workflow + ) + checks.append("candidate-preservation-marker-validated") + + class FlushProbe(io.StringIO): + def __init__(self, guarded_path: Path) -> None: + super().__init__() + self.guarded_path = guarded_path + self.flushed_before_delete = False + + def flush(self) -> None: + assert self.guarded_path.exists() + self.flushed_before_delete = True + super().flush() + + output = FlushProbe(lane_path) + checked_plan, results = _execute_checked_deletions( + [lane], + root=root, + workflow=workflow, + action="self-test-candidate", + apply=True, + output=output, + ) + deletion = results[0] + assert output.flushed_before_delete + assert checked_plan["total_allocated_bytes"] == lane["allocated_bytes"] + assert "self-test-candidate-checked-plan" in output.getvalue() + assert not deletion["blocked"] and deletion["deleted"] + assert not lane_path.exists() + assert dev_path.exists() and evidence.is_file() and preserved_artifact.is_file() + checks.append("checked-plan-flushed-before-candidate-prune") + checks.append("candidate-pruned-artifact-evidence-and-dev-preserved") + + # Recreating the candidate lane invalidates the old generation-bound + # marker, so a stale preservation decision cannot authorize deletion. + _write_test_marker( + lane_path, + root, + "candidate", + "build", + last_use="2099-01-01T00:00:00+00:00", + ) + lane["allocated_bytes"] = measure_tree(lane_path).allocated_bytes + stale = _delete_lane(lane, root=root, workflow=workflow, apply=False) + assert any("lane_generation" in error for error in stale["blocked"]) + checks.append("stale-candidate-preservation-marker-rejected") + + # Enforcement reports pressure and eligible lanes but never performs a + # deletion. Auto-prune must emit its complete checked plan first. + hot_path = cache / workspace_hash(root) / "hot-reload" + _write_test_marker( + hot_path, + root, + "hot-reload", + "build", + last_use="2000-01-01T00:00:00+00:00", + ) + workflow["build_storage"].update( + { + "soft_total_gib": 0, + "hard_total_gib": 100000, + "min_free_gib": 0, + "min_free_percent": 0, + "hot_reload_retention_hours": 0, + "auto_prune_disposable": True, + } + ) + enforce_arguments = argparse.Namespace( + phase="pre", + json=True, + simulate_total_gib=None, + simulate_free_gib=None, + simulate_lane_gib=[], + ) + enforce_output = io.StringIO() + enforce_plan_output = io.StringIO() + with contextlib.redirect_stdout(enforce_output), contextlib.redirect_stderr( + enforce_plan_output + ): + assert _enforce(root, workflow, enforce_arguments) == 0 + enforce_event = json.loads(enforce_output.getvalue()) + enforce_plan = json.loads(enforce_plan_output.getvalue()) + assert enforce_plan["action"] == "enforce-pre-checked-plan" + assert enforce_plan["total_allocated_bytes"] > 0 + assert enforce_event["destructive"] is True + assert "hot-reload" in enforce_event["eligible_prune_lanes"] + assert not hot_path.exists() + checks.append("enforce-pressure-prunes-after-checked-plan") + + return {"ok": True, "checks": checks} + + +def _add_simulation_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--simulate-total-gib", type=float) + parser.add_argument("--simulate-free-gib", type=float) + parser.add_argument( + "--simulate-lane-gib", + action="append", + default=[], + metavar="LANE=GIB", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, help="workspace root override") + subparsers = parser.add_subparsers(dest="action", required=True) + + for name in ("status", "plan"): + command = subparsers.add_parser(name) + command.add_argument("--json", action="store_true") + _add_simulation_options(command) + + enforce = subparsers.add_parser("enforce") + enforce.add_argument("--phase", choices=("pre", "post"), required=True) + enforce.add_argument("--json", action="store_true") + _add_simulation_options(enforce) + + prune = subparsers.add_parser("prune") + mode = prune.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + _add_simulation_options(prune) + + reset = subparsers.add_parser("reset-lane") + reset.add_argument("lane") + mode = reset.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + _add_simulation_options(reset) + + preserved = subparsers.add_parser("mark-candidate-preserved") + preserved.add_argument( + "--evidence", + type=Path, + required=True, + help="preserved candidate evidence manifest outside the disposable lane", + ) + preserved.add_argument( + "--artifact", + type=Path, + action="append", + required=True, + help="preserved candidate binary/package file; repeat for multiple artifacts", + ) + + subparsers.add_parser("self-test") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + if arguments.action == "self-test": + print(json.dumps(_self_test(), indent=2, sort_keys=True)) + return 0 + try: + root = workspace_root(arguments.workspace) + workflow = load_workflow(root) + if arguments.action == "status": + return _status(root, workflow, arguments) + if arguments.action == "plan": + return _plan(root, workflow, arguments) + if arguments.action == "enforce": + return _enforce(root, workflow, arguments) + if arguments.action == "prune": + return _prune(root, workflow, arguments) + if arguments.action == "reset-lane": + return _reset_lane(root, workflow, arguments) + if arguments.action == "mark-candidate-preserved": + return _mark_candidate_preserved(root, workflow, arguments) + except (LaneError, StorageError, OSError, ValueError) as error: + print(f"build-storage: {error}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/cargo_lane.py b/scripts/codex/cargo_lane.py new file mode 100755 index 0000000..ac43e3e --- /dev/null +++ b/scripts/codex/cargo_lane.py @@ -0,0 +1,848 @@ +#!/usr/bin/env python3 +"""Resolve and execute Cargo commands in bounded, signature-stable build lanes. + +This module intentionally uses only the Python standard library. It is also +imported by build_storage.py, so lane identity and sentinel validation have one +implementation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +try: + import tomllib +except ModuleNotFoundError as error: # pragma: no cover - Python < 3.11 + raise SystemExit("cargo_lane.py requires Python 3.11 or newer") from error + + +SCHEMA_VERSION = 1 +SENTINEL_NAME = ".blacksite-cargo-lane.json" +LANE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +SEPARATE_BUILD_DIR_MINIMUM = (1, 91, 0) + +DEFAULT_BUILD_STORAGE: dict[str, Any] = { + "cache_root": "cargo-cache-home", + "workspace_partition": "canonical-path-hash", + "dev_target_dir": "target", + "exceptional_target_root": "target/lanes", + "persistent_lane": "dev", + "candidate_lane": "candidate", + "hot_reload_lane": "hot-reload", + "full_debug_lane": "full-debug", + "package_lane": "package", +} + + +class LaneError(RuntimeError): + """A lane cannot be resolved or safely reused.""" + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def canonical_path(path: Path | str) -> Path: + return Path(path).expanduser().resolve(strict=False) + + +def workspace_root(start: Path | None = None) -> Path: + override = os.environ.get("BLACKSITE_WORKSPACE_ROOT") + if override: + candidate = canonical_path(override) + elif start is not None: + candidate = canonical_path(start) + else: + candidate = canonical_path(Path(__file__).parents[2]) + + for current in (candidate, *candidate.parents): + if (current / "Cargo.toml").is_file() and (current / ".git").exists(): + return current + raise LaneError(f"could not locate a Cargo workspace from {candidate}") + + +def workspace_hash(root: Path) -> str: + return hashlib.sha256(os.fsencode(str(canonical_path(root)))).hexdigest()[:20] + + +def cargo_home() -> Path: + return canonical_path(os.environ.get("CARGO_HOME", Path.home() / ".cargo")) + + +def load_workflow(root: Path) -> dict[str, Any]: + path = root / ".codex" / "workflow.toml" + document: dict[str, Any] = {} + if path.is_file(): + with path.open("rb") as handle: + loaded = tomllib.load(handle) + if not isinstance(loaded, dict): + raise LaneError(f"workflow config is not a TOML table: {path}") + document = loaded + + storage = dict(DEFAULT_BUILD_STORAGE) + configured = document.get("build_storage", {}) + if configured: + if not isinstance(configured, dict): + raise LaneError("[build_storage] must be a TOML table") + storage.update(configured) + + verification = document.get("verification", {}) + if isinstance(verification, dict): + for source, destination in ( + ("persistent_lane", "persistent_lane"), + ("candidate_lane", "candidate_lane"), + ("hot_reload_lane", "hot_reload_lane"), + ("full_debug_lane", "full_debug_lane"), + ("package_lane", "package_lane"), + ): + if source in verification: + storage[destination] = verification[source] + + document["build_storage"] = storage + return document + + +def _configured_path(root: Path, value: str, *, base: Path | None = None) -> Path: + expanded = Path(os.path.expandvars(os.path.expanduser(value))) + if expanded.is_absolute(): + return canonical_path(expanded) + return canonical_path((base or root) / expanded) + + +def cache_root(root: Path, workflow: Mapping[str, Any]) -> Path: + override = os.environ.get("BLACKSITE_BUILD_CACHE_ROOT") + if override: + return canonical_path(override) + + storage = workflow["build_storage"] + configured = str(storage.get("cache_root", "cargo-cache-home")) + if configured == "cargo-cache-home": + return cargo_home() / "blacksite-build" + if configured == "workspace-parent-cache": + return canonical_path(root.parent / ".blacksite-build-cache") + return _configured_path(root, configured) + + +def parse_version(text: str) -> tuple[int, int, int]: + match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", text) + if not match: + return (0, 0, 0) + return tuple(int(part) for part in match.groups()) # type: ignore[return-value] + + +def _capture(command: Sequence[str]) -> str: + try: + completed = subprocess.run( + command, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as error: + return f"unavailable: {error}" + return completed.stdout.strip() + + +def toolchain_info() -> dict[str, Any]: + cargo_text = _capture(["cargo", "-Vv"]) + rustc_text = _capture(["rustc", "-Vv"]) + host = "unknown" + for line in rustc_text.splitlines(): + if line.startswith("host:"): + host = line.partition(":")[2].strip() + break + version = parse_version(cargo_text) + return { + "cargo": cargo_text.splitlines()[0] if cargo_text else "unavailable", + "cargo_version": ".".join(str(part) for part in version), + "rustc": rustc_text.splitlines()[0] if rustc_text else "unavailable", + "host": host, + "separate_build_dir": version >= SEPARATE_BUILD_DIR_MINIMUM, + } + + +def _repo_target_config(root: Path, target: str) -> dict[str, Any]: + path = root / ".cargo" / "config.toml" + result: dict[str, Any] = {"linker": None, "rustflags": []} + if path.is_file(): + with path.open("rb") as handle: + config = tomllib.load(handle) + target_table = config.get("target", {}) + if isinstance(target_table, dict): + values = target_table.get(target, {}) + if isinstance(values, dict): + result["linker"] = values.get("linker") + flags = values.get("rustflags", []) + if isinstance(flags, list): + result["rustflags"] = [str(flag) for flag in flags] + encoded_flags = os.environ.get("CARGO_ENCODED_RUSTFLAGS") + if encoded_flags: + result["rustflags"] = [flag for flag in encoded_flags.split("\x1f") if flag] + result["rustflags_source"] = "CARGO_ENCODED_RUSTFLAGS" + elif os.environ.get("RUSTFLAGS"): + result["rustflags"] = shlex.split(os.environ["RUSTFLAGS"]) + result["rustflags_source"] = "environment" + else: + result["rustflags_source"] = str(path) if path.is_file() else "cargo-default" + result["rustc_wrapper"] = os.environ.get("RUSTC_WRAPPER") + linker_variable = "CARGO_TARGET_" + target.upper().replace("-", "_") + "_LINKER" + if os.environ.get(linker_variable): + result["linker"] = os.environ[linker_variable] + return result + + +def validate_lane_name(lane: str) -> str: + if not LANE_RE.fullmatch(lane): + raise LaneError( + f"invalid lane {lane!r}; use lowercase letters, digits, and hyphens" + ) + return lane + + +def profile_directory(profile: str) -> str: + if profile in {"dev", "test"}: + return "debug" + if profile in {"release", "bench"}: + return "release" + return profile + + +@dataclass(frozen=True) +class CommandContext: + profile: str + profile_dir: str + feature_signature: str + cargo_subcommand: str | None + target_triple: str | None + + +PROJECT_ALIAS_SUBCOMMANDS = { + "bake-navigation", + "package-project", + "process-assets", + "upgrade-project", + "validate-levels", + "validate-samples", +} + + +def command_context(command: Sequence[str], lane: str) -> CommandContext: + args = list(command) + cargo_index: int | None = None + for index, value in enumerate(args[:2]): + if Path(value).name == "cargo": + cargo_index = index + break + + cargo_args = args[cargo_index + 1 :] if cargo_index is not None else [] + subcommand = next((arg for arg in cargo_args if not arg.startswith("-")), None) + # Arguments following Blacksite's Cargo aliases are forwarded to the xtask + # binary by the alias's trailing `--`. They are not Cargo options. In + # particular, `cargo package-project --profile package-qa` selects the + # package manifest profile and must not repartition the Cargo build lane. + cargo_options = ( + cargo_args[: cargo_args.index(subcommand) + 1] + if subcommand in PROJECT_ALIAS_SUBCOMMANDS + else cargo_args + ) + if "--release" in cargo_options: + profile = "release" + else: + profile = "test" if subcommand == "test" else "dev" + for index, value in enumerate(cargo_options): + if value.startswith("--profile="): + profile = value.partition("=")[2] + elif value == "--profile" and index + 1 < len(cargo_options): + profile = cargo_options[index + 1] + + features: set[str] = set() + all_features = "--all-features" in cargo_options + no_default = "--no-default-features" in cargo_options + for index, value in enumerate(cargo_options): + raw: str | None = None + if value.startswith("--features="): + raw = value.partition("=")[2] + elif value.startswith("-F") and value != "-F": + raw = value[2:] + elif value in {"--features", "-F"} and index + 1 < len(cargo_options): + raw = cargo_options[index + 1] + if raw: + features.update(part for part in re.split(r"[ ,]+", raw) if part) + + target_triple: str | None = None + for index, value in enumerate(cargo_options): + if value.startswith("--target="): + target_triple = value.partition("=")[2] + elif value == "--target" and index + 1 < len(cargo_options): + target_triple = cargo_options[index + 1] + + if all_features: + feature_signature = "all-features" + else: + parts = ["no-default" if no_default else "default"] + if features: + parts.append("features=" + ",".join(sorted(features))) + feature_signature = ";".join(parts) + + # These disposable lanes intentionally contain a bounded family of + # package-specific feature sets. Hot reload needs the editor's + # `dev,hot-reload` build and `game_hot`'s `dylib` build in one isolated + # runtime lane; package aliases likewise expand to different internal + # features. They must never spill into the persistent default-feature lane. + if lane == "hot-reload": + if all_features: + raise LaneError("all-features belongs in the candidate lane") + feature_signature = "hot-reload-family" + elif lane == "package": + feature_signature = "package-family" + + if lane == "dev" and (all_features or "hot-reload" in features): + raise LaneError( + "the persistent dev lane cannot be used for all-features or hot-reload" + ) + if lane == "candidate" and subcommand is not None and not all_features: + raise LaneError("the candidate lane requires --all-features") + + return CommandContext( + profile=profile, + profile_dir=profile_directory(profile), + feature_signature=feature_signature, + cargo_subcommand=subcommand, + target_triple=target_triple, + ) + + +@dataclass(frozen=True) +class LaneLayout: + workspace: str + workspace_hash: str + lane: str + mode: str + cache_root: str + lane_root: str + target_dir: str + build_dir: str + profile: str + profile_dir: str + runtime_deps: str + feature_signature: str + toolchain: dict[str, Any] + linker: str | None + rustflags: list[str] + rustflags_source: str + rustc_wrapper: str | None + target_triple: str + + +def lane_layout( + root: Path, + lane: str, + workflow: Mapping[str, Any], + command: Sequence[str] = (), + *, + toolchain: Mapping[str, Any] | None = None, +) -> LaneLayout: + lane = validate_lane_name(lane) + root = canonical_path(root) + info = dict(toolchain or toolchain_info()) + context = command_context(command, lane) + storage = workflow["build_storage"] + cache = canonical_path(cache_root(root, workflow)) + partition = cache / workspace_hash(root) + lane_root = partition / lane + separate = bool(info.get("separate_build_dir")) + effective_target = context.target_triple or str(info.get("host", "unknown")) + if ( + context.target_triple + and context.target_triple != info.get("host") + and not lane.startswith("cross-") + ): + raise LaneError("cross-target Cargo commands require a cross-* disposable lane") + + persistent = str(storage.get("persistent_lane", "dev")) + if separate: + if lane == persistent: + target = _configured_path( + root, str(storage.get("dev_target_dir", "target")) + ) + else: + exceptional = _configured_path( + root, str(storage.get("exceptional_target_root", "target/lanes")) + ) + target = exceptional / lane + build = lane_root + mode = "separate-build-dir" + else: + target = lane_root / "target" + build = target + mode = "external-target-dir-fallback" + + target_config = _repo_target_config(root, effective_target) + profile_path = Path(context.profile_dir) + if context.target_triple: + profile_path = Path(context.target_triple) / profile_path + return LaneLayout( + workspace=str(root), + workspace_hash=workspace_hash(root), + lane=lane, + mode=mode, + cache_root=str(cache), + lane_root=str(lane_root), + target_dir=str(target), + build_dir=str(build), + profile=context.profile, + profile_dir=context.profile_dir, + runtime_deps=str(build / profile_path / "deps"), + feature_signature=context.feature_signature, + toolchain=info, + linker=target_config.get("linker"), + rustflags=list(target_config.get("rustflags", [])), + rustflags_source=str(target_config.get("rustflags_source")), + rustc_wrapper=target_config.get("rustc_wrapper"), + target_triple=effective_target, + ) + + +def lane_environment(layout: LaneLayout) -> dict[str, str]: + environment = { + "CARGO_TARGET_DIR": layout.target_dir, + "BLACKSITE_CARGO_LANE": layout.lane, + "BLACKSITE_WORKSPACE_HASH": layout.workspace_hash, + } + if layout.mode == "separate-build-dir": + environment["CARGO_BUILD_BUILD_DIR"] = layout.build_dir + if layout.lane in {"candidate", "package", "full-debug"}: + environment["CARGO_INCREMENTAL"] = "0" + if layout.lane == "full-debug": + environment["CARGO_PROFILE_DEV_DEBUG"] = "full" + environment["CARGO_PROFILE_TEST_DEBUG"] = "full" + return environment + + +def sentinel_path(path: Path) -> Path: + return path / SENTINEL_NAME + + +def read_sentinel(path: Path) -> dict[str, Any] | None: + marker = sentinel_path(path) + if not marker.is_file() or marker.is_symlink(): + return None + try: + with marker.open("r", encoding="utf-8") as handle: + value = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def validate_sentinel( + path: Path, + document: Mapping[str, Any], + *, + root: Path, + lane: str, + role: str, +) -> list[str]: + errors: list[str] = [] + expected = { + "schema_version": SCHEMA_VERSION, + "workspace": str(canonical_path(root)), + "workspace_hash": workspace_hash(root), + "lane": lane, + "role": role, + "path": str(canonical_path(path)), + } + for key, value in expected.items(): + if document.get(key) != value: + errors.append(f"{key}: expected {value!r}, found {document.get(key)!r}") + return errors + + +def _signature(layout: LaneLayout) -> dict[str, Any]: + # The persistent development and candidate lanes intentionally serve both + # Cargo's `dev` and `test` profiles. Cargo fingerprints those profiles + # independently, while keeping them in one bounded ordinary/candidate lane + # avoids multiplying the Bevy dependency graph into permanent caches. + profile_signature = "dev-test" if layout.profile in {"dev", "test"} else layout.profile + return { + "toolchain": layout.toolchain, + "target_triple": layout.target_triple, + "linker": layout.linker, + "rustflags": layout.rustflags, + "rustc_wrapper": layout.rustc_wrapper, + "feature_signature": layout.feature_signature, + "profile_signature": profile_signature, + "mode": layout.mode, + "lane_environment": lane_environment(layout), + } + + +def _signature_compatible(existing: object, requested: Mapping[str, Any]) -> bool: + if existing == requested: + return True + if not isinstance(existing, Mapping): + return False + previous = dict(existing) + current = dict(requested) + previous_profile = previous.pop("profile_signature", None) + current_profile = current.pop("profile_signature", None) + return ( + previous == current + and previous_profile in {"dev", "test"} + and current_profile == "dev-test" + ) + + +def _atomic_json(path: Path, document: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def _prepare_marker( + path: Path, layout: LaneLayout, role: str, *, allow_create: bool +) -> dict[str, Any]: + if path.is_symlink(): + raise LaneError(f"refusing symlinked lane path: {path}") + if allow_create: + path.mkdir(parents=True, exist_ok=True) + if not path.is_dir(): + raise LaneError(f"lane path does not exist: {path}") + + existing = read_sentinel(path) + now = utc_now() + signature = _signature(layout) + if existing is not None: + errors = validate_sentinel( + path, existing, root=Path(layout.workspace), lane=layout.lane, role=role + ) + if errors: + raise LaneError( + f"invalid lane sentinel at {path}: " + "; ".join(errors) + ) + if not _signature_compatible(existing.get("signature"), signature): + raise LaneError( + "lane signature changed; select the correct exceptional lane or reset " + f"the whole lane after a dry run: {layout.lane}" + ) + document = dict(existing) + document["signature"] = signature + document["last_use"] = now + document["last_profile"] = layout.profile + else: + document = { + "schema_version": SCHEMA_VERSION, + "workspace": layout.workspace, + "workspace_hash": layout.workspace_hash, + "lane": layout.lane, + "role": role, + "path": str(canonical_path(path)), + "target_dir": layout.target_dir, + "build_dir": layout.build_dir, + "signature": signature, + "created_at": now, + "last_use": now, + "last_profile": layout.profile, + } + _atomic_json(sentinel_path(path), document) + return document + + +def prepare_lane(layout: LaneLayout) -> None: + lane_root = Path(layout.lane_root) + _prepare_marker(lane_root, layout, "build", allow_create=True) + + target = Path(layout.target_dir) + workspace_target = Path(layout.workspace) / "target" + if target != workspace_target and target != Path(layout.build_dir): + _prepare_marker(target, layout, "target", allow_create=True) + + +def require_prepared_lane(layout: LaneLayout) -> None: + lane_root = Path(layout.lane_root) + marker = read_sentinel(lane_root) + if marker is None: + raise LaneError( + f"lane {layout.lane!r} has not been built through cargo_lane.py" + ) + errors = validate_sentinel( + lane_root, + marker, + root=Path(layout.workspace), + lane=layout.lane, + role="build", + ) + if errors: + raise LaneError("invalid prepared lane: " + "; ".join(errors)) + + +def layout_document(layout: LaneLayout) -> dict[str, Any]: + document = asdict(layout) + document["environment"] = lane_environment(layout) + document["sentinel"] = str(sentinel_path(Path(layout.lane_root))) + return document + + +def _shell_environment(values: Mapping[str, str]) -> str: + return "\n".join( + f"export {name}={shlex.quote(value)}" for name, value in sorted(values.items()) + ) + + +def _run_command(layout: LaneLayout, command: Sequence[str], *, dry_run: bool) -> int: + if not command: + raise LaneError("missing command after --") + if dry_run: + print( + json.dumps( + {"layout": layout_document(layout), "command": list(command)}, + indent=2, + sort_keys=True, + ) + ) + return 0 + prepare_lane(layout) + environment = os.environ.copy() + environment.update(lane_environment(layout)) + return subprocess.run(command, cwd=layout.workspace, env=environment).returncode + + +def _resolve_runtime_command(layout: LaneLayout, command: Sequence[str]) -> list[str]: + if not command: + raise LaneError("missing runtime command after --") + resolved = list(command) + executable = Path(resolved[0]) + if not executable.is_absolute(): + parts = executable.parts + if parts and parts[0] == "target": + executable = Path(layout.target_dir).joinpath(*parts[1:]) + elif "/" in resolved[0]: + executable = Path(layout.workspace) / executable + resolved[0] = str(canonical_path(executable)) if "/" in str(executable) else str(executable) + return resolved + + +def _run_runtime(layout: LaneLayout, command: Sequence[str], *, dry_run: bool) -> int: + require_prepared_lane(layout) + resolved = _resolve_runtime_command(layout, command) + environment = os.environ.copy() + dependencies = layout.runtime_deps + existing = environment.get("LD_LIBRARY_PATH") + environment["LD_LIBRARY_PATH"] = ( + dependencies if not existing else dependencies + os.pathsep + existing + ) + environment.update(lane_environment(layout)) + if dry_run: + print( + json.dumps( + { + "layout": layout_document(layout), + "command": resolved, + "LD_LIBRARY_PATH": environment["LD_LIBRARY_PATH"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + os.chdir(layout.workspace) + os.execvpe(resolved[0], resolved, environment) + raise AssertionError("os.execvpe unexpectedly returned") # pragma: no cover + + +def _self_test() -> dict[str, Any]: + checks: list[str] = [] + assert parse_version("cargo 1.97.0 (abc)") == (1, 97, 0) + checks.append("cargo-version-parsing") + assert profile_directory("test") == "debug" + assert profile_directory("package-qa") == "package-qa" + checks.append("profile-directory-mapping") + try: + validate_lane_name("../escape") + except LaneError: + checks.append("lane-name-traversal-rejected") + else: # pragma: no cover + raise AssertionError("unsafe lane name accepted") + + with tempfile.TemporaryDirectory(prefix="blacksite-lane-self-test-") as temporary: + root = Path(temporary) / "workspace" + (root / ".git").mkdir(parents=True) + (root / "Cargo.toml").write_text("[workspace]\nmembers = []\n", encoding="utf-8") + workflow = {"build_storage": dict(DEFAULT_BUILD_STORAGE)} + workflow["build_storage"]["cache_root"] = str(Path(temporary) / "cache") + fake_toolchain = { + "cargo": "cargo 1.97.0", + "cargo_version": "1.97.0", + "rustc": "rustc 1.97.0", + "host": "x86_64-unknown-linux-gnu", + "separate_build_dir": True, + } + layout = lane_layout(root, "dev", workflow, ["cargo", "check"], toolchain=fake_toolchain) + prepare_lane(layout) + marker = read_sentinel(Path(layout.lane_root)) + assert marker is not None + assert not validate_sentinel( + Path(layout.lane_root), marker, root=root, lane="dev", role="build" + ) + checks.append("sentinel-round-trip") + assert Path(layout.target_dir) == root / "target" + assert Path(layout.build_dir) != Path(layout.target_dir) + checks.append("separate-build-dir-layout") + test_layout = lane_layout( + root, "dev", workflow, ["cargo", "test"], toolchain=fake_toolchain + ) + prepare_lane(test_layout) + assert read_sentinel(Path(layout.lane_root))["signature"]["profile_signature"] == "dev-test" + checks.append("dev-and-test-share-persistent-lane") + hot_editor = lane_layout( + root, + "hot-reload", + workflow, + ["cargo", "build", "-p", "editor", "--features", "dev,hot-reload"], + toolchain=fake_toolchain, + ) + hot_game = lane_layout( + root, + "hot-reload", + workflow, + ["cargo", "build", "-p", "game_hot", "--features", "dylib"], + toolchain=fake_toolchain, + ) + assert hot_editor.feature_signature == hot_game.feature_signature == "hot-reload-family" + checks.append("hot-reload-feature-family-isolated") + package = lane_layout( + root, + "package", + workflow, + ["cargo", "package-project", "--project", ".", "--profile", "package-qa"], + toolchain=fake_toolchain, + ) + assert package.profile == "dev" + assert package.feature_signature == "package-family" + checks.append("project-alias-options-not-cargo-options") + + fallback_toolchain = dict(fake_toolchain) + fallback_toolchain["cargo_version"] = "1.90.0" + fallback_toolchain["separate_build_dir"] = False + fallback = lane_layout( + root, + "candidate", + workflow, + ["cargo", "test", "--all-features"], + toolchain=fallback_toolchain, + ) + assert fallback.mode == "external-target-dir-fallback" + assert Path(fallback.target_dir) == Path(fallback.lane_root) / "target" + assert fallback.build_dir == fallback.target_dir + checks.append("external-target-dir-fallback") + + cross = lane_layout( + root, + "cross-wasm", + workflow, + ["cargo", "check", "--target", "wasm32-unknown-unknown"], + toolchain=fake_toolchain, + ) + assert cross.target_triple == "wasm32-unknown-unknown" + assert "wasm32-unknown-unknown" in cross.runtime_deps + checks.append("cross-target-partition") + + return {"ok": True, "checks": checks} + + +def _command_tail(values: Iterable[str]) -> list[str]: + result = list(values) + if result and result[0] == "--": + result.pop(0) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, help="workspace root override") + subparsers = parser.add_subparsers(dest="action", required=True) + + env_parser = subparsers.add_parser("env", help="describe a lane environment") + env_parser.add_argument("lane") + env_parser.add_argument("--json", action="store_true") + + exec_parser = subparsers.add_parser("exec", help="execute a command in a lane") + exec_parser.add_argument("lane") + exec_parser.add_argument("--dry-run", action="store_true") + exec_parser.add_argument("command", nargs=argparse.REMAINDER) + + run_parser = subparsers.add_parser("run", help="run an existing lane artifact") + run_parser.add_argument("lane") + run_parser.add_argument("--dry-run", action="store_true") + run_parser.add_argument("command", nargs=argparse.REMAINDER) + + subparsers.add_parser("self-test", help="run temp-directory safety checks") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + if arguments.action == "self-test": + print(json.dumps(_self_test(), indent=2, sort_keys=True)) + return 0 + + try: + root = workspace_root(arguments.workspace) + workflow = load_workflow(root) + raw_command = list(getattr(arguments, "command", [])) + # argparse.REMAINDER intentionally preserves arbitrary child-command + # flags. Accept our one wrapper flag after the lane as well as before it. + if arguments.action in {"exec", "run"} and "--dry-run" in raw_command: + separator = raw_command.index("--") if "--" in raw_command else len(raw_command) + if raw_command.index("--dry-run") < separator: + arguments.dry_run = True + raw_command.remove("--dry-run") + command = _command_tail(raw_command) + layout = lane_layout(root, arguments.lane, workflow, command) + + if arguments.action == "env": + document = layout_document(layout) + if arguments.json: + print(json.dumps(document, indent=2, sort_keys=True)) + else: + print(_shell_environment(document["environment"])) + return 0 + if arguments.action == "exec": + return _run_command(layout, command, dry_run=arguments.dry_run) + if arguments.action == "run": + return _run_runtime(layout, command, dry_run=arguments.dry_run) + except LaneError as error: + print(f"cargo-lane: {error}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/docs_audit.py b/scripts/codex/docs_audit.py new file mode 100755 index 0000000..e518b66 --- /dev/null +++ b/scripts/codex/docs_audit.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Validate Blacksite documentation authority coverage and lifecycle banners.""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import re +import subprocess +import sys +import tomllib +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +AUTHORITY_PATH = Path("docs/authority.toml") +BANNER_MARKERS = { + "active-plan": "**active plan", + "evidence": "**evidence record", + "historical": "**historical", + "superseded": "**superseded", +} +LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)") + + +@dataclass(frozen=True) +class Rule: + id: str + classification: str + priority: int + path: str | None = None + prefix: str | None = None + role: str | None = None + replacement: str | None = None + topics: tuple[str, ...] = () + + def matches(self, candidate: str) -> bool: + if self.path is not None: + return candidate == self.path + if self.prefix is not None: + return candidate.startswith(self.prefix) + return False + + +def repository_root() -> Path: + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / AUTHORITY_PATH).is_file(): + return parent + raise RuntimeError(f"cannot locate repository root containing {AUTHORITY_PATH}") + + +def load_authority(root: Path) -> tuple[dict[str, Any], list[Rule]]: + raw = tomllib.loads((root / AUTHORITY_PATH).read_text(encoding="utf-8")) + rules: list[Rule] = [] + for item in raw.get("rules", []): + selectors = [name for name in ("path", "prefix") if item.get(name) is not None] + if len(selectors) != 1: + raise ValueError(f"rule {item.get('id', '')} must set exactly one path or prefix") + rules.append( + Rule( + id=item["id"], + classification=item["classification"], + priority=int(item.get("priority", 0)), + path=item.get("path"), + prefix=item.get("prefix"), + role=item.get("role"), + replacement=item.get("replacement"), + topics=tuple(item.get("topics", [])), + ) + ) + return raw, rules + + +def discover_documents(root: Path, authority: dict[str, Any]) -> list[str]: + discovery = authority.get("discovery", {}) + found: set[str] = set() + for relative in discovery.get("paths", []): + path = root / relative + if path.is_file() and path.suffix == ".md": + found.add(path.relative_to(root).as_posix()) + for relative in discovery.get("trees", []): + tree = root / relative + if tree.is_dir(): + found.update(path.relative_to(root).as_posix() for path in tree.rglob("*.md")) + return sorted(found) + + +def resolve_rule(path: str, rules: list[Rule]) -> tuple[Rule | None, str | None]: + matches = [rule for rule in rules if rule.matches(path)] + if not matches: + return None, None + highest = max(rule.priority for rule in matches) + winners = [rule for rule in matches if rule.priority == highest] + classifications = {rule.classification for rule in winners} + if len(classifications) != 1: + names = ", ".join(rule.id for rule in winners) + return None, f"equal-priority rules disagree: {names}" + winners.sort(key=lambda rule: (rule.path is not None, len(rule.path or rule.prefix or "")), reverse=True) + return winners[0], None + + +def normalized_link_target(root: Path, document: Path, raw_target: str) -> str | None: + target = raw_target.split("#", 1)[0].strip().strip("<>") + if not target or "://" in target or target.startswith("mailto:"): + return None + resolved = (document.parent / target).resolve() + try: + return resolved.relative_to(root.resolve()).as_posix() + except ValueError: + return None + + +def path_matches(path: str, pattern: str) -> bool: + if pattern.endswith("/**"): + return path.startswith(pattern[:-2]) + return fnmatch.fnmatchcase(path, pattern) + + +def validate_links(root: Path, relative: str) -> list[str]: + path = root / relative + text = path.read_text(encoding="utf-8") + errors: list[str] = [] + for raw_target in LINK_RE.findall(text): + target = raw_target.split("#", 1)[0].strip().strip("<>") + if not target or "://" in target or target.startswith(("mailto:", "#")): + continue + normalized = normalized_link_target(root, path, raw_target) + if normalized is None: + errors.append(f"link escapes the repository: {raw_target}") + continue + if not (root / normalized).exists(): + errors.append(f"broken relative link: {raw_target}") + return errors + + +def changed_documents(root: Path) -> set[str]: + changed: set[str] = set() + commands = [ + ["git", "diff", "--name-only", "--relative", "HEAD"], + ["git", "ls-files", "--others", "--exclude-standard"], + ] + for command in commands: + result = subprocess.run(command, cwd=root, check=False, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"failed to query changed files: {' '.join(command)}") + changed.update(line.strip() for line in result.stdout.splitlines() if line.strip()) + return changed + + +def validate_banner(root: Path, relative: str, rule: Rule) -> list[str]: + if rule.classification == "current": + return [] + path = root / relative + text = path.read_text(encoding="utf-8") + head = "\n".join(text.splitlines()[:180]) + lowered = head.lower() + marker = BANNER_MARKERS[rule.classification] + if marker not in lowered: + # ADR 0019 predates Markdown heading style but has an explicit first-line status. + if not (rule.classification == "superseded" and lowered.startswith("status: superseded")): + return [f"missing {rule.classification} banner near the start of the document"] + + if rule.classification not in {"evidence", "historical", "superseded"}: + return [] + links = { + normalized + for target in LINK_RE.findall(head) + if (normalized := normalized_link_target(root, path, target)) is not None + } + if not links: + return ["lifecycle banner must link to current canonical guidance"] + if rule.replacement and rule.replacement not in links: + return [f"lifecycle banner must link to configured replacement {rule.replacement}"] + return [] + + +def audit(root: Path, *, changed_only: bool = False, topic: str | None = None) -> dict[str, Any]: + authority, rules = load_authority(root) + allowed = set(authority.get("classifications", [])) + allowed_roles = set(authority.get("roles", [])) + errors: list[str] = [] + warnings: list[str] = [] + + if authority.get("version") != 1: + errors.append("docs/authority.toml: unsupported or missing version") + if allowed != {"current", "active-plan", "evidence", "historical", "superseded"}: + errors.append("docs/authority.toml: classifications must be the five workflow classes") + + for rule in rules: + if rule.classification not in allowed: + errors.append(f"rule {rule.id}: unknown classification {rule.classification}") + if not rule.role or rule.role not in allowed_roles: + errors.append(f"rule {rule.id}: unknown or missing role {rule.role}") + if rule.path and not (root / rule.path).is_file(): + errors.append(f"rule {rule.id}: exact path does not exist: {rule.path}") + if rule.replacement and not (root / rule.replacement).is_file(): + errors.append(f"rule {rule.id}: replacement does not exist: {rule.replacement}") + + counts: Counter[str] = Counter() + documents: list[dict[str, str]] = [] + resolved: list[tuple[str, Rule]] = [] + for relative in discover_documents(root, authority): + rule, conflict = resolve_rule(relative, rules) + if conflict: + errors.append(f"{relative}: {conflict}") + continue + if rule is None: + errors.append(f"{relative}: unclassified") + continue + resolved.append((relative, rule)) + counts[rule.classification] += 1 + documents.append( + { + "path": relative, + "classification": rule.classification, + "rule": rule.id, + "role": rule.role or "", + "topics": ",".join(rule.topics), + } + ) + + canonical_owners: dict[str, list[str]] = {} + for relative, rule in resolved: + if rule.role == "canonical" and rule.classification == "current": + for owned_topic in rule.topics: + canonical_owners.setdefault(owned_topic, []).append(relative) + for owned_topic, owners in sorted(canonical_owners.items()): + if len(owners) > 1: + errors.append(f"topic {owned_topic}: multiple canonical owners: {', '.join(owners)}") + + selected = resolved + if changed_only: + changed = changed_documents(root) + if AUTHORITY_PATH.as_posix() not in changed: + selected = [(path, rule) for path, rule in selected if path in changed] + if topic: + selected = [(path, rule) for path, rule in selected if topic in rule.topics] + if not selected: + errors.append(f"topic {topic}: no classified documents") + + stale_terms = authority.get("stale_terms", []) + for relative, rule in selected: + for error in validate_banner(root, relative, rule): + errors.append(f"{relative}: {error}") + for error in validate_links(root, relative): + errors.append(f"{relative}: {error}") + + if rule.classification != "current": + continue + text = (root / relative).read_text(encoding="utf-8") + lowered = text.lower() + for stale in stale_terms: + if any(path_matches(relative, allowed_path) for allowed_path in stale.get("allowed_paths", [])): + continue + pattern = stale["pattern"] + if pattern.lower() in lowered: + errors.append( + f"{relative}: stale term {pattern!r}; use {stale.get('replacement', 'current terminology')}" + ) + if rule.role == "canonical": + if re.search(r"(?im)^\s*- \[ \]", text): + warnings.append(f"{relative}: unchecked checklist in canonical documentation; review status semantics") + if re.search(r"(?i)\bnot implemented\b|\((?:still\s+)?planned\)", text): + warnings.append(f"{relative}: possible future-status language in canonical documentation; review semantically") + + return { + "ok": not errors, + "authority": AUTHORITY_PATH.as_posix(), + "documents": documents, + "counts": dict(sorted(counts.items())), + "errors": errors, + "warnings": warnings, + "selected": len(selected), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="emit the complete machine-readable report") + parser.add_argument("--list", action="store_true", help="list every classified document") + parser.add_argument("--changed", action="store_true", help="check lifecycle, links, and terms only in changed documents") + parser.add_argument("--topic", help="check lifecycle, links, and terms only for one configured topic") + args = parser.parse_args() + + try: + result = audit(repository_root(), changed_only=args.changed, topic=args.topic) + except (OSError, RuntimeError, ValueError, tomllib.TOMLDecodeError) as error: + print(f"FAIL docs-authority — {error}", file=sys.stderr) + return 1 + + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + status = "PASS" if result["ok"] else "FAIL" + counts = ", ".join(f"{name}={count}" for name, count in result["counts"].items()) + print( + f"{status} docs-authority — {len(result['documents'])} classified, " + f"{result['selected']} checked — {counts}" + ) + if args.list: + for document in result["documents"]: + print(f"{document['classification']:11} {document['path']} ({document['rule']})") + for error in result["errors"]: + print(f"ERROR {error}", file=sys.stderr) + for warning in result["warnings"]: + print(f"WARN {warning}", file=sys.stderr) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/native_qa.sh b/scripts/codex/native_qa.sh new file mode 100755 index 0000000..c802406 --- /dev/null +++ b/scripts/codex/native_qa.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +SCENARIO_ROOT="$ROOT/.agents/skills/blacksite-native-qa/references/scenarios" +STATE_ROOT="$ROOT/.codex/session" +EVIDENCE_ROOT="$ROOT/.codex/evidence" + +usage() { + echo "usage: $0 plan|launch|attach|capture|record|status|restore|close [arguments]" >&2 + exit 2 +} + +[[ $# -ge 2 ]] || usage +ACTION="$1" +NAME="$2" +SCENARIO="$SCENARIO_ROOT/$NAME.yaml" +[[ -f "$SCENARIO" ]] || { echo "native-qa: unknown scenario $NAME" >&2; exit 2; } + +field() { + local key="$1" + sed -n "s/^${key}:[[:space:]]*//p" "$SCENARIO" | head -n 1 +} + +LANE="$(field build_lane)" +[[ -n "$LANE" ]] || LANE="dev" +FIXTURE="$(field fixture)" +mkdir -p "$STATE_ROOT" "$EVIDENCE_ROOT" + +lane_json() { + python "$ROOT/scripts/codex/cargo_lane.py" env "$LANE" --json +} + +list_section() { + local section="$1" + awk -v section="$section" ' + $0 == section ":" { active = 1; next } + active && /^[^[:space:]]/ { exit } + active && /^[[:space:]]+-[[:space:]]+/ { + sub(/^[[:space:]]+-[[:space:]]+/, "") + print + } + ' "$SCENARIO" +} + +snapshot_fixture() { + local record="$STATE_ROOT/native-$NAME.json" + local snapshot="$STATE_ROOT/native-$NAME.fixture.snapshot" + python - "$ROOT" "$FIXTURE" "$record" "$snapshot" "$LANE" "$SCENARIO" <<'PY' +import hashlib, json, os, pathlib, shutil, sys, time + +root = pathlib.Path(sys.argv[1]).resolve() +fixture_text, record_text, snapshot_text, lane, scenario = sys.argv[2:] +record = pathlib.Path(record_text) +snapshot = pathlib.Path(snapshot_text) +if record.is_file(): + previous = json.loads(record.read_text(encoding="utf-8")) + pid = previous.get("pid") + if pid: + try: + os.kill(int(pid), 0) + except ProcessLookupError: + pass + except PermissionError: + raise SystemExit(f"native-qa: cannot verify existing runner PID {pid}; refusing launch") + else: + raise SystemExit(f"native-qa: existing runner PID {pid} is still active") + old_fixture = previous.get("fixture") + if old_fixture and not old_fixture.get("restored"): + raise SystemExit("native-qa: restore the previous fixture before launching this scenario again") + record.unlink() + snapshot.unlink(missing_ok=True) +data = { + "pid": None, + "lane": lane, + "scenario": scenario, + "spawned_by_runner": False, + "prepared_at": time.time(), + "assertions": {}, + "evidence": [], +} +if fixture_text: + fixture = (root / fixture_text).resolve() + if fixture != root and root not in fixture.parents: + raise SystemExit(f"fixture escapes workspace: {fixture}") + if fixture.exists() and not fixture.is_file(): + raise SystemExit(f"fixture is not a regular file: {fixture}") + existed = fixture.is_file() + baseline = hashlib.sha256(fixture.read_bytes()).hexdigest() if existed else None + if existed: + shutil.copy2(fixture, snapshot) + data["fixture"] = { + "path": str(fixture), + "existed": existed, + "baseline_sha256": baseline, + "snapshot": str(snapshot) if existed else None, + "restored": False, + } +record.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY +} + +case "$ACTION" in + plan) + LANE_JSON="$(lane_json)" python - "$SCENARIO" "$FIXTURE" <<'PY' +import json, os, pathlib, sys +layout = json.loads(os.environ["LANE_JSON"]) +scenario = pathlib.Path(sys.argv[1]) +fixture = sys.argv[2] +binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / "editor" +print(f"PASS native-qa-plan — {scenario.stem}") +print(f"Lane: {layout['lane']} ({layout['mode']})") +print(f"Binary: {binary}") +print(f"Runtime deps: {layout['runtime_deps']}") +print(f"Fixture: {fixture or 'scenario-defined'}") +print("Visual interaction remains manual/user-controlled until explicitly delegated.") +PY + echo "Purpose: $(field purpose)" + echo "Preconditions: $(field preconditions)" + echo "Steps:" + list_section steps | nl -w2 -s'. ' + echo "Assertions:" + list_section assertions | nl -w2 -s'. ' + echo "Evidence: $(field evidence)" + echo "Cleanup: $(field cleanup)" + ;; + launch) + snapshot_fixture + python "$ROOT/scripts/codex/build_storage.py" enforce --phase pre + python "$ROOT/scripts/codex/summarize_command.py" \ + --gate "native-$NAME-build" \ + --log "$ROOT/.codex/logs/native-$NAME-build.log" \ + --json-result "$ROOT/.codex/logs/native-$NAME-build.json" \ + -- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p editor --bin editor + python "$ROOT/scripts/codex/build_storage.py" enforce --phase post + nohup python "$ROOT/scripts/codex/cargo_lane.py" run "$LANE" -- \ + target/debug/editor --project "$ROOT" \ + >"$ROOT/.codex/logs/native-$NAME-launch.log" 2>&1 & + PID=$! + python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +data.update({"pid": int(sys.argv[2]), "spawned_at": time.time(), "spawned_by_runner": True}) +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + echo "PASS native-qa-launch — $NAME — PID $PID" + ;; + attach) + [[ $# -eq 3 ]] || usage + PID="$3" + kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; } + CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" + [[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; } + snapshot_fixture + python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +data.update({"pid": int(sys.argv[2]), "attached_at": time.time(), "spawned_by_runner": False}) +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + echo "PASS native-qa-attach — $NAME — PID $PID" + ;; + capture) + [[ $# -eq 3 ]] || usage + PID="$3" + kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; } + CLIENTS="$(hyprctl clients -j)" + GEOMETRY="$(CLIENTS_JSON="$CLIENTS" python - "$PID" <<'PY' +import json, os, sys +pid = int(sys.argv[1]) +matches = [c for c in json.loads(os.environ["CLIENTS_JSON"]) if c.get("pid") == pid] +if len(matches) != 1: + raise SystemExit(f"expected one Hyprland window for PID {pid}, found {len(matches)}") +client = matches[0] +x, y = client["at"] +w, h = client["size"] +if w <= 0 or h <= 0: + raise SystemExit("target window has invalid geometry") +print(f"{x},{y} {w}x{h}") +PY +)" + STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + IMAGE="$EVIDENCE_ROOT/$NAME-$STAMP.png" + grim -g "$GEOMETRY" "$IMAGE" + HASH="$(sha256sum "$IMAGE" | awk '{print $1}')" + python - "$STATE_ROOT/native-$NAME-evidence.json" "$PID" "$IMAGE" "$HASH" "$GEOMETRY" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +path.write_text(json.dumps({ + "pid": int(sys.argv[2]), "image": sys.argv[3], "sha256": sys.argv[4], + "geometry": sys.argv[5], "captured_at": time.time(), +}, indent=2) + "\n", encoding="utf-8") +PY + python - "$STATE_ROOT/native-$NAME.json" "$IMAGE" "$HASH" "$GEOMETRY" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +if not path.is_file(): + raise SystemExit("native-qa: missing runner record") +data = json.loads(path.read_text(encoding="utf-8")) +data.setdefault("evidence", []).append({ + "image": sys.argv[2], "sha256": sys.argv[3], "geometry": sys.argv[4], + "captured_at": time.time(), +}) +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + echo "PASS native-qa-capture — $NAME — $IMAGE — $HASH" + ;; + record) + [[ $# -ge 4 && $# -le 5 ]] || usage + INDEX="$3" + RESULT="$4" + NOTE="${5:-}" + [[ "$INDEX" =~ ^[1-9][0-9]*$ ]] || { echo "native-qa: assertion index must be positive" >&2; exit 2; } + [[ "$RESULT" = "pass" || "$RESULT" = "fail" ]] || { echo "native-qa: result must be pass or fail" >&2; exit 2; } + ASSERTION_COUNT="$(list_section assertions | wc -l)" + (( INDEX <= ASSERTION_COUNT )) || { echo "native-qa: assertion $INDEX exceeds count $ASSERTION_COUNT" >&2; exit 2; } + ASSERTION_TEXT="$(list_section assertions | sed -n "${INDEX}p")" + python - "$STATE_ROOT/native-$NAME.json" "$INDEX" "$RESULT" "$NOTE" "$ASSERTION_TEXT" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +if not path.is_file(): + raise SystemExit("native-qa: missing runner record") +data = json.loads(path.read_text(encoding="utf-8")) +data.setdefault("assertions", {})[sys.argv[2]] = { + "result": sys.argv[3], "note": sys.argv[4], "text": sys.argv[5], + "recorded_at": time.time(), +} +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + echo "PASS native-qa-record — $NAME — assertion $INDEX = $RESULT" + ;; + status) + [[ $# -eq 2 ]] || usage + RECORD="$STATE_ROOT/native-$NAME.json" + [[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; } + ASSERTION_COUNT="$(list_section assertions | wc -l)" + python - "$RECORD" "$ASSERTION_COUNT" <<'PY' +import json, pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +expected = int(sys.argv[2]) +results = data.get("assertions", {}) +passed = sum(item.get("result") == "pass" for item in results.values()) +failed = sum(item.get("result") == "fail" for item in results.values()) +print(f"native-qa-status — assertions {passed} pass, {failed} fail, {expected - len(results)} unrecorded") +print(f"evidence: {len(data.get('evidence', []))} capture(s)") +fixture = data.get("fixture") +if fixture: + print(f"fixture restored: {bool(fixture.get('restored'))}") +complete = not failed and len(results) == expected and bool(data.get("evidence")) +data["scenario_result"] = {"result": "PASS" if complete else "FAIL", "recorded_at": time.time()} +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +if not complete: + raise SystemExit(1) +PY + ;; + restore) + [[ $# -eq 3 ]] || usage + MODE="$3" + [[ "$MODE" = "--dry-run" || "$MODE" = "--apply" ]] || usage + RECORD="$STATE_ROOT/native-$NAME.json" + [[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; } + python - "$RECORD" "$MODE" "$ROOT" <<'PY' +import hashlib, json, os, pathlib, shutil, sys, tempfile, time + +record = pathlib.Path(sys.argv[1]) +apply = sys.argv[2] == "--apply" +root = pathlib.Path(sys.argv[3]).resolve() +data = json.loads(record.read_text(encoding="utf-8")) +fixture = data.get("fixture") +if not fixture: + print("PASS native-qa-restore — scenario has no fixture") + raise SystemExit(0) +path = pathlib.Path(fixture["path"]).resolve() +if path != root and root not in path.parents: + raise SystemExit(f"native-qa: fixture escapes workspace: {path}") +pid = data.get("pid") +if apply and pid: + try: + os.kill(int(pid), 0) + except ProcessLookupError: + pass + except PermissionError: + raise SystemExit(f"native-qa: cannot verify runner PID {pid}; refusing fixture restore") + else: + raise SystemExit(f"native-qa: close runner PID {pid} before restoring the fixture") +current = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None +baseline = fixture.get("baseline_sha256") +action = "restore snapshot" if fixture.get("existed") else "remove scenario-created fixture" +print(f"native-qa-restore-plan — {action}") +print(f"fixture: {path}") +print(f"baseline sha256: {baseline or '(absent)'}") +print(f"current sha256: {current or '(absent)'}") +sys.stdout.flush() +if not apply: + raise SystemExit(0) +if fixture.get("existed"): + snapshot = pathlib.Path(fixture["snapshot"]) + if not snapshot.is_file(): + raise SystemExit(f"native-qa: missing fixture snapshot: {snapshot}") + snapshot_hash = hashlib.sha256(snapshot.read_bytes()).hexdigest() + if snapshot_hash != baseline: + raise SystemExit("native-qa: fixture snapshot hash does not match recorded baseline") + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as handle: + temp = pathlib.Path(handle.name) + handle.write(snapshot.read_bytes()) + os.replace(temp, path) +else: + if path.exists() and not path.is_file(): + raise SystemExit(f"native-qa: refusing to remove non-file fixture path: {path}") + path.unlink(missing_ok=True) +fixture["restored"] = True +fixture["restored_at"] = time.time() +record.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +print("PASS native-qa-restore") +PY + ;; + close) + [[ $# -eq 3 ]] || usage + PID="$3" + RECORD="$STATE_ROOT/native-$NAME.json" + [[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; } + RECORDED="$(python -c 'import json,sys; print(json.load(open(sys.argv[1]))["pid"])' "$RECORD")" + [[ "$RECORDED" = "$PID" ]] || { echo "native-qa: PID does not match runner record" >&2; exit 2; } + SPAWNED="$(python -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("spawned_by_runner"))).lower())' "$RECORD")" + if [[ "$SPAWNED" = "true" ]] && kill -0 "$PID" 2>/dev/null; then + CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" + [[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; } + kill -TERM "$PID" + fi + python - "$RECORD" <<'PY' +import json, pathlib, time, sys +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +data["closed_at"] = time.time() +data["detached_only"] = not bool(data.get("spawned_by_runner")) +if data["detached_only"]: + data["detached_pid"] = data.get("pid") + data["pid"] = None +path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") +PY + echo "PASS native-qa-close — $NAME — PID $PID" + ;; + *) usage ;; +esac diff --git a/scripts/codex/state.py b/scripts/codex/state.py new file mode 100755 index 0000000..a805e97 --- /dev/null +++ b/scripts/codex/state.py @@ -0,0 +1,995 @@ +#!/usr/bin/env python3 +"""Maintain Blacksite's compact, resumable Codex session state. + +The state is intentionally Markdown rather than an opaque database so a resumed +agent can read it cheaply. This module uses only the Python standard library. +""" + +from __future__ import annotations + +import argparse +import contextlib +import copy +import hashlib +import io +import os +import subprocess +import sys +import tempfile +import tomllib +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Sequence + + +TITLE = "# Codex session state" +WORKFLOW_PATH = Path(".codex/workflow.toml") +VALID_STATES = ( + "Implementing", + "Engineering-complete", + "Acceptance-in-progress", + "Candidate-ready", +) +CLASSIFICATIONS = { + "active-slice-refinement": "active-slice refinement", + "active-slice refinement": "active-slice refinement", + "newly-discovered-blocker": "newly discovered blocker", + "newly discovered blocker": "newly discovered blocker", + "discovered-blocker": "newly discovered blocker", + "added-acceptance-criterion": "added acceptance criterion", + "added acceptance criterion": "added acceptance criterion", + "separate-follow-up": "separate follow-up", + "separate follow-up": "separate follow-up", +} + + +class StateError(RuntimeError): + """The state operation cannot be completed safely.""" + + +def clean_text(value: str | None, default: str = "None.") -> str: + """Keep state entries single-line and compact.""" + + if value is None: + return default + compact = " ".join(value.split()) + return compact or default + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def find_repository(start: Path | None = None) -> Path: + """Locate the nearest repository containing the workflow configuration.""" + + override = os.environ.get("BLACKSITE_WORKSPACE_ROOT") + configured = None + config_path = (start or Path.cwd()) / ".codex/config.toml" + if not override and config_path.is_file(): + with config_path.open("rb") as handle: + configured = tomllib.load(handle).get("workspace_root") + candidates = [Path(override or configured)] if (override or configured) else [start or Path.cwd()] + if start is None and not override: + candidates.append(Path(__file__).parent) + + visited: set[Path] = set() + for candidate in candidates: + candidate = candidate.expanduser().absolute() + for current in (candidate, *candidate.parents): + canonical = current.resolve(strict=False) + if canonical in visited: + continue + visited.add(canonical) + if (current / WORKFLOW_PATH).is_file(): + return current + searched = ", ".join(str(path) for path in candidates) + raise StateError(f"could not locate {WORKFLOW_PATH} from {searched}") + + +def load_workflow(root: Path) -> dict[str, Any]: + path = root / WORKFLOW_PATH + try: + with path.open("rb") as handle: + workflow = tomllib.load(handle) + except OSError as error: + raise StateError(f"cannot read workflow configuration {path}: {error}") from error + except tomllib.TOMLDecodeError as error: + raise StateError(f"invalid workflow configuration {path}: {error}") from error + if workflow.get("version") != 1: + raise StateError(f"unsupported or missing workflow version in {path}") + return workflow + + +def configured_path(root: Path, raw: str, *, label: str) -> Path: + candidate = Path(os.path.expandvars(os.path.expanduser(raw))) + if not candidate.is_absolute(): + candidate = root / candidate + candidate = candidate.absolute() + try: + candidate.resolve(strict=False).relative_to(root.resolve(strict=False)) + except ValueError as error: + raise StateError(f"configured {label} must remain inside the repository: {candidate}") from error + return candidate + + +def state_path(root: Path, workflow: dict[str, Any]) -> Path: + session = workflow.get("session", {}) + if not isinstance(session, dict): + raise StateError("[session] must be a TOML table") + raw = session.get("state_file", ".codex/session/STATE.md") + if not isinstance(raw, str) or not raw: + raise StateError("session.state_file must be a non-empty path string") + return configured_path(root, raw, label="session.state_file") + + +def max_state_lines(workflow: dict[str, Any]) -> int: + session = workflow.get("session", {}) + value = session.get("max_state_lines", 180) if isinstance(session, dict) else 180 + if not isinstance(value, int) or value < 48: + raise StateError("session.max_state_lines must be an integer of at least 48") + return value + + +def run_git(root: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except OSError as error: + raise StateError(f"cannot execute git: {error}") from error + if check and result.returncode != 0: + detail = clean_text(result.stderr or result.stdout, "git command failed") + raise StateError(detail) + return result + + +def worktree_summary(root: Path) -> str: + lines = run_git(root, "status", "--porcelain=v1", "--untracked-files=normal").stdout.splitlines() + if not lines: + return "clean" + + staged = modified = deleted = untracked = conflicts = 0 + conflict_codes = {"DD", "AU", "UD", "UA", "DU", "AA", "UU"} + for line in lines: + if len(line) < 2: + continue + code = line[:2] + if code == "??": + untracked += 1 + continue + if code in conflict_codes: + conflicts += 1 + if code[0] not in {" ", "?"}: + staged += 1 + if code[1] not in {" ", "?"}: + modified += 1 + if "D" in code: + deleted += 1 + return ( + "dirty " + f"(staged={staged}, modified={modified}, deleted={deleted}, " + f"untracked={untracked}, conflicts={conflicts})" + ) + + +@dataclass +class RepositoryState: + root: str + real_path: str + branch: str + head: str + worktree: str + active_processes: str = "None recorded." + + +def repository_snapshot(root: Path, active_processes: str = "None recorded.") -> RepositoryState: + git_root = Path(run_git(root, "rev-parse", "--show-toplevel").stdout.strip()) + real_path = git_root.resolve(strict=False) + branch_result = run_git(root, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) + branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "(detached)" + head = run_git(root, "rev-parse", "HEAD").stdout.strip() + return RepositoryState( + root=str(git_root), + real_path=str(real_path), + branch=branch, + head=head, + worktree=worktree_summary(root), + active_processes=clean_text(active_processes, "None recorded."), + ) + + +@dataclass +class AcceptanceCriterion: + text: str + checked: bool = False + + +@dataclass +class ScopeDelta: + title: str + source: str + classification: str + requirement: str + evidence_valid: str + invalidated_gates: str + tracker_sync: str + + +@dataclass +class IntentionalFile: + path: str + reason: str + + +@dataclass +class GateRecord: + gate: str + status: str + digest: str + command: str + date: str + + +@dataclass +class SessionState: + repository: RepositoryState + goal: str + current_state: str = "Implementing" + issues: str = "None." + milestone: str = "None." + active_slice: str = "None." + non_goals: str = "None." + acceptance: list[AcceptanceCriterion] = field(default_factory=list) + deltas: list[ScopeDelta] = field(default_factory=list) + files: list[IntentionalFile] = field(default_factory=list) + gates: list[GateRecord] = field(default_factory=list) + native_evidence: list[str] = field(default_factory=list) + decisions: list[str] = field(default_factory=list) + remote_actions: list[str] = field(default_factory=list) + next_action: str = "Define the next action." + + +def section_map(text: str) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: str | None = None + for line in text.splitlines(): + if line.startswith("## "): + current = line[3:].strip() + sections[current] = [] + elif current is not None: + sections[current].append(line) + return sections + + +def field_value(lines: Iterable[str], name: str, default: str = "None.") -> str: + prefix = f"- {name}:" + for line in lines: + if line.startswith(prefix): + return clean_text(line[len(prefix) :], default) + return default + + +def bullet_values(lines: Iterable[str]) -> list[str]: + values: list[str] = [] + for line in lines: + if line.startswith("- ") and not line.startswith("- ["): + value = clean_text(line[2:]) + if value != "None.": + values.append(value) + return values + + +def parse_deltas(lines: list[str]) -> list[ScopeDelta]: + chunks: list[tuple[str, list[str]]] = [] + title: str | None = None + body: list[str] = [] + for line in lines: + if line.startswith("### Delta "): + if title is not None: + chunks.append((title, body)) + heading = line.partition("—")[2].strip() + title = heading or "Untitled scope delta" + body = [] + elif title is not None: + body.append(line) + if title is not None: + chunks.append((title, body)) + + return [ + ScopeDelta( + title=clean_text(title), + source=field_value(body, "Source"), + classification=field_value(body, "Classification"), + requirement=field_value(body, "Added/changed requirement"), + evidence_valid=field_value(body, "Prior evidence still valid"), + invalidated_gates=field_value(body, "Invalidated gates"), + tracker_sync=field_value(body, "Tracker sync required", "no"), + ) + for title, body in chunks + ] + + +def parse_state(text: str) -> SessionState: + if not text.startswith(TITLE): + raise StateError(f"state file must begin with {TITLE!r}") + sections = section_map(text) + repository_lines = sections.get("Repository", []) + task_lines = sections.get("Active task", []) + repository = RepositoryState( + root=field_value(repository_lines, "Root"), + real_path=field_value(repository_lines, "Real path"), + branch=field_value(repository_lines, "Branch"), + head=field_value(repository_lines, "HEAD"), + worktree=field_value(repository_lines, "Worktree status summary"), + active_processes=field_value(repository_lines, "Active processes/windows", "None recorded."), + ) + + acceptance: list[AcceptanceCriterion] = [] + for line in sections.get("Acceptance target", []): + stripped = line.strip() + if stripped.startswith("- [ ] "): + acceptance.append(AcceptanceCriterion(clean_text(stripped[6:]))) + elif stripped.lower().startswith("- [x] "): + acceptance.append(AcceptanceCriterion(clean_text(stripped[6:]), checked=True)) + + files: list[IntentionalFile] = [] + for value in bullet_values(sections.get("Files intentionally changed", [])): + path, separator, reason = value.partition(" — ") + files.append(IntentionalFile(path, reason if separator else "Reason not recorded.")) + + gates: list[GateRecord] = [] + for value in bullet_values(sections.get("Verification ledger summary", [])): + parts = value.split(" — ", 4) + if len(parts) == 5: + gates.append(GateRecord(*parts)) + + next_values = bullet_values(sections.get("Exact next action", [])) + return SessionState( + repository=repository, + goal=field_value(task_lines, "Goal"), + current_state=field_value(task_lines, "Current state", "Implementing"), + issues=field_value(task_lines, "Gitea issue(s)"), + milestone=field_value(task_lines, "Gitea milestone"), + active_slice=field_value(task_lines, "Active slice"), + non_goals=field_value(task_lines, "Non-goals"), + acceptance=acceptance, + deltas=parse_deltas(sections.get("Scope deltas", [])), + files=files, + gates=gates, + native_evidence=bullet_values(sections.get("Native evidence", [])), + decisions=bullet_values(sections.get("Decisions", [])), + remote_actions=bullet_values(sections.get("Remote actions already performed", [])), + next_action=next_values[0] if next_values else "Define the next action.", + ) + + +def render_lines(state: SessionState) -> list[str]: + lines = [ + TITLE, + "", + "## Repository", + f"- Root: {clean_text(state.repository.root)}", + f"- Real path: {clean_text(state.repository.real_path)}", + f"- Branch: {clean_text(state.repository.branch)}", + f"- HEAD: {clean_text(state.repository.head)}", + f"- Worktree status summary: {clean_text(state.repository.worktree)}", + f"- Active processes/windows: {clean_text(state.repository.active_processes, 'None recorded.')}", + "", + "## Active task", + f"- Goal: {clean_text(state.goal)}", + f"- Current state: {clean_text(state.current_state, 'Implementing')}", + f"- Gitea issue(s): {clean_text(state.issues)}", + f"- Gitea milestone: {clean_text(state.milestone)}", + f"- Active slice: {clean_text(state.active_slice)}", + f"- Non-goals: {clean_text(state.non_goals)}", + "", + "## Acceptance target", + ] + lines.extend( + f"- [{'x' if criterion.checked else ' '}] {clean_text(criterion.text)}" + for criterion in state.acceptance + ) + lines.extend(["", "## Scope deltas"]) + for index, delta in enumerate(state.deltas, start=1): + lines.extend( + [ + f"### Delta {index} — {clean_text(delta.title)}", + f"- Source: {clean_text(delta.source)}", + f"- Classification: {clean_text(delta.classification)}", + f"- Added/changed requirement: {clean_text(delta.requirement)}", + f"- Prior evidence still valid: {clean_text(delta.evidence_valid)}", + f"- Invalidated gates: {clean_text(delta.invalidated_gates)}", + f"- Tracker sync required: {clean_text(delta.tracker_sync, 'no')}", + "", + ] + ) + lines.extend(["## Files intentionally changed"]) + lines.extend(f"- {clean_text(item.path)} — {clean_text(item.reason)}" for item in state.files) + lines.extend(["", "## Verification ledger summary"]) + lines.extend( + f"- {clean_text(item.gate)} — {clean_text(item.status)} — {clean_text(item.digest)} — " + f"{clean_text(item.command)} — {clean_text(item.date)}" + for item in state.gates + ) + lines.extend(["", "## Native evidence"]) + lines.extend(f"- {clean_text(item)}" for item in state.native_evidence) + lines.extend(["", "## Decisions"]) + lines.extend(f"- {clean_text(item)}" for item in state.decisions) + lines.extend(["", "## Remote actions already performed"]) + lines.extend(f"- {clean_text(item)}" for item in state.remote_actions) + lines.extend( + [ + "", + "## Exact next action", + f"- {clean_text(state.next_action, 'Define the next action.')}", + ] + ) + return lines + + +def compact_state(state: SessionState, limit: int) -> SessionState: + """Discard oldest ledger detail until the state fits its configured budget.""" + + compacted = copy.deepcopy(state) + collections: list[tuple[list[Any], int]] = [ + (compacted.gates, 1), + (compacted.native_evidence, 0), + (compacted.remote_actions, 0), + (compacted.decisions, 1), + (compacted.deltas, 1), + (compacted.files, 1), + (compacted.acceptance, 1), + ] + while len(render_lines(compacted)) > limit: + for values, keep in collections: + if len(values) > keep: + values.pop(0) + break + else: + raise StateError( + f"required state structure exceeds session.max_state_lines={limit}" + ) + return compacted + + +def ensure_state_is_ignored(root: Path, path: Path) -> None: + relative = path.resolve(strict=False).relative_to(root.resolve(strict=False)).as_posix() + result = run_git(root, "check-ignore", "--quiet", "--", relative, check=False) + if result.returncode != 0: + raise StateError( + f"refusing to write tracked session state; add /{relative} or its directory to .gitignore" + ) + + +def write_state(root: Path, workflow: dict[str, Any], state: SessionState) -> Path: + path = state_path(root, workflow) + ensure_state_is_ignored(root, path) + state = compact_state(state, max_state_lines(workflow)) + text = "\n".join(render_lines(state)) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + os.replace(temporary, path) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + return path + + +def read_state(root: Path, workflow: dict[str, Any]) -> SessionState: + path = state_path(root, workflow) + try: + return parse_state(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise StateError(f"no session state exists at {path}; run state.py init") from error + except OSError as error: + raise StateError(f"cannot read session state {path}: {error}") from error + + +def refresh_repository(root: Path, state: SessionState) -> list[str]: + previous = state.repository + current = repository_snapshot(root, previous.active_processes) + changes: list[str] = [] + for label, before, after in ( + ("real path", previous.real_path, current.real_path), + ("branch", previous.branch, current.branch), + ("HEAD", previous.head, current.head), + ): + if before not in {"None.", after}: + changes.append(f"{label}: {before} -> {after}") + state.repository = current + return changes + + +def normalize_repo_file(root: Path, raw: str) -> str: + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + candidate = candidate.resolve(strict=False) + repository = root.resolve(strict=False) + try: + return candidate.relative_to(repository).as_posix() + except ValueError as error: + raise StateError(f"intentional file must be inside the repository: {candidate}") from error + + +def replace_or_append_file(state: SessionState, path: str, reason: str) -> None: + state.files = [item for item in state.files if item.path != path] + state.files.append(IntentionalFile(path, clean_text(reason, "Reason not recorded."))) + + +def replace_or_append_gate(state: SessionState, record: GateRecord) -> None: + state.gates = [item for item in state.gates if item.gate != record.gate] + state.gates.append(record) + + +def candidate_commit(root: Path, revision: str) -> str: + result = run_git(root, "rev-parse", "--verify", f"{revision}^{{commit}}", check=False) + if result.returncode != 0: + raise StateError(f"candidate revision is not a commit: {revision}") + return result.stdout.strip() + + +def git_bytes(root: Path, *arguments: str) -> bytes: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as error: + raise StateError(f"cannot execute git: {error}") from error + if result.returncode != 0: + detail = clean_text(result.stderr.decode("utf-8", "replace"), "git command failed") + raise StateError(detail) + return result.stdout + + +def _dirty_tree_digest_once(root: Path) -> str: + status_arguments = ("status", "--porcelain=v1", "-z", "--untracked-files=all") + status_before = git_bytes(root, *status_arguments) + if not status_before: + raise StateError("--dirty-tree requires a dirty worktree") + + digest = hashlib.sha256() + digest.update(b"blacksite-dirty-tree-v1\0") + digest.update(git_bytes(root, "rev-parse", "HEAD")) + digest.update(b"\0status\0") + digest.update(status_before) + digest.update(b"\0tracked-diff\0") + digest.update( + git_bytes( + root, + "diff", + "--binary", + "--no-ext-diff", + "--submodule=diff", + "HEAD", + "--", + ) + ) + + untracked = git_bytes(root, "ls-files", "--others", "--exclude-standard", "-z") + repository = root.resolve(strict=False) + for encoded in sorted(path for path in untracked.split(b"\0") if path): + relative = os.fsdecode(encoded) + relative_path = Path(relative) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise StateError(f"untracked candidate path escapes the repository: {relative}") + path = repository / relative_path + digest.update(b"\0untracked\0") + digest.update(encoded) + if path.is_symlink(): + digest.update(b"\0symlink\0") + digest.update(os.fsencode(os.readlink(path))) + elif path.is_file(): + digest.update(b"\0file\0") + digest.update(str(path.stat().st_mode & 0o777).encode("ascii")) + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + else: + digest.update(b"\0special\0") + + if git_bytes(root, *status_arguments) != status_before: + raise StateError("worktree changed while computing the candidate digest; nominate again") + return digest.hexdigest() + + +def dirty_tree_digest(root: Path) -> str: + """Hash an exact stable base commit, tracked diff, and untracked source set.""" + + first = _dirty_tree_digest_once(root) + second = _dirty_tree_digest_once(root) + if first != second: + raise StateError("worktree changed while computing the candidate digest; nominate again") + return first + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="operation", required=True) + + init = subparsers.add_parser("init", help="create a new compact session state") + init.add_argument("--goal", required=True) + init.add_argument("--accept", action="append", required=True, help="acceptance criterion; repeat as needed") + init.add_argument("--slice", dest="active_slice", required=True) + init.add_argument("--next", dest="next_action", required=True) + init.add_argument("--non-goals", default="None.") + init.add_argument("--issues", default="None.") + init.add_argument("--milestone", default="None.") + init.add_argument("--active-processes", default="None recorded.") + init.add_argument("--force", action="store_true", help="replace an existing state") + + show = subparsers.add_parser("show", help="print the current state") + show.add_argument("--path", action="store_true", help="print only the state path") + + subparsers.add_parser("resume", help="refresh repository identity and show the exact next action") + + update = subparsers.add_parser("set", help="update active task fields") + update.add_argument("--goal") + update.add_argument("--state", choices=VALID_STATES) + update.add_argument("--issues") + update.add_argument("--milestone") + update.add_argument("--slice", dest="active_slice") + update.add_argument("--non-goals") + update.add_argument("--next", dest="next_action") + update.add_argument("--active-processes") + update.add_argument("--accept", action="append", help="replace acceptance criteria; repeat as needed") + update.add_argument("--decision", action="append", help="append a concise decision") + + delta = subparsers.add_parser("scope-delta", help="record authoritative user steering") + delta.add_argument("--title", required=True) + delta.add_argument("--source", required=True) + delta.add_argument("--classification", required=True) + delta.add_argument("--requirement", required=True) + delta.add_argument("--evidence-valid", required=True) + delta.add_argument("--invalidated-gates", required=True) + delta.add_argument("--tracker-sync", choices=("yes", "no"), required=True) + delta.add_argument("--next", dest="next_action") + + record_file = subparsers.add_parser("record-file", help="record an intentionally changed file") + record_file.add_argument("path") + record_file.add_argument("--reason", required=True) + + record_gate = subparsers.add_parser("record-gate", help="record one compact verification result") + record_gate.add_argument("--gate", required=True) + record_gate.add_argument("--status", type=str.upper, choices=("PASS", "FAIL"), required=True) + record_gate.add_argument("--digest", required=True) + record_gate.add_argument("--command", required=True) + record_gate.add_argument("--date", default=None) + record_gate.add_argument("--next", dest="next_action") + + nominate = subparsers.add_parser("nominate", help="nominate an exact candidate commit or tree digest") + candidate = nominate.add_mutually_exclusive_group() + candidate.add_argument("--commit", default="HEAD") + candidate.add_argument( + "--dirty-tree", + action="store_true", + help="explicitly nominate the current dirty tree by a reproducible digest", + ) + nominate.add_argument("--next", dest="next_action") + return parser + + +def perform_operation(root: Path, workflow: dict[str, Any], args: argparse.Namespace) -> int: + path = state_path(root, workflow) + if args.operation == "init": + if path.exists() and not args.force: + raise StateError(f"session state already exists at {path}; use init --force to replace it") + state = SessionState( + repository=repository_snapshot(root, args.active_processes), + goal=clean_text(args.goal), + issues=clean_text(args.issues), + milestone=clean_text(args.milestone), + active_slice=clean_text(args.active_slice), + non_goals=clean_text(args.non_goals), + acceptance=[AcceptanceCriterion(clean_text(value)) for value in args.accept], + next_action=clean_text(args.next_action), + ) + write_state(root, workflow, state) + print(f"PASS state-init — {path.relative_to(root.resolve(strict=False))}") + print(f"NEXT {state.next_action}") + return 0 + + if args.operation == "show": + # Loading the file validates that it remains template-compatible. + read_state(root, workflow) + if args.path: + print(path) + else: + print(path.read_text(encoding="utf-8"), end="") + return 0 + + state = read_state(root, workflow) + repository_changes = refresh_repository(root, state) + + if args.operation == "resume": + write_state(root, workflow, state) + print( + f"RESUME {state.current_state} — {state.repository.branch}@{state.repository.head[:12]} — " + f"{state.goal}" + ) + for change in repository_changes: + print(f"REPOSITORY CHANGED {change}") + print(f"NEXT {state.next_action}") + return 0 + + if args.operation == "set": + changed = False + for argument, attribute in ( + ("goal", "goal"), + ("state", "current_state"), + ("issues", "issues"), + ("milestone", "milestone"), + ("active_slice", "active_slice"), + ("non_goals", "non_goals"), + ("next_action", "next_action"), + ): + value = getattr(args, argument) + if value is not None: + setattr(state, attribute, clean_text(value)) + changed = True + if args.active_processes is not None: + state.repository.active_processes = clean_text(args.active_processes, "None recorded.") + changed = True + if args.accept is not None: + state.acceptance = [AcceptanceCriterion(clean_text(value)) for value in args.accept] + changed = True + if args.decision: + state.decisions.extend(clean_text(value) for value in args.decision) + changed = True + if not changed: + raise StateError("state.py set requires at least one field to update") + + elif args.operation == "scope-delta": + classification_key = clean_text(args.classification).lower() + classification = CLASSIFICATIONS.get(classification_key) + if classification is None: + allowed = ", ".join(sorted(key for key in CLASSIFICATIONS if "-" in key)) + raise StateError(f"unknown scope-delta classification; use one of: {allowed}") + state.deltas.append( + ScopeDelta( + title=clean_text(args.title), + source=clean_text(args.source), + classification=classification, + requirement=clean_text(args.requirement), + evidence_valid=clean_text(args.evidence_valid), + invalidated_gates=clean_text(args.invalidated_gates), + tracker_sync=args.tracker_sync, + ) + ) + if classification == "added acceptance criterion": + state.acceptance.append(AcceptanceCriterion(clean_text(args.requirement))) + if args.next_action: + state.next_action = clean_text(args.next_action) + + elif args.operation == "record-file": + relative = normalize_repo_file(root, args.path) + replace_or_append_file(state, relative, args.reason) + + elif args.operation == "record-gate": + replace_or_append_gate( + state, + GateRecord( + gate=clean_text(args.gate), + status=args.status, + digest=clean_text(args.digest), + command=clean_text(args.command), + date=clean_text(args.date or utc_now()), + ), + ) + if args.next_action: + state.next_action = clean_text(args.next_action) + + elif args.operation == "nominate": + if args.dirty_tree: + digest = dirty_tree_digest(root) + candidate_label = f"dirty-tree sha256:{digest} based on {state.repository.head}" + else: + if state.repository.worktree != "clean": + raise StateError( + "candidate nomination requires a clean worktree; use --dirty-tree to nominate an exact dirty-tree digest" + ) + commit = candidate_commit(root, args.commit) + candidate_label = f"commit {commit}" + state.current_state = "Candidate-ready" + state.decisions = [ + item for item in state.decisions if not item.startswith("Candidate nominated:") + ] + state.decisions.append(f"Candidate nominated: {candidate_label} at {utc_now()}.") + state.next_action = clean_text( + args.next_action, + f"Run candidate verification for {candidate_label}.", + ) + else: # pragma: no cover - argparse prevents this + raise StateError(f"unknown operation: {args.operation}") + + write_state(root, workflow, state) + relative = path.resolve(strict=False).relative_to(root.resolve(strict=False)) + print(f"PASS state-{args.operation} — {relative}") + print(f"NEXT {state.next_action}") + return 0 + + +def _git_fixture(root: Path) -> None: + (root / ".codex").mkdir(parents=True) + (root / ".codex/workflow.toml").write_text( + """version = 1 +[session] +state_file = ".codex/session/STATE.md" +max_state_lines = 70 +""", + encoding="utf-8", + ) + (root / ".gitignore").write_text("/.codex/session/\n", encoding="utf-8") + (root / "tracked.txt").write_text("fixture\n", encoding="utf-8") + env = dict(os.environ) + env.update( + { + "GIT_AUTHOR_NAME": "Blacksite self-test", + "GIT_AUTHOR_EMAIL": "self-test@example.invalid", + "GIT_COMMITTER_NAME": "Blacksite self-test", + "GIT_COMMITTER_EMAIL": "self-test@example.invalid", + } + ) + for command in ( + ["git", "init", "--quiet", str(root)], + ["git", "-C", str(root), "add", "."], + ["git", "-C", str(root), "commit", "--quiet", "-m", "fixture"], + ): + subprocess.run(command, check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def self_test() -> int: + with tempfile.TemporaryDirectory(prefix="blacksite-state-self-test-") as temporary: + root = Path(temporary) + _git_fixture(root) + found = find_repository(root / "nested") + if found.resolve() != root.resolve(): + raise AssertionError("repository locator did not find the temporary workflow") + workflow = load_workflow(found) + parser = build_parser() + + operations = [ + [ + "init", + "--goal", + "Exercise resumable state", + "--accept", + "State stays concise", + "--slice", + "Workflow helpers", + "--next", + "Update the fixture state.", + ], + ["set", "--state", "Engineering-complete", "--next", "Record steering."], + [ + "scope-delta", + "--title", + "Self-test steering", + "--source", + "self-test", + "--classification", + "active-slice-refinement", + "--requirement", + "Keep the newest exact action", + "--evidence-valid", + "Earlier fixture setup remains valid", + "--invalidated-gates", + "None", + "--tracker-sync", + "no", + "--next", + "Record the intentional file.", + ], + [ + "record-file", + "scripts/codex/state.py", + "--reason", + "exercise file recording", + ], + [ + "record-gate", + "--gate", + "state-fixture", + "--status", + "PASS", + "--digest", + "digest-fixture", + "--command", + "python state.py --self-test", + "--next", + "Resume the fixture.", + ], + ["resume"], + ["nominate", "--next", "Run the candidate fixture gate."], + ] + for operation in operations: + with contextlib.redirect_stdout(io.StringIO()): + result = perform_operation(root, workflow, parser.parse_args(operation)) + if result != 0: + raise AssertionError(f"state operation failed: {operation[0]}") + + state = read_state(root, workflow) + if state.current_state != "Candidate-ready": + raise AssertionError("nominate did not enter Candidate-ready state") + for index in range(12): + state.gates.append( + GateRecord( + gate=f"gate-{index}", + status="PASS", + digest=f"digest-{index}", + command=f"fixture {index}", + date=utc_now(), + ) + ) + state.next_action = "Nominate the fixture commit." + path = write_state(root, workflow, state) + lines = path.read_text(encoding="utf-8").splitlines() + if len(lines) > max_state_lines(workflow): + raise AssertionError("state exceeded configured line budget") + resumed = read_state(root, workflow) + if resumed.next_action != "Nominate the fixture commit.": + raise AssertionError("state round-trip lost the exact next action") + if resumed.repository.real_path != str(root.resolve()): + raise AssertionError("state did not record the canonical repository path") + commit = candidate_commit(root, "HEAD") + if len(commit) != 40: + raise AssertionError("candidate nomination did not resolve a commit") + tracked = root / "tracked.txt" + tracked.write_text("changed\n", encoding="utf-8") + dirty_digest = dirty_tree_digest(root) + if dirty_digest != dirty_tree_digest(root): + raise AssertionError("dirty candidate digest is not deterministic") + tracked.write_text("changed again\n", encoding="utf-8") + if dirty_digest == dirty_tree_digest(root): + raise AssertionError("dirty candidate digest ignored changed source bytes") + tracked.write_text("fixture\n", encoding="utf-8") + if worktree_summary(root) != "clean": + raise AssertionError("ignored state unexpectedly dirtied the fixture repository") + print("PASS state-self-test — temp repository lifecycle and line budget") + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments in (["--self-test"], ["self-test"]): + try: + return self_test() + except (AssertionError, OSError, StateError, subprocess.SubprocessError) as error: + print(f"FAIL state-self-test — {error}", file=sys.stderr) + return 1 + + parser = build_parser() + args = parser.parse_args(arguments) + try: + root = find_repository() + workflow = load_workflow(root) + return perform_operation(root, workflow, args) + except StateError as error: + print(f"FAIL state-{args.operation} — {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/summarize_command.py b/scripts/codex/summarize_command.py new file mode 100755 index 0000000..48ff0a2 --- /dev/null +++ b/scripts/codex/summarize_command.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Run one command, retain its complete log, and print only a bounded result.""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile +import time +import tomllib +from collections import deque +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Sequence + + +WORKFLOW_PATH = Path(".codex/workflow.toml") +ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +ACTIONABLE_PATTERNS = ( + re.compile(r"^\s*error(?:\[[A-Z0-9]+\])?:", re.IGNORECASE), + re.compile(r"^\s*error\b", re.IGNORECASE), + re.compile(r"\bpanicked at\b", re.IGNORECASE), + re.compile(r"^\s*thread .+ panicked", re.IGNORECASE), + re.compile(r"^\s*Traceback \(most recent call last\):"), + re.compile(r"^\s*(?:AssertionError|RuntimeError|ValueError|TypeError):"), + re.compile(r"^\s*failures:\s*$", re.IGNORECASE), + re.compile(r"^\s*test result: FAILED", re.IGNORECASE), + re.compile(r"^\s*FAILED(?:\s|$)", re.IGNORECASE), + re.compile(r"^\s*FAIL(?:\s|$)", re.IGNORECASE), + re.compile(r"^\s*Caused by:\s*\S", re.IGNORECASE), +) +TEST_RESULT_RE = re.compile( + r"test result: ok\.\s*(\d+) passed;\s*(\d+) failed;\s*(\d+) ignored", + re.IGNORECASE, +) +SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") + + +class SummaryError(RuntimeError): + """The wrapper cannot locate its configuration or output paths.""" + + +def find_repository(start: Path | None = None) -> Path: + override = os.environ.get("BLACKSITE_WORKSPACE_ROOT") + configured = None + config_path = (start or Path.cwd()) / ".codex/config.toml" + if not override and config_path.is_file(): + with config_path.open("rb") as handle: + configured = tomllib.load(handle).get("workspace_root") + candidates = [Path(override or configured)] if (override or configured) else [start or Path.cwd()] + if start is None and not override: + candidates.append(Path(__file__).parent) + visited: set[Path] = set() + for candidate in candidates: + candidate = candidate.expanduser().absolute() + for current in (candidate, *candidate.parents): + canonical = current.resolve(strict=False) + if canonical in visited: + continue + visited.add(canonical) + if (current / WORKFLOW_PATH).is_file(): + return current + searched = ", ".join(str(path) for path in candidates) + raise SummaryError(f"could not locate {WORKFLOW_PATH} from {searched}") + + +def load_workflow(root: Path) -> dict[str, Any]: + path = root / WORKFLOW_PATH + try: + with path.open("rb") as handle: + workflow = tomllib.load(handle) + except OSError as error: + raise SummaryError(f"cannot read workflow configuration {path}: {error}") from error + except tomllib.TOMLDecodeError as error: + raise SummaryError(f"invalid workflow configuration {path}: {error}") from error + if workflow.get("version") != 1: + raise SummaryError(f"unsupported or missing workflow version in {path}") + return workflow + + +def output_settings(workflow: dict[str, Any]) -> tuple[str, int]: + output = workflow.get("output", {}) + if not isinstance(output, dict): + raise SummaryError("[output] must be a TOML table") + log_dir = output.get("log_dir", ".codex/logs") + max_lines = output.get("max_failure_lines", 120) + if not isinstance(log_dir, str) or not log_dir: + raise SummaryError("output.log_dir must be a non-empty path string") + if not isinstance(max_lines, int) or not 1 <= max_lines <= 1000: + raise SummaryError("output.max_failure_lines must be an integer from 1 to 1000") + return log_dir, max_lines + + +def resolve_path(root: Path, raw: str) -> Path: + path = Path(os.path.expandvars(os.path.expanduser(raw))) + if not path.is_absolute(): + path = root / path + return path.absolute() + + +def relative_display(root: Path, path: Path) -> str: + try: + return path.relative_to(root.resolve(strict=False)).as_posix() + except ValueError: + return str(path) + + +def safe_name(value: str) -> str: + cleaned = SAFE_NAME_RE.sub("-", value.strip()).strip("-._") + return cleaned[:80] or "command" + + +def default_log_path(root: Path, log_dir: str, gate: str) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + return resolve_path(root, str(Path(log_dir) / f"{timestamp}-{safe_name(gate)}.log")) + + +def strip_ansi(value: str) -> str: + return ANSI_RE.sub("", value).rstrip("\r\n") + + +def duration_text(seconds: float) -> str: + if seconds < 10: + return f"{seconds:.2f}s" + if seconds < 60: + return f"{seconds:.1f}s" + minutes, remainder = divmod(seconds, 60) + return f"{int(minutes)}m {remainder:.0f}s" + + +def is_actionable(line: str) -> bool: + plain = strip_ansi(line) + return any(pattern.search(plain) for pattern in ACTIONABLE_PATTERNS) + + +def failure_excerpt(path: Path, limit: int) -> tuple[list[str], str | None]: + """Return bounded context around the first actionable failure. + + The log is streamed rather than loaded wholesale, keeping the wrapper useful + for very large compiler output. + """ + + before_count = min(12, max(0, limit // 4)) + before: deque[str] = deque(maxlen=before_count) + excerpt: list[str] = [] + first_actionable: str | None = None + matched = False + + with path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + line = strip_ansi(raw_line) + if not matched: + if is_actionable(line): + matched = True + first_actionable = line.strip() or None + excerpt.extend(before) + excerpt.append(line) + else: + before.append(line) + elif len(excerpt) < limit: + excerpt.append(line) + else: + break + + if not matched: + # With no recognizable error, the command's tail is normally the most + # useful bounded evidence (signal termination, tool-specific errors). + tail: deque[str] = deque(maxlen=limit) + with path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + tail.append(strip_ansi(raw_line)) + excerpt = list(tail) + first_actionable = next((line.strip() for line in excerpt if line.strip()), None) + + while excerpt and not excerpt[0].strip(): + excerpt.pop(0) + while excerpt and not excerpt[-1].strip(): + excerpt.pop() + return excerpt[:limit], first_actionable + + +def success_summary(path: Path) -> str: + last_nonempty = "" + test_result: tuple[str, str, str] | None = None + explicit_pass = "" + with path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + line = strip_ansi(raw_line).strip() + if not line: + continue + last_nonempty = line + match = TEST_RESULT_RE.search(line) + if match: + test_result = match.groups() + if line.startswith("PASS "): + explicit_pass = line + if test_result is not None: + passed, failed, ignored = test_result + return f"{passed} passed, {failed} failed, {ignored} ignored" + if explicit_pass: + return explicit_pass[:240] + if last_nonempty.startswith("Finished "): + return last_nonempty[:240] + return "exit 0" + + +def write_result(path: Path, result: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(result, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + os.replace(temporary, path) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + +def normalized_exit_code(returncode: int) -> int: + return 128 + abs(returncode) if returncode < 0 else returncode + + +def execute( + *, + root: Path, + workflow: dict[str, Any], + gate: str, + command: Sequence[str], + log: Path | None = None, + json_result: Path | None = None, + cwd: Path | None = None, +) -> int: + if not command: + raise SummaryError("a command is required after --") + configured_log_dir, max_lines = output_settings(workflow) + log_path = log or default_log_path(root, configured_log_dir, gate) + result_path = json_result or log_path.with_suffix(log_path.suffix + ".json") + if result_path == log_path: + raise SummaryError("the JSON result path must differ from the full log path") + command_cwd = cwd or Path.cwd() + if not command_cwd.is_dir(): + raise SummaryError(f"command working directory does not exist: {command_cwd}") + log_path.parent.mkdir(parents=True, exist_ok=True) + + started_wall = datetime.now(timezone.utc) + started = time.monotonic() + returncode = 127 + launch_error: str | None = None + interrupted = False + with log_path.open("wb") as log_handle: + try: + process = subprocess.Popen( + list(command), + cwd=command_cwd, + stdout=log_handle, + stderr=subprocess.STDOUT, + shell=False, + ) + except OSError as error: + launch_error = f"error: unable to execute {command[0]!r}: {error}" + log_handle.write((launch_error + "\n").encode("utf-8", errors="replace")) + else: + try: + returncode = normalized_exit_code(process.wait()) + except KeyboardInterrupt: + interrupted = True + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + returncode = 130 + log_handle.write(b"\nerror: command interrupted by user\n") + + duration = time.monotonic() - started + status = "PASS" if returncode == 0 else "FAIL" + summary = success_summary(log_path) if returncode == 0 else "command failed" + excerpt: list[str] = [] + actionable: str | None = launch_error + if returncode != 0: + excerpt, detected = failure_excerpt(log_path, max_lines) + actionable = actionable or detected + + result = { + "schema_version": 1, + "gate": gate, + "status": status, + "exit_code": returncode, + "duration_seconds": round(duration, 6), + "log": relative_display(root, log_path), + "result": relative_display(root, result_path), + "command": list(command), + "command_display": shlex.join(command), + "cwd": str(command_cwd.resolve(strict=False)), + "started_at": started_wall.isoformat(timespec="seconds"), + "finished_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "summary": summary, + "first_actionable_failure": actionable, + "interrupted": interrupted, + } + write_result(result_path, result) + + if returncode == 0: + print(f"PASS {gate} — {duration_text(duration)} — {summary}") + else: + print(f"FAIL {gate} — {duration_text(duration)}") + for line in excerpt: + print(line) + print(f"Full log: {relative_display(root, log_path)}") + return returncode + + +def parse_arguments(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gate", required=True, help="short verification gate name") + parser.add_argument("--json-result", help="machine-readable result path") + parser.add_argument("--log", help="complete combined-output log path") + parser.add_argument("--cwd", help="command working directory; defaults to the invocation directory") + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + if args.command and args.command[0] == "--": + args.command = args.command[1:] + if not args.command: + parser.error("a command is required after --") + return args + + +def self_test() -> int: + with tempfile.TemporaryDirectory(prefix="blacksite-summary-self-test-") as temporary: + root = Path(temporary) + (root / ".codex").mkdir() + (root / ".codex/workflow.toml").write_text( + """version = 1 +[output] +log_dir = ".codex/logs" +max_failure_lines = 4 +""", + encoding="utf-8", + ) + nested = root / "nested" + nested.mkdir() + found = find_repository(nested) + workflow = load_workflow(found) + parsed = parse_arguments( + [ + "--gate", + "parser-fixture", + "--json-result", + ".codex/logs/parser.json", + "--", + sys.executable, + "--version", + ] + ) + if parsed.command != [sys.executable, "--version"]: + raise AssertionError("command arguments after -- were not preserved") + + success_log = root / ".codex/logs/success.log" + success_json = root / ".codex/logs/success.json" + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + success = execute( + root=root, + workflow=workflow, + gate="self-test-pass", + command=[ + sys.executable, + "-c", + "print('test result: ok. 3 passed; 0 failed; 1 ignored')", + ], + log=success_log, + json_result=success_json, + cwd=root, + ) + if success != 0: + raise AssertionError("successful fixture returned a failure") + if not captured.getvalue().startswith("PASS self-test-pass"): + raise AssertionError("successful fixture did not emit a compact PASS summary") + success_data = json.loads(success_json.read_text(encoding="utf-8")) + for key in ("status", "exit_code", "duration_seconds", "log"): + if key not in success_data: + raise AssertionError(f"result JSON omitted required key {key}") + if success_data["status"] != "PASS" or success_data["exit_code"] != 0: + raise AssertionError("successful result JSON is incorrect") + + failure_log = root / ".codex/logs/failure.log" + failure_json = root / ".codex/logs/failure.json" + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + failure = execute( + root=root, + workflow=workflow, + gate="self-test-fail", + command=[ + sys.executable, + "-c", + "print('noise'); print('error[E0001]: actionable'); print('detail'); raise SystemExit(7)", + ], + log=failure_log, + json_result=failure_json, + cwd=root, + ) + if failure != 7: + raise AssertionError("failing fixture did not preserve exit code 7") + visible_lines = captured.getvalue().splitlines() + if not visible_lines or not visible_lines[0].startswith("FAIL self-test-fail"): + raise AssertionError("failing fixture did not emit a compact FAIL summary") + if len(visible_lines[1:-1]) > 4: + raise AssertionError("failure excerpt exceeded output.max_failure_lines") + failure_data = json.loads(failure_json.read_text(encoding="utf-8")) + if failure_data["status"] != "FAIL" or failure_data["exit_code"] != 7: + raise AssertionError("failure result JSON is incorrect") + if "error[E0001]" not in failure_log.read_text(encoding="utf-8"): + raise AssertionError("full failure output was not captured") + print("PASS summarize-command-self-test — logs, bounded failure, JSON, exit preservation") + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments in (["--self-test"], ["self-test"]): + try: + return self_test() + except (AssertionError, OSError, SummaryError, subprocess.SubprocessError, json.JSONDecodeError) as error: + print(f"FAIL summarize-command-self-test — {error}", file=sys.stderr) + return 1 + + try: + args = parse_arguments(arguments) + root = find_repository() + workflow = load_workflow(root) + log = resolve_path(root, args.log) if args.log else None + result = resolve_path(root, args.json_result) if args.json_result else None + cwd = resolve_path(root, args.cwd) if args.cwd else Path.cwd() + return execute( + root=root, + workflow=workflow, + gate=args.gate, + command=args.command, + log=log, + json_result=result, + cwd=cwd, + ) + except (OSError, SummaryError) as error: + print(f"FAIL summarize-command — {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/verify.py b/scripts/codex/verify.py new file mode 100755 index 0000000..efa85db --- /dev/null +++ b/scripts/codex/verify.py @@ -0,0 +1,1167 @@ +#!/usr/bin/env python3 +"""Select and run the smallest valid Blacksite verification gates.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Sequence + +try: + import tomllib +except ModuleNotFoundError as error: # pragma: no cover + raise SystemExit("verify.py requires Python 3.11 or newer") from error + + +@dataclass(frozen=True) +class Gate: + name: str + command: tuple[str, ...] + reason: str + lane: str | None = None + compile_gate: bool = False + native_required: bool = False + + +@dataclass(frozen=True) +class LocalPackage: + name: str + root: str + dependencies: frozenset[str] + + +DIGEST_SCHEMA_VERSION = 2 +RUST_GATE_CONFIG_INPUTS = ( + "Cargo.toml", + "Cargo.lock", + ".cargo/config.toml", + ".codex/workflow.toml", + "rust-toolchain", + "rust-toolchain.toml", + ".rustfmt.toml", + "rustfmt.toml", + "clippy.toml", + "scripts/codex/cargo_lane.py", +) +RUST_GATE_ENVIRONMENT = ( + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_TARGET", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_HOME", + "CC", + "CFLAGS", + "CXX", + "CXXFLAGS", + "HOST", + "LD", + "RUSTC", + "RUSTC_WRAPPER", + "RUSTDOCFLAGS", + "RUSTFLAGS", + "RUSTUP_TOOLCHAIN", + "TARGET", +) +IGNORED_SCOPE_DIRECTORIES = { + ".git", + ".import-cache", + ".codex", + "node_modules", + "target", +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def repository_root() -> Path: + override = os.environ.get("BLACKSITE_WORKSPACE_ROOT") + configured = None + config_path = Path.cwd() / ".codex/config.toml" + if not override and config_path.is_file(): + with config_path.open("rb") as handle: + configured = tomllib.load(handle).get("workspace_root") + candidate = Path(override or configured).expanduser() if (override or configured) else Path(__file__).parents[2] + candidate = candidate.absolute() + for current in (candidate, *candidate.parents): + if (current / "Cargo.toml").is_file() and (current / ".git").exists(): + return current + raise SystemExit(f"could not locate Blacksite repository from {candidate}") + + +def load_workflow(root: Path) -> dict[str, Any]: + path = root / ".codex" / "workflow.toml" + if not path.is_file(): + raise SystemExit(f"missing workflow configuration: {path}") + with path.open("rb") as handle: + document = tomllib.load(handle) + if document.get("version") != 1: + raise SystemExit(f"unsupported workflow version in {path}") + return document + + +def run_text(root: Path, command: Sequence[str]) -> str: + completed = subprocess.run( + command, + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if completed.returncode: + raise SystemExit( + f"command failed ({completed.returncode}): {' '.join(command)}\n" + + completed.stdout[-4000:] + ) + return completed.stdout + + +def changed_paths(root: Path) -> list[str]: + output = subprocess.run( + ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], + cwd=root, + check=True, + stdout=subprocess.PIPE, + ).stdout + fields = output.split(b"\0") + paths: list[str] = [] + index = 0 + while index < len(fields): + field = fields[index] + index += 1 + if not field: + continue + text = field.decode("utf-8", "surrogateescape") + status = text[:2] + path = text[3:] + if status[:1] in {"R", "C"} and index < len(fields): + target = fields[index].decode("utf-8", "surrogateescape") + index += 1 + paths.extend((path, target)) + else: + paths.append(path) + return sorted(set(paths)) + + +def normalize_paths(values: Iterable[str]) -> list[str]: + return sorted({value.replace("\\", "/").removeprefix("./") for value in values}) + + +def classify(paths: Sequence[str]) -> set[str]: + areas: set[str] = set() + for path in paths: + if ( + path == "AGENTS.md" + or path.endswith("/AGENTS.md") + or path.startswith((".agents/", ".codex/", "scripts/codex/", ".cursor/rules/")) + or path in {".gitignore", ".cargo/config.toml"} + ): + areas.add("workflow") + if path == "README.md" or path.endswith(".md") or path.startswith("docs/"): + areas.add("docs") + if path.startswith("crates/content_pipeline/"): + areas.add("content_pipeline") + if path.startswith("crates/shared/"): + areas.add("shared") + if path.startswith("crates/editor/"): + areas.add("editor") + if path.startswith("crates/editor/src/ui/"): + areas.add("editor_ui") + if path.startswith("crates/blacksite_surface/") or path.endswith((".wgsl", ".shader.ron")): + areas.add("surface") + if path.startswith("xtask/"): + areas.add("xtask") + if path.startswith("crates/scene/"): + areas.update(("scene", "migration")) + if path.startswith("crates/settings/"): + areas.add("settings") + if path.startswith("crates/game/"): + areas.add("game") + if path.startswith("assets/"): + areas.add("assets") + if any(token in path for token in ("migrat", "upgrade", "schema")) and not path.endswith(".md"): + areas.add("migration") + if path.endswith(".rs") or path in {"Cargo.toml", "Cargo.lock", ".cargo/config.toml"}: + areas.add("rust") + return areas + + +def cargo_gate(name: str, lane: str, reason: str, *cargo_args: str) -> Gate: + return Gate( + name=name, + command=("cargo", *cargo_args), + lane=lane, + reason=reason, + compile_gate=any( + item in cargo_args + for item in ("check", "test", "clippy", "build", "run") + ), + ) + + +def command_gate(name: str, reason: str, *command: str) -> Gate: + return Gate(name=name, command=tuple(command), reason=reason) + + +def add_unique(gates: list[Gate], gate: Gate) -> None: + if not any(existing.name == gate.name for existing in gates): + gates.append(gate) + + +def select_gates(tier: str, areas: set[str], root: Path) -> tuple[list[Gate], list[str]]: + gates: list[Gate] = [] + skipped: list[str] = [] + python = sys.executable + + if not areas: + return [], ["No changed inputs; all gates skipped."] + + if "workflow" in areas: + for script in ( + "state.py", + "summarize_command.py", + "cargo_lane.py", + "build_storage.py", + "architecture_audit.py", + ): + if (root / "scripts" / "codex" / script).is_file(): + add_unique( + gates, + command_gate( + f"workflow-{script.removesuffix('.py')}", + "Workflow implementation changed.", + python, + f"scripts/codex/{script}", + "self-test", + ), + ) + add_unique( + gates, + command_gate( + "workflow-verify-plan", + "Verification selection must remain deterministic.", + python, + "scripts/codex/verify.py", + "plan", + "--paths", + "docs/README.md", + ), + ) + + if "docs" in areas or "workflow" in areas: + add_unique( + gates, + command_gate( + "docs-audit-changed", + "Documentation or workflow authority changed.", + python, + "scripts/codex/docs_audit.py", + "--changed", + ), + ) + + rust_areas = areas.intersection( + {"content_pipeline", "shared", "editor", "surface", "xtask", "scene", "settings", "game"} + ) + if rust_areas or "workflow" in areas: + add_unique( + gates, + command_gate( + "architecture-audit", + "Production architecture inputs changed.", + python, + "scripts/codex/architecture_audit.py", + "check", + ), + ) + if rust_areas: + add_unique( + gates, + cargo_gate("rustfmt", "dev", "Rust source changed.", "fmt", "--all", "--", "--check"), + ) + + package_specs = { + "content_pipeline": ("content_pipeline", "lib"), + "shared": ("shared", "lib"), + "editor": ("editor", "lib"), + "surface": ("blacksite_surface", "lib"), + "scene": ("scene", "lib"), + "settings": ("settings", "lib"), + "game": ("game", "lib"), + "xtask": ("xtask", "bins"), + } + for area, (package, target_kind) in package_specs.items(): + if area not in areas: + continue + target_flag = "--bins" if target_kind == "bins" else "--lib" + add_unique( + gates, + cargo_gate( + f"check-{package}", + "dev", + f"{area} owns changed Rust inputs.", + "check", + "-p", + package, + target_flag, + ), + ) + if area not in {"editor", "xtask"}: + add_unique( + gates, + cargo_gate( + f"test-{package}", + "dev", + f"Focused {area} invariants are lightweight enough for this tier.", + "test", + "-p", + package, + target_flag, + ), + ) + elif tier == "fast": + skipped.append(f"Skipped heavy {package} test binary in the fast loop.") + + if tier == "slice": + for area, (package, target_kind) in package_specs.items(): + if area not in areas: + continue + target_flag = "--bins" if target_kind == "bins" else "--lib" + add_unique( + gates, + cargo_gate( + f"clippy-{package}", + "dev", + f"Stable {area} slice requires strict affected-package lint.", + "clippy", + "-p", + package, + target_flag, + "--", + "-D", + "warnings", + ), + ) + if "editor_ui" in areas: + gates.append( + Gate( + name="native-editor-ui", + command=("bash", "scripts/codex/native_qa.sh", "plan", "material-slot-live-edit"), + reason="Editor UI changed; native interaction is a slice requirement.", + native_required=True, + ) + ) + if "assets" in areas or "content_pipeline" in areas: + add_unique( + gates, + cargo_gate( + "process-assets-check", + "package", + "Asset processing behavior changed.", + "process-assets", + "--project", + ".", + "--check", + ), + ) + if "migration" in areas: + skipped.append("Migration dry-run is required at slice review and needs an explicit fixture/project.") + + if tier == "candidate": + gates = [ + command_gate( + "candidate-architecture", + "Candidate modules must satisfy the architecture debt ratchet.", + python, + "scripts/codex/architecture_audit.py", + "check", + ), + cargo_gate( + "candidate-tests", + "candidate", + "Nominated candidate requires full all-feature tests once.", + "test", + "--workspace", + "--all-features", + ), + cargo_gate( + "candidate-clippy", + "candidate", + "Nominated candidate requires distinct strict lint evidence.", + "clippy", + "--workspace", + "--all-features", + "--all-targets", + "--", + "-D", + "warnings", + ), + cargo_gate( + "candidate-process-assets", + "package", + "Candidate content catalog must validate deterministically.", + "process-assets", + "--project", + ".", + "--check", + ), + cargo_gate( + "candidate-levels", + "package", + "Candidate levels require headless validation.", + "validate-levels", + ), + cargo_gate( + "candidate-samples", + "package", + "Candidate samples require headless validation.", + "validate-samples", + ), + cargo_gate( + "candidate-package", + "package", + "Candidate package output must be generated and validated.", + "package-project", + "--project", + ".", + "--profile", + "qa", + ), + command_gate( + "candidate-docs", + "Candidate documentation authority must be coherent.", + python, + "scripts/codex/docs_audit.py", + ), + ] + skipped.append("No redundant workspace check precedes candidate workspace tests.") + + return gates, skipped + + +def state_is_candidate_ready(root: Path, workflow: dict[str, Any]) -> bool: + state_path = root / workflow["session"]["state_file"] + if not state_path.is_file(): + return False + return "Current state: Candidate-ready" in state_path.read_text(encoding="utf-8") + + +def ledger_path(root: Path, workflow: dict[str, Any]) -> Path: + return root / workflow["session"]["verification_file"] + + +def read_ledger(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {"version": 1, "gates": {}, "invalidations": []} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"version": 1, "gates": {}, "invalidations": []} + return document if isinstance(document, dict) else {"version": 1, "gates": {}} + + +def atomic_json(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def _dependency_tables(document: dict[str, Any]) -> Iterable[dict[str, Any]]: + for key in ("dependencies", "dev-dependencies", "build-dependencies"): + table = document.get(key) + if isinstance(table, dict): + yield table + targets = document.get("target") + if isinstance(targets, dict): + for target in targets.values(): + if not isinstance(target, dict): + continue + for key in ("dependencies", "dev-dependencies", "build-dependencies"): + table = target.get(key) + if isinstance(table, dict): + yield table + + +def workspace_packages(root: Path) -> dict[str, LocalPackage]: + workspace_manifest = root / "Cargo.toml" + if not workspace_manifest.is_file(): + return {} + try: + with workspace_manifest.open("rb") as handle: + workspace = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError): + return {} + + members = workspace.get("workspace", {}).get("members", []) + package_documents: list[tuple[str, str, dict[str, Any]]] = [] + for member in members if isinstance(members, list) else []: + if not isinstance(member, str): + continue + for candidate in sorted(root.glob(member)): + manifest = candidate / "Cargo.toml" if candidate.is_dir() else candidate + if not manifest.is_file(): + continue + try: + with manifest.open("rb") as handle: + document = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError): + continue + package = document.get("package") + if not isinstance(package, dict) or not isinstance(package.get("name"), str): + continue + package_documents.append( + ( + package["name"], + manifest.parent.relative_to(root).as_posix(), + document, + ) + ) + + local_names = {name for name, _, _ in package_documents} + packages: dict[str, LocalPackage] = {} + for name, relative_root, document in package_documents: + dependencies: set[str] = set() + for table in _dependency_tables(document): + for dependency_name, specification in table.items(): + resolved_name = dependency_name + if isinstance(specification, dict) and isinstance(specification.get("package"), str): + resolved_name = specification["package"] + if resolved_name in local_names: + dependencies.add(resolved_name) + packages[name] = LocalPackage(name, relative_root, frozenset(dependencies)) + return packages + + +def local_dependency_closure(packages: dict[str, LocalPackage], names: Iterable[str]) -> set[str]: + closure: set[str] = set() + pending = list(names) + while pending: + name = pending.pop() + if name in closure or name not in packages: + continue + closure.add(name) + pending.extend(packages[name].dependencies) + return closure + + +def cargo_scope(root: Path, gate: Gate) -> tuple[set[str], set[str], bool]: + """Return local package names, extra roots, and whether only Rust source matters.""" + + packages = workspace_packages(root) + arguments = list(gate.command[1:]) if gate.command[:1] == ("cargo",) else list(gate.command) + package_name: str | None = None + for index, argument in enumerate(arguments): + if argument in {"-p", "--package"} and index + 1 < len(arguments): + package_name = arguments[index + 1] + break + if argument.startswith("--package="): + package_name = argument.partition("=")[2] + break + + subcommand = arguments[0] if arguments else "" + rust_only = subcommand == "fmt" + if package_name is not None: + selected = local_dependency_closure(packages, (package_name,)) + elif subcommand in {"process-assets", "validate-levels", "validate-samples", "package-project"}: + selected = local_dependency_closure(packages, ("xtask",)) + else: + selected = set(packages) + + extra_roots: set[str] = set() + if subcommand in {"process-assets", "validate-levels", "validate-samples", "package-project"}: + extra_roots.add("assets") + return selected, extra_roots, rust_only + + +def _under_scope(relative: str, roots: Iterable[str]) -> bool: + return any( + root in {"", "."} or relative == root or relative.startswith(f"{root}/") + for root in roots + ) + + +def _is_documentation(relative: str) -> bool: + path = relative.lower() + return path == "readme.md" or path == "agents.md" or path.endswith(".md") or path.startswith("docs/") + + +def _walk_files(root: Path, roots: Iterable[str]) -> set[str]: + found: set[str] = set() + for relative_root in roots: + start = root if relative_root in {"", "."} else root / relative_root + if start.is_file(): + found.add(start.relative_to(root).as_posix()) + continue + if not start.is_dir(): + continue + for directory, directory_names, file_names in os.walk(start): + directory_names[:] = [ + name for name in directory_names if name not in IGNORED_SCOPE_DIRECTORIES + ] + base = Path(directory) + for file_name in file_names: + found.add((base / file_name).relative_to(root).as_posix()) + return found + + +def _tracked_scope_files(root: Path, roots: Iterable[str]) -> set[str]: + normalized_roots = sorted(set(roots)) + if not normalized_roots: + return set() + if (root / ".git").exists(): + completed = subprocess.run( + ["git", "ls-files", "-z", "--", *normalized_roots], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if completed.returncode == 0: + return { + item.decode("utf-8", "surrogateescape") + for item in completed.stdout.split(b"\0") + if item + } + return _walk_files(root, normalized_roots) + + +def gate_static_inputs(root: Path, gate: Gate) -> set[str]: + inputs: set[str] = set() + if gate.lane is not None: + inputs.update(RUST_GATE_CONFIG_INPUTS) + packages = workspace_packages(root) + selected, _, _ = cargo_scope(root, gate) + inputs.update(f"{packages[name].root}/Cargo.toml" for name in selected if name in packages) + return inputs + + for argument in gate.command: + normalized = argument.replace("\\", "/").removeprefix("./") + if ( + normalized + and not normalized.startswith("-") + and not Path(normalized).is_absolute() + and (root / normalized).is_file() + ): + inputs.add(normalized) + if gate.name.startswith("workflow-"): + inputs.add(".codex/workflow.toml") + if gate.name.startswith("docs-") or gate.name == "candidate-docs": + inputs.add("docs/authority.toml") + if gate.native_required and gate.command: + scenario = gate.command[-1] + inputs.add( + f".agents/skills/blacksite-native-qa/references/scenarios/{scenario}.yaml" + ) + return inputs + + +def gate_input_paths(root: Path, gate: Gate, changed: Sequence[str]) -> list[str]: + inputs = gate_static_inputs(root, gate) + normalized_changes = normalize_paths(changed) + + if gate.lane is not None: + packages = workspace_packages(root) + selected, extra_roots, rust_only = cargo_scope(root, gate) + roots = {packages[name].root for name in selected if name in packages} | extra_roots + scoped = _tracked_scope_files(root, roots) + + def relevant(relative: str) -> bool: + if relative in inputs: + return True + if not _under_scope(relative, roots) or _is_documentation(relative): + return False + return relative.endswith(".rs") if rust_only else True + + inputs.update(relative for relative in scoped if relevant(relative)) + # Add relevant untracked and deleted paths supplied by Git status or --paths. + inputs.update(relative for relative in normalized_changes if relevant(relative)) + elif gate.name == "candidate-docs": + inputs.update( + relative + for relative in _tracked_scope_files(root, ("docs",)) + if _is_documentation(relative) + ) + inputs.update(relative for relative in normalized_changes if _is_documentation(relative)) + elif gate.name.startswith("docs-"): + inputs.update(relative for relative in normalized_changes if _is_documentation(relative)) + elif gate.name.startswith("workflow-"): + inputs.update( + relative + for relative in normalized_changes + if relative in inputs or relative == ".codex/workflow.toml" + ) + elif gate.native_required: + inputs.update( + relative + for relative in normalized_changes + if "editor_ui" in classify((relative,)) + ) + else: + inputs.update(normalized_changes) + return sorted(inputs) + + +def _version_output(root: Path, command: Sequence[str]) -> str: + completed = subprocess.run( + command, + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if completed.returncode: + return f"unavailable(exit={completed.returncode})" + return completed.stdout.strip() + + +def gate_tool_versions(root: Path, gate: Gate) -> dict[str, str]: + if gate.lane is not None: + versions = { + "cargo": _version_output(root, ("cargo", "--version", "--verbose")), + "rustc": _version_output(root, ("rustc", "--version", "--verbose")), + } + if gate.command[1:2] == ("fmt",): + versions["rustfmt"] = _version_output(root, ("rustfmt", "--version")) + if gate.command[1:2] == ("clippy",): + versions["clippy-driver"] = _version_output(root, ("clippy-driver", "--version")) + return versions + executable = Path(gate.command[0]).name if gate.command else "" + if executable.startswith("python") or any(argument.endswith(".py") for argument in gate.command): + return {"python": f"{sys.executable}\n{sys.version}"} + if executable == "bash": + return {"bash": _version_output(root, ("bash", "--version")).splitlines()[0]} + return {"executable": executable} + + +def gate_environment(gate: Gate) -> dict[str, str | None]: + if gate.lane is None: + return {} + return {name: os.environ.get(name) for name in RUST_GATE_ENVIRONMENT} + + +def gate_digest_details( + root: Path, + gate: Gate, + paths: Sequence[str], + *, + tool_versions: dict[str, str] | None = None, + environment: dict[str, str | None] | None = None, +) -> tuple[str, list[str], dict[str, str], dict[str, str | None]]: + inputs = gate_input_paths(root, gate, paths) + versions = tool_versions if tool_versions is not None else gate_tool_versions(root, gate) + environment_values = environment if environment is not None else gate_environment(gate) + digest = hashlib.sha256() + digest.update( + json.dumps( + { + "schema": DIGEST_SCHEMA_VERSION, + "gate": asdict(gate), + "tool_versions": versions, + "environment": environment_values, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ) + for relative in inputs: + digest.update(b"\0path\0") + digest.update(relative.encode("utf-8", "surrogateescape")) + candidate = root / relative + if not candidate.is_file(): + digest.update(b"\0missing") + continue + digest.update(b"\0file\0") + digest.update(candidate.read_bytes()) + return digest.hexdigest(), inputs, versions, environment_values + + +def gate_digest( + root: Path, + gate: Gate, + paths: Sequence[str], + *, + tool_versions: dict[str, str] | None = None, + environment: dict[str, str | None] | None = None, +) -> str: + return gate_digest_details( + root, + gate, + paths, + tool_versions=tool_versions, + environment=environment, + )[0] + + +def gate_command(root: Path, gate: Gate) -> list[str]: + if gate.lane is None: + return list(gate.command) + return [ + sys.executable, + "scripts/codex/cargo_lane.py", + "exec", + gate.lane, + "--", + *gate.command, + ] + + +def run_storage(root: Path, phase: str) -> int: + script = root / "scripts" / "codex" / "build_storage.py" + if not script.is_file(): + return 0 + return subprocess.run( + [sys.executable, str(script), "enforce", "--phase", phase], cwd=root + ).returncode + + +def execute_gate( + root: Path, + workflow: dict[str, Any], + gate: Gate, + digest: str, +) -> dict[str, Any]: + log_dir = root / workflow["output"]["log_dir"] + log_dir.mkdir(parents=True, exist_ok=True) + result_path = log_dir / f"{gate.name}-{digest[:12]}.json" + log_path = log_dir / f"{gate.name}-{digest[:12]}.log" + command = gate_command(root, gate) + if gate.native_required: + return { + "status": "NOT_RUN", + "exit_code": 0, + "duration_seconds": 0.0, + "log": None, + "note": "Native QA remains user-controlled until explicitly delegated.", + } + if gate.compile_gate and run_storage(root, "pre") != 0: + return { + "status": "BLOCKED", + "exit_code": 2, + "duration_seconds": 0.0, + "log": None, + "note": "Build-storage preflight blocked this compile gate.", + } + summarizer = [ + sys.executable, + "scripts/codex/summarize_command.py", + "--gate", + gate.name, + "--log", + str(log_path), + "--json-result", + str(result_path), + "--", + *command, + ] + started = time.monotonic() + exit_code = subprocess.run(summarizer, cwd=root).returncode + duration = time.monotonic() - started + if gate.compile_gate: + post = run_storage(root, "post") + if exit_code == 0 and post != 0: + exit_code = post + if result_path.is_file(): + result = json.loads(result_path.read_text(encoding="utf-8")) + else: + result = { + "status": "PASS" if exit_code == 0 else "FAIL", + "exit_code": exit_code, + "duration_seconds": duration, + "log": str(log_path.relative_to(root)), + } + result["exit_code"] = exit_code + return result + + +def print_plan(tier: str, paths: Sequence[str], areas: set[str], gates: Sequence[Gate], skipped: Sequence[str]) -> None: + print(f"Verification tier: {tier}") + print("Changed areas: " + (", ".join(sorted(areas)) if areas else "none")) + for gate in gates: + lane = f" [{gate.lane}]" if gate.lane else "" + print(f"SELECT {gate.name}{lane} — {gate.reason}") + for reason in skipped: + print(f"SKIP — {reason}") + if paths: + print(f"Inputs: {len(paths)} changed path(s)") + + +def diagnose_build(root: Path) -> int: + commands = [ + [sys.executable, "scripts/codex/cargo_lane.py", "env", "dev", "--json"], + [sys.executable, "scripts/codex/build_storage.py", "status", "--json"], + ] + for command in commands: + completed = subprocess.run(command, cwd=root) + if completed.returncode: + return completed.returncode + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("action", choices=("plan", "fast", "slice", "candidate", "status", "invalidate", "diagnose-build", "self-test")) + result.add_argument("--paths", nargs="*", help="override Git changes for planning/self-tests") + result.add_argument( + "--tier", + choices=("fast", "slice", "candidate"), + default="fast", + help="tier to preview with `plan` (default: fast)", + ) + result.add_argument("--reason", help="invalidation reason") + return result + + +def self_test(root: Path) -> int: + cases = { + "docs-only": (["docs/editor/material-system.md"], {"docs"}), + "pipeline": (["crates/content_pipeline/src/lib.rs"], {"content_pipeline", "rust"}), + "editor-ui": (["crates/editor/src/ui/inspector.rs"], {"editor", "editor_ui", "rust"}), + } + for name, (paths, expected) in cases.items(): + found = classify(paths) + if not expected.issubset(found): + raise AssertionError(f"{name}: expected {expected}, found {found}") + gates, _ = select_gates("fast", found, root) + names = {gate.name for gate in gates} + if name == "docs-only" and any(gate.lane for gate in gates): + raise AssertionError("docs-only plan selected a Cargo lane") + if name == "pipeline" and "check-content_pipeline" not in names: + raise AssertionError("pipeline plan missed focused check") + if name == "editor-ui" and "check-editor" not in names: + raise AssertionError("editor UI plan missed editor library check") + if any(gate.name.startswith("candidate-") for gate in gates): + raise AssertionError(f"{name}: fast plan selected candidate gate") + + candidate_gates, _ = select_gates("candidate", {"rust"}, root) + candidate_package = next( + gate for gate in candidate_gates if gate.name == "candidate-package" + ) + if candidate_package.command[-2:] != ("--profile", "qa"): + raise AssertionError("candidate package gate must select assets/build_profiles/qa.ron") + + with tempfile.TemporaryDirectory(prefix="blacksite-verify-digest-") as temporary: + fixture = Path(temporary) + + def write(relative: str, content: str) -> None: + path = fixture / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + write( + "Cargo.toml", + '[workspace]\nmembers = ["crates/content_pipeline", "crates/shared", "crates/editor"]\n', + ) + write("Cargo.lock", "version = 4\n") + write(".cargo/config.toml", "[build]\nincremental = true\n") + write(".codex/workflow.toml", "version = 1\n") + write("scripts/codex/cargo_lane.py", "# fixture lane wrapper\n") + write( + "crates/content_pipeline/Cargo.toml", + '[package]\nname = "content_pipeline"\nversion = "0.0.0"\n' + '[dependencies]\nshared = { path = "../shared" }\n', + ) + write("crates/content_pipeline/src/lib.rs", "pub fn pipeline() {}\n") + write("crates/content_pipeline/README.md", "pipeline notes\n") + write( + "crates/shared/Cargo.toml", + '[package]\nname = "shared"\nversion = "0.0.0"\n', + ) + write("crates/shared/src/lib.rs", "pub struct Shared;\n") + write( + "crates/editor/Cargo.toml", + '[package]\nname = "editor"\nversion = "0.0.0"\n', + ) + write("crates/editor/src/lib.rs", "pub fn editor() {}\n") + write("docs/note.md", "first docs revision\n") + + pipeline_gate = cargo_gate( + "check-content_pipeline", + "dev", + "digest fixture", + "check", + "-p", + "content_pipeline", + "--lib", + ) + fake_tools = {"cargo": "cargo fixture", "rustc": "rustc fixture"} + empty_environment: dict[str, str | None] = {} + changed = ["crates/content_pipeline/src/lib.rs"] + baseline, scoped_inputs, _, _ = gate_digest_details( + fixture, + pipeline_gate, + changed, + tool_versions=fake_tools, + environment=empty_environment, + ) + if "crates/shared/src/lib.rs" not in scoped_inputs: + raise AssertionError("package digest omitted a local dependency input") + if "docs/note.md" in scoped_inputs or "crates/content_pipeline/README.md" in scoped_inputs: + raise AssertionError("package digest included documentation") + if "crates/editor/src/lib.rs" in scoped_inputs: + raise AssertionError("package digest included an unrelated package") + + write("docs/note.md", "second docs revision\n") + write("crates/content_pipeline/README.md", "updated pipeline notes\n") + docs_changed = gate_digest( + fixture, + pipeline_gate, + [*changed, "docs/note.md", "crates/content_pipeline/README.md"], + tool_versions=fake_tools, + environment=empty_environment, + ) + if docs_changed != baseline: + raise AssertionError("unrelated documentation invalidated Rust evidence") + + write("crates/editor/src/lib.rs", "pub fn unrelated_editor_change() {}\n") + unrelated_package_changed = gate_digest( + fixture, + pipeline_gate, + [*changed, "crates/editor/src/lib.rs"], + tool_versions=fake_tools, + environment=empty_environment, + ) + if unrelated_package_changed != baseline: + raise AssertionError("unrelated package input invalidated a focused Rust gate") + + write("crates/shared/src/lib.rs", "pub struct ChangedShared;\n") + dependency_changed = gate_digest( + fixture, + pipeline_gate, + [*changed, "crates/shared/src/lib.rs"], + tool_versions=fake_tools, + environment=empty_environment, + ) + if dependency_changed == baseline: + raise AssertionError("local dependency change did not invalidate Rust evidence") + + write("crates/shared/src/lib.rs", "pub struct Shared;\n") + write(".cargo/config.toml", "[build]\nincremental = false\n") + config_changed = gate_digest( + fixture, + pipeline_gate, + changed, + tool_versions=fake_tools, + environment=empty_environment, + ) + if config_changed == baseline: + raise AssertionError("relevant Cargo configuration did not invalidate Rust evidence") + + write(".cargo/config.toml", "[build]\nincremental = true\n") + tool_changed = gate_digest( + fixture, + pipeline_gate, + changed, + tool_versions={"cargo": "cargo fixture", "rustc": "rustc changed"}, + environment=empty_environment, + ) + if tool_changed == baseline: + raise AssertionError("toolchain change did not invalidate Rust evidence") + + print( + "PASS verify-self-test — selection and gate digests are scoped; " + "unrelated docs/packages reuse evidence and relevant dependency/config/tool changes invalidate it" + ) + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + root = repository_root() + workflow = load_workflow(root) + ledger_file = ledger_path(root, workflow) + ledger = read_ledger(ledger_file) + + if args.action == "self-test": + return self_test(root) + if args.action == "status": + gates = ledger.get("gates", {}) + if not gates: + print("Verification ledger is empty.") + return 0 + for name, record in sorted(gates.items()): + print(f"{record.get('status', 'UNKNOWN')} {name} — {record.get('checked_at', 'unknown')} — {str(record.get('digest', ''))[:12]}") + return 0 + if args.action == "invalidate": + if not args.reason: + raise SystemExit("invalidate requires --reason") + for record in ledger.setdefault("gates", {}).values(): + record["valid"] = False + ledger.setdefault("invalidations", []).append({"at": utc_now(), "reason": args.reason}) + atomic_json(ledger_file, ledger) + print(f"Invalidated verification evidence: {args.reason}") + return 0 + if args.action == "diagnose-build": + return diagnose_build(root) + + paths = normalize_paths(args.paths if args.paths is not None else changed_paths(root)) + areas = classify(paths) + tier = args.tier if args.action == "plan" else args.action + gates, skipped = select_gates(tier, areas, root) + print_plan(tier, paths, areas, gates, skipped) + if args.action == "plan": + return 0 + if args.action == "candidate" and workflow["verification"].get("candidate_requires_explicit_state", True): + if not state_is_candidate_ready(root, workflow): + print("candidate gate refused: session state is not Candidate-ready", file=sys.stderr) + return 2 + + failed = False + for gate in gates: + digest, input_paths, tool_versions, environment = gate_digest_details( + root, gate, paths + ) + previous = ledger.setdefault("gates", {}).get(gate.name, {}) + if ( + workflow["verification"].get("reuse_by_input_digest", True) + and previous.get("valid", True) + and previous.get("status") == "PASS" + and previous.get("digest") == digest + and previous.get("command") == gate_command(root, gate) + ): + print(f"PASS {gate.name} — reused {digest[:12]}") + continue + result = execute_gate(root, workflow, gate, digest) + record = { + **result, + "valid": result.get("status") == "PASS", + "digest": digest, + "digest_schema": DIGEST_SCHEMA_VERSION, + "input_paths": input_paths, + "tool_versions": tool_versions, + "environment": environment, + "command": gate_command(root, gate), + "lane": gate.lane, + "checked_at": utc_now(), + } + ledger["gates"][gate.name] = record + atomic_json(ledger_file, ledger) + if result.get("status") not in {"PASS", "NOT_RUN"}: + failed = True + break + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/editor/blacksite-editor b/scripts/editor/blacksite-editor new file mode 100755 index 0000000..914383e --- /dev/null +++ b/scripts/editor/blacksite-editor @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +set -u + +readonly REPO="/home/Rbanh/Documents/Bevy" +readonly TARGET_DIR="$REPO/target" +readonly EDITOR_BINARY="$TARGET_DIR/debug/editor" +readonly LAUNCHER_BINARY="$TARGET_DIR/debug/project_launcher" +readonly STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blacksite-editor" +readonly LOG_FILE="$STATE_DIR/launch.log" +readonly STATUS_FILE="$STATE_DIR/splash.status" +readonly SPLASH="$REPO/scripts/editor/splash.py" +readonly CARGO_LANE="$REPO/scripts/codex/cargo_lane.py" + +build_only=false +project_browser=false +gpu_validation=0 +sdr_fallback=false +splash_pid="" +keep_splash=false + +case "${1:-}" in + --build-only) build_only=true ;; + --launcher) project_browser=true ;; + --gpu-validation) gpu_validation=1 ;; + --sdr) sdr_fallback=true ;; + "") ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 2 + ;; +esac + +if $project_browser; then + binary="$LAUNCHER_BINARY" + process_name="project_launcher" + service_description="Blacksite Project Browser" +else + binary="$EDITOR_BINARY" + process_name="editor" + service_description="Blacksite Editor" +fi + +if ! $build_only && [[ "${BLACKSITE_EDITOR_SERVICE:-0}" != 1 ]]; then + service_environment=(--setenv=BLACKSITE_EDITOR_SERVICE=1 --setenv=PATH) + for variable in \ + WAYLAND_DISPLAY DISPLAY XAUTHORITY XDG_RUNTIME_DIR \ + HYPRLAND_INSTANCE_SIGNATURE DBUS_SESSION_BUS_ADDRESS \ + XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE \ + XDG_DATA_HOME XDG_CONFIG_HOME XDG_STATE_HOME GDK_BACKEND; do + if [[ -v "$variable" ]]; then + service_environment+=(--setenv="$variable") + fi + done + launcher_arguments=() + [[ $# -gt 0 ]] && launcher_arguments+=("$1") + exec systemd-run --user --collect --quiet --service-type=exec \ + --description="$service_description" \ + "${service_environment[@]}" \ + "$0" "${launcher_arguments[@]}" +fi + +write_status() { + local phase="$1" + local detail="$2" + local progress="$3" + local state="${4:-running}" + local crate_count="${5:-}" + printf '%s\n%s\n%s\n%s\n%s\n' \ + "$phase" "$detail" "$progress" "$state" "$crate_count" >"$STATUS_FILE.tmp" + mv "$STATUS_FILE.tmp" "$STATUS_FILE" +} + +close_splash() { + if [[ -n "$splash_pid" ]] && ! $keep_splash; then + write_status "Editor ready" "Opening workspace" 100 close + fi +} +trap close_splash EXIT + +focus_existing() { + local pid executable + while read -r pid; do + [[ -n "$pid" ]] || continue + executable="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" + if [[ "$executable" == "$binary" ]]; then + if command -v hyprctl >/dev/null 2>&1; then + hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true + fi + return 0 + fi + done < <(pgrep -x "$process_name" 2>/dev/null || true) + return 1 +} + +if ! $build_only && focus_existing; then + exit 0 +fi + +mkdir -p "$STATE_DIR" +exec 9>"$STATE_DIR/launch.lock" +if ! flock -n 9; then + exit 0 +fi + +: >"$LOG_FILE" +write_status "Preparing workspace" "Reading project configuration" 5 + +if ! $build_only; then + GSK_RENDERER=gl python3 "$SPLASH" "$STATUS_FILE" "$LOG_FILE" & + splash_pid=$! +fi + +if ! cd "$REPO"; then + write_status "Project unavailable" "$REPO could not be opened" 100 error + keep_splash=true + exit 1 +fi + +if $build_only; then + exec python3 "$CARGO_LANE" exec dev -- cargo build -p editor --bin editor +fi + +if [[ ! -x "$binary" ]]; then + write_status "Editor build unavailable" \ + "Run blacksite-editor --build-only from a terminal" 100 error + keep_splash=true + exit 1 +fi + +export RUST_BACKTRACE=1 +export WGPU_VALIDATION="$gpu_validation" +if $sdr_fallback; then + export BEVY_FPS_HDR=0 +fi + +export LD_LIBRARY_PATH="$TARGET_DIR/debug/deps${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +write_status "Starting editor" "Opening the current development build" 82 + +watch_editor_window() { + local editor_pid="$1" + trap - EXIT + if command -v hyprctl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + for ((attempt = 0; attempt < 300; attempt++)); do + if ! kill -0 "$editor_pid" 2>/dev/null; then + write_status "Editor stopped during startup" \ + "Open the launch log for runtime details" 100 error + return + fi + if hyprctl clients -j 2>/dev/null | jq -e --argjson pid "$editor_pid" \ + 'any(.[]; .pid == $pid and .mapped == true)' >/dev/null; then + write_status "Editor ready" "Opening workspace" 100 + sleep 0.4 + write_status "Editor ready" "Opening workspace" 100 close + return + fi + if ((attempt == 10)); then + write_status "Initializing renderer" "Connecting to the graphics device" 88 + elif ((attempt == 35)); then + write_status "Loading editor scene" "Preparing the workspace" 94 + fi + sleep 0.1 + done + else + write_status "Loading editor scene" "Preparing the workspace" 94 + sleep 5 + if kill -0 "$editor_pid" 2>/dev/null; then + write_status "Editor ready" "Opening workspace" 100 + sleep 0.4 + write_status "Editor ready" "Opening workspace" 100 close + return + fi + fi + write_status "Editor is taking longer than expected" \ + "The process is still running; check the launch log" 100 error +} + +watch_editor_window "$$" & +exec "$binary" >>"$LOG_FILE" 2>&1 + +write_status "Editor could not be started" \ + "Open the launch log for runtime details" 100 error +keep_splash=true +exit 1 diff --git a/scripts/editor/build_progress.py b/scripts/editor/build_progress.py new file mode 100755 index 0000000..5b63786 --- /dev/null +++ b/scripts/editor/build_progress.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Build the Blacksite editor while publishing exact crate progress.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +from typing import Iterable + + +def write_status( + path: Path, + phase: str, + detail: str, + progress: int, + state: str = "running", + crate_count: str = "", +) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + f"{phase}\n{detail}\n{progress}\n{state}\n{crate_count}\n", + encoding="utf-8", + ) + os.replace(temporary, path) + + +def unique_crates(tree_output: str) -> set[str]: + return {line.strip() for line in tree_output.splitlines() if line.strip()} + + +def completed_crate( + message: object, completed_package_ids: set[str] +) -> tuple[bool, str]: + if not isinstance(message, dict) or message.get("reason") != "compiler-artifact": + return False, "" + package_id = message.get("package_id") + if not isinstance(package_id, str) or package_id in completed_package_ids: + return False, "" + completed_package_ids.add(package_id) + target = message.get("target") + if isinstance(target, dict) and isinstance(target.get("name"), str): + return True, target["name"] + return True, package_id.rsplit("#", 1)[-1] + + +def cargo_tree(cargo: str) -> list[str]: + return [ + cargo, + "tree", + "-p", + "editor", + "--features", + "dev", + "--edges", + "normal,build", + "--prefix", + "none", + "--format", + "{p}", + "--no-dedupe", + "--color", + "never", + ] + + +def cargo_build(cargo: str) -> list[str]: + return [ + cargo, + "build", + "-p", + "editor", + "--bins", + "--features", + "dev", + "--message-format=json-render-diagnostics", + "--color", + "never", + ] + + +def rendered_lines(message: object, raw_line: str) -> Iterable[str]: + if not isinstance(message, dict): + yield raw_line + return + if message.get("reason") == "compiler-message": + diagnostic = message.get("message") + if isinstance(diagnostic, dict): + rendered = diagnostic.get("rendered") + if isinstance(rendered, str) and rendered: + yield rendered.rstrip("\n") + + +def run(status_path: Path, log_path: Path, cargo: str) -> int: + write_status( + status_path, + "Preparing build", + "Resolving editor crate graph", + 8, + ) + tree = subprocess.run( + cargo_tree(cargo), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + crates = unique_crates(tree.stdout) if tree.returncode == 0 else set() + if not crates: + with log_path.open("a", encoding="utf-8") as log: + log.write(tree.stdout) + log.write(tree.stderr) + write_status( + status_path, + "Build graph failed", + "Open the launch log for Cargo details", + 8, + "error", + ) + return tree.returncode or 1 + + total = len(crates) + write_status( + status_path, + "Compiling project", + "Preparing editor crates", + 12, + crate_count=f"0/{total}", + ) + + process = subprocess.Popen( + cargo_build(cargo), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + assert process.stdout is not None + completed_package_ids: set[str] = set() + with log_path.open("a", encoding="utf-8") as log: + for raw_line in process.stdout: + stripped = raw_line.rstrip("\n") + try: + message: object = json.loads(stripped) + except json.JSONDecodeError: + message = stripped + + for rendered in rendered_lines(message, stripped): + log.write(rendered + "\n") + log.flush() + + changed, crate_name = completed_crate(message, completed_package_ids) + if not changed: + continue + completed = min(len(completed_package_ids), total) + progress = min(78, 12 + (66 * completed // total)) + write_status( + status_path, + "Compiling project", + f"{crate_name} ready", + progress, + crate_count=f"{completed}/{total}", + ) + + return_code = process.wait() + completed = min(len(completed_package_ids), total) + if return_code != 0: + write_status( + status_path, + "Build failed", + "Open the launch log for compiler details", + max(12, min(78, 12 + (66 * completed // total))), + "error", + f"{completed}/{total}", + ) + return return_code + + write_status( + status_path, + "Build complete", + "Preparing engine process", + 78, + crate_count=f"{total}/{total}", + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("status_path", type=Path) + parser.add_argument("log_path", type=Path) + parser.add_argument("--cargo", default="/usr/bin/cargo") + arguments = parser.parse_args() + return run(arguments.status_path, arguments.log_path, arguments.cargo) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/editor/splash.py b/scripts/editor/splash.py new file mode 100755 index 0000000..f042924 --- /dev/null +++ b/scripts/editor/splash.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 + +import pathlib +import subprocess +import sys + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 + + +CSS = """ +window#blacksite-splash { + background: #111418; + color: #edf0f2; + border: 1px solid #4b535c; +} + +.top-rule { background: #d7a83c; min-height: 4px; } +.eyebrow { color: #d7a83c; font-family: monospace; font-size: 11px; font-weight: 700; } +.brand { color: #f4f5f6; font-family: sans-serif; font-size: 48px; font-weight: 800; } +.edition { color: #9da6ae; font-family: monospace; font-size: 12px; } +.monogram { color: #313840; font-family: monospace; font-size: 88px; font-weight: 800; } +.build-chip { + background: #242a30; color: #c7cdd2; border: 1px solid #3e464e; + border-radius: 2px; font-family: monospace; font-size: 10px; padding: 5px 9px; +} +.footer { background: #1a1f24; border-top: 1px solid #353d45; } +.phase { color: #f0f2f4; font-size: 14px; font-weight: 700; } +.detail { color: #9da6ae; font-family: monospace; font-size: 11px; } +.crate-count { + color: #c7cdd2; font-family: monospace; font-size: 12px; font-weight: 700; + min-width: 74px; +} +.percentage { color: #d7a83c; font-family: monospace; font-size: 12px; font-weight: 700; } +progressbar trough { background: #30373e; border: 0; border-radius: 0; min-height: 5px; } +progressbar progress { background: #d7a83c; border: 0; border-radius: 0; min-height: 5px; } +progressbar.error progress { background: #d55b55; } +.error-text { color: #ef8b84; } +button { + background: #293038; color: #eef0f2; border: 1px solid #4a535c; + border-radius: 3px; padding: 6px 14px; font-weight: 600; +} +button:hover { background: #343d45; } +button.suggested-action { background: #b98b2f; color: #101317; border-color: #d7a83c; } +""" + + +class BlacksiteSplash(Gtk.Application): + def __init__(self, status_path: pathlib.Path, log_path: pathlib.Path) -> None: + super().__init__(application_id="com.fallingmetal.BlacksiteSplash") + self.status_path = status_path + self.log_path = log_path + self.last_status = None + + def do_activate(self) -> None: + provider = Gtk.CssProvider() + provider.load_from_data(CSS.encode("utf-8")) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + self.window = Gtk.ApplicationWindow(application=self) + self.window.set_name("blacksite-splash") + self.window.set_title("Blacksite Editor") + self.window.set_decorated(False) + self.window.set_resizable(False) + self.window.set_default_size(760, 410) + + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.window.set_child(root) + top_rule = Gtk.Box() + top_rule.add_css_class("top-rule") + root.append(top_rule) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + content.set_margin_top(34) + content.set_margin_start(44) + content.set_margin_end(44) + content.set_vexpand(True) + root.append(content) + + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + content.append(header) + brand_stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=7) + brand_stack.set_hexpand(True) + header.append(brand_stack) + eyebrow = Gtk.Label(label="FALLING METAL INTERACTIVE") + eyebrow.set_halign(Gtk.Align.START) + eyebrow.add_css_class("eyebrow") + brand_stack.append(eyebrow) + brand = Gtk.Label() + brand.set_markup("BLACKSITE") + brand.set_halign(Gtk.Align.START) + brand.add_css_class("brand") + brand_stack.append(brand) + edition = Gtk.Label(label="PROJECT EDITOR / DEVELOPMENT BUILD") + edition.set_halign(Gtk.Align.START) + edition.add_css_class("edition") + brand_stack.append(edition) + monogram = Gtk.Label(label="B/S") + monogram.set_halign(Gtk.Align.END) + monogram.add_css_class("monogram") + header.append(monogram) + spacer = Gtk.Box() + spacer.set_vexpand(True) + content.append(spacer) + chip = Gtk.Label(label="BEVY ENGINE / EDITOR RUNTIME") + chip.set_halign(Gtk.Align.START) + chip.add_css_class("build-chip") + content.append(chip) + + footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=9) + footer.set_margin_top(25) + footer.set_margin_bottom(26) + footer.set_margin_start(44) + footer.set_margin_end(44) + footer.add_css_class("footer") + root.append(footer) + status_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + status_row.set_margin_top(20) + footer.append(status_row) + labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) + labels.set_hexpand(True) + status_row.append(labels) + self.phase = Gtk.Label(label="Preparing workspace") + self.phase.set_halign(Gtk.Align.START) + self.phase.add_css_class("phase") + labels.append(self.phase) + self.detail = Gtk.Label(label="Reading project configuration") + self.detail.set_halign(Gtk.Align.START) + self.detail.set_ellipsize(3) + self.detail.add_css_class("detail") + labels.append(self.detail) + + self.crate_count = Gtk.Label(label="") + self.crate_count.set_valign(Gtk.Align.CENTER) + self.crate_count.add_css_class("crate-count") + self.crate_count.set_visible(False) + status_row.append(self.crate_count) + self.percentage = Gtk.Label(label="05%") + self.percentage.set_valign(Gtk.Align.CENTER) + self.percentage.add_css_class("percentage") + status_row.append(self.percentage) + self.actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.actions.set_visible(False) + status_row.append(self.actions) + open_log = Gtk.Button(label="Open Log") + open_log.connect("clicked", self.open_log) + self.actions.append(open_log) + close = Gtk.Button(label="Close") + close.add_css_class("suggested-action") + close.connect("clicked", lambda _button: self.quit()) + self.actions.append(close) + self.progress = Gtk.ProgressBar() + self.progress.set_fraction(0.05) + footer.append(self.progress) + GLib.timeout_add(100, self.poll_status) + self.window.present() + + def open_log(self, _button: Gtk.Button) -> None: + if not self.log_path.exists(): + return + try: + subprocess.Popen( + ["xdg-open", str(self.log_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + pass + + def poll_status(self) -> bool: + try: + raw_status = self.status_path.read_text(encoding="utf-8") + except OSError: + return GLib.SOURCE_CONTINUE + if raw_status == self.last_status: + return GLib.SOURCE_CONTINUE + self.last_status = raw_status + lines = raw_status.splitlines() + if len(lines) < 4: + return GLib.SOURCE_CONTINUE + phase, detail, progress_text, state = lines[:4] + crate_count = lines[4] if len(lines) >= 5 else "" + try: + progress = max(0, min(100, int(progress_text))) + except ValueError: + progress = 0 + if state == "close": + self.quit() + return GLib.SOURCE_REMOVE + self.phase.set_label(phase) + self.detail.set_label(detail) + self.progress.set_fraction(progress / 100.0) + self.percentage.set_label(f"{progress:02d}%") + self.crate_count.set_label(f"({crate_count})" if crate_count else "") + self.crate_count.set_visible(bool(crate_count)) + if state == "error": + self.phase.add_css_class("error-text") + self.progress.add_css_class("error") + self.percentage.set_visible(False) + self.actions.set_visible(True) + return GLib.SOURCE_CONTINUE + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: splash.py STATUS_FILE LOG_FILE", file=sys.stderr) + return 2 + app = BlacksiteSplash(pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])) + return app.run([sys.argv[0]]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/editor/test_build_progress.py b/scripts/editor/test_build_progress.py new file mode 100755 index 0000000..7e60286 --- /dev/null +++ b/scripts/editor/test_build_progress.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +import importlib.util +from pathlib import Path +import unittest + + +MODULE_PATH = Path(__file__).with_name("build_progress.py") +SPEC = importlib.util.spec_from_file_location("build_progress", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +BUILD_PROGRESS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BUILD_PROGRESS) + + +class BuildProgressTests(unittest.TestCase): + def test_unique_crates_deduplicates_repeated_tree_entries(self) -> None: + output = "editor v0.1.0\nserde v1.0.0\nserde v1.0.0\n" + self.assertEqual( + BUILD_PROGRESS.unique_crates(output), + {"editor v0.1.0", "serde v1.0.0"}, + ) + + def test_completed_crate_counts_each_package_once(self) -> None: + completed: set[str] = set() + message = { + "reason": "compiler-artifact", + "package_id": "path+file:///workspace#editor@0.1.0", + "target": {"name": "editor"}, + } + self.assertEqual( + BUILD_PROGRESS.completed_crate(message, completed), (True, "editor") + ) + self.assertEqual( + BUILD_PROGRESS.completed_crate(message, completed), (False, "") + ) + + def test_non_artifact_does_not_advance_progress(self) -> None: + completed: set[str] = set() + self.assertEqual( + BUILD_PROGRESS.completed_crate({"reason": "build-script-executed"}, completed), + (False, ""), + ) + self.assertFalse(completed) + + +if __name__ == "__main__": + unittest.main() diff --git a/source_assets/Models/metal_office_desk.bin b/source_assets/Models/metal_office_desk.bin new file mode 100644 index 0000000..9802899 Binary files /dev/null and b/source_assets/Models/metal_office_desk.bin differ diff --git a/source_assets/Models/metal_office_desk_2k.gltf b/source_assets/Models/metal_office_desk_2k.gltf new file mode 100644 index 0000000..3fc15c9 --- /dev/null +++ b/source_assets/Models/metal_office_desk_2k.gltf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4323f2e6cae9abb82146449b45445ee9446068c5a9b2ca23e018503b3b0af035 +size 13510 diff --git a/source_assets/Models/textures/metal_office_desk_arm_2k.jpg b/source_assets/Models/textures/metal_office_desk_arm_2k.jpg new file mode 100644 index 0000000..7986b9c --- /dev/null +++ b/source_assets/Models/textures/metal_office_desk_arm_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c94b99aec8c9b105ff4d843ac41f229b0effb97abe921f0f8b506ccfd76952 +size 2724325 diff --git a/source_assets/Models/textures/metal_office_desk_diff_2k.jpg b/source_assets/Models/textures/metal_office_desk_diff_2k.jpg new file mode 100644 index 0000000..fe6aafc --- /dev/null +++ b/source_assets/Models/textures/metal_office_desk_diff_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5854edc8a7979ed4576b914c184bda64d7e6ee9969357738703be49118da30 +size 1787420 diff --git a/source_assets/Models/textures/metal_office_desk_nor_gl_2k.jpg b/source_assets/Models/textures/metal_office_desk_nor_gl_2k.jpg new file mode 100644 index 0000000..093cd80 --- /dev/null +++ b/source_assets/Models/textures/metal_office_desk_nor_gl_2k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25392e7682c43ca07abbbbe717388b9e6d40bbca663836916204f99ac0378d34 +size 490814 diff --git a/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks.bin b/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks.bin new file mode 100644 index 0000000..f533a26 Binary files /dev/null and b/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks.bin differ diff --git a/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks_4k.gltf b/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks_4k.gltf new file mode 100644 index 0000000..6043300 --- /dev/null +++ b/source_assets/textures/pebble_bricks_4k.gltf/pebble_bricks_4k.gltf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abe2d69811650bf94f75867c72ea7c16734e0d5a6a07a4f36bef1cf347ec9120 +size 2788 diff --git a/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_arm_4k.jpg b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_arm_4k.jpg new file mode 100644 index 0000000..2c8ebc4 --- /dev/null +++ b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_arm_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c918907704ae266e2ccef6784be62e64de67833b3967b8acf41c833fcdc5b15 +size 16434309 diff --git a/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_diff_4k.jpg b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_diff_4k.jpg new file mode 100644 index 0000000..9801ee9 --- /dev/null +++ b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_diff_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38993a7b7efb9c037d12b99bbb464c0b46ab9f1c1d0ab93e5370452b7d61498d +size 14259465 diff --git a/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_nor_gl_4k.jpg b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_nor_gl_4k.jpg new file mode 100644 index 0000000..6c32003 --- /dev/null +++ b/source_assets/textures/pebble_bricks_4k.gltf/textures/pebble_bricks_nor_gl_4k.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:565457e7271e8546d938ec17505f9565a734769023c1cd0438524e727002bbbc +size 20424876 diff --git a/xtask/AGENTS.md b/xtask/AGENTS.md new file mode 100644 index 0000000..ad70ca6 --- /dev/null +++ b/xtask/AGENTS.md @@ -0,0 +1,8 @@ +# xtask subtree rules + +- Packaging, migration, validation, and headless commands must be deterministic and scriptable. +- Packaging is a slice or candidate gate, not an inner-loop command. +- Package tests use compact fixtures and must not require the editor crate. +- Repeated package builds must be idempotent. +- Use the isolated package lane; do not invalidate the fast editor lane. +- Run package-focused tests before any full-workspace gate when packaging changes. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index fcb40df..df85115 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -33,10 +33,16 @@ name = "upgrade-project" path = "src/upgrade_project.rs" required-features = ["validate-levels"] +[[bin]] +name = "process-assets" +path = "src/process_assets.rs" +required-features = ["content-pipeline"] + [dependencies] walkdir = "2.5" scene = { path = "../crates/scene", optional = true } shared = { path = "../crates/shared", optional = true } +content_pipeline = { path = "../crates/content_pipeline", optional = true } settings = { workspace = true, optional = true } serde_json = "1" serde = { version = "1", features = ["derive"] } @@ -48,5 +54,6 @@ libc = "0.2" [features] default = [] -validate-levels = ["dep:scene", "dep:settings", "dep:shared"] +validate-levels = ["dep:scene", "dep:settings", "dep:shared", "dep:content_pipeline"] validate-samples = ["validate-levels"] +content-pipeline = ["dep:content_pipeline", "dep:shared"] diff --git a/xtask/src/clean_target.rs b/xtask/src/clean_target.rs index 775886b..31be03d 100644 --- a/xtask/src/clean_target.rs +++ b/xtask/src/clean_target.rs @@ -1,410 +1,13 @@ -use std::{ - env, fs, io, - path::{Path, PathBuf}, - process::ExitCode, - time::{Duration, SystemTime}, -}; +//! Compatibility entry point for the retired age-based target cleaner. +//! +//! Build artifacts are now managed as whole, sentinel-marked lanes by +//! `scripts/codex/build_storage.py`. Deleting selected entries from Cargo's +//! internal directories is intentionally unsupported. -use walkdir::WalkDir; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Profile { - Debug, - Release, -} - -impl Profile { - fn as_str(self) -> &'static str { - match self { - Profile::Debug => "debug", - Profile::Release => "release", - } - } -} - -#[derive(Debug)] -struct Config { - apply: bool, - days: u64, - target_dir: PathBuf, - profiles: Vec, - include_artifacts: bool, - keep_incremental: bool, - verbose: bool, -} - -#[derive(Debug)] -struct Candidate { - path: PathBuf, - kind: &'static str, - reason: &'static str, - bytes: u64, -} - -fn main() -> ExitCode { - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("error: {error}"); - ExitCode::FAILURE - } - } -} - -fn run() -> Result<(), String> { - let config = parse_args(env::args().skip(1))?; - let cutoff = SystemTime::now() - .checked_sub(Duration::from_secs(config.days * 24 * 60 * 60)) - .ok_or("invalid --days value")?; - let candidates = collect_candidates(&config, cutoff)?; - print_report(&config, &candidates); - - if config.apply { - apply_candidates(&candidates)?; - } - - Ok(()) -} - -fn parse_args(args: impl IntoIterator) -> Result { - let mut config = Config { - apply: false, - days: 7, - target_dir: env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("target")), - profiles: vec![Profile::Debug], - include_artifacts: false, - keep_incremental: false, - verbose: false, - }; - - let mut args = args.into_iter(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--apply" => config.apply = true, - "--include-artifacts" => config.include_artifacts = true, - "--keep-incremental" => config.keep_incremental = true, - "--verbose" => config.verbose = true, - "--days" => { - let value = args - .next() - .ok_or_else(|| "--days requires a number".to_string())?; - config.days = value - .parse() - .map_err(|_| format!("invalid --days value: {value}"))?; - } - "--profile" => { - let value = args - .next() - .ok_or_else(|| "--profile requires debug, release, or all".to_string())?; - config.profiles = match value.as_str() { - "debug" | "dev" => vec![Profile::Debug], - "release" => vec![Profile::Release], - "all" => vec![Profile::Debug, Profile::Release], - _ => return Err(format!("invalid --profile value: {value}")), - }; - } - "--target-dir" => { - config.target_dir = args - .next() - .map(PathBuf::from) - .ok_or_else(|| "--target-dir requires a path".to_string())?; - } - "-h" | "--help" => { - print_help(); - std::process::exit(0); - } - _ => return Err(format!("unknown argument: {arg}")), - } - } - - Ok(config) -} - -fn collect_candidates(config: &Config, cutoff: SystemTime) -> Result, String> { - let mut candidates = Vec::new(); - - if !config.target_dir.exists() { - return Ok(candidates); - } - - collect_flycheck_dirs(config, cutoff, &mut candidates)?; - - for profile in &config.profiles { - let profile_dir = config.target_dir.join(profile.as_str()); - if !profile_dir.exists() { - continue; - } - - if !config.keep_incremental { - let incremental = profile_dir.join("incremental"); - if incremental.exists() { - push_candidate( - &mut candidates, - incremental, - "incremental", - "safe Cargo incremental cache", - )?; - } - } - - if config.include_artifacts { - collect_stale_children( - &profile_dir.join("deps"), - cutoff, - "deps", - "stale hashed dependency artifact", - &mut candidates, - )?; - collect_stale_children( - &profile_dir.join("build"), - cutoff, - "build", - "stale build-script output", - &mut candidates, - )?; - collect_stale_children( - &profile_dir.join(".fingerprint"), - cutoff, - "fingerprint", - "stale Cargo fingerprint", - &mut candidates, - )?; - collect_stale_children( - &profile_dir.join("examples"), - cutoff, - "examples", - "stale example artifact", - &mut candidates, - )?; - } - } - - candidates.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(candidates) -} - -fn collect_flycheck_dirs( - config: &Config, - cutoff: SystemTime, - candidates: &mut Vec, -) -> Result<(), String> { - let entries = fs::read_dir(&config.target_dir) - .map_err(|error| format!("failed to read {}: {error}", config.target_dir.display()))?; - for entry in entries { - let entry = entry.map_err(|error| format!("failed to read target entry: {error}"))?; - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - if file_name.starts_with("flycheck") && is_stale(&entry.path(), cutoff).unwrap_or(false) { - push_candidate( - candidates, - entry.path(), - "flycheck", - "stale rust-analyzer flycheck target dir", - )?; - } - } - Ok(()) -} - -fn collect_stale_children( - dir: &Path, - cutoff: SystemTime, - kind: &'static str, - reason: &'static str, - candidates: &mut Vec, -) -> Result<(), String> { - if !dir.exists() { - return Ok(()); - } - - let entries = - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; - for entry in entries { - let entry = - entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - let path = entry.path(); - if is_stale(&path, cutoff).unwrap_or(false) { - push_candidate(candidates, path, kind, reason)?; - } - } - Ok(()) -} - -fn push_candidate( - candidates: &mut Vec, - path: PathBuf, - kind: &'static str, - reason: &'static str, -) -> Result<(), String> { - let bytes = - path_size(&path).map_err(|error| format!("failed to size {}: {error}", path.display()))?; - candidates.push(Candidate { - path, - kind, - reason, - bytes, - }); - Ok(()) -} - -fn is_stale(path: &Path, cutoff: SystemTime) -> io::Result { - let modified = fs::metadata(path)?.modified()?; - Ok(modified < cutoff) -} - -fn path_size(path: &Path) -> io::Result { - let metadata = fs::symlink_metadata(path)?; - if metadata.is_file() { - return Ok(metadata.len()); - } - - let mut bytes = 0; - for entry in WalkDir::new(path).follow_links(false) { - let entry = entry?; - let metadata = entry.metadata()?; - if metadata.is_file() { - bytes += metadata.len(); - } - } - Ok(bytes) -} - -fn print_report(config: &Config, candidates: &[Candidate]) { - let total: u64 = candidates.iter().map(|candidate| candidate.bytes).sum(); - let mode = if config.apply { "APPLY" } else { "DRY RUN" }; - println!( - "{mode}: {} candidate(s), {} reclaimable in {}", - candidates.len(), - format_bytes(total), - config.target_dir.display() - ); - println!( - "profiles={}, days={}, include_artifacts={}, incremental={}", - config - .profiles - .iter() - .map(|profile| profile.as_str()) - .collect::>() - .join(","), - config.days, - config.include_artifacts, - !config.keep_incremental - ); - - print_kind_summary(candidates); - - let mut sorted: Vec<_> = candidates.iter().collect(); - sorted.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path))); - let limit = if config.verbose { sorted.len() } else { 50 }; - for candidate in sorted.iter().take(limit) { - println!( - "{:>10} {:<12} {} ({})", - format_bytes(candidate.bytes), - candidate.kind, - candidate.path.display(), - candidate.reason - ); - } - if sorted.len() > limit { - println!( - "... {} more candidate(s) omitted; rerun with --verbose to list all.", - sorted.len() - limit - ); - } - - if !config.apply { - println!(); - println!("No files were deleted. Add --apply to delete these candidates."); - if !config.include_artifacts { - println!( - "Add --include-artifacts to also prune stale deps/build/.fingerprint entries." - ); - } - } -} - -fn print_kind_summary(candidates: &[Candidate]) { - let mut rows: Vec<(&str, usize, u64)> = Vec::new(); - for candidate in candidates { - if let Some((_, count, bytes)) = - rows.iter_mut().find(|(kind, _, _)| *kind == candidate.kind) - { - *count += 1; - *bytes += candidate.bytes; - } else { - rows.push((candidate.kind, 1, candidate.bytes)); - } - } - rows.sort_by_key(|row| std::cmp::Reverse(row.2)); - for (kind, count, bytes) in rows { - println!( - "summary: {:<12} {:>5} item(s), {}", - kind, - count, - format_bytes(bytes) - ); - } -} - -fn apply_candidates(candidates: &[Candidate]) -> Result<(), String> { - for candidate in candidates { - let metadata = fs::symlink_metadata(&candidate.path) - .map_err(|error| format!("failed to inspect {}: {error}", candidate.path.display()))?; - if metadata.is_dir() { - fs::remove_dir_all(&candidate.path).map_err(|error| { - format!("failed to remove {}: {error}", candidate.path.display()) - })?; - } else { - fs::remove_file(&candidate.path).map_err(|error| { - format!("failed to remove {}: {error}", candidate.path.display()) - })?; - } - } - Ok(()) -} - -fn format_bytes(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - let mut value = bytes as f64; - let mut unit = UNITS[0]; - for next in &UNITS[1..] { - if value < 1024.0 { - break; - } - value /= 1024.0; - unit = next; - } - if unit == "B" { - format!("{bytes} B") - } else { - format!("{value:.1} {unit}") - } -} - -fn print_help() { - println!( - "\ -Prune Cargo target cache for this workspace. - -USAGE: - cargo run -p xtask --bin clean-target -- [OPTIONS] - -OPTIONS: - --apply Delete candidates. Without this, only prints a dry run. - --days N Staleness cutoff for artifact caches [default: 7]. - --profile PROFILE debug, release, or all [default: debug]. - --target-dir PATH Target directory [default: CARGO_TARGET_DIR or target]. - --include-artifacts Also prune stale deps/build/.fingerprint/example artifacts. - --keep-incremental Do not remove incremental cache. - --verbose List every candidate instead of the largest 50. - -h, --help Print help. - -EXAMPLES: - cargo run -p xtask --bin clean-target -- - cargo run -p xtask --bin clean-target -- --apply - cargo run -p xtask --bin clean-target -- --include-artifacts --days 3 --apply -" +fn main() { + eprintln!( + "cargo clean-target is retired. Use `python scripts/codex/build_storage.py status`, \ + `prune --dry-run`, or `prune --apply`." ); + std::process::exit(2); } diff --git a/xtask/src/package_project.rs b/xtask/src/package_project.rs index 54e9f0f..ca6adc2 100644 --- a/xtask/src/package_project.rs +++ b/xtask/src/package_project.rs @@ -319,6 +319,7 @@ fn run(options: Options) -> Result { } fn validate_release_ready(project_root: &Path, phase: &str) -> Result<(), String> { + validate_generated_content(project_root, phase)?; let validation = scene::validate_project(project_root); if validation.is_release_ready() { return Ok(()); @@ -342,6 +343,99 @@ fn validate_release_ready(project_root: &Path, phase: &str) -> Result<(), String )) } +fn validate_generated_content(project_root: &Path, phase: &str) -> Result<(), String> { + let registry_path = project_root.join(content_pipeline::REGISTRY_PATH); + let source = fs::read_to_string(®istry_path) + .map_err(|error| format!("could not read {}: {error}", registry_path.display()))?; + let loaded = shared::parse_asset_registry(&source)?; + if loaded.migration_required { + return Err(format!( + "asset registry requires explicit migration {phase}; run `cargo upgrade-project --project . --apply`" + )); + } + let mut processed = content_pipeline::scan_project(project_root, &loaded.document)?; + let mut model_plans = Vec::new(); + for record in processed + .registry + .records + .iter_mut() + .filter(|record| record.kind == shared::AssetKind::Model) + { + let plan = content_pipeline::plan_model_artifacts_at(project_root, record)?; + *record = plan.record.clone(); + model_plans.push(plan); + } + let expected_registry = content_pipeline::serialize_registry(&processed.registry)?; + if source.as_bytes() != expected_registry { + return Err(format!( + "asset registry is stale {phase}; run `cargo process-assets --project .`" + )); + } + if model_plans.iter().any(|plan| { + fs::read(&plan.static_path).ok().as_deref() != Some(plan.static_bytes.as_slice()) + || fs::read(&plan.animation_path).ok().as_deref() + != Some(plan.animation_bytes.as_slice()) + }) { + return Err(format!( + "model artifacts are stale {phase}; run `cargo process-assets --project .`" + )); + } + + processed.runtime_catalog = shared::RuntimeContentCatalog::from(&processed.registry); + for record in processed + .registry + .records + .iter() + .filter(|record| record.kind == shared::AssetKind::Texture) + { + let plan = content_pipeline::plan_texture_artifact(project_root, record)?; + if !plan.reused + && fs::read(&plan.output_path).ok().as_deref() != Some(plan.output_bytes.as_slice()) + { + return Err(format!( + "texture artifacts are stale {phase}; run `cargo process-assets --project .`" + )); + } + content_pipeline::apply_texture_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + } + for record in processed.registry.records.iter().filter(|record| { + matches!( + record.kind, + shared::AssetKind::Material | shared::AssetKind::MaterialInstance + ) + }) { + let Some(plan) = + content_pipeline::plan_material_artifact(project_root, record, &processed.registry)? + else { + continue; + }; + if !plan.reused + && fs::read(&plan.output_path).ok().as_deref() != Some(plan.output_bytes.as_slice()) + { + return Err(format!( + "material artifacts are stale {phase}; run `cargo process-assets --project .`" + )); + } + content_pipeline::apply_material_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + } + let catalog_path = project_root.join(content_pipeline::RUNTIME_CATALOG_PATH); + let expected_catalog = content_pipeline::serialize_runtime_catalog(&processed.runtime_catalog)?; + if fs::read(&catalog_path).ok().as_deref() != Some(expected_catalog.as_slice()) { + return Err(format!( + "runtime content catalog is stale {phase}; run `cargo process-assets --project .`" + )); + } + Ok(()) +} + fn load_profile(project_root: &Path, profile_name: &str) -> Result { if profile_name.is_empty() || !profile_name @@ -674,7 +768,14 @@ fn resolve_output(project_root: &Path, output: &str) -> Result let suffix = lexical_output .strip_prefix(existing) .map_err(|error| format!("could not resolve build output suffix: {error}"))?; - let resolved_output = resolved_ancestor.join(suffix); + // Joining an empty suffix can preserve a trailing separator on some platforms. The package + // lock stores this path as its identity, so first publication (output absent) and subsequent + // publication (output present) must resolve to byte-identical strings. + let resolved_output = if suffix.as_os_str().is_empty() { + resolved_ancestor + } else { + resolved_ancestor.join(suffix) + }; if resolved_output == resolved_dist || !resolved_output.starts_with(&resolved_dist) { return Err("build output must resolve inside the project dist directory".into()); } @@ -771,7 +872,7 @@ impl PackageLock { })?; if record.magic != PACKAGE_LOCK_MAGIC || record.schema_version != PACKAGE_SCHEMA_VERSION - || record.output != output_identity + || Path::new(&record.output) != output { return Err(format!( "refusing to overwrite unrecognized package lock {}", @@ -1478,7 +1579,50 @@ fn runtime_asset_sources(project_root: &Path) -> Result, sources.push((relative.to_path_buf(), entry.path().to_path_buf())); } } + let catalog_path = assets.join("content.catalog.ron"); + let catalog_source = fs::read_to_string(&catalog_path) + .map_err(|error| format!("could not read {}: {error}", catalog_path.display()))?; + let catalog: shared::RuntimeContentCatalog = ron::from_str(&catalog_source) + .map_err(|error| format!("invalid runtime content catalog: {error}"))?; + for runtime_path in catalog.records.iter().flat_map(|record| { + record + .texture + .as_ref() + .and_then(|texture| texture.processed_path.as_deref()) + .into_iter() + .chain( + record + .material + .as_ref() + .and_then(|material| material.packed_arm_path.as_deref()), + ) + }) { + let relative = runtime_path + .strip_prefix("assets/") + .ok_or_else(|| format!("runtime artifact is outside assets/: {runtime_path}"))?; + let relative = PathBuf::from(relative); + if !relative.starts_with(".import-cache/runtime") { + return Err(format!( + "runtime artifact is outside managed runtime storage: {runtime_path}" + )); + } + let source = assets.join(&relative); + let metadata = fs::symlink_metadata(&source).map_err(|error| { + format!( + "runtime artifact is missing at {}: {error}", + source.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "runtime artifact must be a regular file: {}", + source.display() + )); + } + sources.push((relative, source)); + } sources.sort_by(|left, right| left.0.cmp(&right.0)); + sources.dedup_by(|left, right| left.0 == right.0); Ok(sources) } @@ -1755,7 +1899,7 @@ mod tests { fs::write(root.join("assets/.index/registry.ron"), "[]").unwrap(); fs::write( root.join("assets/project.ron"), - "(version:1,template_version:1,default_level:\"assets/levels/main.scn.ron\",asset_roots:[\"assets/levels\"],capabilities:[])", + "(version:2,template_version:2,default_level:\"assets/levels/main.scn.ron\",asset_roots:[\"assets\"],capabilities:[])", ) .unwrap(); fs::write( @@ -1884,6 +2028,10 @@ mod tests { })})"#, ) .unwrap(); + let processed = + content_pipeline::scan_project(&root, &shared::AssetRegistryDocument::default()) + .unwrap(); + content_pipeline::publish_content_documents(&root, &processed.registry).unwrap(); let error = run(Options { project_root: root.clone(), profile_name: "release".into(), @@ -1949,6 +2097,19 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn package_output_identity_is_stable_after_output_creation() { + let root = fixture_root(); + fs::create_dir_all(root.join("dist")).unwrap(); + let before = resolve_output(&root, "dist/qa").unwrap(); + fs::create_dir_all(&before).unwrap(); + let after = resolve_output(&root, "dist/qa").unwrap(); + + assert_eq!(before, after); + assert_eq!(before.to_string_lossy(), after.to_string_lossy()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn build_profile_rejects_unsafe_target_triple() { let root = fixture_root(); @@ -2153,6 +2314,28 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn package_lock_accepts_an_equivalent_output_with_a_trailing_separator() { + let root = fixture_root(); + let output = root.join("dist/release"); + fs::create_dir_all(root.join("dist")).unwrap(); + let lock_path = root.join("dist/.blacksite-release.lock"); + let record = PackageLockRecord { + magic: PACKAGE_LOCK_MAGIC.into(), + schema_version: PACKAGE_SCHEMA_VERSION, + output: format!("{}/", output.display()), + pid: 1, + }; + fs::write(&lock_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + PackageLock::acquire(&output).unwrap(); + + let refreshed: PackageLockRecord = + serde_json::from_slice(&fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!(refreshed.output, output.to_string_lossy()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn unrecognized_lock_file_is_preserved_and_rejected() { let root = fixture_root(); diff --git a/xtask/src/process_assets.rs b/xtask/src/process_assets.rs new file mode 100644 index 0000000..10c2ced --- /dev/null +++ b/xtask/src/process_assets.rs @@ -0,0 +1,239 @@ +//! Deterministic, GPU-free content registry and runtime-catalog processor. + +use content_pipeline::{ + scan_project, serialize_registry, serialize_runtime_catalog, write_if_changed, REGISTRY_PATH, + RUNTIME_CATALOG_PATH, +}; +use serde::Serialize; +use shared::{parse_asset_registry, AssetRegistryDocument}; +use std::path::{Path, PathBuf}; + +#[derive(Serialize)] +struct CommandReport { + project: String, + check: bool, + registry_changed: bool, + runtime_catalog_changed: bool, + model_artifacts_changed: usize, + texture_artifacts_changed: usize, + material_artifacts_changed: usize, + processing: content_pipeline::ProcessingReport, +} + +fn main() { + if let Err(error) = run() { + eprintln!("process-assets failed: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut project = PathBuf::from("."); + let mut check = false; + let mut json = false; + let mut arguments = std::env::args().skip(1); + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--project" => { + project = PathBuf::from( + arguments + .next() + .ok_or_else(|| "--project requires a path".to_string())?, + ); + } + "--check" => check = true, + "--json" => json = true, + other => return Err(format!("unknown argument `{other}`")), + } + } + + let project_display = project.to_string_lossy().into_owned(); + let project = std::fs::canonicalize(&project) + .map_err(|error| format!("could not resolve project {project_display}: {error}"))?; + std::env::set_current_dir(&project) + .map_err(|error| format!("could not enter project {}: {error}", project.display()))?; + let project = PathBuf::from("."); + let registry_path = project.join(REGISTRY_PATH); + let previous = load_registry(®istry_path)?; + let mut processed = scan_project(&project, &previous)?; + let mut model_plans = Vec::new(); + for record in processed + .registry + .records + .iter_mut() + .filter(|record| record.kind == shared::AssetKind::Model) + { + let plan = content_pipeline::plan_model_artifacts(record) + .map_err(|error| format!("could not process model {}: {error}", record.path))?; + *record = plan.record.clone(); + model_plans.push(plan); + } + processed.runtime_catalog = shared::RuntimeContentCatalog::from(&processed.registry); + let mut texture_plans = Vec::new(); + for record in processed + .registry + .records + .iter() + .filter(|record| record.kind == shared::AssetKind::Texture) + { + let plan = content_pipeline::plan_texture_artifact(&project, record) + .map_err(|error| format!("could not process Texture {}: {error}", record.path))?; + content_pipeline::apply_texture_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + texture_plans.push(plan); + } + let mut material_plans = Vec::new(); + for record in processed.registry.records.iter().filter(|record| { + matches!( + record.kind, + shared::AssetKind::Material | shared::AssetKind::MaterialInstance + ) + }) { + if let Some(plan) = + content_pipeline::plan_material_artifact(&project, record, &processed.registry)? + { + content_pipeline::apply_material_plan_to_catalog( + &mut processed.runtime_catalog, + &record.id, + &plan, + )?; + material_plans.push(plan); + } + } + processed.report.changed = processed.registry != previous; + let model_artifacts_changed = model_plans + .iter() + .filter(|plan| { + file_differs(&plan.static_path, &plan.static_bytes) + || file_differs(&plan.animation_path, &plan.animation_bytes) + }) + .count(); + let texture_artifacts_changed = texture_plans + .iter() + .filter(|plan| !plan.reused && file_differs(&plan.output_path, &plan.output_bytes)) + .count(); + let material_artifacts_changed = material_plans + .iter() + .filter(|plan| !plan.reused && file_differs(&plan.output_path, &plan.output_bytes)) + .count(); + let registry_bytes = serialize_registry(&processed.registry)?; + let runtime_bytes = serialize_runtime_catalog(&processed.runtime_catalog)?; + let runtime_path = project.join(RUNTIME_CATALOG_PATH); + let registry_changed = file_differs(®istry_path, ®istry_bytes); + let runtime_catalog_changed = file_differs(&runtime_path, &runtime_bytes); + let report = CommandReport { + project: project_display, + check, + registry_changed, + runtime_catalog_changed, + model_artifacts_changed, + texture_artifacts_changed, + material_artifacts_changed, + processing: processed.report, + }; + + if !check { + let mut publication_paths = model_plans + .iter() + .flat_map(|plan| [plan.static_path.clone(), plan.animation_path.clone()]) + .collect::>(); + publication_paths.extend(texture_plans.iter().map(|plan| plan.output_path.clone())); + publication_paths.extend(material_plans.iter().map(|plan| plan.output_path.clone())); + publication_paths.push(registry_path.clone()); + publication_paths.push(runtime_path.clone()); + publication_paths.sort(); + publication_paths.dedup(); + let backups = publication_paths + .iter() + .map(|path| (path.clone(), std::fs::read(path).ok())) + .collect::>(); + let publication = (|| { + for plan in &model_plans { + content_pipeline::publish_model_artifacts(plan)?; + } + for plan in &texture_plans { + content_pipeline::publish_texture_artifact(plan)?; + } + for plan in &material_plans { + content_pipeline::publish_material_artifact(plan)?; + } + write_if_changed(®istry_path, ®istry_bytes)?; + write_if_changed(&runtime_path, &runtime_bytes)?; + Ok::<(), String>(()) + })(); + if let Err(error) = publication { + for (path, bytes) in &backups { + restore_file(path, bytes.as_deref()); + } + return Err(format!("headless content publication rolled back: {error}")); + } + } + if json { + println!( + "{}", + serde_json::to_string_pretty(&report) + .map_err(|error| format!("could not serialize report: {error}"))? + ); + } else { + println!( + "process-assets: discovered={}, added={}, moved={}, removed={}, registry_changed={}, runtime_catalog_changed={}, model_artifacts_changed={}, texture_artifacts_changed={}, material_artifacts_changed={}, mode={}", + report.processing.discovered, + report.processing.added, + report.processing.moved, + report.processing.removed, + registry_changed, + runtime_catalog_changed, + model_artifacts_changed, + texture_artifacts_changed, + material_artifacts_changed, + if check { "check" } else { "write" } + ); + for diagnostic in &report.processing.diagnostics { + eprintln!(" {}: {}", diagnostic.path, diagnostic.message); + } + } + if check + && (registry_changed + || runtime_catalog_changed + || model_artifacts_changed > 0 + || texture_artifacts_changed > 0 + || material_artifacts_changed > 0) + { + return Err( + "generated content state is stale; run `cargo process-assets --project `".into(), + ); + } + Ok(()) +} + +fn load_registry(path: &Path) -> Result { + let source = match std::fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AssetRegistryDocument::default()); + } + Err(error) => return Err(format!("could not read {}: {error}", path.display())), + }; + let loaded = parse_asset_registry(&source)?; + if loaded.migration_required { + return Err( + "asset registry v1 requires `cargo upgrade-project --project --apply`".into(), + ); + } + Ok(loaded.document) +} + +fn file_differs(path: &Path, expected: &[u8]) -> bool { + std::fs::read(path).map_or(true, |current| current != expected) +} + +fn restore_file(path: &Path, bytes: Option<&[u8]>) { + if let Some(bytes) = bytes { + let _ = std::fs::write(path, bytes); + } else { + let _ = std::fs::remove_file(path); + } +}