Compare commits
No commits in common. "634ec16c5e627d598aaccf130974224ba76b0e13" and "cf6eaba53473d2825d0109f5a18f44794af5bd48" have entirely different histories.
634ec16c5e
...
cf6eaba534
@ -7,8 +7,6 @@ name:
|
|||||||
purpose:
|
purpose:
|
||||||
preconditions:
|
preconditions:
|
||||||
build_lane:
|
build_lane:
|
||||||
package:
|
|
||||||
binary:
|
|
||||||
fixture:
|
fixture:
|
||||||
steps:
|
steps:
|
||||||
assertions:
|
assertions:
|
||||||
@ -17,9 +15,6 @@ cleanup:
|
|||||||
invalidates:
|
invalidates:
|
||||||
```
|
```
|
||||||
|
|
||||||
`package` and `binary` are optional and default to `editor`. Focused tools such as the UI gallery
|
|
||||||
can therefore use the same exact-window evidence and cleanup workflow without launching a project.
|
|
||||||
|
|
||||||
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.
|
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:
|
The runner exposes the scenario instead of pretending manual interactions are automated:
|
||||||
|
|||||||
@ -1,30 +1,20 @@
|
|||||||
name: content-browser-card-layout
|
name: content-browser-card-layout
|
||||||
purpose: Verify that the production Content Browser uses the shared Penpot body presentation at wide and compact sizes.
|
purpose: Verify production-quality grid/list card layout and model-thumbnail identity at wide and narrow browser sizes.
|
||||||
preconditions: The focused shared-UI checks pass and the project Content Browser contains folders, long asset names, and the Poly Haven desk.
|
preconditions: The running editor has the project Content Browser available with long asset names and the Poly Haven desk imported.
|
||||||
build_lane: dev
|
build_lane: dev
|
||||||
fixture:
|
fixture:
|
||||||
steps:
|
steps:
|
||||||
- Open the Content Browser grid at the project assets root with Sources and Details visible.
|
- Open the Content Browser grid and navigate to the Office model folder.
|
||||||
- Confirm folders and assets form one continuous row-major stream under the 56 px content heading.
|
|
||||||
- Navigate to the Office model folder.
|
|
||||||
- Inspect long Material, Texture, and model names with clean and dirty status states.
|
- Inspect long Material, Texture, and model names with clean and dirty status states.
|
||||||
- At an expanded width, confirm Name sort and the thumbnail-size slider are visible in the toolbar.
|
|
||||||
- Sweep the thumbnail-size slider through its minimum, default, and maximum positions.
|
|
||||||
- Resize the Content Browser and Details splitter through narrow and wide layouts.
|
- Resize the Content Browser and Details splitter through narrow and wide layouts.
|
||||||
- Switch to list view and compare status markers and column alignment.
|
- Switch to list view and compare status markers and column alignment.
|
||||||
- Inspect the Poly Haven metal office desk card and one mesh-subasset preview.
|
- Inspect the Poly Haven metal office desk card and one mesh-subasset preview.
|
||||||
assertions:
|
assertions:
|
||||||
- Toolbar, body, and footer form one continuous stack with no unowned horizontal gutter.
|
- Grid labels stay inside clipped two-line footers with ellipsis and no status overlap.
|
||||||
- Folders and assets use the same bounded card footprint and assets fill the remaining cells of a folder row.
|
|
||||||
- Grid cards show one clipped and ellipsized name plus a separate kind line with no status overlap.
|
|
||||||
- Every thumbnail scale preserves the complete name/kind footer and square image aspect; the minimum card never paints its preview through the footer.
|
|
||||||
- Sort and thumbnail size are first-class controls at expanded width, remain in overflow below the expanded breakpoint, and never appear twice.
|
|
||||||
- Every asset category keeps its distinct bottom-card keyline across placeholder, pending, ready, failed, hovered, and selected states; folders remain neutral.
|
|
||||||
- Dirty, processing, and error state use the fixed top-right status rail rather than text over the label.
|
- Dirty, processing, and error state use the fixed top-right status rail rather than text over the label.
|
||||||
- Sources labels and Details identity, paths, dependencies, and status remain clipped to their owning panes.
|
|
||||||
- List status markers occupy fixed space and do not shift or truncate neighboring columns unpredictably.
|
- 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.
|
- 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.
|
- The office desk and mesh-subasset thumbnails show rendered geometry, never an albedo texture substitute.
|
||||||
evidence: Target-window captures for root-grid wide, Office-grid wide, compact grid, list, and desk geometry states plus hashes.
|
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.
|
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.
|
invalidates: Asset-card, Content Browser layout, status model, or thumbnail-routing changes.
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
name: penpot-material-inspector
|
name: penpot-material-inspector
|
||||||
purpose: Verify the current Penpot Materials list and material-slot panel at its 620 px and 420 px Inspector reference widths with side-by-side design evidence.
|
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.
|
preconditions: editor_scene contains primitive, static-mesh, and skinned-mesh material users; no editor process is running.
|
||||||
build_lane: dev
|
build_lane: dev
|
||||||
fixture: assets/materials/pebble_bricks.ron
|
fixture: assets/materials/pebble_bricks.ron
|
||||||
steps:
|
steps:
|
||||||
- Launch the editor on the real desktop and size the Inspector to the 620 px reference width.
|
- 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.
|
- 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.
|
- 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.
|
- Narrow the Inspector to the 420 px floor and verify every value, channel, texture, Locate, and Clear control reflows.
|
||||||
@ -14,7 +14,7 @@ steps:
|
|||||||
- Edit a scalar and color, release the controls, switch actors, then save and relaunch the editor.
|
- 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.
|
- Inspect imported-source, DefaultGrid, and broken-reference presentations through the available fixture states.
|
||||||
assertions:
|
assertions:
|
||||||
- The 620 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 596 px slot, 569 px parameter body, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift.
|
- 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.
|
- 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.
|
- 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.
|
- Direct assignments have no Explicit badge; inherited, dirty, processing, failed, read-only, imported-source, and built-in states retain one header geometry.
|
||||||
@ -22,6 +22,6 @@ assertions:
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- Save and relaunch preserve the authored value, while broken explicit references retain failed identity and visible DefaultGrid fallback.
|
||||||
evidence: Font-faithful Penpot PNGs paired beside fresh target-window captures for 620 px primitive/static/skinned, 420 px reflow, component headers, renderer composition, color modes, invalid drop, dirty state, and relaunch plus hashes and a written delta matrix.
|
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.
|
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.
|
invalidates: Design tokens, fonts, material panel geometry, material picker, color picker, authored documents, or material resolver changes.
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
name: ui-gallery-framework
|
|
||||||
purpose: Prove the lightweight production-component gallery launches independently and exposes the Penpot comparison workflow.
|
|
||||||
preconditions: The focused ui_gallery build and workflow tests pass in the managed dev lane.
|
|
||||||
build_lane: dev
|
|
||||||
package: ui_gallery
|
|
||||||
binary: ui_gallery
|
|
||||||
fixture:
|
|
||||||
steps:
|
|
||||||
- Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime.
|
|
||||||
- Inspect the default Content Browser 1240 side-by-side reference and production component canvases.
|
|
||||||
- Switch through Content Browser 620, Materials 620, Materials 420, Renderer 760, and Renderer 480 presets.
|
|
||||||
- Exercise Live, Overlay, and Difference modes and inspect the reported difference statistics.
|
|
||||||
- Switch through direct, inherited, imported, dirty, processing, failed, read-only, and built-in fixtures.
|
|
||||||
assertions:
|
|
||||||
- The gallery opens as one responsive native window without launching the Blacksite editor or game runtime.
|
|
||||||
- All six exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome.
|
|
||||||
- Content Browser 1240 and Content Browser 620 each report fewer than 15 percent changed pixels in Difference mode.
|
|
||||||
- Side-by-side, overlay, and difference modes remain interactive and Difference reports numeric delta statistics.
|
|
||||||
- Representative material ownership and health fixtures preserve the same shared panel geometry.
|
|
||||||
- Theme and material fixture JSON changes appear without rebuilding or restarting the gallery.
|
|
||||||
evidence: Exact ui_gallery window capture plus the hashed native-QA record.
|
|
||||||
cleanup: Close the runner-owned ui_gallery process; no project fixture is mutated.
|
|
||||||
invalidates: editor_ui, material_schema, ui_gallery, Penpot references, gallery fixtures, or gallery workflow scripts change.
|
|
||||||
@ -38,7 +38,7 @@ design-system reference. Penpot owns geometry, typography, and interaction-state
|
|||||||
Blacksite's editor-owned semantic palette owns color and is published into Penpot Assets / Colors.
|
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
|
The current palette is the default, with preference selection deferred behind the same palette
|
||||||
seam. Shared semantic tokens, Source Sans Pro typography, action-returning
|
seam. Shared semantic tokens, Source Sans Pro typography, action-returning
|
||||||
material panels, responsive property grids, and custom asset/color pickers must match the 620 px
|
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.
|
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
|
Runtime material authority and explicit-save behavior remain
|
||||||
owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice.
|
owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice.
|
||||||
@ -100,10 +100,9 @@ loader.
|
|||||||
- [Implemented 2026-07-14] Typed thumbnail keys separate textures, models, mesh subassets, and
|
- [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
|
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.
|
exact mesh subasset; the Poly Haven metal office desk regression is unit-covered.
|
||||||
- [Implemented 2026-07-19] Typed texture/model/mesh/material previews persist as content-addressed
|
- [Implemented 2026-07-15] Thumbnail cache replacement, retry, and catalog invalidation release the
|
||||||
256 px artifacts under the managed `.thumbnails` cache. Visual source/dependency hashes and a
|
last Egui image registration for each key. Repeated studio renders no longer retain hidden GPU
|
||||||
renderer version invalidate only affected entries; catalog refresh retains last-good GPU images,
|
render targets after the visible cache entry is gone.
|
||||||
corrupt artifacts self-heal, studio retries are bounded, and manual regeneration evicts one key.
|
|
||||||
- [Implemented 2026-07-14] Asset-keyed authored documents separate live Material/Instance and
|
- [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,
|
import-setting edits from explicit source publication. Context Save, Save All, recovery,
|
||||||
conflict state, separate Git/processing badges, and revisioned background processing remove
|
conflict state, separate Git/processing badges, and revisioned background processing remove
|
||||||
@ -167,29 +166,6 @@ loader.
|
|||||||
Inspector. The new wide/narrow Material canvases are 596/396 px with 569/369 px parameter
|
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
|
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.
|
blocked until refreshed engineering and Codex-owned native evidence pass on one exact tree.
|
||||||
- [Engineering-complete; acceptance in progress 2026-07-18] A lightweight `ui_gallery` now renders
|
|
||||||
the same `editor_ui` production components without project/scene/renderer/gameplay startup. It
|
|
||||||
provides exact Materials 620/420 and Renderer 760/480 presets, direct Penpot side-by-side,
|
|
||||||
overlay, and Difference modes, all material ownership/health fixtures, runtime token/fixture
|
|
||||||
reload, and a managed last-good rebuild/restart loop with bounded dependency progress. Focused
|
|
||||||
tests, strict lint, and architecture audit pass; paired full-editor evidence remains required.
|
|
||||||
- [Acceptance-in-progress 2026-07-18] The current Page 03 Content Browser boards supersede the earlier
|
|
||||||
Asset Browser chrome. Production maps the standalone 36 px title row to the existing dock tab,
|
|
||||||
owns an 88 px deterministic toolbar, uses 216/290 px wide Sources/Details panes, and exposes
|
|
||||||
compact Sources/Details drawers alongside preserved Search, Grid/List, Import, New, and overflow.
|
|
||||||
Engineering verification, paired wide/compact native captures, Sources/Details interaction, and
|
|
||||||
repeated 1200×760 through 1800×1100 resize stress pass. Exact candidate publication remains
|
|
||||||
pending.
|
|
||||||
- [Corrective implementation 2026-07-19; production acceptance pending] Content Browser Gallery
|
|
||||||
evidence exposed a split renderer: only toolbar/status and item ordering were shared, while the
|
|
||||||
fixture hand-painted Sources, cards, content heading, and Details. Those body presentations now
|
|
||||||
live in `editor_ui::content_browser` and are invoked by both Gallery fixtures and production state.
|
|
||||||
Production also removes the unowned toolbar/body/footer gutter, clips every exact panel region,
|
|
||||||
uses one bounded name plus kind per card, and ellipsizes Details values. The shared implementation
|
|
||||||
now also owns the Penpot panel fill/border, 16+6 px grid surface/card inset, card preview and status
|
|
||||||
rail coordinates, and the fixed Details heading/identity stack; production no longer inserts a
|
|
||||||
private spacer before Details. The prior Gallery-only pixel score is invalid as production
|
|
||||||
evidence until a refreshed full-editor capture passes.
|
|
||||||
|
|
||||||
## Target Contracts
|
## Target Contracts
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,7 +17,6 @@
|
|||||||
# Editor/runtime generated session state
|
# Editor/runtime generated session state
|
||||||
/assets/levels/.pie_session.scn.ron
|
/assets/levels/.pie_session.scn.ron
|
||||||
/assets/.trash/
|
/assets/.trash/
|
||||||
/assets/.thumbnails/
|
|
||||||
/.blacksite/backups/
|
/.blacksite/backups/
|
||||||
|
|
||||||
# Backup, temporary, and log files
|
# Backup, temporary, and log files
|
||||||
|
|||||||
242
Cargo.lock
generated
242
Cargo.lock
generated
@ -2564,15 +2564,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cgl"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chiapos-chacha8"
|
name = "chiapos-chacha8"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -3235,14 +3226,12 @@ dependencies = [
|
|||||||
"blacksite_surface",
|
"blacksite_surface",
|
||||||
"blake3",
|
"blake3",
|
||||||
"content_pipeline",
|
"content_pipeline",
|
||||||
"editor_ui",
|
|
||||||
"egui_dock",
|
"egui_dock",
|
||||||
"egui_phosphor_icons",
|
"egui_phosphor_icons",
|
||||||
"game",
|
"game",
|
||||||
"game_hot",
|
"game_hot",
|
||||||
"gltf",
|
"gltf",
|
||||||
"hot-lib-reloader",
|
"hot-lib-reloader",
|
||||||
"image",
|
|
||||||
"notify",
|
"notify",
|
||||||
"polyanya",
|
"polyanya",
|
||||||
"protocol",
|
"protocol",
|
||||||
@ -3260,51 +3249,6 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "editor_ui"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_math",
|
|
||||||
"egui",
|
|
||||||
"egui_phosphor_icons",
|
|
||||||
"material_schema",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "eframe"
|
|
||||||
version = "0.34.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f98fe83b2589105b69dd25ca1e0fa2135a6e864d502fd8e08978f937e128cfef"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"bytemuck",
|
|
||||||
"document-features",
|
|
||||||
"egui",
|
|
||||||
"egui-wgpu",
|
|
||||||
"egui-winit",
|
|
||||||
"egui_glow",
|
|
||||||
"glow",
|
|
||||||
"glutin",
|
|
||||||
"glutin-winit",
|
|
||||||
"image",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"objc2 0.6.4",
|
|
||||||
"objc2-app-kit 0.3.2",
|
|
||||||
"objc2-foundation 0.3.2",
|
|
||||||
"parking_lot",
|
|
||||||
"percent-encoding",
|
|
||||||
"profiling",
|
|
||||||
"raw-window-handle",
|
|
||||||
"static_assertions",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"web-time",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
"winit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui"
|
name = "egui"
|
||||||
version = "0.34.3"
|
version = "0.34.3"
|
||||||
@ -3324,47 +3268,6 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "egui-wgpu"
|
|
||||||
version = "0.34.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9f0c0559ac5598a1b887a6206dccbab7e3e6246c57cb00ae287262bd44776c9c"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"bytemuck",
|
|
||||||
"document-features",
|
|
||||||
"egui",
|
|
||||||
"epaint",
|
|
||||||
"log",
|
|
||||||
"profiling",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"type-map",
|
|
||||||
"web-time",
|
|
||||||
"wgpu",
|
|
||||||
"winit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "egui-winit"
|
|
||||||
version = "0.34.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "967c5b323625d46d46a59b5daba3fef742248d27693cc18972458619858c4239"
|
|
||||||
dependencies = [
|
|
||||||
"arboard",
|
|
||||||
"bytemuck",
|
|
||||||
"egui",
|
|
||||||
"log",
|
|
||||||
"objc2 0.6.4",
|
|
||||||
"objc2-foundation 0.3.2",
|
|
||||||
"objc2-ui-kit 0.3.2",
|
|
||||||
"profiling",
|
|
||||||
"raw-window-handle",
|
|
||||||
"smithay-clipboard",
|
|
||||||
"web-time",
|
|
||||||
"webbrowser",
|
|
||||||
"winit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_dock"
|
name = "egui_dock"
|
||||||
version = "0.19.1"
|
version = "0.19.1"
|
||||||
@ -3378,23 +3281,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "egui_glow"
|
|
||||||
version = "0.34.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62b652957fa7e1ab01e8fecbfbf4e35f6e43a53fa98af8a562b50d5403cd44b9"
|
|
||||||
dependencies = [
|
|
||||||
"bytemuck",
|
|
||||||
"egui",
|
|
||||||
"glow",
|
|
||||||
"log",
|
|
||||||
"memoffset",
|
|
||||||
"profiling",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
"winit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_phosphor_icons"
|
name = "egui_phosphor_icons"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@ -4167,63 +4053,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glutin"
|
|
||||||
version = "0.32.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"cfg_aliases",
|
|
||||||
"cgl",
|
|
||||||
"dispatch2",
|
|
||||||
"glutin_egl_sys",
|
|
||||||
"glutin_glx_sys",
|
|
||||||
"glutin_wgl_sys",
|
|
||||||
"libloading",
|
|
||||||
"objc2 0.6.4",
|
|
||||||
"objc2-app-kit 0.3.2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-foundation 0.3.2",
|
|
||||||
"once_cell",
|
|
||||||
"raw-window-handle",
|
|
||||||
"wayland-sys",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
"x11-dl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glutin-winit"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"glutin",
|
|
||||||
"raw-window-handle",
|
|
||||||
"winit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glutin_egl_sys"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
|
|
||||||
dependencies = [
|
|
||||||
"gl_generator",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glutin_glx_sys"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
|
|
||||||
dependencies = [
|
|
||||||
"gl_generator",
|
|
||||||
"x11-dl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glutin_wgl_sys"
|
name = "glutin_wgl_sys"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@ -5189,16 +5018,6 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "material_schema"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"bevy_color",
|
|
||||||
"bevy_math",
|
|
||||||
"bevy_reflect 0.19.0",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -5224,15 +5043,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memoffset"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@ -5298,7 +5108,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pp-rs",
|
"pp-rs",
|
||||||
"rustc-hash 1.1.0",
|
"rustc-hash",
|
||||||
"spirv",
|
"spirv",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
@ -5315,7 +5125,7 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
"naga",
|
"naga",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash 1.1.0",
|
"rustc-hash",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
@ -5614,7 +5424,6 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
]
|
]
|
||||||
@ -5900,18 +5709,6 @@ dependencies = [
|
|||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-ui-kit"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"objc2 0.6.4",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-foundation 0.3.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-uniform-type-identifiers"
|
name = "objc2-uniform-type-identifiers"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -6959,12 +6756,6 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -7169,7 +6960,6 @@ dependencies = [
|
|||||||
"avian3d",
|
"avian3d",
|
||||||
"bevy",
|
"bevy",
|
||||||
"blake3",
|
"blake3",
|
||||||
"material_schema",
|
|
||||||
"ron 0.8.1",
|
"ron 0.8.1",
|
||||||
"serde",
|
"serde",
|
||||||
"settings",
|
"settings",
|
||||||
@ -7909,15 +7699,6 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "type-map"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
|
|
||||||
dependencies = [
|
|
||||||
"rustc-hash 2.1.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@ -7939,20 +7720,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ui_gallery"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"editor_ui",
|
|
||||||
"eframe",
|
|
||||||
"egui",
|
|
||||||
"egui_phosphor_icons",
|
|
||||||
"image",
|
|
||||||
"material_schema",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@ -8385,7 +8152,6 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"wgpu-core",
|
"wgpu-core",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
@ -8414,7 +8180,7 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"profiling",
|
"profiling",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"rustc-hash 1.1.0",
|
"rustc-hash",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu-core-deps-apple",
|
"wgpu-core-deps-apple",
|
||||||
@ -9020,7 +8786,7 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-ui-kit 0.2.2",
|
"objc2-ui-kit",
|
||||||
"orbclient",
|
"orbclient",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
|
|||||||
@ -2,13 +2,10 @@
|
|||||||
members = [
|
members = [
|
||||||
"crates/blacksite_surface",
|
"crates/blacksite_surface",
|
||||||
"crates/content_pipeline",
|
"crates/content_pipeline",
|
||||||
"crates/editor_ui",
|
|
||||||
"crates/game",
|
"crates/game",
|
||||||
"crates/game_hot",
|
"crates/game_hot",
|
||||||
"crates/material_schema",
|
|
||||||
"crates/shared",
|
"crates/shared",
|
||||||
"crates/editor",
|
"crates/editor",
|
||||||
"crates/ui_gallery",
|
|
||||||
"crates/protocol",
|
"crates/protocol",
|
||||||
"crates/sim",
|
"crates/sim",
|
||||||
"crates/settings",
|
"crates/settings",
|
||||||
@ -47,13 +44,9 @@ blacksite_surface = { path = "crates/blacksite_surface" }
|
|||||||
content_pipeline = { path = "crates/content_pipeline" }
|
content_pipeline = { path = "crates/content_pipeline" }
|
||||||
bevy_ufbx = "0.18.1-rc.1"
|
bevy_ufbx = "0.18.1-rc.1"
|
||||||
bevy_egui = "0.40"
|
bevy_egui = "0.40"
|
||||||
egui = "0.34.3"
|
|
||||||
eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
|
||||||
bevy-inspector-egui = "0.37"
|
bevy-inspector-egui = "0.37"
|
||||||
egui_dock = { version = "0.19.1", features = ["serde"] }
|
egui_dock = { version = "0.19.1", features = ["serde"] }
|
||||||
egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold", "fill"] }
|
egui_phosphor_icons = { version = "0.3.1", default-features = false }
|
||||||
editor_ui = { path = "crates/editor_ui" }
|
|
||||||
material_schema = { path = "crates/material_schema" }
|
|
||||||
transform-gizmo-bevy = "0.9"
|
transform-gizmo-bevy = "0.9"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] }
|
nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] }
|
||||||
|
|||||||
39
README.md
39
README.md
@ -89,15 +89,10 @@ cargo run -p editor --bin project_launcher --features dev
|
|||||||
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
|
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 /
|
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
|
Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor
|
||||||
process with that root. A normal desktop click incrementally builds the editor and project browser
|
process with that root. A normal desktop click launches the current managed development binary
|
||||||
through Blacksite's persistent managed development lane before launching, so saved Rust changes are
|
without invoking Cargo. This keeps editor startup deterministic and prevents build-planning work
|
||||||
always represented by the opened binary. The splash reports exact resolved-crate progress while
|
from consuming memory in the desktop session. When an explicit rebuild is required, run
|
||||||
Cargo reuses warm artifacts; a build or storage-preflight failure leaves the splash open with an
|
`~/.local/bin/blacksite-editor --build-only`; that command uses Blacksite's managed Cargo lane.
|
||||||
actionable log. Run `~/.local/bin/blacksite-editor --build-only` to perform the same managed update
|
|
||||||
without opening the editor. Clicking the shortcut while an editor is open still performs the update
|
|
||||||
check. If the executable changed, Blacksite protects unsaved work by focusing the running process
|
|
||||||
and showing an explicit close-and-reopen notification instead of terminating it or pretending that
|
|
||||||
it has hot-swapped to the new binary.
|
|
||||||
|
|
||||||
### Hot reload (gameplay iteration)
|
### Hot reload (gameplay iteration)
|
||||||
|
|
||||||
@ -131,23 +126,6 @@ workflow never installs it implicitly.
|
|||||||
|
|
||||||
> Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times.
|
> Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times.
|
||||||
|
|
||||||
### Focused editor UI iteration
|
|
||||||
|
|
||||||
Use the lightweight gallery when changing shared Inspector/design-system components instead of
|
|
||||||
restarting the project editor for every visual edit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/editor/ui_gallery.py watch
|
|
||||||
```
|
|
||||||
|
|
||||||
The gallery renders the production `editor_ui` components without loading a project, scene,
|
|
||||||
renderer, or gameplay runtime. It includes exact Materials 620/420 and Renderer 760/480 presets,
|
|
||||||
direct Penpot side-by-side/overlay/difference views, representative material states, and live
|
|
||||||
theme/material fixture reload. Rust edits rebuild the focused dependency closure with a bounded
|
|
||||||
`resolved/rebuilt` counter; the last-good gallery stays open unless its replacement launches
|
|
||||||
successfully. See the [Editor Design System](docs/editor/design-system.md#focused-ui-gallery-workflow)
|
|
||||||
for shortcuts, fixtures, and acceptance rules.
|
|
||||||
|
|
||||||
### Target Cache Cleanup
|
### Target Cache Cleanup
|
||||||
|
|
||||||
Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup
|
Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup
|
||||||
@ -216,9 +194,9 @@ and remove only an entire verified disposable lane. See the
|
|||||||
| Viewport selection/orientation HUD | Identify the primary selection, multi-selection count, overlapping-pick position, camera axes, shading mode, and active render path |
|
| 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`) |
|
| 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 |
|
| Select Project Sun | Inspect project default lighting; create a scene sun override |
|
||||||
| Content Browser project/file views | Browse `assets/` through a dense file-manager workspace: wide layouts keep navigation, breadcrumbs, import/new, search/filter, and view controls in one 55 px row; expanded widths also expose sort and thumbnail scale directly, while compact layouts use an 88 px two-row toolbar with Sources/Details drawers; folders and assets share one continuous card flow; search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; resize the wide 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 |
|
| 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 |
|
| Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead |
|
||||||
| Content 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 |
|
| 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 |
|
| 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 |
|
| 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 primitive/static/skinned slot or brush face under the pointer; release commits one undo step, while Escape/right-click/outside restores the preview |
|
| 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 |
|
||||||
@ -539,7 +517,6 @@ crates/
|
|||||||
- [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] 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))
|
- [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))
|
- [ ] 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))
|
||||||
- [ ] Penpot-led Content Browser wide/compact composition, dock-header ownership, responsive Sources/Details access, and catalog-processing status pass current Page 03 native comparison and resize stress; exact candidate publication remains pending ([content workspace guide](docs/editor/content-workspace.md), [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] 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 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] 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))
|
||||||
@ -563,9 +540,7 @@ crates/
|
|||||||
- Crouch lowers the camera and movement speed; the collider stays full-height for stability.
|
- 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,
|
- The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views,
|
||||||
direct texture thumbnails, offscreen geometry renders for all models and mesh subassets,
|
direct texture thumbnails, offscreen geometry renders for all models and mesh subassets,
|
||||||
material sphere thumbnails, and a managed content-addressed 256 px disk cache that reuses valid
|
material sphere thumbnails, search/filter/sort controls, expandable model
|
||||||
previews across editor sessions while restoring the last-good image after a restart during
|
|
||||||
dependency-driven regeneration or source failure. It also provides search/filter/sort controls, expandable model
|
|
||||||
subasset shelves, and a staged details pane. **File -> Import Assets**
|
subasset shelves, and a staged details pane. **File -> Import Assets**
|
||||||
accepts glTF/GLB and binary **FBX**. FBX import parses and transactionally preserves every safe
|
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
|
referenced sibling `textures/` or `.fbm/` file while rejecting traversal and external absolute
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
r: 1.0,
|
r: 1.0,
|
||||||
g: 1.0,
|
g: 1.0,
|
||||||
b: 1.0,
|
b: 1.0,
|
||||||
a: 0.0,
|
a: 1.0,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]),
|
"shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]),
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
4294966520: (components: {
|
4294966529: (components: {
|
||||||
"bevy_ecs::name::Name": "Brush Part 2",
|
"bevy_ecs::name::Name": "Brush Part 2",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (25.75, 0.0, 9.5),
|
translation: (25.75, 0.0, 9.5),
|
||||||
@ -170,7 +170,7 @@
|
|||||||
uv_tiling: (1.0, 1.0),
|
uv_tiling: (1.0, 1.0),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966521: (components: {
|
4294966530: (components: {
|
||||||
"bevy_ecs::name::Name": "Brush Part 1",
|
"bevy_ecs::name::Name": "Brush Part 1",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (24.5, 0.0, 7.75),
|
translation: (24.5, 0.0, 7.75),
|
||||||
@ -338,7 +338,7 @@
|
|||||||
uv_tiling: (1.0, 1.0),
|
uv_tiling: (1.0, 1.0),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966522: (components: {
|
4294966531: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate Copy",
|
"bevy_ecs::name::Name": "Crate Copy",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (15.624287, 0.75, 1.8058548),
|
translation: (15.624287, 0.75, 1.8058548),
|
||||||
@ -506,7 +506,7 @@
|
|||||||
uv_tiling: (1.0, 1.0),
|
uv_tiling: (1.0, 1.0),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966523: (components: {
|
4294966532: (components: {
|
||||||
"bevy_ecs::name::Name": "Post Process Volume",
|
"bevy_ecs::name::Name": "Post Process Volume",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (28.0, 2.0, -1.0),
|
translation: (28.0, 2.0, -1.0),
|
||||||
@ -661,7 +661,7 @@
|
|||||||
label: None,
|
label: None,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966524: (components: {
|
4294966533: (components: {
|
||||||
"bevy_ecs::name::Name": "Pillar",
|
"bevy_ecs::name::Name": "Pillar",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-8.0, 3.5, -6.0),
|
translation: (-8.0, 3.5, -6.0),
|
||||||
@ -697,7 +697,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966525: (components: {
|
4294966534: (components: {
|
||||||
"bevy_ecs::name::Name": "Pillar",
|
"bevy_ecs::name::Name": "Pillar",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (8.0, 3.5, -6.0),
|
translation: (8.0, 3.5, -6.0),
|
||||||
@ -733,7 +733,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966526: (components: {
|
4294966535: (components: {
|
||||||
"bevy_ecs::name::Name": "Pillar",
|
"bevy_ecs::name::Name": "Pillar",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-8.0, 3.5, 6.0),
|
translation: (-8.0, 3.5, 6.0),
|
||||||
@ -769,7 +769,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966527: (components: {
|
4294966536: (components: {
|
||||||
"bevy_ecs::name::Name": "Pillar",
|
"bevy_ecs::name::Name": "Pillar",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (8.0, 3.5, 6.0),
|
translation: (8.0, 3.5, 6.0),
|
||||||
@ -805,10 +805,10 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966528: (components: {
|
4294966537: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate",
|
"bevy_ecs::name::Name": "Crate",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-2.8005953, 0.3732614, 0.21393776),
|
translation: (-0.8005953, 0.3732614, 0.21393776),
|
||||||
rotation: (0.0, 0.86742324, 0.0, -0.49757105),
|
rotation: (0.0, 0.86742324, 0.0, -0.49757105),
|
||||||
scale: (1.0, 1.0, 1.0),
|
scale: (1.0, 1.0, 1.0),
|
||||||
),
|
),
|
||||||
@ -841,10 +841,10 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966529: (components: {
|
4294966538: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate",
|
"bevy_ecs::name::Name": "Crate",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (2.5994043, 0.3732614, 0.013936996),
|
translation: (0.59940434, 0.3732614, 0.013936996),
|
||||||
rotation: (0.0, -0.53118616, 0.0, 0.8472551),
|
rotation: (0.0, -0.53118616, 0.0, 0.8472551),
|
||||||
scale: (1.0, 1.0, 1.0),
|
scale: (1.0, 1.0, 1.0),
|
||||||
),
|
),
|
||||||
@ -877,10 +877,10 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966530: (components: {
|
4294966539: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate",
|
"bevy_ecs::name::Name": "Crate",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (2.899404, 1.3955705, 0.11393738),
|
translation: (-0.10059604, 1.3955705, 0.11393738),
|
||||||
rotation: (0.0, 0.72083056, 0.0, -0.6931113),
|
rotation: (0.0, 0.72083056, 0.0, -0.6931113),
|
||||||
scale: (1.0, 1.0, 1.0),
|
scale: (1.0, 1.0, 1.0),
|
||||||
),
|
),
|
||||||
@ -913,7 +913,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966531: (components: {
|
4294966540: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate",
|
"bevy_ecs::name::Name": "Crate",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (14.575503, 4.5, -15.5),
|
translation: (14.575503, 4.5, -15.5),
|
||||||
@ -1080,7 +1080,7 @@
|
|||||||
uv_tiling: (1.0, 1.0),
|
uv_tiling: (1.0, 1.0),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966532: (components: {
|
4294966541: (components: {
|
||||||
"bevy_ecs::name::Name": "Crate",
|
"bevy_ecs::name::Name": "Crate",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (5.4, 0.5, 2.4),
|
translation: (5.4, 0.5, 2.4),
|
||||||
@ -1116,7 +1116,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966533: (components: {
|
4294966542: (components: {
|
||||||
"bevy_ecs::name::Name": "Ramp",
|
"bevy_ecs::name::Name": "Ramp",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-14.0, 1.6, 0.0),
|
translation: (-14.0, 1.6, 0.0),
|
||||||
@ -1152,7 +1152,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966534: (components: {
|
4294966543: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.09, 14.0),
|
translation: (12.0, 0.09, 14.0),
|
||||||
@ -1188,7 +1188,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966535: (components: {
|
4294966544: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.18, 13.5),
|
translation: (12.0, 0.18, 13.5),
|
||||||
@ -1224,7 +1224,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966536: (components: {
|
4294966545: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.27, 13.0),
|
translation: (12.0, 0.27, 13.0),
|
||||||
@ -1260,7 +1260,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966537: (components: {
|
4294966546: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.36, 12.5),
|
translation: (12.0, 0.36, 12.5),
|
||||||
@ -1296,7 +1296,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966538: (components: {
|
4294966547: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.45000002, 12.0),
|
translation: (12.0, 0.45000002, 12.0),
|
||||||
@ -1332,7 +1332,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966539: (components: {
|
4294966548: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.54, 11.5),
|
translation: (12.0, 0.54, 11.5),
|
||||||
@ -1368,7 +1368,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966540: (components: {
|
4294966549: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.63, 11.0),
|
translation: (12.0, 0.63, 11.0),
|
||||||
@ -1404,7 +1404,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966541: (components: {
|
4294966550: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.72, 10.5),
|
translation: (12.0, 0.72, 10.5),
|
||||||
@ -1440,7 +1440,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966542: (components: {
|
4294966551: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.81000006, 10.0),
|
translation: (12.0, 0.81000006, 10.0),
|
||||||
@ -1476,7 +1476,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966543: (components: {
|
4294966552: (components: {
|
||||||
"bevy_ecs::name::Name": "Stair",
|
"bevy_ecs::name::Name": "Stair",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (12.0, 0.90000004, 9.5),
|
translation: (12.0, 0.90000004, 9.5),
|
||||||
@ -1512,7 +1512,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966544: (components: {
|
4294966553: (components: {
|
||||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-8.0, 0.6, 4.0),
|
translation: (-8.0, 0.6, 4.0),
|
||||||
@ -1546,7 +1546,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966545: (components: {
|
4294966554: (components: {
|
||||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-5.8, 0.6, 4.0),
|
translation: (-5.8, 0.6, 4.0),
|
||||||
@ -1580,7 +1580,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966546: (components: {
|
4294966555: (components: {
|
||||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-3.6, 0.6, 4.0),
|
translation: (-3.6, 0.6, 4.0),
|
||||||
@ -1614,7 +1614,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966547: (components: {
|
4294966556: (components: {
|
||||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (-1.3999996, 0.6, 4.0),
|
translation: (-1.3999996, 0.6, 4.0),
|
||||||
@ -1648,7 +1648,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966548: (components: {
|
4294966557: (components: {
|
||||||
"bevy_ecs::name::Name": "Player Start",
|
"bevy_ecs::name::Name": "Player Start",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.9900649, 12.46175),
|
translation: (0.0, 0.9900649, 12.46175),
|
||||||
@ -1663,7 +1663,7 @@
|
|||||||
"shared::components::LevelObject": (),
|
"shared::components::LevelObject": (),
|
||||||
"shared::components::PlayerSpawn": (),
|
"shared::components::PlayerSpawn": (),
|
||||||
}),
|
}),
|
||||||
4294966549: (components: {
|
4294966558: (components: {
|
||||||
"bevy_ecs::name::Name": "Ground",
|
"bevy_ecs::name::Name": "Ground",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, -0.5, 0.0),
|
translation: (0.0, -0.5, 0.0),
|
||||||
@ -1699,7 +1699,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966550: (components: {
|
4294966559: (components: {
|
||||||
"bevy_ecs::name::Name": "Scene Sun",
|
"bevy_ecs::name::Name": "Scene Sun",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 5.316672, 0.0),
|
translation: (0.0, 5.316672, 0.0),
|
||||||
@ -1727,11 +1727,11 @@
|
|||||||
outer_angle_deg: 35.0,
|
outer_angle_deg: 35.0,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966551: (components: {
|
68719475876: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966558),
|
"bevy_ecs::hierarchy::ChildOf": (107374181688),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (1.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||||
scale: (1.0, 1.0, 1.0),
|
scale: (1.0, 1.0, 1.0),
|
||||||
),
|
),
|
||||||
@ -1803,8 +1803,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966552: (components: {
|
77309410416: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -1870,8 +1870,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966553: (components: {
|
81604377613: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -1937,8 +1937,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966554: (components: {
|
85899344880: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -2004,8 +2004,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966555: (components: {
|
85899345209: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -2071,8 +2071,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966556: (components: {
|
90194312192: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -2138,8 +2138,8 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966557: (components: {
|
98784246793: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
@ -2205,7 +2205,7 @@
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
4294966558: (components: {
|
107374181688: (components: {
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_2k",
|
"bevy_ecs::name::Name": "metal_office_desk_2k",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (2.0, 0.1, 9.0),
|
translation: (2.0, 0.1, 9.0),
|
||||||
@ -2220,8 +2220,8 @@
|
|||||||
"shared::components::HierarchySiblingIndex": (30),
|
"shared::components::HierarchySiblingIndex": (30),
|
||||||
"shared::components::LevelObject": (),
|
"shared::components::LevelObject": (),
|
||||||
}),
|
}),
|
||||||
4294966559: (components: {
|
120259083352: (components: {
|
||||||
"bevy_ecs::hierarchy::ChildOf": (4294966551),
|
"bevy_ecs::hierarchy::ChildOf": (68719475876),
|
||||||
"bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0",
|
"bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 0.0, 0.0),
|
translation: (0.0, 0.0, 0.0),
|
||||||
|
|||||||
@ -31,7 +31,6 @@ bevy_egui.workspace = true
|
|||||||
bevy-inspector-egui.workspace = true
|
bevy-inspector-egui.workspace = true
|
||||||
egui_dock.workspace = true
|
egui_dock.workspace = true
|
||||||
egui_phosphor_icons.workspace = true
|
egui_phosphor_icons.workspace = true
|
||||||
editor_ui.workspace = true
|
|
||||||
bevy_ufbx.workspace = true
|
bevy_ufbx.workspace = true
|
||||||
content_pipeline.workspace = true
|
content_pipeline.workspace = true
|
||||||
ufbx = "0.9"
|
ufbx = "0.9"
|
||||||
@ -54,4 +53,3 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
walkdir = "2.5"
|
walkdir = "2.5"
|
||||||
gltf = { version = "1.4", features = ["extras"] }
|
gltf = { version = "1.4", features = ["extras"] }
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
|
|
||||||
use super::disk_cache::{ThumbnailArtifact, ThumbnailDiskCache};
|
|
||||||
use super::sources::gltf::validate_gltf_dependencies;
|
use super::sources::gltf::validate_gltf_dependencies;
|
||||||
use super::studio::{model_file_exists, ThumbnailStudio};
|
use super::studio::{model_file_exists, ThumbnailStudio};
|
||||||
use super::ThumbnailJobSource;
|
use super::ThumbnailJobSource;
|
||||||
@ -29,66 +28,21 @@ struct ThumbnailFailure {
|
|||||||
retryable: bool,
|
retryable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DecodedThumbnail {
|
#[derive(Resource, Default)]
|
||||||
image: Image,
|
|
||||||
artifact: ThumbnailArtifact,
|
|
||||||
is_current: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct AssetThumbnailCache {
|
pub struct AssetThumbnailCache {
|
||||||
pub texture_ids: HashMap<String, egui::TextureId>,
|
pub texture_ids: HashMap<String, egui::TextureId>,
|
||||||
registered_images: HashMap<String, AssetId<Image>>,
|
registered_images: HashMap<String, AssetId<Image>>,
|
||||||
pending: HashMap<String, Handle<Image>>,
|
pending: HashMap<String, Handle<Image>>,
|
||||||
decoded_pending: HashMap<String, DecodedThumbnail>,
|
|
||||||
pub(crate) studio_pending: HashSet<String>,
|
pub(crate) studio_pending: HashSet<String>,
|
||||||
failed: HashMap<String, ThumbnailFailure>,
|
failed: HashMap<String, ThumbnailFailure>,
|
||||||
requested_signatures: HashMap<String, String>,
|
|
||||||
visual_signatures: HashMap<String, String>,
|
|
||||||
validated_epochs: HashMap<String, u64>,
|
|
||||||
catalog_epoch: u64,
|
|
||||||
prefetched_folder: Option<String>,
|
prefetched_folder: Option<String>,
|
||||||
disk: ThumbnailDiskCache,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AssetThumbnailCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(std::env::current_dir().unwrap_or_else(|_| ".".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AssetThumbnailCache {
|
|
||||||
fn new(project_root: impl Into<std::path::PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
texture_ids: HashMap::new(),
|
|
||||||
registered_images: HashMap::new(),
|
|
||||||
pending: HashMap::new(),
|
|
||||||
decoded_pending: HashMap::new(),
|
|
||||||
studio_pending: HashSet::new(),
|
|
||||||
failed: HashMap::new(),
|
|
||||||
requested_signatures: HashMap::new(),
|
|
||||||
visual_signatures: HashMap::new(),
|
|
||||||
validated_epochs: HashMap::new(),
|
|
||||||
catalog_epoch: 0,
|
|
||||||
prefetched_folder: None,
|
|
||||||
disk: ThumbnailDiskCache::new(project_root),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AssetThumbnailsPlugin;
|
pub struct AssetThumbnailsPlugin;
|
||||||
|
|
||||||
impl Plugin for AssetThumbnailsPlugin {
|
impl Plugin for AssetThumbnailsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
let project_root = app
|
app.init_resource::<AssetThumbnailCache>()
|
||||||
.world()
|
|
||||||
.get_resource::<crate::project_io::ProjectWorkspace>()
|
|
||||||
.map(|workspace| std::path::PathBuf::from(&workspace.root))
|
|
||||||
.or_else(|| std::env::current_dir().ok())
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
||||||
let cache = AssetThumbnailCache::new(project_root);
|
|
||||||
cache.disk.spawn_maintenance();
|
|
||||||
app.insert_resource(cache)
|
|
||||||
.add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
|
.add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,10 +59,7 @@ impl AssetThumbnailCache {
|
|||||||
retryable: failure.retryable,
|
retryable: failure.retryable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if self.pending.contains_key(key)
|
if self.pending.contains_key(key) || self.studio_pending.contains(key) {
|
||||||
|| self.decoded_pending.contains_key(key)
|
|
||||||
|| self.studio_pending.contains(key)
|
|
||||||
{
|
|
||||||
return Some(ThumbnailState::Pending);
|
return Some(ThumbnailState::Pending);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@ -118,48 +69,46 @@ impl AssetThumbnailCache {
|
|||||||
/// The caller must remove that registration from `EguiUserTextures` after releasing the cache
|
/// The caller must remove that registration from `EguiUserTextures` after releasing the cache
|
||||||
/// borrow.
|
/// borrow.
|
||||||
pub fn retry(&mut self, key: &str) -> Option<AssetId<Image>> {
|
pub fn retry(&mut self, key: &str) -> Option<AssetId<Image>> {
|
||||||
self.disk.evict_key(key);
|
|
||||||
self.failed.remove(key);
|
self.failed.remove(key);
|
||||||
self.pending.remove(key);
|
self.pending.remove(key);
|
||||||
self.decoded_pending.remove(key);
|
|
||||||
self.studio_pending.remove(key);
|
self.studio_pending.remove(key);
|
||||||
self.requested_signatures.remove(key);
|
|
||||||
self.visual_signatures.remove(key);
|
|
||||||
self.validated_epochs.remove(key);
|
|
||||||
self.texture_ids.remove(key);
|
self.texture_ids.remove(key);
|
||||||
self.release_registered_image(key)
|
self.release_registered_image(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) {
|
pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) {
|
||||||
if self.request_is_current(&key) {
|
if matches!(
|
||||||
|
self.state(&key),
|
||||||
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.failed.contains_key(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !self.texture_source_ready(&key, &path) {
|
if !self.texture_source_ready(&key, &path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let source = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: path.clone(),
|
|
||||||
};
|
|
||||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
||||||
self.pending.insert(key, handle);
|
self.pending.insert(key, handle);
|
||||||
self.disk.spawn_texture_persist(artifact, &path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_model(&mut self, key: String, model_path: String, studio: &mut ThumbnailStudio) {
|
pub fn request_model(&mut self, key: String, model_path: String, studio: &mut ThumbnailStudio) {
|
||||||
if self.request_is_current(&key) {
|
if matches!(
|
||||||
|
self.state(&key),
|
||||||
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.failed.contains_key(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if !self.model_source_ready(&key, &model_path) {
|
if !self.model_source_ready(&key, &model_path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let source = ThumbnailJobSource::Model { model_path };
|
|
||||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
if studio.enqueue(key.clone(), model_path) {
|
||||||
return;
|
|
||||||
};
|
|
||||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
|
||||||
self.studio_pending.insert(key);
|
self.studio_pending.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,7 +122,11 @@ impl AssetThumbnailCache {
|
|||||||
requires_scene_instance: bool,
|
requires_scene_instance: bool,
|
||||||
studio: &mut ThumbnailStudio,
|
studio: &mut ThumbnailStudio,
|
||||||
) {
|
) {
|
||||||
if self.request_is_current(&key) {
|
if matches!(
|
||||||
|
self.state(&key),
|
||||||
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||||
|
) || self.failed.contains_key(&key)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !self.model_source_ready(&key, &model_path) {
|
if !self.model_source_ready(&key, &model_path) {
|
||||||
@ -188,10 +141,7 @@ impl AssetThumbnailCache {
|
|||||||
material_label,
|
material_label,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
if studio.enqueue_source(key.clone(), source) {
|
||||||
return;
|
|
||||||
};
|
|
||||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
|
||||||
self.studio_pending.insert(key);
|
self.studio_pending.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,20 +153,23 @@ impl AssetThumbnailCache {
|
|||||||
material_label: String,
|
material_label: String,
|
||||||
studio: &mut ThumbnailStudio,
|
studio: &mut ThumbnailStudio,
|
||||||
) {
|
) {
|
||||||
if self.request_is_current(&key) {
|
if matches!(
|
||||||
|
self.state(&key),
|
||||||
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||||
|
) || self.failed.contains_key(&key)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !self.source_material_ready(&key, &model_path) {
|
if !self.source_material_ready(&key, &model_path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let source = ThumbnailJobSource::SourceMaterial {
|
if studio.enqueue_source(
|
||||||
|
key.clone(),
|
||||||
|
ThumbnailJobSource::SourceMaterial {
|
||||||
model_path,
|
model_path,
|
||||||
material_label,
|
material_label,
|
||||||
};
|
},
|
||||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
) {
|
||||||
return;
|
|
||||||
};
|
|
||||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
|
||||||
self.studio_pending.insert(key);
|
self.studio_pending.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -227,7 +180,11 @@ impl AssetThumbnailCache {
|
|||||||
path: String,
|
path: String,
|
||||||
studio: &mut ThumbnailStudio,
|
studio: &mut ThumbnailStudio,
|
||||||
) {
|
) {
|
||||||
if self.request_is_current(&key) {
|
if matches!(
|
||||||
|
self.state(&key),
|
||||||
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||||
|
) || self.failed.contains_key(&key)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let material = match shared::load_resolved_material_from_path(&path) {
|
let material = match shared::load_resolved_material_from_path(&path) {
|
||||||
@ -243,118 +200,24 @@ impl AssetThumbnailCache {
|
|||||||
shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label)
|
shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label)
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|_| "Material".into());
|
.unwrap_or_else(|_| "Material".into());
|
||||||
let source = ThumbnailJobSource::MaterialAsset {
|
if studio.enqueue_source(
|
||||||
source_path: path,
|
key.clone(),
|
||||||
|
ThumbnailJobSource::MaterialAsset {
|
||||||
label,
|
label,
|
||||||
material: Box::new(material),
|
material: Box::new(material),
|
||||||
};
|
},
|
||||||
let Some(artifact) = self.prepare_request(&key, &source) else {
|
) {
|
||||||
return;
|
|
||||||
};
|
|
||||||
if studio.enqueue_source(key.clone(), source, artifact) {
|
|
||||||
self.studio_pending.insert(key);
|
self.studio_pending.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_is_current(&self, key: &str) -> bool {
|
|
||||||
self.validated_epochs.get(key).copied() == Some(self.catalog_epoch)
|
|
||||||
&& (self.texture_ids.contains_key(key)
|
|
||||||
|| self.pending.contains_key(key)
|
|
||||||
|| self.decoded_pending.contains_key(key)
|
|
||||||
|| self.studio_pending.contains(key)
|
|
||||||
|| self.failed.contains_key(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an artifact only when generation is required. A valid disk hit is queued for
|
|
||||||
/// publication on the egui pass; an existing last-good texture remains registered meanwhile.
|
|
||||||
fn prepare_request(
|
|
||||||
&mut self,
|
|
||||||
key: &str,
|
|
||||||
source: &ThumbnailJobSource,
|
|
||||||
) -> Option<ThumbnailArtifact> {
|
|
||||||
let artifact = match self.disk.artifact_for(key, source) {
|
|
||||||
Ok(artifact) => artifact,
|
|
||||||
Err(error) => {
|
|
||||||
self.mark_studio_failed(key, &error, true);
|
|
||||||
self.queue_last_good(key, None);
|
|
||||||
self.validated_epochs
|
|
||||||
.insert(key.to_string(), self.catalog_epoch);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.validated_epochs
|
|
||||||
.insert(key.to_string(), self.catalog_epoch);
|
|
||||||
|
|
||||||
if self.visual_signatures.get(key) == Some(&artifact.signature)
|
|
||||||
&& self.texture_ids.contains_key(key)
|
|
||||||
{
|
|
||||||
self.failed.remove(key);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self.requested_signatures.get(key) == Some(&artifact.signature)
|
|
||||||
&& (self.pending.contains_key(key)
|
|
||||||
|| self.decoded_pending.contains_key(key)
|
|
||||||
|| self.studio_pending.contains(key))
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.failed.remove(key);
|
|
||||||
self.requested_signatures
|
|
||||||
.insert(key.to_string(), artifact.signature.clone());
|
|
||||||
match self.disk.load(&artifact) {
|
|
||||||
Ok(Some(image)) => {
|
|
||||||
self.decoded_pending.insert(
|
|
||||||
key.to_string(),
|
|
||||||
DecodedThumbnail {
|
|
||||||
image,
|
|
||||||
artifact,
|
|
||||||
is_current: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
self.queue_last_good(key, Some(&artifact.path));
|
|
||||||
Some(artifact)
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
warn!("{error}; regenerating {}", source.label());
|
|
||||||
self.queue_last_good(key, Some(&artifact.path));
|
|
||||||
Some(artifact)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_last_good(&mut self, key: &str, excluded_path: Option<&Path>) {
|
|
||||||
if self.texture_ids.contains_key(key) || self.decoded_pending.contains_key(key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match self.disk.load_last_good(key, excluded_path) {
|
|
||||||
Ok(Some((image, artifact))) => {
|
|
||||||
self.decoded_pending.insert(
|
|
||||||
key.to_string(),
|
|
||||||
DecodedThumbnail {
|
|
||||||
image,
|
|
||||||
artifact,
|
|
||||||
is_current: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(error) => warn!("Could not restore last-good thumbnail for {key}: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool {
|
fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool {
|
||||||
if !model_file_exists(model_path) {
|
if !model_file_exists(model_path) {
|
||||||
self.mark_studio_failed(key, "file not found", false);
|
self.mark_studio_failed(key, "file not found", false);
|
||||||
self.queue_last_good(key, None);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Err(reason) = validate_gltf_dependencies(model_path) {
|
if let Err(reason) = validate_gltf_dependencies(model_path) {
|
||||||
self.mark_studio_failed(key, &reason, false);
|
self.mark_studio_failed(key, &reason, false);
|
||||||
self.queue_last_good(key, None);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@ -365,7 +228,6 @@ impl AssetThumbnailCache {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false);
|
self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false);
|
||||||
self.queue_last_good(key, None);
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,7 +243,6 @@ impl AssetThumbnailCache {
|
|||||||
crate::assets::import::validate_fbx_dependencies(std::path::Path::new(model_path))
|
crate::assets::import::validate_fbx_dependencies(std::path::Path::new(model_path))
|
||||||
{
|
{
|
||||||
self.mark_studio_failed(key, &reason, false);
|
self.mark_studio_failed(key, &reason, false);
|
||||||
self.queue_last_good(key, None);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -392,23 +253,16 @@ impl AssetThumbnailCache {
|
|||||||
&mut self,
|
&mut self,
|
||||||
key: &str,
|
key: &str,
|
||||||
image: Handle<Image>,
|
image: Handle<Image>,
|
||||||
artifact: &ThumbnailArtifact,
|
|
||||||
textures: &mut EguiUserTextures,
|
textures: &mut EguiUserTextures,
|
||||||
) {
|
) {
|
||||||
self.studio_pending.remove(key);
|
self.studio_pending.remove(key);
|
||||||
self.failed.remove(key);
|
self.failed.remove(key);
|
||||||
self.requested_signatures.remove(key);
|
|
||||||
self.visual_signatures
|
|
||||||
.insert(key.to_string(), artifact.signature.clone());
|
|
||||||
self.register_thumbnail(key, image, textures);
|
self.register_thumbnail(key, image, textures);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) {
|
pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) {
|
||||||
self.studio_pending.remove(key);
|
self.studio_pending.remove(key);
|
||||||
self.pending.remove(key);
|
self.pending.remove(key);
|
||||||
self.decoded_pending.remove(key);
|
|
||||||
self.validated_epochs
|
|
||||||
.insert(key.to_string(), self.catalog_epoch);
|
|
||||||
self.failed.insert(
|
self.failed.insert(
|
||||||
key.to_string(),
|
key.to_string(),
|
||||||
ThumbnailFailure {
|
ThumbnailFailure {
|
||||||
@ -423,12 +277,8 @@ impl AssetThumbnailCache {
|
|||||||
pub fn invalidate_all(&mut self) -> Vec<AssetId<Image>> {
|
pub fn invalidate_all(&mut self) -> Vec<AssetId<Image>> {
|
||||||
self.texture_ids.clear();
|
self.texture_ids.clear();
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
self.decoded_pending.clear();
|
|
||||||
self.studio_pending.clear();
|
self.studio_pending.clear();
|
||||||
self.failed.clear();
|
self.failed.clear();
|
||||||
self.requested_signatures.clear();
|
|
||||||
self.visual_signatures.clear();
|
|
||||||
self.validated_epochs.clear();
|
|
||||||
self.prefetched_folder = None;
|
self.prefetched_folder = None;
|
||||||
self.registered_images
|
self.registered_images
|
||||||
.drain()
|
.drain()
|
||||||
@ -472,7 +322,6 @@ impl AssetThumbnailCache {
|
|||||||
pending_keys: self
|
pending_keys: self
|
||||||
.pending
|
.pending
|
||||||
.keys()
|
.keys()
|
||||||
.chain(self.decoded_pending.keys())
|
|
||||||
.chain(self.studio_pending.iter())
|
.chain(self.studio_pending.iter())
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect(),
|
.collect(),
|
||||||
@ -484,40 +333,13 @@ impl AssetThumbnailCache {
|
|||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Catalog refreshes invalidate fingerprints, not usable pixels. This preserves last-good
|
|
||||||
/// previews while the next visible request checks whether its visual dependencies changed.
|
|
||||||
fn invalidate_catalog_epoch(&mut self) {
|
|
||||||
self.catalog_epoch = self.catalog_epoch.wrapping_add(1);
|
|
||||||
self.pending.clear();
|
|
||||||
self.decoded_pending.clear();
|
|
||||||
self.studio_pending.clear();
|
|
||||||
self.failed.clear();
|
|
||||||
self.requested_signatures.clear();
|
|
||||||
self.validated_epochs.clear();
|
|
||||||
self.prefetched_folder = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_loaded_thumbnails(
|
fn register_loaded_thumbnails(
|
||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
mut cache: ResMut<AssetThumbnailCache>,
|
mut cache: ResMut<AssetThumbnailCache>,
|
||||||
mut textures: ResMut<EguiUserTextures>,
|
mut textures: ResMut<EguiUserTextures>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
|
||||||
) {
|
) {
|
||||||
let decoded = cache.decoded_pending.drain().collect::<Vec<_>>();
|
|
||||||
for (key, decoded) in decoded {
|
|
||||||
let handle = images.add(decoded.image);
|
|
||||||
if decoded.is_current {
|
|
||||||
cache.failed.remove(&key);
|
|
||||||
cache.requested_signatures.remove(&key);
|
|
||||||
}
|
|
||||||
cache
|
|
||||||
.visual_signatures
|
|
||||||
.insert(key.clone(), decoded.artifact.signature);
|
|
||||||
cache.register_thumbnail(&key, handle, &mut textures);
|
|
||||||
}
|
|
||||||
|
|
||||||
let failed: Vec<(String, String)> = cache
|
let failed: Vec<(String, String)> = cache
|
||||||
.pending
|
.pending
|
||||||
.iter()
|
.iter()
|
||||||
@ -540,9 +362,6 @@ fn register_loaded_thumbnails(
|
|||||||
for (key, handle) in ready {
|
for (key, handle) in ready {
|
||||||
cache.pending.remove(&key);
|
cache.pending.remove(&key);
|
||||||
cache.failed.remove(&key);
|
cache.failed.remove(&key);
|
||||||
if let Some(signature) = cache.requested_signatures.remove(&key) {
|
|
||||||
cache.visual_signatures.insert(key.clone(), signature);
|
|
||||||
}
|
|
||||||
cache.register_thumbnail(&key, handle, &mut textures);
|
cache.register_thumbnail(&key, handle, &mut textures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -699,9 +518,12 @@ pub fn retry_thumbnail(world: &mut World, key: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
||||||
world
|
let registrations = world.resource_mut::<AssetThumbnailCache>().invalidate_all();
|
||||||
.resource_mut::<AssetThumbnailCache>()
|
if let Some(mut textures) = world.get_resource_mut::<EguiUserTextures>() {
|
||||||
.invalidate_catalog_epoch();
|
for registration in registrations {
|
||||||
|
textures.remove_image(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
if !world.contains_resource::<ThumbnailStudio>() {
|
if !world.contains_resource::<ThumbnailStudio>() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -720,14 +542,6 @@ pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn test_artifact(key: &str, signature: impl Into<String>) -> ThumbnailArtifact {
|
|
||||||
ThumbnailArtifact {
|
|
||||||
cache_key: key.into(),
|
|
||||||
signature: signature.into(),
|
|
||||||
path: std::env::temp_dir().join("blacksite-test-thumbnail.png"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_fbx_material_dependencies_are_one_stable_non_retryable_state() {
|
fn missing_fbx_material_dependencies_are_one_stable_non_retryable_state() {
|
||||||
let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
@ -779,11 +593,7 @@ mod tests {
|
|||||||
.join("../../assets/Furniture/Office/metal_office_desk_2k.gltf")
|
.join("../../assets/Furniture/Office/metal_office_desk_2k.gltf")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
let cache_root = std::env::temp_dir().join(format!(
|
let mut cache = AssetThumbnailCache::default();
|
||||||
"blacksite-thumbnail-queue-test-{}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let mut cache = AssetThumbnailCache::new(cache_root);
|
|
||||||
let mut studio = ThumbnailStudio::for_test();
|
let mut studio = ThumbnailStudio::for_test();
|
||||||
|
|
||||||
cache.request_model("model:desk".into(), model_path.clone(), &mut studio);
|
cache.request_model("model:desk".into(), model_path.clone(), &mut studio);
|
||||||
@ -807,12 +617,7 @@ mod tests {
|
|||||||
for revision in 0..32 {
|
for revision in 0..32 {
|
||||||
let image = images.add(Image::default());
|
let image = images.add(Image::default());
|
||||||
let image_id = image.id();
|
let image_id = image.id();
|
||||||
cache.complete_studio_thumbnail(
|
cache.complete_studio_thumbnail("material:shared", image, &mut textures);
|
||||||
"material:shared",
|
|
||||||
image,
|
|
||||||
&test_artifact("material:shared", revision.to_string()),
|
|
||||||
&mut textures,
|
|
||||||
);
|
|
||||||
assert!(textures.image_id(image_id).is_some(), "revision {revision}");
|
assert!(textures.image_id(image_id).is_some(), "revision {revision}");
|
||||||
assert_eq!(cache.registered_images.len(), 1);
|
assert_eq!(cache.registered_images.len(), 1);
|
||||||
}
|
}
|
||||||
@ -834,18 +639,8 @@ mod tests {
|
|||||||
let image = images.add(Image::default());
|
let image = images.add(Image::default());
|
||||||
let image_id = image.id();
|
let image_id = image.id();
|
||||||
|
|
||||||
cache.complete_studio_thumbnail(
|
cache.complete_studio_thumbnail("first", image.clone(), &mut textures);
|
||||||
"first",
|
cache.complete_studio_thumbnail("second", image, &mut textures);
|
||||||
image.clone(),
|
|
||||||
&test_artifact("first", "one"),
|
|
||||||
&mut textures,
|
|
||||||
);
|
|
||||||
cache.complete_studio_thumbnail(
|
|
||||||
"second",
|
|
||||||
image,
|
|
||||||
&test_artifact("second", "one"),
|
|
||||||
&mut textures,
|
|
||||||
);
|
|
||||||
assert!(cache.retry("first").is_none());
|
assert!(cache.retry("first").is_none());
|
||||||
assert!(textures.image_id(image_id).is_some());
|
assert!(textures.image_id(image_id).is_some());
|
||||||
let registration = cache.retry("second").expect("last owner releases image");
|
let registration = cache.retry("second").expect("last owner releases image");
|
||||||
@ -853,71 +648,4 @@ mod tests {
|
|||||||
textures.remove_image(registration);
|
textures.remove_image(registration);
|
||||||
assert!(textures.image_id(image_id).is_none());
|
assert!(textures.image_id(image_id).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn catalog_refresh_keeps_last_good_registration_and_revalidates_requests() {
|
|
||||||
let root = std::env::temp_dir().join(format!(
|
|
||||||
"blacksite-thumbnail-last-good-{}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let mut cache = AssetThumbnailCache::new(&root);
|
|
||||||
let mut textures = EguiUserTextures::default();
|
|
||||||
let mut images = Assets::<Image>::default();
|
|
||||||
let image = images.add(Image::default());
|
|
||||||
let image_id = image.id();
|
|
||||||
cache.complete_studio_thumbnail(
|
|
||||||
"material:last-good",
|
|
||||||
image,
|
|
||||||
&test_artifact("material:last-good", "old"),
|
|
||||||
&mut textures,
|
|
||||||
);
|
|
||||||
cache
|
|
||||||
.validated_epochs
|
|
||||||
.insert("material:last-good".into(), cache.catalog_epoch);
|
|
||||||
|
|
||||||
cache.invalidate_catalog_epoch();
|
|
||||||
|
|
||||||
assert!(cache.texture_ids.contains_key("material:last-good"));
|
|
||||||
assert_eq!(cache.registered_images.len(), 1);
|
|
||||||
assert_eq!(cache.registered_images["material:last-good"], image_id);
|
|
||||||
assert!(!cache.request_is_current("material:last-good"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn changed_revision_queues_generation_while_restoring_disk_last_good() {
|
|
||||||
let nonce = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let root = std::env::temp_dir().join(format!(
|
|
||||||
"blacksite-thumbnail-restart-fallback-{}-{nonce}",
|
|
||||||
std::process::id(),
|
|
||||||
));
|
|
||||||
let source_path = root.join("assets/source.png");
|
|
||||||
std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&source_path, b"first revision").unwrap();
|
|
||||||
let mut cache = AssetThumbnailCache::new(&root);
|
|
||||||
let source = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/source.png".into(),
|
|
||||||
};
|
|
||||||
let previous = cache.disk.artifact_for("texture:source", &source).unwrap();
|
|
||||||
super::super::disk_cache::persist_rgba8(&previous, 4, 4, &[32_u8; 4 * 4 * 4]).unwrap();
|
|
||||||
|
|
||||||
std::fs::write(&source_path, b"changed revision").unwrap();
|
|
||||||
let replacement = cache
|
|
||||||
.prepare_request("texture:source", &source)
|
|
||||||
.expect("changed revision still needs generation");
|
|
||||||
assert_ne!(replacement.signature, previous.signature);
|
|
||||||
let fallback = cache
|
|
||||||
.decoded_pending
|
|
||||||
.get("texture:source")
|
|
||||||
.expect("last-good preview is queued immediately");
|
|
||||||
assert!(!fallback.is_current);
|
|
||||||
assert_eq!(fallback.artifact, previous);
|
|
||||||
assert_eq!(
|
|
||||||
cache.requested_signatures.get("texture:source"),
|
|
||||||
Some(&replacement.signature)
|
|
||||||
);
|
|
||||||
std::fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,652 +0,0 @@
|
|||||||
//! Persistent, content-addressed thumbnail artifacts.
|
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
|
||||||
use std::fs;
|
|
||||||
use std::io::{BufReader, Read};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
use bevy::asset::RenderAssetUsages;
|
|
||||||
use bevy::prelude::Image;
|
|
||||||
use image::{DynamicImage, ImageFormat};
|
|
||||||
|
|
||||||
use super::sources::gltf::gltf_dependency_paths;
|
|
||||||
use super::ThumbnailJobSource;
|
|
||||||
|
|
||||||
pub(crate) const THUMBNAIL_EDGE: u32 = 256;
|
|
||||||
const THUMBNAIL_CACHE_VERSION: &str = "blacksite-thumbnail-v3";
|
|
||||||
const CACHE_DIRECTORY: &str = "assets/.thumbnails/v3";
|
|
||||||
const RETAINED_PREVIOUS_GENERATIONS: usize = 1;
|
|
||||||
const CACHE_SOFT_LIMIT_BYTES: u64 = 512 * 1024 * 1024;
|
|
||||||
const CACHE_PRUNE_TARGET_BYTES: u64 = 410 * 1024 * 1024;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub(crate) struct ThumbnailArtifact {
|
|
||||||
pub(crate) cache_key: String,
|
|
||||||
pub(crate) signature: String,
|
|
||||||
pub(crate) path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(crate) struct ThumbnailDiskCache {
|
|
||||||
project_root: PathBuf,
|
|
||||||
fingerprints: Arc<Mutex<HashMap<PathBuf, MemoizedFingerprint>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct MemoizedFingerprint {
|
|
||||||
byte_length: u64,
|
|
||||||
modified: Option<SystemTime>,
|
|
||||||
digest: blake3::Hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ThumbnailDiskCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ThumbnailDiskCache {
|
|
||||||
pub(crate) fn new(project_root: impl Into<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
project_root: project_root.into(),
|
|
||||||
fingerprints: Arc::new(Mutex::new(HashMap::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn artifact_for(
|
|
||||||
&self,
|
|
||||||
cache_key: &str,
|
|
||||||
source: &ThumbnailJobSource,
|
|
||||||
) -> Result<ThumbnailArtifact, String> {
|
|
||||||
let mut hasher = blake3::Hasher::new();
|
|
||||||
hasher.update(THUMBNAIL_CACHE_VERSION.as_bytes());
|
|
||||||
hash_source(self, source, &mut hasher)?;
|
|
||||||
let signature = hasher.finalize().to_hex().to_string();
|
|
||||||
let path = self
|
|
||||||
.key_directory(cache_key)
|
|
||||||
.join(format!("{signature}.png"));
|
|
||||||
Ok(ThumbnailArtifact {
|
|
||||||
cache_key: cache_key.to_string(),
|
|
||||||
signature,
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fully decodes the small artifact before publication so truncated PNGs never enter Bevy's
|
|
||||||
/// asset cache as a permanent failed load.
|
|
||||||
pub(crate) fn load(&self, artifact: &ThumbnailArtifact) -> Result<Option<Image>, String> {
|
|
||||||
if !artifact.path.is_file() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let image = match image::open(&artifact.path) {
|
|
||||||
Ok(image) => image,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = fs::remove_file(&artifact.path);
|
|
||||||
return Err(format!(
|
|
||||||
"discarded corrupt thumbnail {}: {error}",
|
|
||||||
artifact.path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if image.width() == 0
|
|
||||||
|| image.height() == 0
|
|
||||||
|| image.width() > THUMBNAIL_EDGE
|
|
||||||
|| image.height() > THUMBNAIL_EDGE
|
|
||||||
{
|
|
||||||
let _ = fs::remove_file(&artifact.path);
|
|
||||||
return Err(format!(
|
|
||||||
"discarded invalid thumbnail dimensions {}x{} at {}",
|
|
||||||
image.width(),
|
|
||||||
image.height(),
|
|
||||||
artifact.path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Some(Image::from_dynamic(
|
|
||||||
image,
|
|
||||||
true,
|
|
||||||
RenderAssetUsages::all(),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the newest still-valid generation for this typed key. This lets a restarted editor
|
|
||||||
/// present the previous preview while a changed revision regenerates, or alongside a broken
|
|
||||||
/// source diagnostic. Corrupt candidates are removed and the next retained generation is
|
|
||||||
/// tried before giving up.
|
|
||||||
pub(crate) fn load_last_good(
|
|
||||||
&self,
|
|
||||||
cache_key: &str,
|
|
||||||
excluded_path: Option<&Path>,
|
|
||||||
) -> Result<Option<(Image, ThumbnailArtifact)>, String> {
|
|
||||||
let Ok(entries) = fs::read_dir(self.key_directory(cache_key)) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mut candidates = entries
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let path = entry.path();
|
|
||||||
if excluded_path.is_some_and(|excluded| path.as_path() == excluded)
|
|
||||||
|| path.extension().and_then(|value| value.to_str()) != Some("png")
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let signature = path.file_stem()?.to_str()?.to_string();
|
|
||||||
let modified = entry.metadata().ok()?.modified().ok()?;
|
|
||||||
Some((modified, signature, path))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
candidates.sort_by_key(|(modified, _, _)| std::cmp::Reverse(*modified));
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
for (_, signature, path) in candidates {
|
|
||||||
let artifact = ThumbnailArtifact {
|
|
||||||
cache_key: cache_key.to_string(),
|
|
||||||
signature,
|
|
||||||
path,
|
|
||||||
};
|
|
||||||
match self.load(&artifact) {
|
|
||||||
Ok(Some(image)) => return Ok(Some((image, artifact))),
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(error) => last_error = Some(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match last_error {
|
|
||||||
Some(error) => Err(error),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn spawn_texture_persist(&self, artifact: ThumbnailArtifact, source_path: &str) {
|
|
||||||
let source = resolve_project_path(&self.project_root, source_path);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = image::open(&source)
|
|
||||||
.map(|image| image.thumbnail(THUMBNAIL_EDGE, THUMBNAIL_EDGE))
|
|
||||||
.map_err(|error| format!("could not decode {}: {error}", source.display()))
|
|
||||||
.and_then(|image| persist_dynamic_image(&artifact, image));
|
|
||||||
if let Err(error) = result {
|
|
||||||
bevy::log::warn!("Texture thumbnail persistence failed: {error}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes abandoned temporary files and trims only the oldest derived artifacts after the
|
|
||||||
/// cache crosses its soft limit. Maintenance is deliberately off the editor frame path.
|
|
||||||
pub(crate) fn spawn_maintenance(&self) {
|
|
||||||
let root = self.project_root.join(CACHE_DIRECTORY);
|
|
||||||
std::thread::spawn(move || maintain_cache(&root));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn evict_key(&self, cache_key: &str) {
|
|
||||||
let directory = self.key_directory(cache_key);
|
|
||||||
let Ok(entries) = fs::read_dir(directory) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
if entry.path().extension().and_then(|value| value.to_str()) == Some("png") {
|
|
||||||
let _ = fs::remove_file(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_directory(&self, cache_key: &str) -> PathBuf {
|
|
||||||
let key_hash = blake3::hash(cache_key.as_bytes()).to_hex().to_string();
|
|
||||||
self.project_root
|
|
||||||
.join(CACHE_DIRECTORY)
|
|
||||||
.join(&key_hash[..24])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maintain_cache(root: &Path) {
|
|
||||||
let mut stack = vec![root.to_path_buf()];
|
|
||||||
let mut artifacts = Vec::new();
|
|
||||||
let mut total = 0_u64;
|
|
||||||
while let Some(directory) = stack.pop() {
|
|
||||||
let Ok(entries) = fs::read_dir(directory) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
let Ok(metadata) = entry.metadata() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if metadata.is_dir() {
|
|
||||||
stack.push(path);
|
|
||||||
} else if path.extension().and_then(|value| value.to_str()) == Some("tmp") {
|
|
||||||
let _ = fs::remove_file(path);
|
|
||||||
} else if path.extension().and_then(|value| value.to_str()) == Some("png") {
|
|
||||||
total = total.saturating_add(metadata.len());
|
|
||||||
artifacts.push((metadata.modified().ok(), metadata.len(), path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total <= CACHE_SOFT_LIMIT_BYTES {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
artifacts.sort_by_key(|(modified, _, _)| *modified);
|
|
||||||
for (_, bytes, path) in artifacts {
|
|
||||||
if total <= CACHE_PRUNE_TARGET_BYTES {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if fs::remove_file(path).is_ok() {
|
|
||||||
total = total.saturating_sub(bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn persist_rgba8(
|
|
||||||
artifact: &ThumbnailArtifact,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
rgba: &[u8],
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 {
|
|
||||||
return Err(format!(
|
|
||||||
"invalid thumbnail readback: {width}x{height} with {} bytes",
|
|
||||||
rgba.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
persist_with(artifact, |temporary| {
|
|
||||||
image::save_buffer_with_format(
|
|
||||||
temporary,
|
|
||||||
rgba,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
image::ColorType::Rgba8,
|
|
||||||
ImageFormat::Png,
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_dynamic_image(artifact: &ThumbnailArtifact, image: DynamicImage) -> Result<(), String> {
|
|
||||||
persist_with(artifact, |temporary| {
|
|
||||||
image
|
|
||||||
.save_with_format(temporary, ImageFormat::Png)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_with(
|
|
||||||
artifact: &ThumbnailArtifact,
|
|
||||||
write: impl FnOnce(&Path) -> Result<(), String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let parent = artifact
|
|
||||||
.path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| "thumbnail artifact has no parent directory".to_string())?;
|
|
||||||
fs::create_dir_all(parent)
|
|
||||||
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
|
|
||||||
if artifact.path.is_file() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let temporary = parent.join(format!(
|
|
||||||
".{}.{}.tmp",
|
|
||||||
artifact.signature,
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let result = write(&temporary).and_then(|()| {
|
|
||||||
fs::rename(&temporary, &artifact.path).map_err(|error| {
|
|
||||||
format!(
|
|
||||||
"could not publish thumbnail {}: {error}",
|
|
||||||
artifact.path.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(&temporary);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
prune_generations(parent, &artifact.path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_generations(directory: &Path, current: &Path) {
|
|
||||||
let Ok(entries) = fs::read_dir(directory) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut previous = entries
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let path = entry.path();
|
|
||||||
if path == current || path.extension().and_then(|value| value.to_str()) != Some("png") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let modified = entry.metadata().ok()?.modified().ok()?;
|
|
||||||
Some((modified, path))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
previous.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
|
|
||||||
for (_, path) in previous.into_iter().skip(RETAINED_PREVIOUS_GENERATIONS) {
|
|
||||||
let _ = fs::remove_file(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_source(
|
|
||||||
cache: &ThumbnailDiskCache,
|
|
||||||
source: &ThumbnailJobSource,
|
|
||||||
hasher: &mut blake3::Hasher,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
match source {
|
|
||||||
ThumbnailJobSource::Texture { texture_path } => {
|
|
||||||
hasher.update(b"texture\0");
|
|
||||||
hash_file(cache, texture_path, hasher)?;
|
|
||||||
}
|
|
||||||
ThumbnailJobSource::Model { model_path } => {
|
|
||||||
hasher.update(b"model\0");
|
|
||||||
hash_model_bundle(cache, model_path, hasher)?;
|
|
||||||
}
|
|
||||||
ThumbnailJobSource::MeshSubAsset {
|
|
||||||
model_path,
|
|
||||||
mesh_label,
|
|
||||||
material_label,
|
|
||||||
} => {
|
|
||||||
hasher.update(b"mesh-subasset\0");
|
|
||||||
hasher.update(mesh_label.as_bytes());
|
|
||||||
hasher.update(b"\0");
|
|
||||||
if let Some(material_label) = material_label {
|
|
||||||
hasher.update(material_label.as_bytes());
|
|
||||||
}
|
|
||||||
hash_model_bundle(cache, model_path, hasher)?;
|
|
||||||
}
|
|
||||||
ThumbnailJobSource::SourceMaterial {
|
|
||||||
model_path,
|
|
||||||
material_label,
|
|
||||||
} => {
|
|
||||||
hasher.update(b"source-material\0");
|
|
||||||
hasher.update(material_label.as_bytes());
|
|
||||||
hash_model_bundle(cache, model_path, hasher)?;
|
|
||||||
}
|
|
||||||
ThumbnailJobSource::MaterialAsset {
|
|
||||||
source_path,
|
|
||||||
label: _,
|
|
||||||
material,
|
|
||||||
} => {
|
|
||||||
hasher.update(b"project-material\0");
|
|
||||||
hash_file(cache, source_path, hasher)?;
|
|
||||||
let mut canonical = (**material).clone();
|
|
||||||
canonical.material_asset_path = None;
|
|
||||||
let serialized = ron::ser::to_string(&canonical)
|
|
||||||
.map_err(|error| format!("could not fingerprint material: {error}"))?;
|
|
||||||
hasher.update(serialized.as_bytes());
|
|
||||||
for dependency in material_dependency_paths(&cache.project_root, &canonical) {
|
|
||||||
hash_file(cache, &dependency, hasher)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_model_bundle(
|
|
||||||
cache: &ThumbnailDiskCache,
|
|
||||||
model_path: &str,
|
|
||||||
hasher: &mut blake3::Hasher,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
hash_file(cache, model_path, hasher)?;
|
|
||||||
let resolved_model = resolve_project_path(&cache.project_root, model_path);
|
|
||||||
let extension = resolved_model
|
|
||||||
.extension()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let dependencies = match extension.as_str() {
|
|
||||||
"gltf" | "glb" => gltf_dependency_paths(&resolved_model)?,
|
|
||||||
"fbx" => content_pipeline::inspect_fbx_dependencies(&resolved_model)
|
|
||||||
.map(|inspection| inspection.resolved_paths)?,
|
|
||||||
_ => Vec::new(),
|
|
||||||
};
|
|
||||||
for dependency in dependencies {
|
|
||||||
if dependency.is_file() {
|
|
||||||
let display = dependency.to_string_lossy();
|
|
||||||
hash_file(cache, &display, hasher)?;
|
|
||||||
} else {
|
|
||||||
hasher.update(b"missing-dependency\0");
|
|
||||||
hash_logical_path(&cache.project_root, &dependency, hasher);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_file(
|
|
||||||
cache: &ThumbnailDiskCache,
|
|
||||||
path: &str,
|
|
||||||
hasher: &mut blake3::Hasher,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let resolved = resolve_project_path(&cache.project_root, path);
|
|
||||||
hasher.update(b"file\0");
|
|
||||||
hash_logical_path(&cache.project_root, &resolved, hasher);
|
|
||||||
hasher.update(b"\0");
|
|
||||||
let metadata = fs::metadata(&resolved)
|
|
||||||
.map_err(|error| format!("could not fingerprint {}: {error}", resolved.display()))?;
|
|
||||||
let modified = metadata.modified().ok();
|
|
||||||
if let Ok(fingerprints) = cache.fingerprints.lock() {
|
|
||||||
if let Some(cached) = fingerprints.get(&resolved) {
|
|
||||||
if cached.byte_length == metadata.len() && cached.modified == modified {
|
|
||||||
hasher.update(cached.digest.as_bytes());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let file = fs::File::open(&resolved)
|
|
||||||
.map_err(|error| format!("could not fingerprint {}: {error}", resolved.display()))?;
|
|
||||||
let mut reader = BufReader::new(file);
|
|
||||||
let mut file_hasher = blake3::Hasher::new();
|
|
||||||
let mut buffer = [0_u8; 64 * 1024];
|
|
||||||
loop {
|
|
||||||
let count = reader
|
|
||||||
.read(&mut buffer)
|
|
||||||
.map_err(|error| format!("could not read {}: {error}", resolved.display()))?;
|
|
||||||
if count == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
file_hasher.update(&buffer[..count]);
|
|
||||||
}
|
|
||||||
let digest = file_hasher.finalize();
|
|
||||||
hasher.update(digest.as_bytes());
|
|
||||||
if let Ok(mut fingerprints) = cache.fingerprints.lock() {
|
|
||||||
fingerprints.insert(
|
|
||||||
resolved,
|
|
||||||
MemoizedFingerprint {
|
|
||||||
byte_length: metadata.len(),
|
|
||||||
modified,
|
|
||||||
digest,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_logical_path(project_root: &Path, path: &Path, hasher: &mut blake3::Hasher) {
|
|
||||||
let logical = path.strip_prefix(project_root).unwrap_or(path);
|
|
||||||
hasher.update(logical.to_string_lossy().replace('\\', "/").as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_dependency_paths(
|
|
||||||
project_root: &Path,
|
|
||||||
material: &shared::MaterialDesc,
|
|
||||||
) -> BTreeSet<String> {
|
|
||||||
let mut paths = [
|
|
||||||
material.base_color_texture.clone(),
|
|
||||||
material.emissive_texture.clone(),
|
|
||||||
material.normal_map_texture.clone(),
|
|
||||||
material.metallic_roughness_texture.clone(),
|
|
||||||
material.occlusion_texture.clone(),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
paths.extend(material.textures.iter().filter_map(|binding| {
|
|
||||||
binding
|
|
||||||
.texture
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|reference| reference.source_path.clone())
|
|
||||||
}));
|
|
||||||
if let Some(path) = material.shader.shader_path.clone() {
|
|
||||||
paths.insert(path);
|
|
||||||
}
|
|
||||||
if let Some(schema_path) = material.shader.schema_path.clone() {
|
|
||||||
let resolved = resolve_project_path(project_root, &schema_path);
|
|
||||||
if let Ok(schema) = shared::ShaderSchemaAsset::load_from_path(&resolved.to_string_lossy()) {
|
|
||||||
if let Some(wgsl_path) = schema.wgsl_path {
|
|
||||||
paths.insert(wgsl_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paths.insert(schema_path);
|
|
||||||
}
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_project_path(project_root: &Path, path: &str) -> PathBuf {
|
|
||||||
let path = Path::new(path);
|
|
||||||
if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
project_root.join(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn fixture_root(name: &str) -> PathBuf {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"blacksite-thumbnail-cache-{name}-{}-{nonce}",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unchanged_source_reuses_artifact_and_changed_bytes_invalidate_it() {
|
|
||||||
let root = fixture_root("fingerprint");
|
|
||||||
let source = root.join("assets/textures/source.png");
|
|
||||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&source, b"first").unwrap();
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let request = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/textures/source.png".into(),
|
|
||||||
};
|
|
||||||
let first = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
let unchanged = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
assert_eq!(first, unchanged);
|
|
||||||
|
|
||||||
fs::write(&source, b"second").unwrap();
|
|
||||||
let changed = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
assert_ne!(first.signature, changed.signature);
|
|
||||||
assert_ne!(first.path, changed.path);
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn typed_cache_keys_cannot_share_an_artifact_directory() {
|
|
||||||
let root = fixture_root("typed");
|
|
||||||
let source = root.join("assets/shared.bin");
|
|
||||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&source, b"shared").unwrap();
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let request = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/shared.bin".into(),
|
|
||||||
};
|
|
||||||
let texture = cache
|
|
||||||
.artifact_for("texture:assets/shared.bin", &request)
|
|
||||||
.unwrap();
|
|
||||||
let model = cache
|
|
||||||
.artifact_for("model:assets/shared.bin", &request)
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(texture.path.parent(), model.path.parent());
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn corrupt_artifact_is_removed_and_reported_as_a_cache_miss() {
|
|
||||||
let root = fixture_root("corrupt");
|
|
||||||
let source = root.join("assets/source.png");
|
|
||||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&source, b"source").unwrap();
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let request = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/source.png".into(),
|
|
||||||
};
|
|
||||||
let artifact = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
fs::create_dir_all(artifact.path.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&artifact.path, b"not a png").unwrap();
|
|
||||||
assert!(cache.load(&artifact).is_err());
|
|
||||||
assert!(!artifact.path.exists());
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn published_artifact_roundtrips_as_a_small_bevy_image() {
|
|
||||||
let root = fixture_root("roundtrip");
|
|
||||||
let source = root.join("assets/source.png");
|
|
||||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&source, b"source").unwrap();
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let request = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/source.png".into(),
|
|
||||||
};
|
|
||||||
let artifact = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
let pixels = vec![128_u8; 4 * 4 * 4];
|
|
||||||
persist_rgba8(&artifact, 4, 4, &pixels).unwrap();
|
|
||||||
|
|
||||||
let image = cache.load(&artifact).unwrap().unwrap();
|
|
||||||
assert_eq!(image.width(), 4);
|
|
||||||
assert_eq!(image.height(), 4);
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn changed_revision_can_restore_the_previous_generation_after_restart() {
|
|
||||||
let root = fixture_root("last-good");
|
|
||||||
let source = root.join("assets/source.png");
|
|
||||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&source, b"first revision").unwrap();
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let request = ThumbnailJobSource::Texture {
|
|
||||||
texture_path: "assets/source.png".into(),
|
|
||||||
};
|
|
||||||
let previous = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
persist_rgba8(&previous, 4, 4, &[64_u8; 4 * 4 * 4]).unwrap();
|
|
||||||
|
|
||||||
fs::write(&source, b"changed revision").unwrap();
|
|
||||||
let changed = cache.artifact_for("texture:source", &request).unwrap();
|
|
||||||
assert_ne!(previous.path, changed.path);
|
|
||||||
let (_, restored) = cache
|
|
||||||
.load_last_good("texture:source", Some(&changed.path))
|
|
||||||
.unwrap()
|
|
||||||
.expect("previous generation remains available");
|
|
||||||
assert_eq!(restored, previous);
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn material_texture_bytes_participate_in_visual_signature() {
|
|
||||||
let root = fixture_root("material-dependency");
|
|
||||||
let material_path = root.join("assets/materials/test.material.ron");
|
|
||||||
let texture_path = root.join("assets/textures/albedo.png");
|
|
||||||
fs::create_dir_all(material_path.parent().unwrap()).unwrap();
|
|
||||||
fs::create_dir_all(texture_path.parent().unwrap()).unwrap();
|
|
||||||
fs::write(&material_path, b"material").unwrap();
|
|
||||||
fs::write(&texture_path, b"first pixels").unwrap();
|
|
||||||
let material = shared::MaterialDesc {
|
|
||||||
base_color_texture: Some("assets/textures/albedo.png".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let source = ThumbnailJobSource::MaterialAsset {
|
|
||||||
source_path: "assets/materials/test.material.ron".into(),
|
|
||||||
label: "Test".into(),
|
|
||||||
material: Box::new(material),
|
|
||||||
};
|
|
||||||
let cache = ThumbnailDiskCache::new(&root);
|
|
||||||
let first = cache.artifact_for("material:test", &source).unwrap();
|
|
||||||
fs::write(&texture_path, b"changed pixels").unwrap();
|
|
||||||
let second = cache.artifact_for("material:test", &source).unwrap();
|
|
||||||
assert_ne!(first.signature, second.signature);
|
|
||||||
fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,21 +2,14 @@
|
|||||||
|
|
||||||
use shared::MaterialDesc;
|
use shared::MaterialDesc;
|
||||||
|
|
||||||
use super::disk_cache::ThumbnailArtifact;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ThumbnailJob {
|
pub struct ThumbnailJob {
|
||||||
pub cache_key: String,
|
pub cache_key: String,
|
||||||
pub source: ThumbnailJobSource,
|
pub source: ThumbnailJobSource,
|
||||||
pub(crate) artifact: ThumbnailArtifact,
|
|
||||||
pub(crate) attempt: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ThumbnailJobSource {
|
pub enum ThumbnailJobSource {
|
||||||
Texture {
|
|
||||||
texture_path: String,
|
|
||||||
},
|
|
||||||
Model {
|
Model {
|
||||||
model_path: String,
|
model_path: String,
|
||||||
},
|
},
|
||||||
@ -30,7 +23,6 @@ pub enum ThumbnailJobSource {
|
|||||||
material_label: String,
|
material_label: String,
|
||||||
},
|
},
|
||||||
MaterialAsset {
|
MaterialAsset {
|
||||||
source_path: String,
|
|
||||||
label: String,
|
label: String,
|
||||||
material: Box<MaterialDesc>,
|
material: Box<MaterialDesc>,
|
||||||
},
|
},
|
||||||
@ -39,7 +31,6 @@ pub enum ThumbnailJobSource {
|
|||||||
impl ThumbnailJobSource {
|
impl ThumbnailJobSource {
|
||||||
pub fn label(&self) -> &str {
|
pub fn label(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Texture { texture_path } => texture_path,
|
|
||||||
Self::Model { model_path }
|
Self::Model { model_path }
|
||||||
| Self::MeshSubAsset { model_path, .. }
|
| Self::MeshSubAsset { model_path, .. }
|
||||||
| Self::SourceMaterial { model_path, .. } => model_path,
|
| Self::SourceMaterial { model_path, .. } => model_path,
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
mod cache;
|
mod cache;
|
||||||
mod disk_cache;
|
|
||||||
mod job;
|
mod job;
|
||||||
mod sources;
|
mod sources;
|
||||||
mod studio;
|
mod studio;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! glTF/GLB dependency validation and thumbnail source inspection.
|
//! glTF/GLB dependency validation and thumbnail source inspection.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
/// Verifies local external buffers and images before Bevy starts an async glTF load.
|
/// Verifies local external buffers and images before Bevy starts an async glTF load.
|
||||||
pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> {
|
pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String> {
|
||||||
@ -10,23 +10,6 @@ pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String>
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
for dependency in gltf_dependency_paths(model_path)? {
|
|
||||||
if !dependency.is_file() {
|
|
||||||
let display = normalize_asset_path(&dependency)
|
|
||||||
.unwrap_or_else(|| dependency.to_string_lossy().replace('\\', "/"));
|
|
||||||
return Err(format!("missing dependency: {display}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves every local external buffer and image used by a glTF. Data URIs and remote URLs are
|
|
||||||
/// self-contained/non-project dependencies and intentionally do not participate in invalidation.
|
|
||||||
pub(crate) fn gltf_dependency_paths(model_path: &Path) -> Result<Vec<PathBuf>, String> {
|
|
||||||
if !is_gltf_path(model_path) {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let gltf = gltf::Gltf::open(model_path).map_err(|error| format!("invalid glTF: {error}"))?;
|
let gltf = gltf::Gltf::open(model_path).map_err(|error| format!("invalid glTF: {error}"))?;
|
||||||
let mut uris = Vec::new();
|
let mut uris = Vec::new();
|
||||||
for buffer in gltf.buffers() {
|
for buffer in gltf.buffers() {
|
||||||
@ -41,16 +24,18 @@ pub(crate) fn gltf_dependency_paths(model_path: &Path) -> Result<Vec<PathBuf>, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
let model_dir = model_path.parent().unwrap_or_else(|| Path::new(""));
|
let model_dir = model_path.parent().unwrap_or_else(|| Path::new(""));
|
||||||
let mut dependencies = Vec::new();
|
|
||||||
for uri in uris {
|
for uri in uris {
|
||||||
if uri.starts_with("data:") || uri.contains("://") {
|
if uri.starts_with("data:") || uri.contains("://") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
dependencies.push(model_dir.join(uri));
|
let dependency = model_dir.join(uri);
|
||||||
|
if !dependency.is_file() {
|
||||||
|
let display = normalize_asset_path(&dependency)
|
||||||
|
.unwrap_or_else(|| dependency.to_string_lossy().replace('\\', "/"));
|
||||||
|
return Err(format!("missing dependency: {display}"));
|
||||||
}
|
}
|
||||||
dependencies.sort();
|
}
|
||||||
dependencies.dedup();
|
Ok(())
|
||||||
Ok(dependencies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns primitive labels that cannot be rendered without their source skin hierarchy.
|
/// Returns primitive labels that cannot be rendered without their source skin hierarchy.
|
||||||
|
|||||||
@ -9,19 +9,17 @@ use bevy::gltf::GltfAssetLabel;
|
|||||||
use bevy::math::bounding::Aabb3d;
|
use bevy::math::bounding::Aabb3d;
|
||||||
use bevy::mesh::VertexAttributeValues;
|
use bevy::mesh::VertexAttributeValues;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::render::gpu_readback::{Readback, ReadbackComplete};
|
use bevy::render::render_resource::TextureFormat;
|
||||||
use bevy::render::render_resource::{TextureFormat, TextureUsages};
|
|
||||||
use bevy_egui::EguiUserTextures;
|
use bevy_egui::EguiUserTextures;
|
||||||
use shared::{material_from_desc, standard_material_asset_path, ModelRef};
|
use shared::{material_from_desc, standard_material_asset_path, ModelRef};
|
||||||
|
|
||||||
use super::cache::AssetThumbnailCache;
|
use super::cache::AssetThumbnailCache;
|
||||||
use super::disk_cache::{persist_rgba8, ThumbnailArtifact, THUMBNAIL_EDGE};
|
|
||||||
use super::job::{ThumbnailJob, ThumbnailJobSource};
|
use super::job::{ThumbnailJob, ThumbnailJobSource};
|
||||||
use super::sources::{source_for_extension, spawn_fbx_mesh_subasset_preview, uses_scene_root};
|
use super::sources::{source_for_extension, spawn_fbx_mesh_subasset_preview, uses_scene_root};
|
||||||
use crate::assets::asset_server_path;
|
use crate::assets::asset_server_path;
|
||||||
use crate::infra::EditorOnly;
|
use crate::infra::EditorOnly;
|
||||||
|
|
||||||
pub(crate) const THUMB_SIZE: u32 = THUMBNAIL_EDGE;
|
pub(crate) const THUMB_SIZE: u32 = 128;
|
||||||
pub(crate) const THUMBNAIL_LAYER: usize = 31;
|
pub(crate) const THUMBNAIL_LAYER: usize = 31;
|
||||||
const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians();
|
const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians();
|
||||||
const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5;
|
const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5;
|
||||||
@ -33,7 +31,6 @@ const FIRST_RENDER_WARMUP_FRAMES: u8 = 120;
|
|||||||
const RENDER_FRAMES: u8 = 3;
|
const RENDER_FRAMES: u8 = 3;
|
||||||
const COOLDOWN_FRAMES: u8 = 2;
|
const COOLDOWN_FRAMES: u8 = 2;
|
||||||
const LOAD_TIMEOUT_FRAMES: u32 = 240;
|
const LOAD_TIMEOUT_FRAMES: u32 = 240;
|
||||||
const MAX_AUTO_RETRIES: u8 = 1;
|
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct ThumbnailStudioCamera;
|
struct ThumbnailStudioCamera;
|
||||||
@ -47,11 +44,6 @@ struct ThumbnailStudioLayer;
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct ThumbnailSceneRoot;
|
struct ThumbnailSceneRoot;
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
struct ThumbnailReadback {
|
|
||||||
artifact: ThumbnailArtifact,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub struct ThumbnailStudio {
|
pub struct ThumbnailStudio {
|
||||||
camera: Entity,
|
camera: Entity,
|
||||||
@ -66,9 +58,6 @@ pub struct ThumbnailStudio {
|
|||||||
struct ActiveModelThumbnail {
|
struct ActiveModelThumbnail {
|
||||||
cache_key: String,
|
cache_key: String,
|
||||||
label: String,
|
label: String,
|
||||||
source: ThumbnailJobSource,
|
|
||||||
artifact: ThumbnailArtifact,
|
|
||||||
attempt: u8,
|
|
||||||
render_image: Handle<Image>,
|
render_image: Handle<Image>,
|
||||||
root: Entity,
|
root: Entity,
|
||||||
meshes_ready: bool,
|
meshes_ready: bool,
|
||||||
@ -90,7 +79,6 @@ enum ThumbnailFraming {
|
|||||||
impl ThumbnailFraming {
|
impl ThumbnailFraming {
|
||||||
fn for_source(source: &ThumbnailJobSource) -> Self {
|
fn for_source(source: &ThumbnailJobSource) -> Self {
|
||||||
match source {
|
match source {
|
||||||
ThumbnailJobSource::Texture { .. } => Self::SceneBounds,
|
|
||||||
ThumbnailJobSource::SourceMaterial { .. }
|
ThumbnailJobSource::SourceMaterial { .. }
|
||||||
| ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere,
|
| ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere,
|
||||||
ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => {
|
ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => {
|
||||||
@ -111,12 +99,11 @@ impl Plugin for ThumbnailStudioPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ThumbnailStudio {
|
impl ThumbnailStudio {
|
||||||
pub(crate) fn enqueue_source(
|
pub fn enqueue(&mut self, cache_key: String, model_path: String) -> bool {
|
||||||
&mut self,
|
self.enqueue_source(cache_key, ThumbnailJobSource::Model { model_path })
|
||||||
cache_key: String,
|
}
|
||||||
source: ThumbnailJobSource,
|
|
||||||
artifact: ThumbnailArtifact,
|
pub fn enqueue_source(&mut self, cache_key: String, source: ThumbnailJobSource) -> bool {
|
||||||
) -> bool {
|
|
||||||
if self
|
if self
|
||||||
.active
|
.active
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -127,12 +114,7 @@ impl ThumbnailStudio {
|
|||||||
if self.queue.iter().any(|job| job.cache_key == cache_key) {
|
if self.queue.iter().any(|job| job.cache_key == cache_key) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.queue.push_back(ThumbnailJob {
|
self.queue.push_back(ThumbnailJob { cache_key, source });
|
||||||
cache_key,
|
|
||||||
source,
|
|
||||||
artifact,
|
|
||||||
attempt: 0,
|
|
||||||
});
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,14 +324,8 @@ fn process_thumbnail_studio(
|
|||||||
cache.complete_studio_thumbnail(
|
cache.complete_studio_thumbnail(
|
||||||
&active.cache_key,
|
&active.cache_key,
|
||||||
active.render_image.clone(),
|
active.render_image.clone(),
|
||||||
&active.artifact,
|
|
||||||
&mut textures,
|
&mut textures,
|
||||||
);
|
);
|
||||||
spawn_thumbnail_readback(
|
|
||||||
&mut commands,
|
|
||||||
active.render_image.clone(),
|
|
||||||
active.artifact.clone(),
|
|
||||||
);
|
|
||||||
finish_active_thumbnail(
|
finish_active_thumbnail(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&mut studio,
|
&mut studio,
|
||||||
@ -412,9 +388,6 @@ fn process_thumbnail_studio(
|
|||||||
ActiveModelThumbnail {
|
ActiveModelThumbnail {
|
||||||
cache_key: job.cache_key,
|
cache_key: job.cache_key,
|
||||||
label,
|
label,
|
||||||
source: job.source,
|
|
||||||
artifact: job.artifact,
|
|
||||||
attempt: job.attempt,
|
|
||||||
render_image,
|
render_image,
|
||||||
root,
|
root,
|
||||||
meshes_ready: false,
|
meshes_ready: false,
|
||||||
@ -435,9 +408,6 @@ fn process_thumbnail_studio(
|
|||||||
studio.active = Some(ActiveModelThumbnail {
|
studio.active = Some(ActiveModelThumbnail {
|
||||||
cache_key: job.cache_key,
|
cache_key: job.cache_key,
|
||||||
label,
|
label,
|
||||||
source: job.source,
|
|
||||||
artifact: job.artifact,
|
|
||||||
attempt: job.attempt,
|
|
||||||
render_image,
|
render_image,
|
||||||
root,
|
root,
|
||||||
meshes_ready: false,
|
meshes_ready: false,
|
||||||
@ -460,7 +430,6 @@ fn spawn_thumbnail_job_content(
|
|||||||
source: &ThumbnailJobSource,
|
source: &ThumbnailJobSource,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match source {
|
match source {
|
||||||
ThumbnailJobSource::Texture { .. } => false,
|
|
||||||
ThumbnailJobSource::Model { model_path } => {
|
ThumbnailJobSource::Model { model_path } => {
|
||||||
if uses_scene_root(model_path) {
|
if uses_scene_root(model_path) {
|
||||||
commands.entity(root).insert(WorldAssetRoot(
|
commands.entity(root).insert(WorldAssetRoot(
|
||||||
@ -582,45 +551,7 @@ fn labeled_asset_path(path: &str, label: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn studio_render_image() -> Image {
|
fn studio_render_image() -> Image {
|
||||||
let mut image =
|
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
|
||||||
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba8UnormSrgb, None);
|
|
||||||
image.texture_descriptor.usage |= TextureUsages::COPY_SRC;
|
|
||||||
image
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_thumbnail_readback(
|
|
||||||
commands: &mut Commands,
|
|
||||||
image: Handle<Image>,
|
|
||||||
artifact: ThumbnailArtifact,
|
|
||||||
) {
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
EditorOnly,
|
|
||||||
ThumbnailReadback { artifact },
|
|
||||||
Readback::texture(image),
|
|
||||||
))
|
|
||||||
.observe(persist_thumbnail_readback);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_thumbnail_readback(
|
|
||||||
event: On<ReadbackComplete>,
|
|
||||||
readbacks: Query<&ThumbnailReadback>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
let Ok(readback) = readbacks.get(event.entity) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let artifact = readback.artifact.clone();
|
|
||||||
let data = event.data.clone();
|
|
||||||
commands.entity(event.entity).despawn();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
if let Err(error) = persist_rgba8(&artifact, THUMB_SIZE, THUMB_SIZE, &data) {
|
|
||||||
warn!(
|
|
||||||
"Could not persist thumbnail {}: {error}",
|
|
||||||
artifact.cache_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_frames(render_pipeline_warmed: bool) -> u8 {
|
fn capture_frames(render_pipeline_warmed: bool) -> u8 {
|
||||||
@ -660,21 +591,6 @@ fn fail_active_thumbnail(
|
|||||||
reason: &str,
|
reason: &str,
|
||||||
retryable: bool,
|
retryable: bool,
|
||||||
) {
|
) {
|
||||||
if retryable && active.attempt < MAX_AUTO_RETRIES {
|
|
||||||
let next_attempt = active.attempt + 1;
|
|
||||||
warn!(
|
|
||||||
"Retrying thumbnail {} after {reason} (attempt {next_attempt})",
|
|
||||||
active.label
|
|
||||||
);
|
|
||||||
studio.queue.push_back(ThumbnailJob {
|
|
||||||
cache_key: active.cache_key.clone(),
|
|
||||||
source: active.source.clone(),
|
|
||||||
artifact: active.artifact.clone(),
|
|
||||||
attempt: next_attempt,
|
|
||||||
});
|
|
||||||
finish_active_thumbnail(commands, studio, cameras, camera_targets, active);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cache.mark_studio_failed(&active.cache_key, reason, retryable);
|
cache.mark_studio_failed(&active.cache_key, reason, retryable);
|
||||||
finish_active_thumbnail(commands, studio, cameras, camera_targets, active);
|
finish_active_thumbnail(commands, studio, cameras, camera_targets, active);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,13 +6,6 @@ pub struct ContentProcessingQueue {
|
|||||||
pub(super) active: Option<ActiveProcessingJob>,
|
pub(super) active: Option<ActiveProcessingJob>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContentProcessingQueue {
|
|
||||||
/// Lightweight UI snapshot that does not expose processing request internals.
|
|
||||||
pub fn job_count(&self) -> usize {
|
|
||||||
self.pending.len() + usize::from(self.active.is_some())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) enum ProcessingRequest {
|
pub(super) enum ProcessingRequest {
|
||||||
Material {
|
Material {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ use crate::assets::{
|
|||||||
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets,
|
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets,
|
||||||
ExternalMoveRepairChoice, PendingExternalMoveRepair, ASSETS_ROOT, BUILTINS_FOLDER,
|
ExternalMoveRepairChoice, PendingExternalMoveRepair, ASSETS_ROOT, BUILTINS_FOLDER,
|
||||||
};
|
};
|
||||||
use crate::project::collaboration::{CollaborationState, FileStatusTone};
|
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
|
||||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
@ -49,17 +49,20 @@ use crate::assets::{
|
|||||||
};
|
};
|
||||||
use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus};
|
use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus};
|
||||||
use crate::ui::document_status::authored_document_status_ui;
|
use crate::ui::document_status::authored_document_status_ui;
|
||||||
|
use crate::ui::helpers::asset_label;
|
||||||
use crate::ui::theme::{
|
use crate::ui::theme::{
|
||||||
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
|
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
|
||||||
};
|
};
|
||||||
|
use crate::ui::widgets::{icon_button_small, tool_button};
|
||||||
|
|
||||||
const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT;
|
const FOOTER_HEIGHT: f32 = 64.0;
|
||||||
const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0;
|
const TOOLBAR_HEIGHT: f32 = 40.0;
|
||||||
|
const DETAILS_MIN_PANEL_WIDTH: f32 = 760.0;
|
||||||
const DETAILS_MIN_WIDTH: f32 = 200.0;
|
const DETAILS_MIN_WIDTH: f32 = 200.0;
|
||||||
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
|
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
|
||||||
const TREE_MIN_PANEL_WIDTH: f32 = editor_ui::content_browser::COMPACT_BREAKPOINT;
|
const TREE_MIN_PANEL_WIDTH: f32 = 540.0;
|
||||||
const TREE_MIN_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH;
|
const TREE_MIN_WIDTH: f32 = 150.0;
|
||||||
const TREE_MAX_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH;
|
const TREE_MAX_WIDTH: f32 = 240.0;
|
||||||
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
||||||
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
||||||
const PHOSPHOR: &str = "phosphor-regular";
|
const PHOSPHOR: &str = "phosphor-regular";
|
||||||
@ -98,6 +101,88 @@ struct ExtractedMaterialBinding {
|
|||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 exact_region<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
size: egui::Vec2,
|
||||||
|
layout: egui::Layout,
|
||||||
|
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout));
|
||||||
|
add_contents(&mut child)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exact_region_with_pointer<R>(
|
||||||
|
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(
|
pub fn asset_browser_ui(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@ -105,9 +190,7 @@ pub fn asset_browser_ui(
|
|||||||
) {
|
) {
|
||||||
let available_width = ui.available_width().max(1.0);
|
let available_width = ui.available_width().max(1.0);
|
||||||
let available_height = ui.available_height().max(1.0);
|
let available_height = ui.available_height().max(1.0);
|
||||||
let toolbar_height =
|
let body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0);
|
||||||
editor_ui::content_browser::ContentBrowserChrome::Docked.height(available_width);
|
|
||||||
let body_height = (available_height - toolbar_height - FOOTER_HEIGHT).max(40.0);
|
|
||||||
let browser_interactions_enabled = !content_browser_modal_active(world);
|
let browser_interactions_enabled = !content_browser_modal_active(world);
|
||||||
|
|
||||||
exact_region(
|
exact_region(
|
||||||
@ -115,30 +198,20 @@ pub fn asset_browser_ui(
|
|||||||
egui::vec2(available_width, available_height),
|
egui::vec2(available_width, available_height),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
// The Content Browser owns a continuous toolbar/body/footer stack. Default egui item
|
|
||||||
// spacing creates an unowned horizontal gutter between those exact regions.
|
|
||||||
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
if !browser_interactions_enabled {
|
if !browser_interactions_enabled {
|
||||||
ui.disable();
|
ui.disable();
|
||||||
}
|
}
|
||||||
exact_region(
|
exact_region(
|
||||||
ui,
|
ui,
|
||||||
egui::vec2(available_width, toolbar_height),
|
egui::vec2(available_width, TOOLBAR_HEIGHT),
|
||||||
egui::Layout::left_to_right(egui::Align::Center),
|
egui::Layout::left_to_right(egui::Align::Center),
|
||||||
|ui| {
|
|ui| {
|
||||||
asset_toolbar(world, ui);
|
asset_toolbar(world, ui);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let (
|
ui.separator();
|
||||||
current_folder,
|
|
||||||
selected,
|
let (current_folder, selected, folders, assets, cache_snapshot, state_snapshot) = {
|
||||||
folders,
|
|
||||||
assets,
|
|
||||||
cache_snapshot,
|
|
||||||
state_snapshot,
|
|
||||||
browser_status,
|
|
||||||
selection_count,
|
|
||||||
) = {
|
|
||||||
let assets = world.resource::<EditorAssets>();
|
let assets = world.resource::<EditorAssets>();
|
||||||
let cache = world.resource::<AssetThumbnailCache>();
|
let cache = world.resource::<AssetThumbnailCache>();
|
||||||
let state = world.resource::<AssetBrowserUiState>();
|
let state = world.resource::<AssetBrowserUiState>();
|
||||||
@ -168,47 +241,28 @@ pub fn asset_browser_ui(
|
|||||||
asset_rows,
|
asset_rows,
|
||||||
cache.snapshot(),
|
cache.snapshot(),
|
||||||
state_snapshot(state),
|
state_snapshot(state),
|
||||||
assets.status.clone(),
|
|
||||||
assets.selections.len(),
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
prefetch_folder_thumbnails(world, ¤t_folder);
|
prefetch_folder_thumbnails(world, ¤t_folder);
|
||||||
|
|
||||||
let compact = available_width < TREE_MIN_PANEL_WIDTH;
|
let show_tree = available_width >= TREE_MIN_PANEL_WIDTH;
|
||||||
let show_tree = if compact {
|
let show_details =
|
||||||
state_snapshot.show_sources
|
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
|
||||||
} else {
|
|
||||||
true
|
|
||||||
};
|
|
||||||
let show_details = if compact {
|
|
||||||
state_snapshot.show_compact_details
|
|
||||||
} else {
|
|
||||||
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH
|
|
||||||
};
|
|
||||||
let tree_width = if show_tree {
|
let tree_width = if show_tree {
|
||||||
TREE_MAX_WIDTH.min((available_width - MIN_CONTENT_WIDTH).max(TREE_MIN_WIDTH))
|
(available_width * 0.22).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let details_width = if show_details {
|
let details_width = if show_details {
|
||||||
if compact {
|
details_width_for_layout(available_width, tree_width, state_snapshot.details_width)
|
||||||
editor_ui::content_browser::DETAILS_WIDTH
|
|
||||||
.min((available_width - MIN_CONTENT_WIDTH).max(DETAILS_MIN_WIDTH))
|
|
||||||
} else {
|
|
||||||
details_width_for_layout(
|
|
||||||
available_width,
|
|
||||||
tree_width,
|
|
||||||
state_snapshot.details_width,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
// Penpot separators and the Details resize target overlay their owning panes. They do
|
let separator_budget = (show_tree as u8 as f32) * 10.0
|
||||||
// not consume layout width: 216 + 734 + 290 must close exactly at the 1240 px reference.
|
+ (show_details as u8 as f32) * DETAILS_RESIZE_HANDLE_WIDTH;
|
||||||
let content_width =
|
let content_width = (available_width - tree_width - details_width - separator_budget)
|
||||||
content_width_for_layout(available_width, tree_width, details_width);
|
.max(MIN_CONTENT_WIDTH.min(available_width));
|
||||||
|
|
||||||
exact_region(
|
exact_region(
|
||||||
ui,
|
ui,
|
||||||
@ -221,10 +275,7 @@ pub fn asset_browser_ui(
|
|||||||
egui::vec2(tree_width, body_height),
|
egui::vec2(tree_width, body_height),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
editor_ui::content_browser::content_browser_pane_background(ui);
|
ui.label(panel_heading("Project"));
|
||||||
editor_ui::content_browser::content_browser_pane_heading(
|
|
||||||
ui, "SOURCES", false,
|
|
||||||
);
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("asset_folder_tree")
|
.id_salt("asset_folder_tree")
|
||||||
.max_height(ui.available_height())
|
.max_height(ui.available_height())
|
||||||
@ -243,6 +294,7 @@ pub fn asset_browser_ui(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
ui.separator();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (visible_order, content_response) = exact_region_with_pointer(
|
let (visible_order, content_response) = exact_region_with_pointer(
|
||||||
@ -250,16 +302,7 @@ pub fn asset_browser_ui(
|
|||||||
egui::vec2(content_width, body_height),
|
egui::vec2(content_width, body_height),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui, content_contains_pointer| {
|
|ui, content_contains_pointer| {
|
||||||
editor_ui::content_browser::content_browser_pane_background(ui);
|
content_header(world, ui, ¤t_folder, &folders, &assets);
|
||||||
content_header(
|
|
||||||
world,
|
|
||||||
ui,
|
|
||||||
¤t_folder,
|
|
||||||
&folders,
|
|
||||||
&assets,
|
|
||||||
selection_count,
|
|
||||||
);
|
|
||||||
editor_ui::content_browser::content_browser_grid_surface_background(ui);
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("asset_content_area")
|
.id_salt("asset_content_area")
|
||||||
.max_height(ui.available_height())
|
.max_height(ui.available_height())
|
||||||
@ -288,9 +331,6 @@ pub fn asset_browser_ui(
|
|||||||
let thumbnail_size = state_snapshot
|
let thumbnail_size = state_snapshot
|
||||||
.thumbnail_size
|
.thumbnail_size
|
||||||
.min((content_width - 26.0).max(48.0));
|
.min((content_width - 26.0).max(48.0));
|
||||||
editor_ui::content_browser::content_browser_grid_inset(
|
|
||||||
ui,
|
|
||||||
|ui| {
|
|
||||||
asset_grid(
|
asset_grid(
|
||||||
world,
|
world,
|
||||||
ui,
|
ui,
|
||||||
@ -302,8 +342,6 @@ pub fn asset_browser_ui(
|
|||||||
thumbnail_size,
|
thumbnail_size,
|
||||||
&visible_order,
|
&visible_order,
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
asset_list(
|
asset_list(
|
||||||
world,
|
world,
|
||||||
@ -324,30 +362,7 @@ pub fn asset_browser_ui(
|
|||||||
content_empty_space(world, content_response, ¤t_folder, &visible_order);
|
content_empty_space(world, content_response, ¤t_folder, &visible_order);
|
||||||
|
|
||||||
if show_details {
|
if show_details {
|
||||||
let details_boundary = ui.next_widget_position();
|
let resize_response = details_resize_handle(ui, body_height);
|
||||||
exact_region(
|
|
||||||
ui,
|
|
||||||
egui::vec2(details_width, body_height),
|
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
|
||||||
|ui| {
|
|
||||||
editor_ui::content_browser::content_browser_pane_background(ui);
|
|
||||||
editor_ui::content_browser::content_browser_details_pane_heading(
|
|
||||||
ui, "DETAILS", true,
|
|
||||||
);
|
|
||||||
egui::ScrollArea::vertical()
|
|
||||||
.id_salt("asset_details_area")
|
|
||||||
.max_height(ui.available_height())
|
|
||||||
.auto_shrink([false, false])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
details_panel(world, ui, selected_entities);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !compact {
|
|
||||||
// Paint and interact after the Details lane so its background cannot
|
|
||||||
// cover the overlaid resize affordance.
|
|
||||||
let resize_response =
|
|
||||||
details_resize_handle(ui, details_boundary, body_height);
|
|
||||||
let requested_width = if resize_response.double_clicked() {
|
let requested_width = if resize_response.double_clicked() {
|
||||||
Some(ASSET_DETAILS_DEFAULT_WIDTH)
|
Some(ASSET_DETAILS_DEFAULT_WIDTH)
|
||||||
} else if resize_response.dragged() {
|
} else if resize_response.dragged() {
|
||||||
@ -363,65 +378,37 @@ pub fn asset_browser_ui(
|
|||||||
requested_width,
|
requested_width,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
exact_region(
|
||||||
|
ui,
|
||||||
|
egui::vec2(details_width, body_height),
|
||||||
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|
|ui| {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("asset_details_area")
|
||||||
|
.max_height(ui.available_height())
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
details_panel(world, ui, selected_entities);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
exact_region(
|
exact_region(
|
||||||
ui,
|
ui,
|
||||||
egui::vec2(available_width, FOOTER_HEIGHT),
|
egui::vec2(available_width, FOOTER_HEIGHT),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
let direct_assets = assets
|
egui::Frame::new()
|
||||||
.iter()
|
.fill(ELEVATED_BG.linear_multiply(0.5))
|
||||||
.filter(|row| row.asset.folder_path == current_folder)
|
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||||
.count();
|
.show(ui, |ui| {
|
||||||
let direct_folders = folders
|
ui.set_max_height(FOOTER_HEIGHT - 12.0);
|
||||||
.iter()
|
selection_footer(world, ui, selected_entities);
|
||||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
|
});
|
||||||
.count();
|
|
||||||
let selection_summary = if selection_count == 1 {
|
|
||||||
selected.as_ref().map_or_else(
|
|
||||||
|| "1 selected".to_string(),
|
|
||||||
|item| format!("{} selected", item.display_label()),
|
|
||||||
)
|
|
||||||
} else if selection_count > 1 {
|
|
||||||
format!("{selection_count} assets selected")
|
|
||||||
} else {
|
|
||||||
format!("{direct_folders} folders · {direct_assets} assets")
|
|
||||||
};
|
|
||||||
let thumbnail_jobs = world
|
|
||||||
.resource::<AssetThumbnailCache>()
|
|
||||||
.snapshot()
|
|
||||||
.pending_keys
|
|
||||||
.len();
|
|
||||||
let processing_jobs = world
|
|
||||||
.get_resource::<crate::asset_documents::ContentProcessingQueue>()
|
|
||||||
.map_or(0, |queue| queue.job_count());
|
|
||||||
let processing = if thumbnail_jobs > 0 {
|
|
||||||
format!("{thumbnail_jobs} thumbnails processing")
|
|
||||||
} else if processing_jobs > 0 {
|
|
||||||
format!("{processing_jobs} content jobs active")
|
|
||||||
} else {
|
|
||||||
"Background processing idle".to_string()
|
|
||||||
};
|
|
||||||
let healthy = !browser_status.to_ascii_lowercase().contains("failed")
|
|
||||||
&& !browser_status.to_ascii_lowercase().contains("error");
|
|
||||||
let catalog = if healthy {
|
|
||||||
"Catalog ready"
|
|
||||||
} else {
|
|
||||||
browser_status.as_str()
|
|
||||||
};
|
|
||||||
editor_ui::content_browser::content_browser_status_bar(
|
|
||||||
ui,
|
|
||||||
editor_ui::content_browser::ContentBrowserStatusViewModel {
|
|
||||||
catalog,
|
|
||||||
processing: &processing,
|
|
||||||
selection: &selection_summary,
|
|
||||||
healthy,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -447,8 +434,6 @@ mod file_operations;
|
|||||||
mod grid;
|
mod grid;
|
||||||
#[path = "panel/import_settings.rs"]
|
#[path = "panel/import_settings.rs"]
|
||||||
mod import_settings;
|
mod import_settings;
|
||||||
#[path = "panel/layout.rs"]
|
|
||||||
mod layout;
|
|
||||||
#[path = "panel/list.rs"]
|
#[path = "panel/list.rs"]
|
||||||
mod list;
|
mod list;
|
||||||
#[path = "panel/material_extraction.rs"]
|
#[path = "panel/material_extraction.rs"]
|
||||||
@ -475,7 +460,6 @@ use embedded::*;
|
|||||||
use file_operations::*;
|
use file_operations::*;
|
||||||
use grid::*;
|
use grid::*;
|
||||||
use import_settings::*;
|
use import_settings::*;
|
||||||
use layout::*;
|
|
||||||
use list::*;
|
use list::*;
|
||||||
pub(crate) use material_extraction::begin_actor_material_extraction;
|
pub(crate) use material_extraction::begin_actor_material_extraction;
|
||||||
use material_extraction::*;
|
use material_extraction::*;
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) {
|
pub(super) fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||||
editor_ui::content_browser::content_browser_detail_row(ui, label, value);
|
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 detail_path_row(ui: &mut egui::Ui, label: &str, value: &str) {
|
});
|
||||||
editor_ui::content_browser::content_browser_detail_path_row(ui, label, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn details_panel(
|
pub(super) fn details_panel(
|
||||||
@ -13,6 +12,8 @@ pub(super) fn details_panel(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
selected_entities: &SelectedEntities,
|
selected_entities: &SelectedEntities,
|
||||||
) {
|
) {
|
||||||
|
ui.label(panel_heading("Details"));
|
||||||
|
ui.separator();
|
||||||
let selections = world.resource::<EditorAssets>().selections.clone();
|
let selections = world.resource::<EditorAssets>().selections.clone();
|
||||||
if selections.len() > 1 {
|
if selections.len() > 1 {
|
||||||
let has_authored_content = !selected_content_paths(world).is_empty();
|
let has_authored_content = !selected_content_paths(world).is_empty();
|
||||||
@ -60,7 +61,7 @@ pub(super) fn details_panel(
|
|||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.unwrap_or(&path),
|
.unwrap_or(&path),
|
||||||
));
|
));
|
||||||
detail_path_row(ui, "Path", &path);
|
detail_row(ui, "Path", &path);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("Open Folder").clicked() {
|
if ui.button("Open Folder").clicked() {
|
||||||
navigate_content_folder(world, path);
|
navigate_content_folder(world, path);
|
||||||
@ -70,7 +71,7 @@ pub(super) fn details_panel(
|
|||||||
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
|
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
|
||||||
top_level_asset_details_panel(world, ui, selected_entities, &asset);
|
top_level_asset_details_panel(world, ui, selected_entities, &asset);
|
||||||
} else {
|
} else {
|
||||||
editor_ui::content_browser::content_browser_details_empty_state(ui, "No asset selected");
|
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,9 +82,9 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
asset: &EditorAsset,
|
asset: &EditorAsset,
|
||||||
) {
|
) {
|
||||||
asset_details_header(world, ui, asset);
|
asset_details_header(world, ui, asset);
|
||||||
editor_ui::content_browser::content_browser_details_section(ui, "IDENTITY");
|
ui.add_space(8.0);
|
||||||
detail_path_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
|
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
|
||||||
detail_path_row(ui, "Folder", asset.folder_path.as_str());
|
detail_row(ui, "Folder", asset.folder_path.as_str());
|
||||||
|
|
||||||
if asset.path.is_none() && asset.label == blacksite_surface::DEFAULT_GRID_LABEL {
|
if asset.path.is_none() && asset.label == blacksite_surface::DEFAULT_GRID_LABEL {
|
||||||
ui.separator();
|
ui.separator();
|
||||||
@ -100,43 +101,66 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
detail_row(ui, "Asset ID", &record.id.as_string());
|
detail_row(ui, "Asset ID", &record.id.as_string());
|
||||||
if matches!(asset.kind, EditorAssetKind::Model) {
|
if matches!(asset.kind, EditorAssetKind::Model) {
|
||||||
let settings = record.model_import();
|
let settings = record.model_import();
|
||||||
editor_ui::content_browser::content_browser_details_section(ui, "IMPORT SETTINGS");
|
ui.separator();
|
||||||
|
ui.label(panel_heading("Import Settings"));
|
||||||
import_settings_editor(world, ui, asset, path, settings);
|
import_settings_editor(world, ui, asset, path, settings);
|
||||||
if let Some(manifest) = settings.static_mesh_manifest_path.as_deref() {
|
if let Some(manifest) = settings.static_mesh_manifest_path.as_deref() {
|
||||||
detail_path_row(ui, "Artifact", manifest);
|
detail_row(ui, "Static mesh artifact", manifest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(asset.kind, EditorAssetKind::Texture) {
|
if matches!(asset.kind, EditorAssetKind::Texture) {
|
||||||
if let Some(settings) = record.texture_import() {
|
if let Some(settings) = record.texture_import() {
|
||||||
editor_ui::content_browser::content_browser_details_section(
|
ui.separator();
|
||||||
ui,
|
ui.label(panel_heading("Texture Properties"));
|
||||||
"TEXTURE PROPERTIES",
|
|
||||||
);
|
|
||||||
texture_import_settings_editor(world, ui, path, &record, settings);
|
texture_import_settings_editor(world, ui, path, &record, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !record.dependencies.is_empty() {
|
if !record.dependencies.is_empty() {
|
||||||
editor_ui::content_browser::content_browser_details_section(ui, "DEPENDENCIES");
|
ui.separator();
|
||||||
let missing_count = record
|
let missing_count = record
|
||||||
.dependencies
|
.dependencies
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|dependency| !dependency_path(dependency).is_file())
|
.filter(|dependency| !dependency_path(dependency).is_file())
|
||||||
.count();
|
.count();
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.label(panel_heading("Dependencies"));
|
||||||
if missing_count > 0 {
|
if missing_count > 0 {
|
||||||
let optional = !model_uses_source_materials(&record);
|
let optional = !model_uses_source_materials(&record);
|
||||||
detail_row(
|
ui.small(
|
||||||
ui,
|
egui::RichText::new(if optional {
|
||||||
"Status",
|
|
||||||
&if optional {
|
|
||||||
format!("{missing_count} missing | override")
|
format!("{missing_count} missing | override")
|
||||||
} else {
|
} else {
|
||||||
format!("{missing_count} missing")
|
format!("{missing_count} missing")
|
||||||
},
|
})
|
||||||
|
.color(if optional {
|
||||||
|
WARNING
|
||||||
|
} else {
|
||||||
|
ERROR
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
for dep in &record.dependencies {
|
for dep in &record.dependencies {
|
||||||
let exists = dependency_path(dep).is_file();
|
let exists = dependency_path(dep).is_file();
|
||||||
detail_path_row(ui, if exists { "Ready" } else { "Missing" }, dep);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,7 +178,8 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
.unwrap_or("-"),
|
.unwrap_or("-"),
|
||||||
);
|
);
|
||||||
if matches!(asset.kind, EditorAssetKind::Material) {
|
if matches!(asset.kind, EditorAssetKind::Material) {
|
||||||
editor_ui::content_browser::content_browser_details_section(ui, "MATERIAL");
|
ui.separator();
|
||||||
|
ui.label(panel_heading("Material"));
|
||||||
crate::ui::design_system::scope(ui, |ui| {
|
crate::ui::design_system::scope(ui, |ui| {
|
||||||
if MaterialInstanceAsset::load_from_path(path).is_ok() {
|
if MaterialInstanceAsset::load_from_path(path).is_ok() {
|
||||||
material_instance_asset_editor(world, ui, asset, path, true);
|
material_instance_asset_editor(world, ui, asset, path, true);
|
||||||
@ -202,81 +227,37 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
asset_action_buttons(world, ui, selected_entities, asset);
|
asset_action_buttons(world, ui, selected_entities, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn asset_details_header(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
||||||
let cache = world
|
ui.horizontal_wrapped(|ui| {
|
||||||
.get_resource::<AssetThumbnailCache>()
|
ui.label(
|
||||||
.map(AssetThumbnailCache::snapshot);
|
egui::RichText::new(kind_icon(&asset.kind).as_str())
|
||||||
let thumbnail = if let Some(texture) = cache.as_ref().and_then(|cache| cache.texture_for(asset))
|
.font(egui::FontId::new(
|
||||||
{
|
30.0,
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Ready(texture)
|
egui::FontFamily::Name(PHOSPHOR.into()),
|
||||||
} else if cache.as_ref().is_some_and(|cache| cache.is_pending(asset)) {
|
))
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Pending
|
.color(ACCENT),
|
||||||
} else if cache
|
);
|
||||||
.as_ref()
|
ui.vertical(|ui| {
|
||||||
.and_then(|cache| cache.failure_reason(asset))
|
ui.strong(asset.label.as_str());
|
||||||
.is_some()
|
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
|
||||||
{
|
if let (Some(state), Some(path)) = (
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Failed
|
world.get_resource::<CollaborationState>(),
|
||||||
} else {
|
asset.path.as_deref(),
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Placeholder
|
) {
|
||||||
};
|
let status = state.file_status(Path::new(path));
|
||||||
let dirty = asset.path.as_deref().is_some_and(|path| {
|
let _ = file_status_indicator_ui(ui, &status, Path::new(path));
|
||||||
|
}
|
||||||
|
if let Some(snapshot) = asset.path.as_deref().and_then(|path| {
|
||||||
world
|
world
|
||||||
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
||||||
.and_then(|store| store.snapshot_for_path(path))
|
.and_then(|store| store.snapshot_for_path(path))
|
||||||
.is_some_and(|snapshot| snapshot.dirty)
|
}) {
|
||||||
|
if snapshot.dirty {
|
||||||
|
ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong());
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let collaboration = asset.path.as_deref().and_then(|path| {
|
|
||||||
world
|
|
||||||
.get_resource::<CollaborationState>()
|
|
||||||
.map(|state| state.file_status(Path::new(path)))
|
|
||||||
});
|
});
|
||||||
let collaboration_tooltip = asset.path.as_deref().and_then(|path| {
|
|
||||||
collaboration
|
|
||||||
.as_ref()
|
|
||||||
.map(|status| status.tooltip(Path::new(path)))
|
|
||||||
});
|
|
||||||
let collaboration_indicator = collaboration.as_ref().and_then(|status| status.indicator());
|
|
||||||
let badge = if dirty {
|
|
||||||
Some((
|
|
||||||
"UNSAVED",
|
|
||||||
editor_ui::content_browser::ContentBrowserBadgeTone::Warning,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
collaboration_indicator.as_ref().map(|indicator| {
|
|
||||||
let tone = match indicator.tone {
|
|
||||||
FileStatusTone::Neutral => {
|
|
||||||
editor_ui::content_browser::ContentBrowserBadgeTone::Neutral
|
|
||||||
}
|
|
||||||
FileStatusTone::Success => {
|
|
||||||
editor_ui::content_browser::ContentBrowserBadgeTone::Healthy
|
|
||||||
}
|
|
||||||
FileStatusTone::Warning => {
|
|
||||||
editor_ui::content_browser::ContentBrowserBadgeTone::Warning
|
|
||||||
}
|
|
||||||
FileStatusTone::Error => editor_ui::content_browser::ContentBrowserBadgeTone::Error,
|
|
||||||
};
|
|
||||||
(indicator.label, tone)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let action = editor_ui::content_browser::content_browser_details_header(
|
|
||||||
ui,
|
|
||||||
editor_ui::content_browser::ContentBrowserDetailsHeaderViewModel {
|
|
||||||
label: &asset.label,
|
|
||||||
kind: kind_label(&asset.kind),
|
|
||||||
icon: kind_icon(&asset.kind),
|
|
||||||
thumbnail,
|
|
||||||
badge,
|
|
||||||
badge_tooltip: collaboration_tooltip.as_deref(),
|
|
||||||
unsaved: false,
|
|
||||||
can_locate: asset.path.is_some(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if action == Some(editor_ui::content_browser::ContentBrowserDetailsAction::Locate) {
|
|
||||||
if let Some(path) = asset.path.as_deref() {
|
|
||||||
locate_content_asset(world, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn subasset_details_panel(
|
pub(super) fn subasset_details_panel(
|
||||||
|
|||||||
@ -8,9 +8,7 @@ pub(super) struct AssetBrowserStateSnapshot {
|
|||||||
pub(super) kind_filter: AssetKindFilter,
|
pub(super) kind_filter: AssetKindFilter,
|
||||||
pub(super) thumbnail_size: f32,
|
pub(super) thumbnail_size: f32,
|
||||||
pub(super) recursive: bool,
|
pub(super) recursive: bool,
|
||||||
pub(super) show_sources: bool,
|
|
||||||
pub(super) show_details: bool,
|
pub(super) show_details: bool,
|
||||||
pub(super) show_compact_details: bool,
|
|
||||||
pub(super) details_width: f32,
|
pub(super) details_width: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,9 +20,7 @@ pub(super) fn state_snapshot(state: &AssetBrowserUiState) -> AssetBrowserStateSn
|
|||||||
kind_filter: state.kind_filter,
|
kind_filter: state.kind_filter,
|
||||||
thumbnail_size: state.thumbnail_size,
|
thumbnail_size: state.thumbnail_size,
|
||||||
recursive: state.recursive,
|
recursive: state.recursive,
|
||||||
show_sources: state.show_sources,
|
|
||||||
show_details: state.show_details,
|
show_details: state.show_details,
|
||||||
show_compact_details: state.show_compact_details,
|
|
||||||
details_width: state.details_width,
|
details_width: state.details_width,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use editor_ui::content_browser::{continuous_grid_entries, ContentBrowserGridEntry};
|
|
||||||
|
|
||||||
pub(super) fn content_empty_space(
|
pub(super) fn content_empty_space(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
@ -30,19 +29,17 @@ pub(super) fn asset_grid(
|
|||||||
thumbnail_size: f32,
|
thumbnail_size: f32,
|
||||||
visible_order: &[AssetSelection],
|
visible_order: &[AssetSelection],
|
||||||
) {
|
) {
|
||||||
let cell_width = editor_ui::content_browser::content_browser_asset_card_size(thumbnail_size).x;
|
let thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
||||||
let gap = editor_ui::content_browser::ASSET_CARD_GAP;
|
let cell_width = thumb_size + 20.0;
|
||||||
|
let gap = 6.0;
|
||||||
let columns = ((ui.available_width() + gap) / (cell_width + gap))
|
let columns = ((ui.available_width() + gap) / (cell_width + gap))
|
||||||
.floor()
|
.floor()
|
||||||
.max(1.0) as usize;
|
.max(1.0) as usize;
|
||||||
|
|
||||||
let entries = continuous_grid_entries(folders.len(), assets.len());
|
for folder_row in folders.chunks(columns) {
|
||||||
for entry_row in entries.chunks(columns) {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing = egui::vec2(gap, gap);
|
ui.spacing_mut().item_spacing = egui::vec2(gap, gap);
|
||||||
for entry in entry_row {
|
for folder in folder_row {
|
||||||
if let ContentBrowserGridEntry::Folder(folder_index) = *entry {
|
|
||||||
let folder = &folders[folder_index];
|
|
||||||
let selection = AssetSelection::Folder(folder.path.clone());
|
let selection = AssetSelection::Folder(folder.path.clone());
|
||||||
let is_selected = world.resource::<EditorAssets>().is_selected(&selection);
|
let is_selected = world.resource::<EditorAssets>().is_selected(&selection);
|
||||||
let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected);
|
let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected);
|
||||||
@ -61,29 +58,28 @@ pub(super) fn asset_grid(
|
|||||||
.start_drag(selection.clone());
|
.start_drag(selection.clone());
|
||||||
}
|
}
|
||||||
response.context_menu(|ui| folder_context_menu(world, ui, folder));
|
response.context_menu(|ui| folder_context_menu(world, ui, folder));
|
||||||
} else {
|
}
|
||||||
let ContentBrowserGridEntry::Asset(asset_index) = *entry else {
|
});
|
||||||
unreachable!("folder entries are handled above")
|
}
|
||||||
};
|
|
||||||
|
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(
|
draw_asset_grid_item(
|
||||||
world,
|
world,
|
||||||
ui,
|
ui,
|
||||||
selected_entities,
|
selected_entities,
|
||||||
selected,
|
selected,
|
||||||
&assets[asset_index],
|
row,
|
||||||
cache_snapshot,
|
cache_snapshot,
|
||||||
thumbnail_size,
|
thumbnail_size,
|
||||||
visible_order,
|
visible_order,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for entry in entry_row {
|
for row in asset_row {
|
||||||
let ContentBrowserGridEntry::Asset(asset_index) = *entry else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let row = &assets[asset_index];
|
|
||||||
if row.asset.path.as_deref().is_some_and(|path| {
|
if row.asset.path.as_deref().is_some_and(|path| {
|
||||||
world
|
world
|
||||||
.resource::<AssetBrowserUiState>()
|
.resource::<AssetBrowserUiState>()
|
||||||
@ -170,22 +166,19 @@ pub(super) fn draw_asset_grid_item(
|
|||||||
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
|
||||||
.and_then(|store| store.snapshot_for_path(path))
|
.and_then(|store| store.snapshot_for_path(path))
|
||||||
});
|
});
|
||||||
let thumbnail_pending = cache_snapshot.is_pending(asset);
|
|
||||||
let mut card_status = AssetCardStatus::for_document(
|
|
||||||
document_status.as_ref(),
|
|
||||||
document_status.as_ref().is_some_and(|status| status.dirty),
|
|
||||||
failure.is_some(),
|
|
||||||
);
|
|
||||||
card_status.processing |= thumbnail_pending;
|
|
||||||
let response = draw_asset_card(
|
let response = draw_asset_card(
|
||||||
ui,
|
ui,
|
||||||
asset,
|
asset,
|
||||||
cache_snapshot.texture_for(asset),
|
cache_snapshot.texture_for(asset),
|
||||||
thumbnail_pending,
|
cache_snapshot.is_pending(asset),
|
||||||
failure,
|
failure,
|
||||||
is_selected,
|
is_selected,
|
||||||
thumbnail_size,
|
thumbnail_size,
|
||||||
card_status,
|
AssetCardStatus::for_document(
|
||||||
|
document_status.as_ref(),
|
||||||
|
document_status.as_ref().is_some_and(|status| status.dirty),
|
||||||
|
failure.is_some(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if has_children {
|
if has_children {
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
pub(super) 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) 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()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn exact_region<R>(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
size: egui::Vec2,
|
|
||||||
layout: egui::Layout,
|
|
||||||
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
|
||||||
) -> R {
|
|
||||||
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
|
|
||||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout));
|
|
||||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
add_contents(&mut child)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn exact_region_with_pointer<R>(
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
let result = add_contents(&mut child, contains_pointer);
|
|
||||||
let response = child.response();
|
|
||||||
(result, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn details_width_for_layout(
|
|
||||||
available_width: f32,
|
|
||||||
tree_width: f32,
|
|
||||||
requested: f32,
|
|
||||||
) -> f32 {
|
|
||||||
let max_width = (available_width - tree_width - MIN_CONTENT_WIDTH).max(DETAILS_MIN_WIDTH);
|
|
||||||
requested.clamp(DETAILS_MIN_WIDTH, max_width)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn content_width_for_layout(
|
|
||||||
available_width: f32,
|
|
||||||
tree_width: f32,
|
|
||||||
details_width: f32,
|
|
||||||
) -> f32 {
|
|
||||||
(available_width - tree_width - details_width).max(MIN_CONTENT_WIDTH.min(available_width))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn details_resize_handle(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
boundary: egui::Pos2,
|
|
||||||
height: f32,
|
|
||||||
) -> egui::Response {
|
|
||||||
let rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(boundary.x - DETAILS_RESIZE_HANDLE_WIDTH * 0.5, boundary.y),
|
|
||||||
egui::vec2(DETAILS_RESIZE_HANDLE_WIDTH, height),
|
|
||||||
);
|
|
||||||
let response = ui.interact(
|
|
||||||
rect,
|
|
||||||
egui::Id::new("asset_details_resize_handle"),
|
|
||||||
egui::Sense::drag(),
|
|
||||||
);
|
|
||||||
let stroke_color = if response.hovered() || response.dragged() {
|
|
||||||
ACCENT
|
|
||||||
} else {
|
|
||||||
BORDER
|
|
||||||
};
|
|
||||||
ui.painter().vline(
|
|
||||||
boundary.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")
|
|
||||||
}
|
|
||||||
@ -368,19 +368,39 @@ pub(super) fn draw_folder_cell(
|
|||||||
thumbnail_size: f32,
|
thumbnail_size: f32,
|
||||||
is_selected: bool,
|
is_selected: bool,
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
editor_ui::content_browser::content_browser_asset_card(
|
let thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
||||||
ui,
|
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0);
|
||||||
editor_ui::content_browser::ContentBrowserCardViewModel {
|
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
|
||||||
label: &folder.name,
|
let fill = if is_selected {
|
||||||
kind: "Folder",
|
crate::ui::theme::SELECTION_BG_MUTED
|
||||||
icon: icons::FOLDER_OPEN,
|
} else if response.hovered() {
|
||||||
icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Fill,
|
ELEVATED_BG
|
||||||
thumbnail: editor_ui::content_browser::ContentBrowserThumbnail::Placeholder,
|
} else {
|
||||||
asset_tone: editor_ui::content_browser::ContentBrowserAssetTone::None,
|
WIDGET_BG
|
||||||
selected: is_selected,
|
};
|
||||||
status: editor_ui::content_browser::ContentBrowserCardStatus::default(),
|
ui.painter().rect(
|
||||||
failure_tooltip: None,
|
rect,
|
||||||
},
|
4.0,
|
||||||
thumbnail_size,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,12 @@ pub(super) fn content_header(
|
|||||||
current_folder: &str,
|
current_folder: &str,
|
||||||
folders: &[FolderSnapshot],
|
folders: &[FolderSnapshot],
|
||||||
assets: &[AssetRow],
|
assets: &[AssetRow],
|
||||||
selection_count: usize,
|
|
||||||
) {
|
) {
|
||||||
let folder_label = if current_folder == BUILTINS_FOLDER {
|
ui.horizontal_wrapped(|ui| {
|
||||||
"Built-ins"
|
ui.label(panel_heading("Assets"));
|
||||||
} else {
|
ui.separator();
|
||||||
Path::new(current_folder)
|
breadcrumb(world, ui, current_folder);
|
||||||
.file_name()
|
});
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(current_folder)
|
|
||||||
};
|
|
||||||
let direct_assets = assets
|
let direct_assets = assets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|row| row.asset.folder_path == current_folder)
|
.filter(|row| row.asset.folder_path == current_folder)
|
||||||
@ -24,15 +20,9 @@ pub(super) fn content_header(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
||||||
.count();
|
.count();
|
||||||
let sort = world.resource::<AssetBrowserUiState>().sort;
|
ui.small(
|
||||||
editor_ui::content_browser::content_browser_content_header(
|
egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets"))
|
||||||
ui,
|
.color(TEXT_DIM),
|
||||||
editor_ui::content_browser::ContentBrowserContentHeaderViewModel {
|
|
||||||
folder: folder_label,
|
|
||||||
item_count: direct_folders + direct_assets,
|
|
||||||
sort: sort_label(sort),
|
|
||||||
selection_count,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +67,176 @@ pub(super) fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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::<EditorAssets>().selected.clone();
|
||||||
|
let selection_count = world.resource::<EditorAssets>().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::<shared::SkinnedMeshRenderer>(*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::<EditorAssets>().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(
|
pub(super) fn folder_tree_branch(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@ -101,25 +261,26 @@ pub(super) fn folder_tree_branch(
|
|||||||
.contains(path);
|
.contains(path);
|
||||||
|
|
||||||
let selected = current_folder == *path;
|
let selected = current_folder == *path;
|
||||||
let folder_count = world
|
ui.horizontal(|ui| {
|
||||||
.resource::<EditorAssets>()
|
ui.add_space(depth as f32 * 12.0);
|
||||||
.assets
|
if children.is_empty() {
|
||||||
.iter()
|
ui.add_sized(
|
||||||
.filter(|asset| asset.folder_path == *path)
|
[18.0, 20.0],
|
||||||
.count();
|
egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)),
|
||||||
let row = editor_ui::content_browser::content_browser_source_row(
|
|
||||||
ui,
|
|
||||||
editor_ui::content_browser::ContentBrowserSourceRowViewModel {
|
|
||||||
id: path,
|
|
||||||
label: name,
|
|
||||||
count: folder_count,
|
|
||||||
depth,
|
|
||||||
has_children: !children.is_empty(),
|
|
||||||
expanded,
|
|
||||||
selected,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if row.disclosure_clicked {
|
} 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::<AssetBrowserUiState>();
|
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||||
if expanded {
|
if expanded {
|
||||||
state.expanded_folders.remove(path);
|
state.expanded_folders.remove(path);
|
||||||
@ -127,7 +288,12 @@ pub(super) fn folder_tree_branch(
|
|||||||
state.expanded_folders.insert(path.clone());
|
state.expanded_folders.insert(path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let folder_response = row.row;
|
}
|
||||||
|
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() {
|
if folder_response.clicked() {
|
||||||
navigate_content_folder(world, path.clone());
|
navigate_content_folder(world, path.clone());
|
||||||
}
|
}
|
||||||
@ -136,6 +302,7 @@ pub(super) fn folder_tree_branch(
|
|||||||
}
|
}
|
||||||
folder_response.context_menu(|ui| folder_context_menu(world, ui, folder));
|
folder_response.context_menu(|ui| folder_context_menu(world, ui, folder));
|
||||||
handle_asset_drop_to_folder(world, ui, &folder_response, path);
|
handle_asset_drop_to_folder(world, ui, &folder_response, path);
|
||||||
|
});
|
||||||
|
|
||||||
if expanded {
|
if expanded {
|
||||||
for child in children {
|
for child in children {
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
fn folder_navigation_clears_hidden_selection_and_drag_state() {
|
fn folder_navigation_clears_hidden_selection_and_drag_state() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
world.insert_resource(content_navigation_fixture());
|
world.insert_resource(content_navigation_fixture());
|
||||||
world.insert_resource(AssetBrowserUiState::default());
|
|
||||||
|
|
||||||
navigate_content_folder(&mut world, "assets/Props".into());
|
navigate_content_folder(&mut world, "assets/Props".into());
|
||||||
|
|
||||||
@ -275,7 +274,6 @@
|
|||||||
kind: EditorAssetKind::Material,
|
kind: EditorAssetKind::Material,
|
||||||
});
|
});
|
||||||
world.insert_resource(assets);
|
world.insert_resource(assets);
|
||||||
world.insert_resource(AssetBrowserUiState::default());
|
|
||||||
|
|
||||||
locate_content_asset(&mut world, "assets/Props/desk.material.ron");
|
locate_content_asset(&mut world, "assets/Props/desk.material.ron");
|
||||||
|
|
||||||
@ -396,27 +394,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn content_browser_details_width_respects_content_and_pane_minimums() {
|
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, 100.0), 200.0);
|
||||||
assert_eq!(details_width_for_layout(1_000.0, 220.0, 900.0), 620.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);
|
assert_eq!(details_width_for_layout(1_000.0, 220.0, 420.0), 420.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn content_browser_details_width_uses_reclaimed_tree_space() {
|
fn details_width_uses_reclaimed_tree_space() {
|
||||||
assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 840.0);
|
assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 830.0);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_browser_wide_penpot_panes_close_without_separator_budget() {
|
|
||||||
assert_eq!(
|
|
||||||
content_width_for_layout(
|
|
||||||
1_240.0,
|
|
||||||
editor_ui::content_browser::SOURCES_WIDTH,
|
|
||||||
editor_ui::content_browser::DETAILS_WIDTH,
|
|
||||||
),
|
|
||||||
734.0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -126,45 +126,6 @@
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_navigation_history_round_trips_like_a_file_manager() {
|
|
||||||
let mut world = World::new();
|
|
||||||
let assets = EditorAssets {
|
|
||||||
current_folder: "assets".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
world.insert_resource(assets);
|
|
||||||
world.insert_resource(AssetBrowserUiState::default());
|
|
||||||
|
|
||||||
navigate_content_folder(&mut world, "assets/Props".into());
|
|
||||||
navigate_content_folder(&mut world, "assets/Props/Office".into());
|
|
||||||
assert_eq!(
|
|
||||||
world.resource::<AssetBrowserUiState>().navigation_back,
|
|
||||||
["assets", "assets/Props"]
|
|
||||||
);
|
|
||||||
|
|
||||||
navigate_content_back(&mut world);
|
|
||||||
assert_eq!(
|
|
||||||
world.resource::<EditorAssets>().current_folder,
|
|
||||||
"assets/Props"
|
|
||||||
);
|
|
||||||
navigate_content_back(&mut world);
|
|
||||||
assert_eq!(world.resource::<EditorAssets>().current_folder, "assets");
|
|
||||||
navigate_content_forward(&mut world);
|
|
||||||
assert_eq!(
|
|
||||||
world.resource::<EditorAssets>().current_folder,
|
|
||||||
"assets/Props"
|
|
||||||
);
|
|
||||||
|
|
||||||
navigate_content_folder(&mut world, "assets/Materials".into());
|
|
||||||
assert!(
|
|
||||||
world
|
|
||||||
.resource::<AssetBrowserUiState>()
|
|
||||||
.navigation_forward
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_filter_matches_only_audio_clips() {
|
fn audio_filter_matches_only_audio_clips() {
|
||||||
let audio = EditorAsset {
|
let audio = EditorAsset {
|
||||||
@ -180,6 +141,7 @@
|
|||||||
|
|
||||||
assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio));
|
assert!(asset_matches_kind_filter(&audio, AssetKindFilter::Audio));
|
||||||
assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio));
|
assert!(!asset_matches_kind_filter(&texture, AssetKindFilter::Audio));
|
||||||
|
assert_eq!(kind_filter_label(AssetKindFilter::Audio), "Audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -5,106 +5,22 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
|||||||
let selection_count = world.resource::<EditorAssets>().selections.len();
|
let selection_count = world.resource::<EditorAssets>().selections.len();
|
||||||
let has_editable_selection = !selected_content_paths(world).is_empty();
|
let has_editable_selection = !selected_content_paths(world).is_empty();
|
||||||
let can_rename = selection_count == 1 && has_editable_selection;
|
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
|
let pending_repairs = world
|
||||||
.get_resource::<PendingExternalMoveRepair>()
|
.get_resource::<PendingExternalMoveRepair>()
|
||||||
.and_then(|pending| pending.review.as_ref())
|
.and_then(|pending| pending.review.as_ref())
|
||||||
.map_or(0, |review| review.conflicts.len());
|
.map_or(0, |review| review.conflicts.len());
|
||||||
let (
|
if ui
|
||||||
search,
|
.add_enabled(
|
||||||
view,
|
pending_repairs > 0,
|
||||||
sort,
|
egui::Button::new(format!("Resolve Moves ({pending_repairs})")),
|
||||||
kind_filter,
|
|
||||||
thumbnail_size,
|
|
||||||
recursive,
|
|
||||||
show_sources,
|
|
||||||
show_details,
|
|
||||||
show_compact_details,
|
|
||||||
can_undo,
|
|
||||||
can_paste,
|
|
||||||
can_go_back,
|
|
||||||
can_go_forward,
|
|
||||||
) = {
|
|
||||||
let state = world.resource::<AssetBrowserUiState>();
|
|
||||||
(
|
|
||||||
state.search.clone(),
|
|
||||||
state.view,
|
|
||||||
state.sort,
|
|
||||||
state.kind_filter,
|
|
||||||
state.thumbnail_size,
|
|
||||||
state.recursive,
|
|
||||||
state.show_sources,
|
|
||||||
state.show_details,
|
|
||||||
state.show_compact_details,
|
|
||||||
!state.content_undo.is_empty(),
|
|
||||||
state.clipboard.is_some() && current_folder != BUILTINS_FOLDER,
|
|
||||||
!state.navigation_back.is_empty(),
|
|
||||||
!state.navigation_forward.is_empty(),
|
|
||||||
)
|
)
|
||||||
};
|
.on_hover_text("Choose which stable asset identities match ambiguous external moves")
|
||||||
let (direct_folder_count, direct_asset_count) = {
|
.clicked()
|
||||||
let assets = world.resource::<EditorAssets>();
|
{
|
||||||
(
|
|
||||||
assets
|
|
||||||
.folders
|
|
||||||
.iter()
|
|
||||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
|
|
||||||
.count(),
|
|
||||||
assets
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.filter(|asset| asset.folder_path == current_folder)
|
|
||||||
.count(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let compact = ui.available_width() < editor_ui::content_browser::COMPACT_BREAKPOINT;
|
|
||||||
let model = editor_ui::content_browser::ContentBrowserToolbarViewModel {
|
|
||||||
chrome: editor_ui::content_browser::ContentBrowserChrome::Docked,
|
|
||||||
path: content_path_segments(¤t_folder),
|
|
||||||
search,
|
|
||||||
kind_filter: kind_filter.into(),
|
|
||||||
sort: sort.into(),
|
|
||||||
view: view.into(),
|
|
||||||
recursive,
|
|
||||||
show_sources,
|
|
||||||
show_details: if compact {
|
|
||||||
show_compact_details
|
|
||||||
} else {
|
|
||||||
show_details
|
|
||||||
},
|
|
||||||
thumbnail_size,
|
|
||||||
can_go_parent: current_folder.contains('/'),
|
|
||||||
can_go_back,
|
|
||||||
can_go_forward,
|
|
||||||
can_edit_folder: current_folder != BUILTINS_FOLDER,
|
|
||||||
can_rename,
|
|
||||||
can_edit_selection: has_editable_selection,
|
|
||||||
can_paste,
|
|
||||||
can_undo,
|
|
||||||
pending_repairs,
|
|
||||||
direct_folder_count,
|
|
||||||
direct_asset_count,
|
|
||||||
};
|
|
||||||
let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model);
|
|
||||||
let mut visible_results_changed = false;
|
|
||||||
for action in actions {
|
|
||||||
use editor_ui::content_browser::ContentBrowserToolbarAction as Action;
|
|
||||||
match action {
|
|
||||||
Action::Back => navigate_content_back(world),
|
|
||||||
Action::Forward => navigate_content_forward(world),
|
|
||||||
Action::Parent => navigate_to_parent(world),
|
|
||||||
Action::Refresh => crate::assets::refresh_content_browser(world),
|
|
||||||
Action::Navigate(path) => navigate_content_folder(world, path),
|
|
||||||
Action::ImportHere => request_import_to_destination(world, current_folder.clone()),
|
|
||||||
Action::ImportTo => begin_import_to(world),
|
|
||||||
Action::NewFolder => begin_new_folder(world, current_folder.clone()),
|
|
||||||
Action::Rename => begin_rename(world),
|
|
||||||
Action::Duplicate => duplicate_selection(world),
|
|
||||||
Action::Undo => undo_last_content_operation(world),
|
|
||||||
Action::Cut => set_content_clipboard(world, true),
|
|
||||||
Action::Copy => set_content_clipboard(world, false),
|
|
||||||
Action::Paste => paste_content_clipboard(world),
|
|
||||||
Action::OpenTrash => world.resource_mut::<AssetBrowserUiState>().show_trash = true,
|
|
||||||
Action::ReviewMoves => {
|
|
||||||
if let Some(review) = world
|
if let Some(review) = world
|
||||||
.resource_mut::<PendingExternalMoveRepair>()
|
.resource_mut::<PendingExternalMoveRepair>()
|
||||||
.review
|
.review
|
||||||
@ -113,136 +29,158 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
|||||||
review.open = true;
|
review.open = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::SetSearch(search) => {
|
if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() {
|
||||||
world.resource_mut::<AssetBrowserUiState>().search = search;
|
request_import_to_destination(world, current_folder.clone());
|
||||||
visible_results_changed = true;
|
|
||||||
}
|
}
|
||||||
Action::SetKindFilter(filter) => {
|
if ui
|
||||||
world.resource_mut::<AssetBrowserUiState>().kind_filter = filter.into();
|
.add_enabled(
|
||||||
visible_results_changed = true;
|
current_folder != BUILTINS_FOLDER,
|
||||||
|
egui::Button::new("Import To..."),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
begin_import_to(world);
|
||||||
}
|
}
|
||||||
Action::SetSort(sort) => world.resource_mut::<AssetBrowserUiState>().sort = sort.into(),
|
if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() {
|
||||||
Action::SetView(view) => world.resource_mut::<AssetBrowserUiState>().view = view.into(),
|
navigate_to_parent(world);
|
||||||
Action::SetRecursive(recursive) => {
|
|
||||||
world.resource_mut::<AssetBrowserUiState>().recursive = recursive;
|
|
||||||
visible_results_changed = true;
|
|
||||||
}
|
}
|
||||||
Action::SetShowSources(show) => {
|
if ui
|
||||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
.add_enabled(
|
||||||
state.show_sources = show;
|
current_folder != BUILTINS_FOLDER,
|
||||||
if show {
|
egui::Button::new("New Folder"),
|
||||||
state.show_compact_details = false;
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
begin_new_folder(world, current_folder.clone());
|
||||||
}
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(can_rename, egui::Button::new("Rename"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
begin_rename(world);
|
||||||
}
|
}
|
||||||
Action::SetShowDetails(show) => {
|
if ui
|
||||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
.add_enabled(has_editable_selection, egui::Button::new("Duplicate"))
|
||||||
if compact {
|
.clicked()
|
||||||
state.show_compact_details = show;
|
{
|
||||||
if show {
|
duplicate_selection(world);
|
||||||
state.show_sources = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
let can_undo_content = !world
|
||||||
state.show_details = show;
|
.resource::<AssetBrowserUiState>()
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
Action::SetThumbnailSize(size) => {
|
if ui
|
||||||
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;
|
.add_enabled(has_editable_selection, egui::Button::new("Copy"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
set_content_clipboard(world, false);
|
||||||
}
|
}
|
||||||
|
let can_paste = world.resource::<AssetBrowserUiState>().clipboard.is_some()
|
||||||
|
&& world.resource::<EditorAssets>().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() {
|
||||||
clear_selection_when_visible_results_change(world, visible_results_changed);
|
world.resource_mut::<AssetBrowserUiState>().show_trash = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content_path_segments(
|
ui.separator();
|
||||||
current_folder: &str,
|
|
||||||
) -> Vec<editor_ui::content_browser::ContentPathSegment> {
|
let visible_results_changed = {
|
||||||
let mut segments = Vec::new();
|
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||||
let mut current = String::new();
|
let mut visible_results_changed = ui
|
||||||
for part in current_folder.split('/').filter(|part| !part.is_empty()) {
|
.add(
|
||||||
if !current.is_empty() {
|
egui::TextEdit::singleline(&mut state.search)
|
||||||
current.push('/');
|
.hint_text("Search assets...")
|
||||||
}
|
.desired_width(fit_width(ui, 120.0, 190.0)),
|
||||||
current.push_str(part);
|
)
|
||||||
segments.push(editor_ui::content_browser::ContentPathSegment {
|
.changed();
|
||||||
path: current.clone(),
|
visible_results_changed |= ui.checkbox(&mut state.recursive, "Subfolders").changed();
|
||||||
label: if current == BUILTINS_FOLDER {
|
|
||||||
"Built-ins".into()
|
let previous_kind_filter = state.kind_filter;
|
||||||
} else {
|
egui::ComboBox::from_id_salt("asset_kind_filter")
|
||||||
part.to_string()
|
.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;
|
||||||
segments
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AssetBrowserView> for editor_ui::content_browser::ContentBrowserViewMode {
|
egui::ComboBox::from_id_salt("asset_sort")
|
||||||
fn from(value: AssetBrowserView) -> Self {
|
.selected_text(sort_label(state.sort))
|
||||||
match value {
|
.show_ui(ui, |ui| {
|
||||||
AssetBrowserView::Grid => Self::Grid,
|
ui.selectable_value(&mut state.sort, AssetSort::Name, "Name");
|
||||||
AssetBrowserView::List => Self::List,
|
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");
|
||||||
}
|
});
|
||||||
|
|
||||||
impl From<editor_ui::content_browser::ContentBrowserViewMode> for AssetBrowserView {
|
ui.separator();
|
||||||
fn from(value: editor_ui::content_browser::ContentBrowserViewMode) -> Self {
|
if tool_button(
|
||||||
match value {
|
ui,
|
||||||
editor_ui::content_browser::ContentBrowserViewMode::Grid => Self::Grid,
|
icons::GRID_FOUR,
|
||||||
editor_ui::content_browser::ContentBrowserViewMode::List => Self::List,
|
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");
|
||||||
impl From<AssetSort> for editor_ui::content_browser::ContentBrowserSort {
|
visible_results_changed
|
||||||
fn from(value: AssetSort) -> Self {
|
};
|
||||||
match value {
|
clear_selection_when_visible_results_change(world, visible_results_changed);
|
||||||
AssetSort::Name => Self::Name,
|
});
|
||||||
AssetSort::Kind => Self::Kind,
|
|
||||||
AssetSort::Modified => Self::Modified,
|
|
||||||
AssetSort::Size => Self::Size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<editor_ui::content_browser::ContentBrowserSort> for AssetSort {
|
|
||||||
fn from(value: editor_ui::content_browser::ContentBrowserSort) -> Self {
|
|
||||||
match value {
|
|
||||||
editor_ui::content_browser::ContentBrowserSort::Name => Self::Name,
|
|
||||||
editor_ui::content_browser::ContentBrowserSort::Kind => Self::Kind,
|
|
||||||
editor_ui::content_browser::ContentBrowserSort::Modified => Self::Modified,
|
|
||||||
editor_ui::content_browser::ContentBrowserSort::Size => Self::Size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AssetKindFilter> for editor_ui::content_browser::ContentBrowserKindFilter {
|
|
||||||
fn from(value: AssetKindFilter) -> Self {
|
|
||||||
match value {
|
|
||||||
AssetKindFilter::All => Self::All,
|
|
||||||
AssetKindFilter::Model => Self::Model,
|
|
||||||
AssetKindFilter::Texture => Self::Texture,
|
|
||||||
AssetKindFilter::Material => Self::Material,
|
|
||||||
AssetKindFilter::Audio => Self::Audio,
|
|
||||||
AssetKindFilter::Level => Self::Level,
|
|
||||||
AssetKindFilter::Prefab => Self::Prefab,
|
|
||||||
AssetKindFilter::Builtin => Self::Builtin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<editor_ui::content_browser::ContentBrowserKindFilter> for AssetKindFilter {
|
|
||||||
fn from(value: editor_ui::content_browser::ContentBrowserKindFilter) -> Self {
|
|
||||||
match value {
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::All => Self::All,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Model => Self::Model,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Texture => Self::Texture,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Material => Self::Material,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Audio => Self::Audio,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Level => Self::Level,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Prefab => Self::Prefab,
|
|
||||||
editor_ui::content_browser::ContentBrowserKindFilter::Builtin => Self::Builtin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) {
|
pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) {
|
||||||
|
|||||||
@ -25,51 +25,6 @@ pub(super) fn navigate_to_parent(world: &mut World) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn navigate_content_folder(world: &mut World, folder: String) {
|
pub(super) fn navigate_content_folder(world: &mut World, folder: String) {
|
||||||
let current = world.resource::<EditorAssets>().current_folder.clone();
|
|
||||||
if current == folder {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
|
||||||
state.navigation_back.push(current);
|
|
||||||
state.navigation_forward.clear();
|
|
||||||
}
|
|
||||||
set_content_folder(world, folder);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn navigate_content_back(world: &mut World) {
|
|
||||||
let Some(folder) = world
|
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
|
||||||
.navigation_back
|
|
||||||
.pop()
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let current = world.resource::<EditorAssets>().current_folder.clone();
|
|
||||||
world
|
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
|
||||||
.navigation_forward
|
|
||||||
.push(current);
|
|
||||||
set_content_folder(world, folder);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn navigate_content_forward(world: &mut World) {
|
|
||||||
let Some(folder) = world
|
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
|
||||||
.navigation_forward
|
|
||||||
.pop()
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let current = world.resource::<EditorAssets>().current_folder.clone();
|
|
||||||
world
|
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
|
||||||
.navigation_back
|
|
||||||
.push(current);
|
|
||||||
set_content_folder(world, folder);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_content_folder(world: &mut World, folder: String) {
|
|
||||||
let mut assets = world.resource_mut::<EditorAssets>();
|
let mut assets = world.resource_mut::<EditorAssets>();
|
||||||
assets.current_folder = folder.clone();
|
assets.current_folder = folder.clone();
|
||||||
assets.clear_selection();
|
assets.clear_selection();
|
||||||
@ -114,6 +69,19 @@ pub(super) fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
pub(super) fn sort_label(sort: AssetSort) -> &'static str {
|
||||||
match sort {
|
match sort {
|
||||||
AssetSort::Name => "Name",
|
AssetSort::Name => "Name",
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use shared::MaterialAsset;
|
use shared::MaterialAsset;
|
||||||
|
|
||||||
pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 290.0;
|
pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 260.0;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum AssetBrowserView {
|
pub(crate) enum AssetBrowserView {
|
||||||
@ -43,12 +43,7 @@ pub struct AssetBrowserUiState {
|
|||||||
pub(crate) kind_filter: AssetKindFilter,
|
pub(crate) kind_filter: AssetKindFilter,
|
||||||
pub(crate) thumbnail_size: f32,
|
pub(crate) thumbnail_size: f32,
|
||||||
pub(crate) recursive: bool,
|
pub(crate) recursive: bool,
|
||||||
pub(crate) show_sources: bool,
|
|
||||||
pub(crate) show_details: bool,
|
pub(crate) show_details: bool,
|
||||||
pub(crate) show_compact_details: bool,
|
|
||||||
/// File-manager navigation history. Content topology operations do not rewrite these stacks.
|
|
||||||
pub(crate) navigation_back: Vec<String>,
|
|
||||||
pub(crate) navigation_forward: Vec<String>,
|
|
||||||
/// Width of the details pane for the current editor session.
|
/// Width of the details pane for the current editor session.
|
||||||
pub(crate) details_width: f32,
|
pub(crate) details_width: f32,
|
||||||
pub(crate) expanded_folders: HashSet<String>,
|
pub(crate) expanded_folders: HashSet<String>,
|
||||||
@ -188,13 +183,9 @@ impl Default for AssetBrowserUiState {
|
|||||||
view: AssetBrowserView::Grid,
|
view: AssetBrowserView::Grid,
|
||||||
sort: AssetSort::Name,
|
sort: AssetSort::Name,
|
||||||
kind_filter: AssetKindFilter::All,
|
kind_filter: AssetKindFilter::All,
|
||||||
thumbnail_size: 96.0,
|
thumbnail_size: 72.0,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
show_sources: false,
|
|
||||||
show_details: true,
|
show_details: true,
|
||||||
show_compact_details: false,
|
|
||||||
navigation_back: Vec::new(),
|
|
||||||
navigation_forward: Vec::new(),
|
|
||||||
details_width: ASSET_DETAILS_DEFAULT_WIDTH,
|
details_width: ASSET_DETAILS_DEFAULT_WIDTH,
|
||||||
expanded_folders,
|
expanded_folders,
|
||||||
expanded_assets: HashSet::new(),
|
expanded_assets: HashSet::new(),
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
|
|
||||||
use crate::assets::{kind_icon, EditorAsset, EditorAssetKind};
|
use crate::assets::{kind_icon, EditorAsset};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct AssetCardStatus {
|
pub struct AssetCardStatus {
|
||||||
@ -50,54 +50,17 @@ pub fn draw_asset_card(
|
|||||||
thumbnail_size: f32,
|
thumbnail_size: f32,
|
||||||
status: AssetCardStatus,
|
status: AssetCardStatus,
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
let thumbnail = if let Some(texture) = texture_id {
|
let response = draw_asset_cell(
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Ready(texture)
|
|
||||||
} else if pending {
|
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Pending
|
|
||||||
} else if failure.is_some() {
|
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Failed
|
|
||||||
} else {
|
|
||||||
editor_ui::content_browser::ContentBrowserThumbnail::Placeholder
|
|
||||||
};
|
|
||||||
editor_ui::content_browser::content_browser_asset_card(
|
|
||||||
ui,
|
ui,
|
||||||
editor_ui::content_browser::ContentBrowserCardViewModel {
|
asset,
|
||||||
label: &asset.label,
|
texture_id,
|
||||||
kind: asset_kind_label(&asset.kind),
|
pending,
|
||||||
icon: kind_icon(&asset.kind),
|
failure,
|
||||||
icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Regular,
|
|
||||||
thumbnail,
|
|
||||||
asset_tone: asset_kind_tone(&asset.kind),
|
|
||||||
selected,
|
selected,
|
||||||
status: editor_ui::content_browser::ContentBrowserCardStatus {
|
|
||||||
dirty: status.dirty,
|
|
||||||
processing: status.processing,
|
|
||||||
error: status.error,
|
|
||||||
},
|
|
||||||
failure_tooltip: failure,
|
|
||||||
},
|
|
||||||
thumbnail_size,
|
thumbnail_size,
|
||||||
)
|
);
|
||||||
}
|
draw_asset_status_rail(ui, response.rect, status);
|
||||||
|
response
|
||||||
fn asset_kind_tone(kind: &EditorAssetKind) -> editor_ui::content_browser::ContentBrowserAssetTone {
|
|
||||||
use editor_ui::content_browser::ContentBrowserAssetTone;
|
|
||||||
|
|
||||||
match kind {
|
|
||||||
EditorAssetKind::Primitive(_) => ContentBrowserAssetTone::Primitive,
|
|
||||||
EditorAssetKind::Light(_) => ContentBrowserAssetTone::Light,
|
|
||||||
EditorAssetKind::Model => ContentBrowserAssetTone::Model,
|
|
||||||
EditorAssetKind::Texture => ContentBrowserAssetTone::Texture,
|
|
||||||
EditorAssetKind::Material => ContentBrowserAssetTone::Material,
|
|
||||||
EditorAssetKind::AudioClip => ContentBrowserAssetTone::Audio,
|
|
||||||
EditorAssetKind::Level => ContentBrowserAssetTone::Level,
|
|
||||||
EditorAssetKind::Prefab => ContentBrowserAssetTone::Prefab,
|
|
||||||
EditorAssetKind::PostProcessVolume | EditorAssetKind::PostProcessEffect => {
|
|
||||||
ContentBrowserAssetTone::PostProcess
|
|
||||||
}
|
|
||||||
EditorAssetKind::RenderingProfile => ContentBrowserAssetTone::RenderingProfile,
|
|
||||||
EditorAssetKind::ShaderSchema => ContentBrowserAssetTone::Shader,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_asset_status_rail(ui: &egui::Ui, rect: egui::Rect, status: AssetCardStatus) {
|
pub fn draw_asset_status_rail(ui: &egui::Ui, rect: egui::Rect, status: AssetCardStatus) {
|
||||||
@ -161,19 +124,102 @@ pub fn draw_asset_status_marker(ui: &mut egui::Ui, status: AssetCardStatus) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_kind_label(kind: &EditorAssetKind) -> &'static str {
|
#[allow(clippy::too_many_arguments)]
|
||||||
match kind {
|
fn draw_asset_cell(
|
||||||
EditorAssetKind::Primitive(_) => "Primitive",
|
ui: &mut egui::Ui,
|
||||||
EditorAssetKind::Light(_) => "Light",
|
asset: &EditorAsset,
|
||||||
EditorAssetKind::Model => "Model",
|
texture_id: Option<egui::TextureId>,
|
||||||
EditorAssetKind::Texture => "Texture",
|
pending: bool,
|
||||||
EditorAssetKind::Material => "Material",
|
failed: Option<&str>,
|
||||||
EditorAssetKind::AudioClip => "Audio Clip",
|
selected: bool,
|
||||||
EditorAssetKind::Level => "Level",
|
thumbnail_size: f32,
|
||||||
EditorAssetKind::Prefab => "Prefab",
|
) -> egui::Response {
|
||||||
EditorAssetKind::PostProcessVolume => "Post Process Volume",
|
let thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
||||||
EditorAssetKind::PostProcessEffect => "Post FX",
|
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 42.0);
|
||||||
EditorAssetKind::RenderingProfile => "Rendering Profile",
|
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
|
||||||
EditorAssetKind::ShaderSchema => "Shader Schema",
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,38 +62,19 @@ fn color_control_impl(
|
|||||||
) -> ColorPickerResponse {
|
) -> ColorPickerResponse {
|
||||||
let palette = super::palette(ui);
|
let palette = super::palette(ui);
|
||||||
let id = ui.make_persistent_id(("penpot_color_picker", salt));
|
let id = ui.make_persistent_id(("penpot_color_picker", salt));
|
||||||
let width = ui.available_width().max(1.0);
|
let (rect, response) = ui.allocate_exact_size(egui::vec2(130.0, 22.0), egui::Sense::click());
|
||||||
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click());
|
let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(24.0, 22.0));
|
||||||
let swatch_width = 44.0_f32.min(width);
|
let hex_rect =
|
||||||
let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0));
|
egui::Rect::from_min_size(rect.min + egui::vec2(30.0, 0.0), egui::vec2(100.0, 22.0));
|
||||||
let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 };
|
|
||||||
let hex_rect = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2(swatch_width + gap, 0.0),
|
|
||||||
egui::vec2((width - swatch_width - gap).max(0.0), 22.0),
|
|
||||||
);
|
|
||||||
popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated);
|
popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated);
|
||||||
let inner_swatch = swatch_rect.shrink(1.0);
|
ui.painter()
|
||||||
let rgb_rect = egui::Rect::from_min_max(
|
.rect_filled(swatch_rect.shrink(1.0), 4.0, color32(*rgba));
|
||||||
inner_swatch.min,
|
|
||||||
egui::pos2(inner_swatch.center().x, inner_swatch.max.y),
|
|
||||||
);
|
|
||||||
let alpha_rect = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(inner_swatch.center().x, inner_swatch.min.y),
|
|
||||||
inner_swatch.max,
|
|
||||||
);
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
rgb_rect,
|
|
||||||
3.0,
|
|
||||||
egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])),
|
|
||||||
);
|
|
||||||
ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba));
|
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
swatch_rect,
|
swatch_rect,
|
||||||
4.0,
|
4.0,
|
||||||
egui::Stroke::new(1.0_f32, palette.border_strong),
|
egui::Stroke::new(1.0_f32, palette.border_strong),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
if hex_rect.width() > 0.0 {
|
|
||||||
ui.painter().rect(
|
ui.painter().rect(
|
||||||
hex_rect,
|
hex_rect,
|
||||||
4.0,
|
4.0,
|
||||||
@ -108,7 +89,6 @@ fn color_control_impl(
|
|||||||
TypeRole::Body.font(),
|
TypeRole::Body.font(),
|
||||||
palette.text_primary,
|
palette.text_primary,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
let opened_at = ui.input(|input| input.time);
|
let opened_at = ui.input(|input| input.time);
|
||||||
|
|||||||
@ -7,10 +7,12 @@ use std::ops::RangeInclusive;
|
|||||||
use super::palette;
|
use super::palette;
|
||||||
use super::typography::TypeRole;
|
use super::typography::TypeRole;
|
||||||
|
|
||||||
const SCALAR_TRACK_X: f32 = 6.0;
|
const SCALAR_CONTROL_WIDTH: f32 = 132.0;
|
||||||
const SCALAR_NUMBER_GAP: f32 = 6.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_TRACK_INTERACTION_HEIGHT: f32 = 10.0;
|
||||||
const SCALAR_NUMBER_WIDTH: f32 = 33.0;
|
const SCALAR_NUMBER_X: f32 = 84.0;
|
||||||
|
const SCALAR_NUMBER_WIDTH: f32 = 48.0;
|
||||||
const SCALAR_THUMB_RADIUS: f32 = 4.0;
|
const SCALAR_THUMB_RADIUS: f32 = 4.0;
|
||||||
|
|
||||||
pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame {
|
pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame {
|
||||||
@ -48,7 +50,7 @@ pub(crate) fn section_header_with_summary(
|
|||||||
} else {
|
} else {
|
||||||
icons::CARET_RIGHT.as_str()
|
icons::CARET_RIGHT.as_str()
|
||||||
},
|
},
|
||||||
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
palette.text_secondary,
|
palette.text_secondary,
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
@ -88,7 +90,10 @@ pub(crate) fn icon_label(
|
|||||||
icon.as_str(),
|
icon.as_str(),
|
||||||
0.0,
|
0.0,
|
||||||
egui::TextFormat {
|
egui::TextFormat {
|
||||||
font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())),
|
font_id: egui::FontId::new(
|
||||||
|
icon_size,
|
||||||
|
egui::FontFamily::Name("phosphor-regular".into()),
|
||||||
|
),
|
||||||
color,
|
color,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@ -118,7 +123,7 @@ pub(crate) fn icon_button(
|
|||||||
enabled,
|
enabled,
|
||||||
egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new(
|
egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new(
|
||||||
14.0,
|
14.0,
|
||||||
egui::FontFamily::Name("phosphor-bold".into()),
|
egui::FontFamily::Name("phosphor-regular".into()),
|
||||||
)))
|
)))
|
||||||
.min_size(egui::vec2(24.0, 24.0))
|
.min_size(egui::vec2(24.0, 24.0))
|
||||||
.fill(palette.control)
|
.fill(palette.control)
|
||||||
@ -128,35 +133,12 @@ pub(crate) fn icon_button(
|
|||||||
response.on_hover_text(tooltip)
|
response.on_hover_text(tooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn status(
|
pub(crate) fn status(ui: &mut egui::Ui, color: egui::Color32, label: &str, tooltip: &str) {
|
||||||
ui: &mut egui::Ui,
|
|
||||||
icon: Icon,
|
|
||||||
color: egui::Color32,
|
|
||||||
label: &str,
|
|
||||||
tooltip: &str,
|
|
||||||
) {
|
|
||||||
let palette = palette(ui);
|
let palette = palette(ui);
|
||||||
let mut job = egui::text::LayoutJob::default();
|
let (dot, response) = ui.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover());
|
||||||
job.append(
|
ui.painter().circle_filled(dot.center(), 4.0, color);
|
||||||
icon.as_str(),
|
|
||||||
0.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
color,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
job.append(
|
|
||||||
label,
|
|
||||||
5.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: TypeRole::Body.font(),
|
|
||||||
color: palette.text_secondary,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover()));
|
|
||||||
response.on_hover_text(tooltip);
|
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 {
|
pub(crate) fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response {
|
||||||
@ -278,15 +260,12 @@ pub(crate) fn scalar_control(
|
|||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
let palette = palette(ui);
|
let palette = palette(ui);
|
||||||
let before = *value;
|
let before = *value;
|
||||||
// The final Penpot component deliberately stretches only the track at the compact
|
let width = SCALAR_CONTROL_WIDTH.min(ui.available_width().max(1.0));
|
||||||
// breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference.
|
|
||||||
// The numeric field remains a stable 33 px in both modes.
|
|
||||||
let width = ui.available_width().max(1.0);
|
|
||||||
let (rect, base_response) =
|
let (rect, base_response) =
|
||||||
ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
|
ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
|
||||||
let number_width = SCALAR_NUMBER_WIDTH.min(width);
|
let number_width = SCALAR_NUMBER_WIDTH.min(width);
|
||||||
let number_x = (width - number_width).max(0.0);
|
let number_x = SCALAR_NUMBER_X.min((width - number_width).max(0.0));
|
||||||
let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0);
|
let track_width = SCALAR_TRACK_WIDTH.min((number_x - 6.0).max(1.0));
|
||||||
let slider_rect = egui::Rect::from_min_size(
|
let slider_rect = egui::Rect::from_min_size(
|
||||||
rect.min + egui::vec2(SCALAR_TRACK_X, 6.0),
|
rect.min + egui::vec2(SCALAR_TRACK_X, 6.0),
|
||||||
egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT),
|
egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT),
|
||||||
@ -358,15 +337,8 @@ mod scalar_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn penpot_scalar_geometry_matches_the_current_export() {
|
fn penpot_scalar_geometry_matches_the_current_export() {
|
||||||
let wide_width = 132.8;
|
assert!((SCALAR_NUMBER_X - (SCALAR_TRACK_X + SCALAR_TRACK_WIDTH) - 6.0).abs() < 0.001);
|
||||||
let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH;
|
assert_eq!(SCALAR_CONTROL_WIDTH, SCALAR_NUMBER_X + SCALAR_NUMBER_WIDTH);
|
||||||
let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
|
|
||||||
assert!((wide_track - 93.8).abs() < 0.001);
|
|
||||||
|
|
||||||
let compact_width = 199.3;
|
|
||||||
let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH;
|
|
||||||
let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
|
|
||||||
assert!((compact_track - 160.3).abs() < 0.001);
|
|
||||||
assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0);
|
assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0);
|
||||||
assert_eq!(SCALAR_THUMB_RADIUS, 4.0);
|
assert_eq!(SCALAR_THUMB_RADIUS, 4.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,7 @@
|
|||||||
use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT};
|
use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT};
|
||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
|
|
||||||
/// Minimum live section width that can safely retain the exported wide-row geometry.
|
pub(crate) const WIDE_SECTION_WIDTH: f32 = 569.0;
|
||||||
///
|
|
||||||
/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome,
|
|
||||||
/// component-card borders, and the floating scrollbar consume up to ten pixels before the
|
|
||||||
/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px
|
|
||||||
/// Inspector renders the authoritative wide reference instead of incorrectly switching to the
|
|
||||||
/// 420 px two-line rows.
|
|
||||||
pub(crate) const WIDE_SECTION_WIDTH: f32 = 559.0;
|
|
||||||
pub(crate) const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH;
|
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
|
/// 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.
|
/// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset.
|
||||||
@ -27,7 +20,6 @@ pub(crate) struct PropertyRowRects {
|
|||||||
pub row: egui::Rect,
|
pub row: egui::Rect,
|
||||||
pub label: egui::Rect,
|
pub label: egui::Rect,
|
||||||
pub value: egui::Rect,
|
pub value: egui::Rect,
|
||||||
pub color_value: egui::Rect,
|
|
||||||
pub channel: egui::Rect,
|
pub channel: egui::Rect,
|
||||||
pub texture_group: egui::Rect,
|
pub texture_group: egui::Rect,
|
||||||
pub texture_field: egui::Rect,
|
pub texture_field: egui::Rect,
|
||||||
@ -68,12 +60,8 @@ impl PropertyGridGeometry {
|
|||||||
egui::vec2(82.0, 22.0),
|
egui::vec2(82.0, 22.0),
|
||||||
),
|
),
|
||||||
value: egui::Rect::from_min_size(
|
value: egui::Rect::from_min_size(
|
||||||
row.min + egui::vec2(103.35, 5.0),
|
row.min + egui::vec2(96.0, 5.0),
|
||||||
egui::vec2(132.8, 22.0),
|
egui::vec2(132.0, 22.0),
|
||||||
),
|
|
||||||
color_value: egui::Rect::from_min_size(
|
|
||||||
row.min + egui::vec2(107.95, 5.0),
|
|
||||||
egui::vec2(182.55, 22.0),
|
|
||||||
),
|
),
|
||||||
channel: egui::Rect::from_min_size(
|
channel: egui::Rect::from_min_size(
|
||||||
row.min + egui::vec2(234.0, 5.0),
|
row.min + egui::vec2(234.0, 5.0),
|
||||||
@ -98,10 +86,8 @@ impl PropertyGridGeometry {
|
|||||||
|
|
||||||
let channel_width = 48.0_f32.min(inner_width);
|
let channel_width = 48.0_f32.min(inner_width);
|
||||||
let channel_x = inset + inner_width - channel_width;
|
let channel_x = inset + inner_width - channel_width;
|
||||||
let value_x = (inset + 91.35).min(channel_x);
|
let value_x = (inset + 84.0).min(channel_x);
|
||||||
let value_width = (channel_x - value_x - 6.35).max(1.0);
|
let value_width = (channel_x - value_x - 6.35).max(1.0);
|
||||||
let color_value_x = (inset + 95.95).min(channel_x);
|
|
||||||
let color_value_width = (inset + inner_width - color_value_x).max(1.0);
|
|
||||||
let texture_group = egui::Rect::from_min_size(
|
let texture_group = egui::Rect::from_min_size(
|
||||||
row.min + egui::vec2(inset, 31.0),
|
row.min + egui::vec2(inset, 31.0),
|
||||||
egui::vec2(inner_width, 22.0),
|
egui::vec2(inner_width, 22.0),
|
||||||
@ -123,10 +109,6 @@ impl PropertyGridGeometry {
|
|||||||
row.min + egui::vec2(value_x, 5.0),
|
row.min + egui::vec2(value_x, 5.0),
|
||||||
egui::vec2(value_width, 22.0),
|
egui::vec2(value_width, 22.0),
|
||||||
),
|
),
|
||||||
color_value: egui::Rect::from_min_size(
|
|
||||||
row.min + egui::vec2(color_value_x, 5.0),
|
|
||||||
egui::vec2(color_value_width, 22.0),
|
|
||||||
),
|
|
||||||
channel: egui::Rect::from_min_size(
|
channel: egui::Rect::from_min_size(
|
||||||
row.min + egui::vec2(channel_x, 5.0),
|
row.min + egui::vec2(channel_x, 5.0),
|
||||||
egui::vec2(channel_width, 22.0),
|
egui::vec2(channel_width, 22.0),
|
||||||
@ -169,15 +151,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_wide_containment_threshold_keeps_reference_rows() {
|
fn penpot_below_reference_width_reflows_without_compression() {
|
||||||
let geometry = PropertyGridGeometry::for_width(559.0);
|
let geometry = PropertyGridGeometry::for_width(568.0);
|
||||||
assert!(!geometry.stacked);
|
|
||||||
assert_eq!(geometry.row_height, 32.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn below_native_wide_containment_threshold_reflows_without_compression() {
|
|
||||||
let geometry = PropertyGridGeometry::for_width(558.0);
|
|
||||||
assert!(geometry.stacked);
|
assert!(geometry.stacked);
|
||||||
assert_eq!(geometry.row_height, 58.0);
|
assert_eq!(geometry.row_height, 58.0);
|
||||||
}
|
}
|
||||||
@ -187,11 +162,9 @@ mod tests {
|
|||||||
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0));
|
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);
|
let rects = PropertyGridGeometry::for_width(569.0).row_rects(row);
|
||||||
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
|
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
|
||||||
assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
|
assert_eq!(rects.value.min, egui::pos2(96.0, 5.0));
|
||||||
assert!((rects.value.width() - 132.8).abs() < 0.001);
|
assert!((rects.value.width() - 132.0).abs() < 0.001);
|
||||||
assert_eq!(rects.value.height(), 22.0);
|
assert_eq!(rects.value.height(), 22.0);
|
||||||
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
|
|
||||||
assert!((rects.color_value.width() - 182.55).abs() < 0.001);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rects.channel,
|
rects.channel,
|
||||||
egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0))
|
egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0))
|
||||||
@ -211,10 +184,7 @@ mod tests {
|
|||||||
let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0));
|
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);
|
let rects = PropertyGridGeometry::for_width(369.0).row_rects(row);
|
||||||
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
|
assert_eq!(rects.label.min, egui::pos2(12.0, 5.0));
|
||||||
assert_eq!(rects.value.min, egui::pos2(103.35, 5.0));
|
assert_eq!(rects.value.min, egui::pos2(96.0, 5.0));
|
||||||
assert!((rects.value.width() - 199.3).abs() < 0.001);
|
|
||||||
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
|
|
||||||
assert!((rects.color_value.width() - 249.05).abs() < 0.001);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rects.channel,
|
rects.channel,
|
||||||
egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0))
|
egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0))
|
||||||
@ -237,7 +207,6 @@ mod tests {
|
|||||||
for rect in [
|
for rect in [
|
||||||
rects.label,
|
rects.label,
|
||||||
rects.value,
|
rects.value,
|
||||||
rects.color_value,
|
|
||||||
rects.channel,
|
rects.channel,
|
||||||
rects.texture_group,
|
rects.texture_group,
|
||||||
rects.texture_field,
|
rects.texture_field,
|
||||||
@ -270,7 +239,6 @@ mod tests {
|
|||||||
let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row);
|
let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row);
|
||||||
assert_eq!(wide.label, reference.label);
|
assert_eq!(wide.label, reference.label);
|
||||||
assert_eq!(wide.value, reference.value);
|
assert_eq!(wide.value, reference.value);
|
||||||
assert_eq!(wide.color_value, reference.color_value);
|
|
||||||
assert_eq!(wide.channel, reference.channel);
|
assert_eq!(wide.channel, reference.channel);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
wide.texture_field.width(),
|
wide.texture_field.width(),
|
||||||
|
|||||||
@ -78,7 +78,7 @@ pub fn tab_label(tab: &EditorTab) -> &'static str {
|
|||||||
EditorTab::Viewport | EditorTab::GameView => "Viewport",
|
EditorTab::Viewport | EditorTab::GameView => "Viewport",
|
||||||
EditorTab::Hierarchy => "Hierarchy",
|
EditorTab::Hierarchy => "Hierarchy",
|
||||||
EditorTab::Inspector => "Inspector",
|
EditorTab::Inspector => "Inspector",
|
||||||
EditorTab::AssetBrowser => "Content Browser",
|
EditorTab::AssetBrowser => "Asset Browser",
|
||||||
EditorTab::MaterialLibrary => "Material Library",
|
EditorTab::MaterialLibrary => "Material Library",
|
||||||
EditorTab::Build => "Build",
|
EditorTab::Build => "Build",
|
||||||
EditorTab::Toolbar => "Toolbar (legacy)",
|
EditorTab::Toolbar => "Toolbar (legacy)",
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::{EguiContext, PrimaryEguiContext};
|
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;
|
pub struct EditorFontsPlugin;
|
||||||
|
|
||||||
@ -19,5 +23,55 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added<PrimaryEgu
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn editor_font_definitions() -> bevy_egui::egui::FontDefinitions {
|
pub(crate) fn editor_font_definitions() -> bevy_egui::egui::FontDefinitions {
|
||||||
editor_ui::fonts::font_definitions()
|
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
|
||||||
|
.families
|
||||||
|
.get(&bevy_egui::egui::FontFamily::Proportional)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
for (family, font_names) in &mut fonts.families {
|
||||||
|
let bevy_egui::egui::FontFamily::Name(name) = family else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name.starts_with("phosphor-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for fallback in &text_fallbacks {
|
||||||
|
if !font_names.contains(fallback) {
|
||||||
|
font_names.push(fallback.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fonts
|
||||||
}
|
}
|
||||||
|
|||||||
@ -280,6 +280,24 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn asset_label(asset: &EditorAsset) -> String {
|
||||||
|
let prefix = match asset.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",
|
||||||
|
};
|
||||||
|
format!("{prefix}: {}", asset.label)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn apply_texture_to_selection(
|
pub fn apply_texture_to_selection(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
asset: &EditorAsset,
|
asset: &EditorAsset,
|
||||||
|
|||||||
@ -166,9 +166,6 @@ struct PendingPropertyBlockPromotion {
|
|||||||
slot_id: ComponentInstanceId,
|
slot_id: ComponentInstanceId,
|
||||||
slot_name: String,
|
slot_name: String,
|
||||||
base: MaterialRef,
|
base: MaterialRef,
|
||||||
/// Actor-owned assignment captured when the review opened. `None` means the selected base
|
|
||||||
/// was inherited from the model or project defaults and must remain inherited until commit.
|
|
||||||
actor_assignment: Option<MaterialRef>,
|
|
||||||
block: MaterialPropertyBlock,
|
block: MaterialPropertyBlock,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
destination_path: PathBuf,
|
destination_path: PathBuf,
|
||||||
@ -365,8 +362,6 @@ mod component_lifecycle;
|
|||||||
mod dispatch;
|
mod dispatch;
|
||||||
#[path = "inspector/imported_material_preview.rs"]
|
#[path = "inspector/imported_material_preview.rs"]
|
||||||
mod imported_material_preview;
|
mod imported_material_preview;
|
||||||
#[path = "inspector/material_resolution.rs"]
|
|
||||||
mod material_resolution;
|
|
||||||
#[path = "inspector/material_slots.rs"]
|
#[path = "inspector/material_slots.rs"]
|
||||||
mod material_slots;
|
mod material_slots;
|
||||||
#[path = "inspector/mesh_renderers.rs"]
|
#[path = "inspector/mesh_renderers.rs"]
|
||||||
@ -389,7 +384,6 @@ pub(crate) use component_lifecycle::apply_component_card_response;
|
|||||||
use component_lifecycle::*;
|
use component_lifecycle::*;
|
||||||
pub use dispatch::authoring_inspector_ui;
|
pub use dispatch::authoring_inspector_ui;
|
||||||
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
|
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
|
||||||
use material_resolution::*;
|
|
||||||
use material_slots::*;
|
use material_slots::*;
|
||||||
use mesh_renderers::*;
|
use mesh_renderers::*;
|
||||||
use primitive_domains::*;
|
use primitive_domains::*;
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
use super::*;
|
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 {
|
pub(super) fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
property_row(ui, "Shader", |ui| {
|
property_row(ui, "Shader", |ui| {
|
||||||
@ -130,41 +137,201 @@ pub(crate) fn component_card(
|
|||||||
context: &ComponentCardContext,
|
context: &ComponentCardContext,
|
||||||
add_contents: impl FnOnce(&mut egui::Ui),
|
add_contents: impl FnOnce(&mut egui::Ui),
|
||||||
) -> ComponentCardResponse {
|
) -> ComponentCardResponse {
|
||||||
let options = editor_ui::inspector::ComponentCardOptions {
|
let mut response = ComponentCardResponse {
|
||||||
type_name: context.options.type_name,
|
type_name: context.options.type_name,
|
||||||
title: context.options.title,
|
..Default::default()
|
||||||
icon: context.options.icon,
|
|
||||||
active_toggle: context.options.active_toggle,
|
|
||||||
removable: context.options.removable,
|
|
||||||
reorderable: context.options.reorderable,
|
|
||||||
resettable: context.options.resettable,
|
|
||||||
copyable: context.options.copyable,
|
|
||||||
summary: context.options.summary,
|
|
||||||
body_margin: context.options.body_margin,
|
|
||||||
};
|
};
|
||||||
let shared = editor_ui::inspector::component_card(
|
crate::ui::design_system::scope(ui, |ui| {
|
||||||
ui,
|
egui::Frame::new()
|
||||||
&editor_ui::inspector::ComponentCardViewModel {
|
.fill(WIDGET_BG)
|
||||||
options,
|
.stroke(egui::Stroke::new(1.0_f32, BORDER))
|
||||||
active: context.active,
|
.corner_radius(egui::CornerRadius::same(5))
|
||||||
collapsed: context.collapsed,
|
.show(ui, |ui| {
|
||||||
pasteable: context.pasteable,
|
let width = ui.available_width().max(1.0);
|
||||||
can_move_up: context.can_move_up,
|
let (header, header_response) = ui.allocate_exact_size(
|
||||||
can_move_down: context.can_move_down,
|
egui::vec2(width, COMPONENT_HEADER_HEIGHT),
|
||||||
},
|
egui::Sense::click(),
|
||||||
add_contents,
|
|
||||||
);
|
);
|
||||||
ComponentCardResponse {
|
ui.painter().rect_filled(
|
||||||
type_name: shared.type_name,
|
egui::Rect::from_min_size(
|
||||||
collapsed: shared.collapsed,
|
header.min + egui::vec2(COMPONENT_RAIL_X, 7.0),
|
||||||
active: shared.active,
|
egui::vec2(3.0, 36.0),
|
||||||
reset: shared.reset,
|
),
|
||||||
copy: shared.copy,
|
2.0,
|
||||||
paste: shared.paste,
|
crate::ui::theme::SELECTION,
|
||||||
move_up: shared.move_up,
|
);
|
||||||
move_down: shared.move_down,
|
let caret_rect = egui::Rect::from_center_size(
|
||||||
remove: shared.remove,
|
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<R>(
|
pub(crate) fn property_row<R>(
|
||||||
@ -334,3 +501,18 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
|
|||||||
set_material_with_history(world, entity, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,174 +0,0 @@
|
|||||||
//! Authoring-layer material resolution shared by Inspector display and guarded transactions.
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Returns only the actor-owned assignment for an exact surface slot.
|
|
||||||
pub(super) fn actor_material_assignment_for_slot(
|
|
||||||
world: &World,
|
|
||||||
entity: Entity,
|
|
||||||
slot_id: &ComponentInstanceId,
|
|
||||||
) -> Option<MaterialRef> {
|
|
||||||
world
|
|
||||||
.get::<SkinnedMeshRenderer>(entity)
|
|
||||||
.and_then(|renderer| renderer.materials.slot(slot_id))
|
|
||||||
.and_then(|slot| slot.material.clone())
|
|
||||||
.or_else(|| {
|
|
||||||
world
|
|
||||||
.get::<StaticMeshRenderer>(entity)
|
|
||||||
.and_then(|renderer| renderer.materials.slot(slot_id))
|
|
||||||
.and_then(|slot| slot.material.clone())
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
world
|
|
||||||
.get::<Primitive>(entity)
|
|
||||||
.filter(|primitive| primitive.surface.id == *slot_id)
|
|
||||||
.and_then(|primitive| primitive.surface.material.clone())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves authored actor/model/project layers for an exact actor slot.
|
|
||||||
pub(super) fn effective_material_for_actor_slot(
|
|
||||||
world: &World,
|
|
||||||
entity: Entity,
|
|
||||||
slot_id: &ComponentInstanceId,
|
|
||||||
) -> Option<MaterialRef> {
|
|
||||||
actor_material_assignment_for_slot(world, entity, slot_id)
|
|
||||||
.or_else(|| model_material_for_actor_slot(world, entity, slot_id).map(|(base, _)| base))
|
|
||||||
.or_else(|| {
|
|
||||||
world
|
|
||||||
.get_resource::<shared::ProjectContentDefaults>()
|
|
||||||
.and_then(|defaults| defaults.default_material.clone())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn model_material_for_actor_slot(
|
|
||||||
world: &World,
|
|
||||||
entity: Entity,
|
|
||||||
slot_id: &ComponentInstanceId,
|
|
||||||
) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> {
|
|
||||||
let asset_id = world
|
|
||||||
.get::<StaticMeshRenderer>(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::<SkinnedMeshRenderer>(entity)
|
|
||||||
.map(|renderer| renderer.asset_id.clone())
|
|
||||||
})?;
|
|
||||||
let record = world
|
|
||||||
.get_resource::<AssetRegistry>()?
|
|
||||||
.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,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn material_ref(id: &str) -> MaterialRef {
|
|
||||||
MaterialRef::new(EditorAssetRef::new(id, "material:source", id))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_default_is_effective_without_becoming_actor_owned() {
|
|
||||||
let mut world = World::new();
|
|
||||||
let fallback = material_ref("project-default");
|
|
||||||
world.insert_resource(shared::ProjectContentDefaults {
|
|
||||||
default_material: Some(fallback.clone()),
|
|
||||||
});
|
|
||||||
let primitive = Primitive::default();
|
|
||||||
let slot_id = primitive.surface.id.clone();
|
|
||||||
let entity = world.spawn(primitive).id();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
actor_material_assignment_for_slot(&world, entity, &slot_id),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
effective_material_for_actor_slot(&world, entity, &slot_id),
|
|
||||||
Some(fallback)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_and_skinned_actor_assignments_override_project_defaults() {
|
|
||||||
let mut world = World::new();
|
|
||||||
world.insert_resource(shared::ProjectContentDefaults {
|
|
||||||
default_material: Some(material_ref("project-default")),
|
|
||||||
});
|
|
||||||
let slot_id = ComponentInstanceId::new("slot:surface");
|
|
||||||
let explicit = material_ref("actor-material");
|
|
||||||
let materials = shared::MaterialSlotSet {
|
|
||||||
slots: vec![shared::MaterialSlot {
|
|
||||||
id: slot_id.clone(),
|
|
||||||
name: "Surface".into(),
|
|
||||||
material: Some(explicit.clone()),
|
|
||||||
}],
|
|
||||||
orphaned_assignments: Vec::new(),
|
|
||||||
};
|
|
||||||
let static_entity = world
|
|
||||||
.spawn(StaticMeshRenderer {
|
|
||||||
materials: materials.clone(),
|
|
||||||
..StaticMeshRenderer::default()
|
|
||||||
})
|
|
||||||
.id();
|
|
||||||
let skinned_entity = world
|
|
||||||
.spawn(SkinnedMeshRenderer {
|
|
||||||
materials,
|
|
||||||
..SkinnedMeshRenderer::default()
|
|
||||||
})
|
|
||||||
.id();
|
|
||||||
|
|
||||||
for entity in [static_entity, skinned_entity] {
|
|
||||||
assert_eq!(
|
|
||||||
actor_material_assignment_for_slot(&world, entity, &slot_id),
|
|
||||||
Some(explicit.clone())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
effective_material_for_actor_slot(&world, entity, &slot_id),
|
|
||||||
Some(explicit.clone())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,6 +10,27 @@ pub(super) fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
pub(super) fn material_drop_invalid_reason(
|
||||||
world: &World,
|
world: &World,
|
||||||
valid_material_candidate: bool,
|
valid_material_candidate: bool,
|
||||||
@ -41,26 +62,15 @@ pub(super) struct MaterialSlotWidgetResponse {
|
|||||||
pub(super) accepted_drop: bool,
|
pub(super) accepted_drop: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct MaterialSlotWidgetContext<'a> {
|
|
||||||
pub(super) candidates: &'a [AssetRefCandidate],
|
|
||||||
pub(super) drop_candidate: Option<&'a AssetRefCandidate>,
|
|
||||||
pub(super) invalid_drop_reason: Option<&'static str>,
|
|
||||||
pub(super) expanded_header_when_parameters_closed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn material_slot_widget_ui(
|
pub(super) fn material_slot_widget_ui(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
slot: &mut shared::MaterialSlot,
|
slot: &mut shared::MaterialSlot,
|
||||||
context: MaterialSlotWidgetContext<'_>,
|
candidates: &[AssetRefCandidate],
|
||||||
|
drop_candidate: Option<&AssetRefCandidate>,
|
||||||
|
invalid_drop_reason: Option<&'static str>,
|
||||||
) -> MaterialSlotWidgetResponse {
|
) -> MaterialSlotWidgetResponse {
|
||||||
let MaterialSlotWidgetContext {
|
|
||||||
candidates,
|
|
||||||
drop_candidate,
|
|
||||||
invalid_drop_reason,
|
|
||||||
expanded_header_when_parameters_closed,
|
|
||||||
} = context;
|
|
||||||
let mut response = MaterialSlotWidgetResponse::default();
|
let mut response = MaterialSlotWidgetResponse::default();
|
||||||
let actor_widget_id = stable_actor_widget_id(world, entity);
|
let actor_widget_id = stable_actor_widget_id(world, entity);
|
||||||
let project_default = world
|
let project_default = world
|
||||||
@ -155,8 +165,6 @@ pub(super) fn material_slot_widget_ui(
|
|||||||
crate::ui::materials::MaterialThumbnailPresentation::Failed
|
crate::ui::materials::MaterialThumbnailPresentation::Failed
|
||||||
} else if let Some(texture) = texture_id {
|
} else if let Some(texture) = texture_id {
|
||||||
crate::ui::materials::MaterialThumbnailPresentation::Ready(texture)
|
crate::ui::materials::MaterialThumbnailPresentation::Ready(texture)
|
||||||
} else if effective.is_none() {
|
|
||||||
crate::ui::materials::MaterialThumbnailPresentation::Empty
|
|
||||||
} else {
|
} else {
|
||||||
crate::ui::materials::MaterialThumbnailPresentation::Pending
|
crate::ui::materials::MaterialThumbnailPresentation::Pending
|
||||||
};
|
};
|
||||||
@ -218,9 +226,7 @@ pub(super) fn material_slot_widget_ui(
|
|||||||
}),
|
}),
|
||||||
can_clear: slot.material.is_some(),
|
can_clear: slot.material.is_some(),
|
||||||
can_extract: is_imported_source,
|
can_extract: is_imported_source,
|
||||||
can_create_instance: is_project_material || is_project_instance,
|
can_create_instance: is_project_material,
|
||||||
parameters_initially_open: true,
|
|
||||||
expanded_header_when_parameters_closed,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let widget_slot_id = slot.id.0.clone();
|
let widget_slot_id = slot.id.0.clone();
|
||||||
@ -327,15 +333,8 @@ pub(super) fn material_slot_widget_ui(
|
|||||||
.get::<MaterialPropertyBlocks>(entity)
|
.get::<MaterialPropertyBlocks>(entity)
|
||||||
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
|
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
|
||||||
.cloned();
|
.cloned();
|
||||||
let project_promotion_base = (is_project_material || is_project_instance)
|
if property_block_promotion_ui(ui, property_block.as_ref(), slot.material.is_some()) {
|
||||||
.then(|| effective.clone())
|
if let (Some(base), Some(block)) = (slot.material.clone(), property_block) {
|
||||||
.flatten();
|
|
||||||
if property_block_promotion_ui(
|
|
||||||
ui,
|
|
||||||
property_block.as_ref(),
|
|
||||||
project_promotion_base.is_some(),
|
|
||||||
) {
|
|
||||||
if let (Some(base), Some(block)) = (project_promotion_base, property_block) {
|
|
||||||
open_property_block_promotion_review(
|
open_property_block_promotion_review(
|
||||||
world,
|
world,
|
||||||
entity,
|
entity,
|
||||||
@ -356,6 +355,67 @@ fn stable_actor_widget_id(world: &World, entity: Entity) -> String {
|
|||||||
.unwrap_or_else(|| format!("runtime:{entity:?}"))
|
.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::<StaticMeshRenderer>(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::<SkinnedMeshRenderer>(entity)
|
||||||
|
.map(|renderer| renderer.asset_id.clone())
|
||||||
|
})?;
|
||||||
|
let record = world
|
||||||
|
.get_resource::<AssetRegistry>()?
|
||||||
|
.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(
|
fn create_instance_and_assign_slot(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
pub(super) fn skinned_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_candidates = brush_face_material_ref_candidates(world);
|
||||||
let material_drop_candidate = world
|
let material_drop_candidate = world
|
||||||
.get_resource::<EditorAssets>()
|
.get_resource::<EditorAssets>()
|
||||||
@ -17,8 +16,6 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
let original = renderer.clone();
|
let original = renderer.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut accepted_drop = false;
|
let mut accepted_drop = false;
|
||||||
let mut browse_mesh = false;
|
|
||||||
let mut locate_mesh = false;
|
|
||||||
let mut options = ComponentCardOptions::removable(
|
let mut options = ComponentCardOptions::removable(
|
||||||
COMPONENT_SKINNED_MESH_RENDERER,
|
COMPONENT_SKINNED_MESH_RENDERER,
|
||||||
"Skinned Mesh Renderer",
|
"Skinned Mesh Renderer",
|
||||||
@ -29,69 +26,8 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
options.resettable = false;
|
options.resettable = false;
|
||||||
options.copyable = false;
|
options.copyable = false;
|
||||||
options.summary = "Skeleton-bound renderer";
|
options.summary = "Skeleton-bound renderer";
|
||||||
options.body_margin = 0;
|
|
||||||
let context = component_card_context(world, entity, options);
|
let context = component_card_context(world, entity, options);
|
||||||
let response = component_card(ui, &context, |ui| {
|
let response = component_card(ui, &context, |ui| {
|
||||||
let renderer_open_id = ui.make_persistent_id(("skinned_renderer_open", entity));
|
|
||||||
let mut renderer_open = ui
|
|
||||||
.ctx()
|
|
||||||
.data_mut(|data| data.get_persisted::<bool>(renderer_open_id))
|
|
||||||
.unwrap_or(true);
|
|
||||||
let source_candidate = mesh_candidates
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.reference.asset_id == renderer.asset_id);
|
|
||||||
let label = Path::new(&renderer.path)
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|stem| stem.to_str())
|
|
||||||
.filter(|label| !label.trim().is_empty())
|
|
||||||
.unwrap_or("Skinned mesh");
|
|
||||||
let thumbnail = source_candidate.and_then(|candidate| candidate.texture_id);
|
|
||||||
|
|
||||||
renderer_panel(ui, |ui| {
|
|
||||||
renderer_panel_header(
|
|
||||||
ui,
|
|
||||||
RendererPanelHeaderViewModel {
|
|
||||||
index: 0,
|
|
||||||
label,
|
|
||||||
path: if renderer.path.trim().is_empty() {
|
|
||||||
"No model source assigned"
|
|
||||||
} else {
|
|
||||||
renderer.path.as_str()
|
|
||||||
},
|
|
||||||
slot_count: renderer.materials.slots.len(),
|
|
||||||
texture_id: thumbnail,
|
|
||||||
},
|
|
||||||
&mut renderer_open,
|
|
||||||
|ui| {
|
|
||||||
if editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::CROSSHAIR,
|
|
||||||
"Locate model",
|
|
||||||
source_candidate.is_some(),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
locate_mesh = true;
|
|
||||||
}
|
|
||||||
if editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Browse models",
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
browse_mesh = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !renderer_open {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
egui::Frame::new()
|
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
renderer_properties_header(ui);
|
|
||||||
property_row(ui, "Source", |ui| {
|
property_row(ui, "Source", |ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(if renderer.path.trim().is_empty() {
|
egui::Label::new(if renderer.path.trim().is_empty() {
|
||||||
@ -108,52 +44,52 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
property_row(ui, "Asset ID", |ui| {
|
property_row(ui, "Asset ID", |ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(if renderer.asset_id.trim().is_empty() {
|
egui::Label::new(if renderer.asset_id.trim().is_empty() {
|
||||||
"Unresolved"
|
"Legacy/path-only"
|
||||||
} else {
|
} else {
|
||||||
renderer.asset_id.as_str()
|
renderer.asset_id.as_str()
|
||||||
})
|
})
|
||||||
.truncate(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.small(
|
ui.label(
|
||||||
egui::RichText::new(
|
egui::RichText::new(
|
||||||
"Imported skeleton hierarchy and skin bindings remain model-owned.",
|
"Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.",
|
||||||
)
|
)
|
||||||
|
.small()
|
||||||
.color(TEXT_DIM),
|
.color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
});
|
ui.add_space(8.0);
|
||||||
renderer_material_slots_header(ui, renderer.materials.slots.len());
|
|
||||||
if renderer.materials.slots.is_empty() {
|
if renderer.materials.slots.is_empty() {
|
||||||
egui::Frame::new()
|
ui.label(
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.small(
|
|
||||||
egui::RichText::new("No imported slots; reimport the source model")
|
egui::RichText::new("No imported slots; reimport the source model")
|
||||||
|
.small()
|
||||||
.color(TEXT_DIM),
|
.color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (index, slot) in renderer.materials.slots.iter_mut().enumerate() {
|
let materials = crate::ui::materials::MaterialsSectionViewModel {
|
||||||
let widget = renderer_material_slot_row(ui, index, |ui| {
|
slot_ids: renderer
|
||||||
material_slot_widget_ui(
|
.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,
|
world,
|
||||||
ui,
|
ui,
|
||||||
entity,
|
entity,
|
||||||
slot,
|
slot,
|
||||||
MaterialSlotWidgetContext {
|
&material_candidates,
|
||||||
candidates: &material_candidates,
|
material_drop_candidate.as_ref(),
|
||||||
drop_candidate: material_drop_candidate.as_ref(),
|
invalid_material_drop,
|
||||||
invalid_drop_reason: invalid_material_drop,
|
);
|
||||||
expanded_header_when_parameters_closed: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
});
|
|
||||||
changed |= widget.changed;
|
changed |= widget.changed;
|
||||||
accepted_drop |= widget.accepted_drop;
|
accepted_drop |= widget.accepted_drop;
|
||||||
|
ui.add_space(8.0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.ctx()
|
|
||||||
.data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open));
|
|
||||||
for orphan in &renderer.materials.orphaned_assignments {
|
for orphan in &renderer.materials.orphaned_assignments {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!(
|
||||||
@ -175,17 +111,6 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
apply_component_card_response(world, entity, response);
|
apply_component_card_response(world, entity, response);
|
||||||
if browse_mesh {
|
|
||||||
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
|
||||||
}
|
|
||||||
if locate_mesh {
|
|
||||||
if let Some(candidate) = mesh_candidates
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.reference.asset_id == renderer.asset_id)
|
|
||||||
{
|
|
||||||
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if accepted_drop {
|
if accepted_drop {
|
||||||
clear_asset_drag(world);
|
clear_asset_drag(world);
|
||||||
}
|
}
|
||||||
@ -225,8 +150,6 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut accepted_drop = false;
|
let mut accepted_drop = false;
|
||||||
let mut remove_entry = None;
|
let mut remove_entry = None;
|
||||||
let mut browse_mesh = false;
|
|
||||||
let mut locate_mesh = None;
|
|
||||||
for index in 0..renderer.slots.len() {
|
for index in 0..renderer.slots.len() {
|
||||||
ensure_slot_id(&mut renderer.slots[index], index);
|
ensure_slot_id(&mut renderer.slots[index], index);
|
||||||
let part = &renderer.slots[index];
|
let part = &renderer.slots[index];
|
||||||
@ -249,7 +172,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
let card = component_card_context(world, entity, options);
|
let card = component_card_context(world, entity, options);
|
||||||
let card_response = component_card(ui, &card, |ui| {
|
let card_response = component_card(ui, &card, |ui| {
|
||||||
let add_slot = renderer_array_header(ui, renderer.slots.len());
|
let add_slot = renderer_array_header(ui, renderer.slots.len());
|
||||||
ui.add_space(26.0);
|
ui.add_space(10.0);
|
||||||
if renderer.slots.is_empty() {
|
if renderer.slots.is_empty() {
|
||||||
ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM));
|
ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM));
|
||||||
}
|
}
|
||||||
@ -267,65 +190,23 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
renderer_panel(ui, |ui| {
|
renderer_panel(ui, |ui| {
|
||||||
renderer_panel_header(
|
renderer_panel_header(
|
||||||
ui,
|
ui,
|
||||||
RendererPanelHeaderViewModel {
|
|
||||||
index,
|
index,
|
||||||
label: &entry.name,
|
&entry.name,
|
||||||
path: entry
|
thumbnail,
|
||||||
.mesh
|
|
||||||
.source_path
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(&entry.mesh.sub_asset_id),
|
|
||||||
slot_count: 1,
|
|
||||||
texture_id: thumbnail,
|
|
||||||
},
|
|
||||||
&mut renderer_open,
|
&mut renderer_open,
|
||||||
|ui| {
|
|ui| {
|
||||||
editor_ui::design_system::controls::icon_button(
|
status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active");
|
||||||
ui,
|
if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() {
|
||||||
icons::DOTS_THREE_VERTICAL,
|
|
||||||
"Renderer actions",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::TRASH,
|
|
||||||
"Remove slot",
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
remove_entry = Some(index);
|
remove_entry = Some(index);
|
||||||
}
|
}
|
||||||
if editor_ui::design_system::controls::icon_button(
|
ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM));
|
||||||
ui,
|
|
||||||
icons::CROSSHAIR,
|
|
||||||
"Locate mesh",
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
locate_mesh = Some(entry.mesh.clone());
|
|
||||||
}
|
|
||||||
if editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Browse mesh",
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
browse_mesh = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if !renderer_open {
|
if !renderer_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
egui::Frame::new()
|
let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| {
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
renderer_properties_header(ui);
|
renderer_properties_header(ui);
|
||||||
ui.add_space(2.0);
|
|
||||||
property_row(ui, "Name", |ui| {
|
property_row(ui, "Name", |ui| {
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
@ -334,7 +215,6 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
)
|
)
|
||||||
.changed();
|
.changed();
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
|
||||||
let mesh_response = asset_selector_row(
|
let mesh_response = asset_selector_row(
|
||||||
ui,
|
ui,
|
||||||
"Mesh",
|
"Mesh",
|
||||||
@ -354,7 +234,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
if mesh_response.locate {
|
if mesh_response.locate {
|
||||||
locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates);
|
locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates);
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
changed |= ui.checkbox(&mut entry.visible, "Visible").changed();
|
changed |= ui.checkbox(&mut entry.visible, "Visible").changed();
|
||||||
changed |= ui
|
changed |= ui
|
||||||
@ -364,8 +244,11 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
.checkbox(&mut entry.receive_shadows, "Receive shadows")
|
.checkbox(&mut entry.receive_shadows, "Receive shadows")
|
||||||
.changed();
|
.changed();
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
};
|
||||||
});
|
|
||||||
|
egui::Frame::new()
|
||||||
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
|
.show(ui, |ui| draw_fields(ui, entry));
|
||||||
renderer_material_slots_header(ui, 1);
|
renderer_material_slots_header(ui, 1);
|
||||||
if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) {
|
if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) {
|
||||||
let widget = renderer_material_slot_row(ui, index, |ui| {
|
let widget = renderer_material_slot_row(ui, index, |ui| {
|
||||||
@ -374,12 +257,9 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
ui,
|
ui,
|
||||||
entity,
|
entity,
|
||||||
slot,
|
slot,
|
||||||
MaterialSlotWidgetContext {
|
&material_candidates,
|
||||||
candidates: &material_candidates,
|
material_drop_candidate.as_ref(),
|
||||||
drop_candidate: material_drop_candidate.as_ref(),
|
invalid_material_drop,
|
||||||
invalid_drop_reason: invalid_material_drop,
|
|
||||||
expanded_header_when_parameters_closed: true,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
changed |= widget.changed;
|
changed |= widget.changed;
|
||||||
@ -407,12 +287,6 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
apply_component_card_response(world, entity, card_response);
|
apply_component_card_response(world, entity, card_response);
|
||||||
if browse_mesh {
|
|
||||||
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
|
||||||
}
|
|
||||||
if let Some(mesh) = locate_mesh.as_ref() {
|
|
||||||
locate_asset_ref(world, Some(mesh), &mesh_candidates);
|
|
||||||
}
|
|
||||||
if accepted_drop {
|
if accepted_drop {
|
||||||
clear_asset_drag(world);
|
clear_asset_drag(world);
|
||||||
}
|
}
|
||||||
@ -443,3 +317,109 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
set_static_mesh_renderer_with_history(world, entity, renderer);
|
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<R>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -65,12 +65,9 @@ pub(super) fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity:
|
|||||||
ui,
|
ui,
|
||||||
entity,
|
entity,
|
||||||
&mut primitive.surface,
|
&mut primitive.surface,
|
||||||
MaterialSlotWidgetContext {
|
&material_candidates,
|
||||||
candidates: &material_candidates,
|
material_drop_candidate.as_ref(),
|
||||||
drop_candidate: material_drop_candidate.as_ref(),
|
invalid_material_drop,
|
||||||
invalid_drop_reason: invalid_material_drop,
|
|
||||||
expanded_header_when_parameters_closed: false,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
changed |= widget.changed;
|
changed |= widget.changed;
|
||||||
|
|||||||
@ -175,7 +175,6 @@ pub(super) fn plan_material_instance_assignment(
|
|||||||
slot_id: block.slot_id.clone(),
|
slot_id: block.slot_id.clone(),
|
||||||
slot_name: slot_name.to_string(),
|
slot_name: slot_name.to_string(),
|
||||||
base: base.clone(),
|
base: base.clone(),
|
||||||
actor_assignment: actor_material_assignment_for_slot(world, entity, &block.slot_id),
|
|
||||||
block: block.clone(),
|
block: block.clone(),
|
||||||
project_root,
|
project_root,
|
||||||
destination_path,
|
destination_path,
|
||||||
@ -244,22 +243,25 @@ pub(super) fn validate_property_block_promotion(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let current_actor_assignment =
|
let current_base = world
|
||||||
actor_material_assignment_for_slot(world, promotion.entity, &promotion.slot_id);
|
.get::<SkinnedMeshRenderer>(promotion.entity)
|
||||||
if current_actor_assignment != promotion.actor_assignment {
|
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
||||||
return Err(
|
.and_then(|slot| slot.material.as_ref())
|
||||||
"the target renderer slot assignment changed; refresh the promotion review".to_string(),
|
.or_else(|| {
|
||||||
);
|
world
|
||||||
}
|
.get::<StaticMeshRenderer>(promotion.entity)
|
||||||
let current_base =
|
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
||||||
effective_material_for_actor_slot(world, promotion.entity, &promotion.slot_id).ok_or_else(
|
.and_then(|slot| slot.material.as_ref())
|
||||||
|| "the target renderer slot is missing or has no project base".to_string(),
|
})
|
||||||
)?;
|
.or_else(|| {
|
||||||
if current_base != promotion.base {
|
world
|
||||||
return Err(
|
.get::<Primitive>(promotion.entity)
|
||||||
"the target renderer slot's effective base changed; refresh the promotion review"
|
.filter(|primitive| primitive.surface.id == promotion.slot_id)
|
||||||
.to_string(),
|
.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 {
|
for source in &promotion.source_snapshots {
|
||||||
if FileSnapshot::capture(&source.path)? != source.snapshot {
|
if FileSnapshot::capture(&source.path)? != source.snapshot {
|
||||||
|
|||||||
@ -1,5 +1,124 @@
|
|||||||
pub(super) use editor_ui::renderer::{
|
//! Penpot renderer-panel chrome shared by static renderer entries.
|
||||||
renderer_array_header, renderer_material_slot_row, renderer_material_slots_header,
|
|
||||||
renderer_panel, renderer_panel_header, renderer_properties_header,
|
use super::*;
|
||||||
RendererPanelHeaderViewModel,
|
|
||||||
|
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<egui::TextureId>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
//! Non-interactive Penpot v2.3.1 renderer-control preview.
|
//! Non-interactive Penpot v2.3.1 renderer-control preview.
|
||||||
|
|
||||||
use egui;
|
use bevy_egui::egui;
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
|
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
use crate::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT};
|
use crate::ui::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT};
|
||||||
|
|
||||||
const PLANNED_TOOLTIP: &str = "Planned — renderer support pending";
|
const PLANNED_TOOLTIP: &str = "Planned — renderer support pending";
|
||||||
|
|
||||||
@ -31,12 +31,12 @@ pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) {
|
|||||||
ui.painter().rect(
|
ui.painter().rect(
|
||||||
rect,
|
rect,
|
||||||
5.0,
|
5.0,
|
||||||
palette.panel,
|
palette.section,
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
egui::Stroke::new(1.0_f32, palette.border),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 30.0));
|
let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 30.0));
|
||||||
ui.painter().rect_filled(header, 4.0, palette.panel);
|
ui.painter().rect_filled(header, 4.0, palette.elevated);
|
||||||
let header_response = ui.interact(header, open_id.with("header"), egui::Sense::click());
|
let header_response = ui.interact(header, open_id.with("header"), egui::Sense::click());
|
||||||
if header_response.clicked() {
|
if header_response.clicked() {
|
||||||
open = !open;
|
open = !open;
|
||||||
@ -55,22 +55,17 @@ pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) {
|
|||||||
} else {
|
} else {
|
||||||
icons::CARET_RIGHT.as_str()
|
icons::CARET_RIGHT.as_str()
|
||||||
},
|
},
|
||||||
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
palette.text_secondary,
|
palette.text_secondary,
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
header.left_center() + egui::vec2(26.0, 0.0),
|
header.left_center() + egui::vec2(30.0, 0.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
egui::Align2::LEFT_CENTER,
|
||||||
"Advanced",
|
"Advanced",
|
||||||
TypeRole::Section.font(),
|
TypeRole::Section.font(),
|
||||||
palette.text_secondary,
|
palette.text_primary,
|
||||||
);
|
);
|
||||||
if !open && rect.width() >= 300.0 {
|
if !open && rect.width() >= 300.0 {
|
||||||
ui.painter().hline(
|
|
||||||
(header.left() + 82.0)..=(header.right() - 158.0),
|
|
||||||
header.center().y,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
header.right_center() - egui::vec2(12.0, 0.0),
|
header.right_center() - egui::vec2(12.0, 0.0),
|
||||||
egui::Align2::RIGHT_CENTER,
|
egui::Align2::RIGHT_CENTER,
|
||||||
@ -23,7 +23,7 @@ pub(crate) fn material_asset_editor(
|
|||||||
.and_then(|selection| {
|
.and_then(|selection| {
|
||||||
texture_assets
|
texture_assets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| material_locate_matches_selection(&candidate.locate, selection))
|
.find(|candidate| candidate.selection == *selection)
|
||||||
})
|
})
|
||||||
.cloned();
|
.cloned();
|
||||||
let original = draft_asset.clone();
|
let original = draft_asset.clone();
|
||||||
@ -53,7 +53,7 @@ pub(crate) fn material_asset_editor(
|
|||||||
&mut draft_asset.render_state,
|
&mut draft_asset.render_state,
|
||||||
Some(&mut draft_asset.inputs),
|
Some(&mut draft_asset.inputs),
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
ui.add_space(8.0);
|
||||||
let input_response = material_input_schema_editor(
|
let input_response = material_input_schema_editor(
|
||||||
ui,
|
ui,
|
||||||
&input_schema,
|
&input_schema,
|
||||||
@ -62,10 +62,8 @@ pub(crate) fn material_asset_editor(
|
|||||||
texture_drop_candidate.as_ref(),
|
texture_drop_candidate.as_ref(),
|
||||||
);
|
);
|
||||||
accepted_texture_drop |= input_response.accepted_drop;
|
accepted_texture_drop |= input_response.accepted_drop;
|
||||||
if let Some(target) = input_response.locate {
|
if let Some(selection) = input_response.locate {
|
||||||
world
|
world.resource_mut::<EditorAssets>().select(selection);
|
||||||
.resource_mut::<EditorAssets>()
|
|
||||||
.select(material_locate_selection(target));
|
|
||||||
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
||||||
}
|
}
|
||||||
if input_response.browse_library {
|
if input_response.browse_library {
|
||||||
@ -114,3 +112,117 @@ fn material_source_diagnostics_ui(ui: &mut egui::Ui, provenance: &shared::Materi
|
|||||||
});
|
});
|
||||||
ui.small("Reimport comparison never overwrites an edited project material automatically.");
|
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<R>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -269,7 +269,7 @@ pub(super) fn material_texture_candidates(world: &mut World) -> Vec<MaterialText
|
|||||||
label: asset.label.clone(),
|
label: asset.label.clone(),
|
||||||
detail: path.to_string(),
|
detail: path.to_string(),
|
||||||
reference,
|
reference,
|
||||||
locate: MaterialLocateTarget::File(path.to_string()),
|
selection: AssetSelection::File(path.to_string()),
|
||||||
thumbnail: thumbnails.as_ref().map_or(
|
thumbnail: thumbnails.as_ref().map_or(
|
||||||
MaterialThumbnailPresentation::Pending,
|
MaterialThumbnailPresentation::Pending,
|
||||||
|cache| {
|
|cache| {
|
||||||
@ -325,10 +325,11 @@ pub(super) fn material_texture_candidates(world: &mut World) -> Vec<MaterialText
|
|||||||
label: label.clone(),
|
label: label.clone(),
|
||||||
detail: format!("{} | {source_path}", asset.label),
|
detail: format!("{} | {source_path}", asset.label),
|
||||||
reference,
|
reference,
|
||||||
locate: MaterialLocateTarget::SubAsset {
|
selection: AssetSelection::SubAsset {
|
||||||
parent_path: parent_path.to_string(),
|
parent_path: parent_path.to_string(),
|
||||||
sub_asset_id,
|
sub_asset_id,
|
||||||
label,
|
label,
|
||||||
|
kind: AssetSubAssetKind::Texture,
|
||||||
source_path: Some(source_path.clone()),
|
source_path: Some(source_path.clone()),
|
||||||
},
|
},
|
||||||
thumbnail: MaterialThumbnailPresentation::Pending,
|
thumbnail: MaterialThumbnailPresentation::Pending,
|
||||||
|
|||||||
@ -1,21 +1,15 @@
|
|||||||
use super::texture_inputs::{
|
|
||||||
ensure_material_texture_binding, material_property_texture_binding,
|
|
||||||
material_texture_channel_ui, texture_binding_ui, texture_channel_label,
|
|
||||||
upsert_material_texture_binding,
|
|
||||||
};
|
|
||||||
use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section};
|
use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::design_system;
|
use crate::ui::design_system;
|
||||||
use crate::design_system::color_picker::{color_control, color_control_with_intensity};
|
use crate::ui::design_system::color_picker::{color_control, color_control_with_intensity};
|
||||||
use crate::design_system::controls::{
|
use crate::ui::design_system::controls::{
|
||||||
scalar_control, section_header, section_header_with_summary, switch,
|
scalar_control, section_frame, section_header, section_header_with_summary, switch,
|
||||||
};
|
};
|
||||||
use crate::design_system::property_grid::PropertyGridGeometry;
|
use crate::ui::design_system::property_grid::PropertyGridGeometry;
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
use material_schema::MaterialShaderKind;
|
|
||||||
|
|
||||||
pub fn material_input_schema_editor(
|
pub(crate) fn material_input_schema_editor(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
schema: &MaterialInputSchema,
|
schema: &MaterialInputSchema,
|
||||||
inputs: &mut MaterialInputSet,
|
inputs: &mut MaterialInputSet,
|
||||||
@ -32,7 +26,7 @@ pub fn material_input_schema_editor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn material_input_schema_editor_with_inheritance(
|
pub(super) fn material_input_schema_editor_with_inheritance(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
schema: &MaterialInputSchema,
|
schema: &MaterialInputSchema,
|
||||||
inputs: &mut MaterialInputSet,
|
inputs: &mut MaterialInputSet,
|
||||||
@ -46,7 +40,7 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
.all(|name| schema.inputs.iter().any(|input| input.name == *name));
|
.all(|name| schema.inputs.iter().any(|input| input.name == *name));
|
||||||
let mut groups = schema.groups.clone();
|
let mut groups = schema.groups.clone();
|
||||||
if groups.is_empty() {
|
if groups.is_empty() {
|
||||||
groups.push(material_schema::MaterialInputGroupDesc {
|
groups.push(shared::MaterialInputGroupDesc {
|
||||||
id: String::new(),
|
id: String::new(),
|
||||||
display_name: "Properties".into(),
|
display_name: "Properties".into(),
|
||||||
order: 0,
|
order: 0,
|
||||||
@ -62,7 +56,7 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
property.group == group.id
|
property.group == group.id
|
||||||
&& matches!(
|
&& matches!(
|
||||||
property.presentation,
|
property.presentation,
|
||||||
material_schema::MaterialInputPresentation::Row
|
shared::MaterialInputPresentation::Row
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@ -71,10 +65,10 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|property| property.group == group.id)
|
.filter(|property| property.group == group.id)
|
||||||
.filter_map(|property| match &property.presentation {
|
.filter_map(|property| match &property.presentation {
|
||||||
material_schema::MaterialInputPresentation::Companion { owner } => {
|
shared::MaterialInputPresentation::Companion { owner } => {
|
||||||
Some((owner.as_str(), property))
|
Some((owner.as_str(), property))
|
||||||
}
|
}
|
||||||
material_schema::MaterialInputPresentation::Row => None,
|
shared::MaterialInputPresentation::Row => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
properties.sort_by_key(|property| property.order);
|
properties.sort_by_key(|property| property.order);
|
||||||
@ -83,9 +77,7 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
}
|
}
|
||||||
if group.id == "uv_transform" {
|
if group.id == "uv_transform" {
|
||||||
material_uv_transform_ui(ui, inputs, inherited);
|
material_uv_transform_ui(ui, inputs, inherited);
|
||||||
// The design-system scope contributes four pixels between vertical items; add the
|
ui.add_space(8.0);
|
||||||
// remaining four to reach the authored eight-pixel section gap.
|
|
||||||
ui.add_space(4.0);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let open_id = ui.make_persistent_id(("material_input_group_open", &group.id));
|
let open_id = ui.make_persistent_id(("material_input_group_open", &group.id));
|
||||||
@ -93,26 +85,30 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
.ctx()
|
.ctx()
|
||||||
.data_mut(|data| data.get_persisted::<bool>(open_id))
|
.data_mut(|data| data.get_persisted::<bool>(open_id))
|
||||||
.unwrap_or(!group.advanced);
|
.unwrap_or(!group.advanced);
|
||||||
let context = MaterialInputGroupContext {
|
section_frame(ui).show(ui, |ui| {
|
||||||
properties: &properties,
|
|
||||||
companions: &companions,
|
|
||||||
texture_assets,
|
|
||||||
drop_candidate,
|
|
||||||
};
|
|
||||||
let rows = material_input_group_ui(ui, context, &mut open, inputs, |ui, open| {
|
|
||||||
if group.id == "surface_inputs" {
|
if group.id == "surface_inputs" {
|
||||||
let summary = format!("{} bindings", properties.len());
|
let summary = format!("{} bindings", properties.len());
|
||||||
section_header_with_summary(ui, "Inputs", Some(&summary), open);
|
section_header_with_summary(ui, "Inputs", Some(&summary), &mut open);
|
||||||
} else {
|
} else {
|
||||||
section_header(ui, &group.display_name, open);
|
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.accepted_drop |= rows.accepted_drop;
|
||||||
response.locate = response.locate.take().or(rows.locate);
|
response.locate = response.locate.take().or(rows.locate);
|
||||||
response.browse_library |= rows.browse_library;
|
response.browse_library |= rows.browse_library;
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.ctx()
|
ui.ctx()
|
||||||
.data_mut(|data| data.insert_persisted(open_id, open));
|
.data_mut(|data| data.insert_persisted(open_id, open));
|
||||||
ui.add_space(4.0);
|
ui.add_space(8.0);
|
||||||
}
|
}
|
||||||
if has_standard_packed_inputs {
|
if has_standard_packed_inputs {
|
||||||
super::advanced_preview::unsupported_advanced_preview(ui);
|
super::advanced_preview::unsupported_advanced_preview(ui);
|
||||||
@ -120,65 +116,6 @@ pub fn material_input_schema_editor_with_inheritance(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct MaterialInputGroupContext<'schema, 'view> {
|
|
||||||
properties: &'view [&'schema MaterialInputDesc],
|
|
||||||
companions: &'view [(&'schema str, &'schema MaterialInputDesc)],
|
|
||||||
texture_assets: &'view [MaterialTextureCandidate],
|
|
||||||
drop_candidate: Option<&'view MaterialTextureCandidate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_input_group_ui(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
context: MaterialInputGroupContext<'_, '_>,
|
|
||||||
open: &mut bool,
|
|
||||||
inputs: &mut MaterialInputSet,
|
|
||||||
header_ui: impl FnOnce(&mut egui::Ui, &mut bool),
|
|
||||||
) -> MaterialInputEditorResponse {
|
|
||||||
let width = ui.available_width().max(1.0);
|
|
||||||
let geometry = PropertyGridGeometry::for_width(width);
|
|
||||||
let body_open = *open;
|
|
||||||
let height = material_input_group_height(width, body_open, context.properties.len());
|
|
||||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
ui.painter().rect(
|
|
||||||
rect,
|
|
||||||
5.0,
|
|
||||||
palette.section,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The parent owns the complete section rect. Child controls may manage local layout state,
|
|
||||||
// but they cannot alter where the next material section begins.
|
|
||||||
let mut section = ui.new_child(egui::UiBuilder::new().max_rect(rect));
|
|
||||||
section.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
let header_rect = egui::Rect::from_min_size(
|
|
||||||
rect.min,
|
|
||||||
egui::vec2(width, design_system::INPUT_SECTION_HEADER_HEIGHT),
|
|
||||||
);
|
|
||||||
let mut header = section.new_child(egui::UiBuilder::new().max_rect(header_rect));
|
|
||||||
header.set_clip_rect(header_rect.intersect(section.clip_rect()));
|
|
||||||
header_ui(&mut header, open);
|
|
||||||
|
|
||||||
if body_open {
|
|
||||||
let rows_rect =
|
|
||||||
egui::Rect::from_min_max(egui::pos2(rect.left(), header_rect.bottom()), rect.max);
|
|
||||||
material_input_rows(&mut section, rows_rect, geometry, context, inputs)
|
|
||||||
} else {
|
|
||||||
MaterialInputEditorResponse::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_input_group_height(width: f32, open: bool, rows: usize) -> f32 {
|
|
||||||
let body = if open {
|
|
||||||
PropertyGridGeometry::for_width(width).row_height * rows as f32
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
design_system::INPUT_SECTION_HEADER_HEIGHT + body
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_uv_transform_ui(
|
fn material_uv_transform_ui(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
inputs: &mut MaterialInputSet,
|
inputs: &mut MaterialInputSet,
|
||||||
@ -201,12 +138,12 @@ fn material_uv_transform_ui(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PackedMapMode {
|
pub(super) enum PackedMapMode {
|
||||||
Arm,
|
Arm,
|
||||||
Separate,
|
Separate,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode {
|
pub(super) fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode {
|
||||||
let channels = [
|
let channels = [
|
||||||
("occlusion", TextureChannel::R),
|
("occlusion", TextureChannel::R),
|
||||||
("roughness", TextureChannel::G),
|
("roughness", TextureChannel::G),
|
||||||
@ -223,7 +160,7 @@ pub fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) {
|
pub(super) fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) {
|
||||||
let shared_texture = ["occlusion", "roughness", "metallic"]
|
let shared_texture = ["occlusion", "roughness", "metallic"]
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|name| {
|
.find_map(|name| {
|
||||||
@ -244,31 +181,34 @@ pub fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) {
|
pub(super) fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) {
|
||||||
for name in ["occlusion", "roughness", "metallic"] {
|
for name in ["occlusion", "roughness", "metallic"] {
|
||||||
ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R;
|
ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn material_input_rows(
|
pub(super) fn material_input_rows(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rows_rect: egui::Rect,
|
properties: &[&MaterialInputDesc],
|
||||||
geometry: PropertyGridGeometry,
|
companions: &[(&str, &MaterialInputDesc)],
|
||||||
context: MaterialInputGroupContext<'_, '_>,
|
|
||||||
inputs: &mut MaterialInputSet,
|
inputs: &mut MaterialInputSet,
|
||||||
|
texture_assets: &[MaterialTextureCandidate],
|
||||||
|
drop_candidate: Option<&MaterialTextureCandidate>,
|
||||||
) -> MaterialInputEditorResponse {
|
) -> MaterialInputEditorResponse {
|
||||||
let palette = design_system::palette(ui);
|
let palette = design_system::palette(ui);
|
||||||
let mut response = MaterialInputEditorResponse::default();
|
let mut response = MaterialInputEditorResponse::default();
|
||||||
for (row, property) in context.properties.iter().enumerate() {
|
let geometry = PropertyGridGeometry::for_width(ui.available_width());
|
||||||
let companion = context
|
// Property rows are a contiguous Penpot table. Default egui item spacing otherwise adds four
|
||||||
.companions
|
// 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()
|
.iter()
|
||||||
.find_map(|(owner, companion)| (*owner == property.name).then_some(*companion));
|
.find_map(|(owner, companion)| (*owner == property.name).then_some(*companion));
|
||||||
ui.push_id(("material_input_row", &property.name), |ui| {
|
ui.push_id(("material_input_row", &property.name), |ui| {
|
||||||
let row_rect = egui::Rect::from_min_size(
|
let width = ui.available_width().max(1.0);
|
||||||
rows_rect.min + egui::vec2(0.0, row as f32 * geometry.row_height),
|
let (row_rect, _) = ui
|
||||||
egui::vec2(rows_rect.width(), geometry.row_height),
|
.allocate_exact_size(egui::vec2(width, geometry.row_height), egui::Sense::hover());
|
||||||
);
|
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
row_rect,
|
row_rect,
|
||||||
0.0,
|
0.0,
|
||||||
@ -280,29 +220,20 @@ fn material_input_rows(
|
|||||||
);
|
);
|
||||||
let rects = geometry.row_rects(row_rect);
|
let rects = geometry.row_rects(row_rect);
|
||||||
rect_ui(ui, rects.label, |ui| material_input_label(ui, property));
|
rect_ui(ui, rects.label, |ui| material_input_label(ui, property));
|
||||||
let is_color = matches!(property.property_type, ShaderPropertyType::Color);
|
rect_ui(ui, rects.value, |ui| {
|
||||||
rect_ui(
|
material_input_value_ui(ui, property, companion, inputs)
|
||||||
ui,
|
});
|
||||||
if is_color {
|
|
||||||
rects.color_value
|
|
||||||
} else {
|
|
||||||
rects.value
|
|
||||||
},
|
|
||||||
|ui| material_input_value_ui(ui, property, companion, inputs),
|
|
||||||
);
|
|
||||||
if !is_color {
|
|
||||||
rect_ui(ui, rects.channel, |ui| {
|
rect_ui(ui, rects.channel, |ui| {
|
||||||
material_input_channel_ui(ui, property, inputs)
|
material_input_channel_ui(ui, property, inputs)
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if material_input_uses_texture(property) {
|
if material_input_uses_texture(property) {
|
||||||
let texture = rect_ui(ui, rects.texture_group, |ui| {
|
let texture = rect_ui(ui, rects.texture_group, |ui| {
|
||||||
material_input_texture_ui(
|
material_input_texture_ui(
|
||||||
ui,
|
ui,
|
||||||
property,
|
property,
|
||||||
inputs,
|
inputs,
|
||||||
context.texture_assets,
|
texture_assets,
|
||||||
context.drop_candidate,
|
drop_candidate,
|
||||||
rects.inline_texture_actions,
|
rects.inline_texture_actions,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@ -462,7 +393,7 @@ fn material_input_channel_ui(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let channel = property.texture.as_ref().map(|texture| {
|
let channel = property.texture.as_ref().map(|texture| {
|
||||||
if matches!(texture.semantic, material_schema::TextureSemantic::Color) {
|
if matches!(texture.semantic, shared::TextureSemantic::Color) {
|
||||||
"RGB"
|
"RGB"
|
||||||
} else {
|
} else {
|
||||||
texture_channel_label(texture.default_channel)
|
texture_channel_label(texture.default_channel)
|
||||||
@ -587,13 +518,13 @@ fn readonly_channel(ui: &mut egui::Ui, channel: &str) {
|
|||||||
rect.right_center() - egui::vec2(8.0, 0.0),
|
rect.right_center() - egui::vec2(8.0, 0.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
egui_phosphor_icons::icons::CARET_DOWN.as_str(),
|
egui_phosphor_icons::icons::CARET_DOWN.as_str(),
|
||||||
egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
palette.text_muted,
|
palette.text_muted,
|
||||||
);
|
);
|
||||||
response.on_hover_text("Channel is fixed by this material input");
|
response.on_hover_text("Channel is fixed by this material input");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_value_for_shader_property(
|
pub(super) fn default_value_for_shader_property(
|
||||||
property_type: &ShaderPropertyType,
|
property_type: &ShaderPropertyType,
|
||||||
) -> MaterialParameterValue {
|
) -> MaterialParameterValue {
|
||||||
match property_type {
|
match property_type {
|
||||||
@ -609,7 +540,7 @@ pub fn default_value_for_shader_property(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parameter_value_matches_property(
|
pub(super) fn parameter_value_matches_property(
|
||||||
value: &MaterialParameterValue,
|
value: &MaterialParameterValue,
|
||||||
property_type: &ShaderPropertyType,
|
property_type: &ShaderPropertyType,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@ -630,7 +561,7 @@ pub fn parameter_value_matches_property(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
|
pub(super) fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_sized([92.0, 20.0], egui::Label::new(label));
|
ui.add_sized([92.0, 20.0], egui::Label::new(label));
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
@ -640,14 +571,14 @@ pub fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) {
|
pub(super) fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_sized([92.0, 20.0], egui::Label::new("Shader"));
|
ui.add_sized([92.0, 20.0], egui::Label::new("Shader"));
|
||||||
shader_kind_combo(ui, kind, "asset_material_shader_kind");
|
shader_kind_combo(ui, kind, "asset_material_shader_kind");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shader_kind_combo(
|
pub(super) fn shader_kind_combo(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
kind: &mut MaterialShaderKind,
|
kind: &mut MaterialShaderKind,
|
||||||
salt: impl std::hash::Hash,
|
salt: impl std::hash::Hash,
|
||||||
@ -666,7 +597,7 @@ pub fn shader_kind_combo(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) {
|
pub(super) fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_sized([92.0, 20.0], egui::Label::new(label));
|
ui.add_sized([92.0, 20.0], egui::Label::new(label));
|
||||||
let mut text = value.clone().unwrap_or_default();
|
let mut text = value.clone().unwrap_or_default();
|
||||||
@ -693,20 +624,13 @@ pub fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option<Str
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn input_group_parent_owns_exact_reference_and_compact_heights() {
|
|
||||||
assert_eq!(material_input_group_height(569.0, true, 6), 216.0);
|
|
||||||
assert_eq!(material_input_group_height(369.0, true, 6), 372.0);
|
|
||||||
assert_eq!(material_input_group_height(569.0, false, 6), 24.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() {
|
fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() {
|
||||||
let context = egui::Context::default();
|
let context = egui::Context::default();
|
||||||
context.set_fonts(crate::fonts::font_definitions());
|
context.set_fonts(crate::ui::fonts::editor_font_definitions());
|
||||||
let mut inputs = MaterialInputSet::default();
|
let mut inputs = MaterialInputSet::default();
|
||||||
let original = inputs.clone();
|
let original = inputs.clone();
|
||||||
let schema = material_schema::standard_lit_input_schema();
|
let schema = shared::standard_lit_input_schema();
|
||||||
let _ = context.run_ui(
|
let _ = context.run_ui(
|
||||||
egui::RawInput {
|
egui::RawInput {
|
||||||
screen_rect: Some(egui::Rect::from_min_size(
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
@ -29,7 +29,7 @@ pub(crate) fn material_instance_asset_editor(
|
|||||||
.and_then(|selection| {
|
.and_then(|selection| {
|
||||||
texture_assets
|
texture_assets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| material_locate_matches_selection(&candidate.locate, selection))
|
.find(|candidate| candidate.selection == *selection)
|
||||||
})
|
})
|
||||||
.cloned();
|
.cloned();
|
||||||
let original = draft.clone();
|
let original = draft.clone();
|
||||||
@ -58,9 +58,13 @@ pub(crate) fn material_instance_asset_editor(
|
|||||||
}
|
}
|
||||||
ui.add_enabled_ui(false, |ui| {
|
ui.add_enabled_ui(false, |ui| {
|
||||||
let mut render_state = base_asset.render_state;
|
let mut render_state = base_asset.render_state;
|
||||||
material_surface_options_ui(ui, &mut render_state, Some(&mut draft.overrides));
|
super::asset_editor::material_surface_options_ui(
|
||||||
|
ui,
|
||||||
|
&mut render_state,
|
||||||
|
Some(&mut draft.overrides),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(8.0);
|
||||||
let input_response = super::instance_inputs::material_instance_input_schema_editor(
|
let input_response = super::instance_inputs::material_instance_input_schema_editor(
|
||||||
ui,
|
ui,
|
||||||
&schema,
|
&schema,
|
||||||
@ -69,10 +73,8 @@ pub(crate) fn material_instance_asset_editor(
|
|||||||
&texture_assets,
|
&texture_assets,
|
||||||
texture_drop_candidate.as_ref(),
|
texture_drop_candidate.as_ref(),
|
||||||
);
|
);
|
||||||
if let Some(target) = input_response.locate.clone() {
|
if let Some(selection) = input_response.locate.clone() {
|
||||||
world
|
world.resource_mut::<EditorAssets>().select(selection);
|
||||||
.resource_mut::<EditorAssets>()
|
|
||||||
.select(material_locate_selection(target));
|
|
||||||
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
||||||
}
|
}
|
||||||
if input_response.browse_library {
|
if input_response.browse_library {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ pub(super) fn material_instance_input_schema_editor(
|
|||||||
let mut effective = normalized_base.clone();
|
let mut effective = normalized_base.clone();
|
||||||
overlay_inputs(&mut effective, overrides);
|
overlay_inputs(&mut effective, overrides);
|
||||||
|
|
||||||
let response = material_input_schema_editor_with_inheritance(
|
let response = super::inputs::material_input_schema_editor_with_inheritance(
|
||||||
ui,
|
ui,
|
||||||
schema,
|
schema,
|
||||||
&mut effective,
|
&mut effective,
|
||||||
@ -110,7 +110,6 @@ fn upsert_texture(textures: &mut Vec<MaterialTextureBinding>, texture: MaterialT
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use shared::MaterialParameterValue;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn instance_editor_retains_only_values_different_from_base() {
|
fn instance_editor_retains_only_values_different_from_base() {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests.
|
//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests.
|
||||||
|
|
||||||
use egui;
|
use bevy_egui::egui;
|
||||||
|
|
||||||
use crate::design_system::{
|
use crate::ui::design_system::{
|
||||||
ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT, COLLAPSED_HEADER_HEIGHT, HEADER_HEIGHT,
|
ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT, COLLAPSED_HEADER_HEIGHT, HEADER_HEIGHT,
|
||||||
INPUT_SECTION_HEADER_HEIGHT, MATERIAL_IDENTITY_WIDTH, MATERIAL_PREVIEW_SIZE, OUTER_PADDING,
|
INPUT_SECTION_HEADER_HEIGHT, MATERIAL_IDENTITY_WIDTH, MATERIAL_PREVIEW_SIZE, OUTER_PADDING,
|
||||||
PARAMETER_ROW_HEIGHT, SECTION_GAP, SURFACE_SECTION_HEIGHT, UV_SECTION_HEIGHT,
|
PARAMETER_ROW_HEIGHT, SECTION_GAP, SURFACE_SECTION_HEIGHT, UV_SECTION_HEIGHT,
|
||||||
@ -26,7 +26,7 @@ pub(super) enum ResponsiveMaterialLayout {
|
|||||||
|
|
||||||
impl ResponsiveMaterialLayout {
|
impl ResponsiveMaterialLayout {
|
||||||
pub(super) fn for_width(width: f32) -> Self {
|
pub(super) fn for_width(width: f32) -> Self {
|
||||||
if width + f32::EPSILON >= crate::design_system::property_grid::WIDE_SECTION_WIDTH {
|
if width + f32::EPSILON >= crate::ui::design_system::property_grid::WIDE_SECTION_WIDTH {
|
||||||
Self::Reference
|
Self::Reference
|
||||||
} else if width + f32::EPSILON >= COMPACT_SECTION_WIDTH {
|
} else if width + f32::EPSILON >= COMPACT_SECTION_WIDTH {
|
||||||
Self::Compact
|
Self::Compact
|
||||||
@ -67,7 +67,7 @@ impl SurfaceSectionGeometry {
|
|||||||
double_sided: local(460.0, 12.0, 99.0, 22.0),
|
double_sided: local(460.0, 12.0, 99.0, 22.0),
|
||||||
},
|
},
|
||||||
ResponsiveMaterialLayout::Compact => Self {
|
ResponsiveMaterialLayout::Compact => Self {
|
||||||
height: crate::design_system::COMPACT_SURFACE_SECTION_HEIGHT,
|
height: crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT,
|
||||||
title: local(10.0, 10.0, 48.0, 26.0),
|
title: local(10.0, 10.0, 48.0, 26.0),
|
||||||
blend: local(66.0, 10.0, 50.0, 26.0),
|
blend: local(66.0, 10.0, 50.0, 26.0),
|
||||||
packed: local(124.0, 11.0, 128.0, 24.0),
|
packed: local(124.0, 11.0, 128.0, 24.0),
|
||||||
@ -105,7 +105,7 @@ impl UvSectionGeometry {
|
|||||||
let mode = ResponsiveMaterialLayout::for_width(rect.width());
|
let mode = ResponsiveMaterialLayout::for_width(rect.width());
|
||||||
let title = egui::Rect::from_min_size(
|
let title = egui::Rect::from_min_size(
|
||||||
rect.min + egui::vec2(12.0, 7.0),
|
rect.min + egui::vec2(12.0, 7.0),
|
||||||
egui::vec2(20.0_f32.min(rect.width()), 14.0_f32.min(rect.height())),
|
egui::vec2(20.0_f32.min(rect.width()), 40.0_f32.min(rect.height())),
|
||||||
);
|
);
|
||||||
if mode == ResponsiveMaterialLayout::Transient {
|
if mode == ResponsiveMaterialLayout::Transient {
|
||||||
let group_width = (rect.width() - 20.0).max(1.0);
|
let group_width = (rect.width() - 20.0).max(1.0);
|
||||||
@ -125,7 +125,7 @@ impl UvSectionGeometry {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Self {
|
Self {
|
||||||
height: crate::design_system::COMPACT_UV_SECTION_HEIGHT,
|
height: crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT,
|
||||||
title,
|
title,
|
||||||
offset: uv_group(rect.min + egui::vec2(118.0, 7.0), 165.0),
|
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),
|
tiling: uv_group(rect.min + egui::vec2(118.5, 47.0), 164.0),
|
||||||
@ -200,7 +200,7 @@ impl MaterialPanelGeometry {
|
|||||||
INPUT_SECTION_HEADER_HEIGHT
|
INPUT_SECTION_HEADER_HEIGHT
|
||||||
+ primary_rows as f32
|
+ primary_rows as f32
|
||||||
* if compact {
|
* if compact {
|
||||||
crate::design_system::COMPACT_PARAMETER_ROW_HEIGHT
|
crate::ui::design_system::COMPACT_PARAMETER_ROW_HEIGHT
|
||||||
} else {
|
} else {
|
||||||
PARAMETER_ROW_HEIGHT
|
PARAMETER_ROW_HEIGHT
|
||||||
}
|
}
|
||||||
@ -282,7 +282,6 @@ mod tests {
|
|||||||
let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
|
let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
|
||||||
let geometry = UvSectionGeometry::for_rect(section);
|
let geometry = UvSectionGeometry::for_rect(section);
|
||||||
assert_eq!(geometry.title.min, egui::pos2(12.0, 7.0));
|
assert_eq!(geometry.title.min, egui::pos2(12.0, 7.0));
|
||||||
assert_eq!(geometry.title.height(), 14.0);
|
|
||||||
assert_eq!(geometry.offset.x.size(), egui::vec2(50.0, 24.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_eq!(geometry.offset.y.size(), egui::vec2(50.0, 24.0));
|
||||||
assert!(geometry.offset.reset.is_some());
|
assert!(geometry.offset.reset.is_some());
|
||||||
@ -7,12 +7,14 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use bevy::prelude::World;
|
use bevy::prelude::{Vec2, Vec3, World};
|
||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
|
use egui_phosphor_icons::{icons, Icon};
|
||||||
use shared::{
|
use shared::{
|
||||||
standard_lit_input_schema, EditorAssetRef, MaterialAsset, MaterialInputSchema,
|
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialAsset,
|
||||||
MaterialInputSet, MaterialInstanceAsset, MaterialParameter, MaterialRef, MaterialShaderKind,
|
MaterialInputDesc, MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset,
|
||||||
MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset,
|
MaterialParameter, MaterialParameterValue, MaterialRef, MaterialShaderKind,
|
||||||
|
MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset, TextureChannel,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry};
|
use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry};
|
||||||
@ -27,10 +29,17 @@ use crate::ui::document_status::authored_document_status_ui;
|
|||||||
|
|
||||||
use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING};
|
use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING};
|
||||||
|
|
||||||
|
mod advanced_preview;
|
||||||
mod asset_editor;
|
mod asset_editor;
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod inputs;
|
||||||
mod instance_editor;
|
mod instance_editor;
|
||||||
mod instance_inputs;
|
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 asset_editor::material_asset_editor;
|
||||||
pub(crate) use editor::{
|
pub(crate) use editor::{
|
||||||
@ -38,57 +47,67 @@ pub(crate) use editor::{
|
|||||||
material_shader_label, set_material_shader_kind,
|
material_shader_label, set_material_shader_kind,
|
||||||
};
|
};
|
||||||
use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets};
|
use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets};
|
||||||
pub(crate) use editor_ui::materials::{
|
pub(crate) use inputs::material_input_schema_editor;
|
||||||
compact_text_edit, default_value_for_shader_property, ensure_material_texture_binding,
|
use inputs::*;
|
||||||
material_input_schema_editor, material_input_schema_editor_with_inheritance,
|
|
||||||
material_slot_panel, material_surface_options_ui, materials_section, optional_path_edit,
|
|
||||||
parameter_value_matches_property, shader_kind_picker, MaterialHealth,
|
|
||||||
MaterialInputEditorResponse, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate,
|
|
||||||
MaterialSlotAction, MaterialSlotPanelViewModel, MaterialTextureCandidate,
|
|
||||||
MaterialThumbnailPresentation, MaterialsSectionViewModel,
|
|
||||||
};
|
|
||||||
pub(crate) use instance_editor::material_instance_asset_editor;
|
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<MaterialTextureCandidate>,
|
||||||
|
locate: Option<AssetSelection>,
|
||||||
|
clear: bool,
|
||||||
|
accepted_drop: bool,
|
||||||
|
browse_library: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct MaterialInputEditorResponse {
|
||||||
|
pub accepted_drop: bool,
|
||||||
|
pub locate: Option<AssetSelection>,
|
||||||
|
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 {
|
fn natural_cmp(left: &str, right: &str) -> std::cmp::Ordering {
|
||||||
left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase())
|
left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn material_locate_selection(target: MaterialLocateTarget) -> AssetSelection {
|
|
||||||
match target {
|
|
||||||
MaterialLocateTarget::File(path) => AssetSelection::File(path),
|
|
||||||
MaterialLocateTarget::SubAsset {
|
|
||||||
parent_path,
|
|
||||||
sub_asset_id,
|
|
||||||
label,
|
|
||||||
source_path,
|
|
||||||
} => AssetSelection::SubAsset {
|
|
||||||
parent_path,
|
|
||||||
sub_asset_id,
|
|
||||||
label,
|
|
||||||
kind: AssetSubAssetKind::Texture,
|
|
||||||
source_path,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_locate_matches_selection(
|
|
||||||
target: &MaterialLocateTarget,
|
|
||||||
selection: &AssetSelection,
|
|
||||||
) -> bool {
|
|
||||||
match (target, selection) {
|
|
||||||
(MaterialLocateTarget::File(left), AssetSelection::File(right)) => left == right,
|
|
||||||
(
|
|
||||||
MaterialLocateTarget::SubAsset {
|
|
||||||
parent_path: left_parent,
|
|
||||||
sub_asset_id: left_sub_asset,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
AssetSelection::SubAsset {
|
|
||||||
parent_path: right_parent,
|
|
||||||
sub_asset_id: right_sub_asset,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
) => left_parent == right_parent && left_sub_asset == right_sub_asset,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,31 +1,22 @@
|
|||||||
//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors.
|
//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors.
|
||||||
|
|
||||||
use egui;
|
use bevy_egui::egui;
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
use material_schema::{EditorAssetRef, MaterialShaderKind};
|
use shared::{EditorAssetRef, MaterialShaderKind};
|
||||||
|
|
||||||
use super::layout::MaterialPanelGeometry;
|
use super::layout::MaterialPanelGeometry;
|
||||||
use super::MaterialThumbnailPresentation;
|
use super::MaterialThumbnailPresentation;
|
||||||
use crate::design_system::controls::status;
|
use crate::ui::design_system::controls::{icon_button, icon_label, status};
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
use crate::design_system::{self, DesignPalette, HEADER_HEIGHT};
|
use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT};
|
||||||
|
use crate::ui::widgets::phosphor_icon;
|
||||||
|
|
||||||
mod drop_feedback;
|
const SLOT_ACCENT_X: f32 = 8.0;
|
||||||
mod header_actions;
|
const SLOT_CARET_CENTER_X: f32 = 23.0;
|
||||||
|
const SLOT_PREVIEW_X: f32 = 35.0;
|
||||||
use drop_feedback::{accepted_drop_action, paint_material_drop_feedback, MaterialDropVisualState};
|
|
||||||
use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH};
|
|
||||||
|
|
||||||
// `Frame` reserves one pixel for the slot stroke before handing its child Ui to us. These local
|
|
||||||
// origins compensate for that inset so the painted rail/caret/preview retain the exported board
|
|
||||||
// coordinates relative to the outer slot rectangle.
|
|
||||||
const SLOT_ACCENT_X: f32 = 7.0;
|
|
||||||
const SLOT_CARET_CENTER_X: f32 = 22.0;
|
|
||||||
const SLOT_PREVIEW_X: f32 = 34.0;
|
|
||||||
const SHADER_CONTROL_WIDTH: f32 = 220.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum MaterialLayerBadge {
|
pub(crate) enum MaterialLayerBadge {
|
||||||
ModelSource,
|
ModelSource,
|
||||||
ModelDefault,
|
ModelDefault,
|
||||||
ProjectDefault,
|
ProjectDefault,
|
||||||
@ -33,7 +24,7 @@ pub enum MaterialLayerBadge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MaterialLayerBadge {
|
impl MaterialLayerBadge {
|
||||||
pub const fn label(self) -> &'static str {
|
pub(crate) const fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::ModelSource => "Model Source",
|
Self::ModelSource => "Model Source",
|
||||||
Self::ModelDefault => "Model Default",
|
Self::ModelDefault => "Model Default",
|
||||||
@ -44,7 +35,7 @@ impl MaterialLayerBadge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum MaterialHealth {
|
pub(crate) enum MaterialHealth {
|
||||||
Healthy,
|
Healthy,
|
||||||
Dirty,
|
Dirty,
|
||||||
Processing,
|
Processing,
|
||||||
@ -56,7 +47,7 @@ impl MaterialHealth {
|
|||||||
fn presentation(self, palette: DesignPalette) -> (egui::Color32, &'static str, &'static str) {
|
fn presentation(self, palette: DesignPalette) -> (egui::Color32, &'static str, &'static str) {
|
||||||
match self {
|
match self {
|
||||||
Self::Healthy => (
|
Self::Healthy => (
|
||||||
palette.text_secondary,
|
palette.healthy,
|
||||||
"Compiled",
|
"Compiled",
|
||||||
"Material is resolved and up to date",
|
"Material is resolved and up to date",
|
||||||
),
|
),
|
||||||
@ -82,20 +73,10 @@ impl MaterialHealth {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon(self) -> egui_phosphor_icons::Icon {
|
|
||||||
match self {
|
|
||||||
Self::Healthy => icons::CHECK_CIRCLE,
|
|
||||||
Self::Dirty => icons::PENCIL_SIMPLE,
|
|
||||||
Self::Processing => icons::CIRCLE_NOTCH,
|
|
||||||
Self::Broken => icons::WARNING_CIRCLE,
|
|
||||||
Self::ReadOnly => icons::LOCK_SIMPLE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MaterialPickerCandidate {
|
pub(crate) struct MaterialPickerCandidate {
|
||||||
pub reference: EditorAssetRef,
|
pub reference: EditorAssetRef,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
@ -103,7 +84,7 @@ pub struct MaterialPickerCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MaterialSlotPanelViewModel {
|
pub(crate) struct MaterialSlotPanelViewModel {
|
||||||
pub slot_id: String,
|
pub slot_id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@ -122,14 +103,10 @@ pub struct MaterialSlotPanelViewModel {
|
|||||||
pub can_clear: bool,
|
pub can_clear: bool,
|
||||||
pub can_extract: bool,
|
pub can_extract: bool,
|
||||||
pub can_create_instance: bool,
|
pub can_create_instance: bool,
|
||||||
/// Initial disclosure used only until this slot has persisted UI state.
|
|
||||||
pub parameters_initially_open: bool,
|
|
||||||
/// Renderer arrays keep the full asset/action header visible when only parameters collapse.
|
|
||||||
pub expanded_header_when_parameters_closed: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum MaterialSlotAction {
|
pub(crate) enum MaterialSlotAction {
|
||||||
Assign {
|
Assign {
|
||||||
reference: EditorAssetRef,
|
reference: EditorAssetRef,
|
||||||
from_drop: bool,
|
from_drop: bool,
|
||||||
@ -143,21 +120,21 @@ pub enum MaterialSlotAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct MaterialSlotPanelAction {
|
pub(crate) struct MaterialSlotPanelAction {
|
||||||
pub slot_id: String,
|
pub slot_id: String,
|
||||||
pub action: MaterialSlotAction,
|
pub action: MaterialSlotAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct MaterialsSectionViewModel {
|
pub(crate) struct MaterialsSectionViewModel {
|
||||||
pub slot_ids: Vec<String>,
|
pub slot_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MaterialSlotPanelResponse {
|
pub(crate) struct MaterialSlotPanelResponse {
|
||||||
pub actions: Vec<MaterialSlotPanelAction>,
|
pub actions: Vec<MaterialSlotPanelAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn materials_section<R>(
|
pub(crate) fn materials_section<R>(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
_view: &MaterialsSectionViewModel,
|
_view: &MaterialsSectionViewModel,
|
||||||
add_slots: impl FnOnce(&mut egui::Ui) -> R,
|
add_slots: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
@ -186,10 +163,7 @@ pub fn materials_section<R>(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// The top-down layout already contributes the design system's four-pixel item gap after
|
ui.add_space(8.0);
|
||||||
// the 32 px heading. Add only the remaining four pixels so the slot begins at the Penpot
|
|
||||||
// board's authored eight-pixel offset instead of drifting four pixels lower.
|
|
||||||
ui.add_space(4.0);
|
|
||||||
add_slots(ui)
|
add_slots(ui)
|
||||||
});
|
});
|
||||||
// `min_rect` is not a safe content-height signal for a child hosted by a ScrollArea: nested
|
// `min_rect` is not a safe content-height signal for a child hosted by a ScrollArea: nested
|
||||||
@ -224,7 +198,7 @@ fn materials_used_bottom(cursor_y: f32, start_y: f32) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn material_slot_panel(
|
pub(crate) fn material_slot_panel(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
id: impl std::hash::Hash,
|
id: impl std::hash::Hash,
|
||||||
view: &MaterialSlotPanelViewModel,
|
view: &MaterialSlotPanelViewModel,
|
||||||
@ -238,12 +212,12 @@ pub fn material_slot_panel(
|
|||||||
let mut parameters_open = ui
|
let mut parameters_open = ui
|
||||||
.ctx()
|
.ctx()
|
||||||
.data_mut(|data| data.get_persisted::<bool>(open_id))
|
.data_mut(|data| data.get_persisted::<bool>(open_id))
|
||||||
.unwrap_or(view.parameters_initially_open);
|
.unwrap_or(true);
|
||||||
let mut slot_actions = Vec::new();
|
let mut slot_actions = Vec::new();
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(palette.recessed)
|
.fill(palette.panel)
|
||||||
.stroke(egui::Stroke::new(1.0_f32, palette.border_strong))
|
.stroke(egui::Stroke::new(1.0_f32, palette.border))
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
slot_header(
|
slot_header(
|
||||||
@ -254,7 +228,7 @@ pub fn material_slot_panel(
|
|||||||
&mut slot_actions,
|
&mut slot_actions,
|
||||||
diagnostics_ui,
|
diagnostics_ui,
|
||||||
);
|
);
|
||||||
if parameters_open && (view.assigned.is_some() || view.effective.is_some()) {
|
if parameters_open {
|
||||||
parameter_body(ui, content_ui);
|
parameter_body(ui, content_ui);
|
||||||
}
|
}
|
||||||
ui.ctx()
|
ui.ctx()
|
||||||
@ -281,42 +255,13 @@ fn slot_header(
|
|||||||
diagnostics_ui: impl FnOnce(&mut egui::Ui),
|
diagnostics_ui: impl FnOnce(&mut egui::Ui),
|
||||||
) {
|
) {
|
||||||
let palette = design_system::palette(ui);
|
let palette = design_system::palette(ui);
|
||||||
let expanded_header = *open || view.expanded_header_when_parameters_closed;
|
let height = if *open {
|
||||||
let height = if expanded_header {
|
|
||||||
HEADER_HEIGHT
|
HEADER_HEIGHT
|
||||||
} else {
|
} else {
|
||||||
MaterialPanelGeometry::REFERENCE.collapsed_header
|
MaterialPanelGeometry::REFERENCE.collapsed_header
|
||||||
};
|
};
|
||||||
let width = ui.available_width().max(1.0);
|
let width = ui.available_width().max(1.0);
|
||||||
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click());
|
||||||
let target_hovered = ui.rect_contains_pointer(rect);
|
|
||||||
if let Some(drop_state) = MaterialDropVisualState::from_view(view, target_hovered) {
|
|
||||||
paint_material_drop_feedback(ui, rect, drop_state, palette);
|
|
||||||
if drop_state == MaterialDropVisualState::Empty {
|
|
||||||
super::pickers::material_picker_popup(
|
|
||||||
ui,
|
|
||||||
id.with("material_picker"),
|
|
||||||
&response,
|
|
||||||
view,
|
|
||||||
actions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(action) = accepted_drop_action(
|
|
||||||
drop_state,
|
|
||||||
ui.input(|input| input.pointer.any_released()),
|
|
||||||
view.drop_candidate.as_ref(),
|
|
||||||
) {
|
|
||||||
actions.push(action);
|
|
||||||
}
|
|
||||||
if drop_state == MaterialDropVisualState::Incompatible {
|
|
||||||
response.on_hover_text(
|
|
||||||
view.invalid_drop_reason
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("Expected a Material asset"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.painter().rect_filled(rect, 7.0, palette.recessed);
|
ui.painter().rect_filled(rect, 7.0, palette.recessed);
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
egui::Rect::from_min_size(
|
egui::Rect::from_min_size(
|
||||||
@ -330,25 +275,22 @@ fn slot_header(
|
|||||||
egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y),
|
egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y),
|
||||||
egui::vec2(18.0, 28.0),
|
egui::vec2(18.0, 28.0),
|
||||||
);
|
);
|
||||||
let caret_icon = if *open {
|
let caret = ui.put(
|
||||||
|
caret_rect,
|
||||||
|
egui::Button::new(phosphor_icon(
|
||||||
|
if *open {
|
||||||
icons::CARET_DOWN
|
icons::CARET_DOWN
|
||||||
} else {
|
} else {
|
||||||
icons::CARET_RIGHT
|
icons::CARET_RIGHT
|
||||||
};
|
},
|
||||||
let caret = ui.put(
|
|
||||||
caret_rect,
|
|
||||||
egui::Button::new(
|
|
||||||
egui::RichText::new(caret_icon.as_str()).font(egui::FontId::new(
|
|
||||||
12.0,
|
12.0,
|
||||||
egui::FontFamily::Name("phosphor-bold".into()),
|
))
|
||||||
)),
|
|
||||||
)
|
|
||||||
.frame(false),
|
.frame(false),
|
||||||
);
|
);
|
||||||
if caret.clicked() {
|
if caret.clicked() {
|
||||||
*open = !*open;
|
*open = !*open;
|
||||||
}
|
}
|
||||||
let preview_size = if expanded_header { 64.0 } else { 32.0 };
|
let preview_size = if *open { 64.0 } else { 32.0 };
|
||||||
let preview_rect = egui::Rect::from_min_size(
|
let preview_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(
|
egui::pos2(
|
||||||
rect.left() + SLOT_PREVIEW_X,
|
rect.left() + SLOT_PREVIEW_X,
|
||||||
@ -357,26 +299,23 @@ fn slot_header(
|
|||||||
egui::vec2(preview_size, preview_size),
|
egui::vec2(preview_size, preview_size),
|
||||||
);
|
);
|
||||||
let preview = material_thumbnail_at(ui, view, preview_rect);
|
let preview = material_thumbnail_at(ui, view, preview_rect);
|
||||||
let action_width = SLOT_STATUS_ACTION_WIDTH;
|
let action_width = 140.0;
|
||||||
let identity_left = preview_rect.right() + 6.0;
|
let identity_left = preview_rect.right() + 6.0;
|
||||||
let actions_left = rect.right() - action_width - 8.0;
|
let actions_left = rect.right() - action_width - 8.0;
|
||||||
let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH)
|
let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH)
|
||||||
.min(actions_left)
|
.min(actions_left)
|
||||||
.max(identity_left + 1.0);
|
.max(identity_left + 1.0);
|
||||||
let identity_rect = egui::Rect::from_min_max(
|
let identity_rect = egui::Rect::from_min_max(
|
||||||
egui::pos2(
|
egui::pos2(identity_left, rect.top() + if *open { 8.0 } else { 7.0 }),
|
||||||
identity_left,
|
|
||||||
rect.top() + if expanded_header { 8.0 } else { 7.0 },
|
|
||||||
),
|
|
||||||
egui::pos2(
|
egui::pos2(
|
||||||
identity_right,
|
identity_right,
|
||||||
rect.bottom() - if expanded_header { 8.0 } else { 7.0 },
|
rect.bottom() - if *open { 8.0 } else { 7.0 },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let identity = ui
|
let identity = ui
|
||||||
.scope_builder(egui::UiBuilder::new().max_rect(identity_rect), |ui| {
|
.scope_builder(egui::UiBuilder::new().max_rect(identity_rect), |ui| {
|
||||||
ui.set_clip_rect(identity_rect.intersect(ui.clip_rect()));
|
ui.set_clip_rect(identity_rect.intersect(ui.clip_rect()));
|
||||||
if expanded_header {
|
if *open {
|
||||||
material_identity(ui, view, actions)
|
material_identity(ui, view, actions)
|
||||||
} else {
|
} else {
|
||||||
compact_material_identity(ui, view)
|
compact_material_identity(ui, view)
|
||||||
@ -384,26 +323,42 @@ fn slot_header(
|
|||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
let actions_rect = egui::Rect::from_min_size(
|
let actions_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(
|
egui::pos2(actions_left, rect.top() + 7.0),
|
||||||
actions_left,
|
egui::vec2(action_width, height - 14.0),
|
||||||
rect.top() + if expanded_header { 9.0 } else { 7.0 },
|
|
||||||
),
|
|
||||||
egui::vec2(
|
|
||||||
action_width,
|
|
||||||
if expanded_header { 64.0 } else { height - 14.0 },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if expanded_header {
|
|
||||||
expanded_status_actions(ui, actions_rect, view, actions, diagnostics_ui, palette);
|
|
||||||
} else {
|
|
||||||
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
|
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| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
overflow_menu(ui, view, actions, diagnostics_ui);
|
overflow_menu(ui, view, actions, diagnostics_ui);
|
||||||
let (color, label, tooltip) = view.health.presentation(palette);
|
let (color, label, tooltip) = view.health.presentation(palette);
|
||||||
ui.horizontal(|ui| status(ui, view.health.icon(), color, label, tooltip));
|
ui.horizontal(|ui| status(ui, color, label, tooltip));
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) {
|
if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) {
|
||||||
if !caret_rect.contains(pointer)
|
if !caret_rect.contains(pointer)
|
||||||
@ -422,6 +377,22 @@ fn slot_header(
|
|||||||
view,
|
view,
|
||||||
actions,
|
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(
|
fn compact_material_identity(
|
||||||
@ -432,7 +403,7 @@ fn compact_material_identity(
|
|||||||
let name = ui
|
let name = ui
|
||||||
.add(
|
.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
TypeRole::AssetTitle
|
TypeRole::Section
|
||||||
.text(&view.label)
|
.text(&view.label)
|
||||||
.color(palette.text_primary),
|
.color(palette.text_primary),
|
||||||
)
|
)
|
||||||
@ -457,58 +428,23 @@ fn compact_material_identity(
|
|||||||
fn parameter_body(ui: &mut egui::Ui, content_ui: impl FnOnce(&mut egui::Ui)) {
|
fn parameter_body(ui: &mut egui::Ui, content_ui: impl FnOnce(&mut egui::Ui)) {
|
||||||
let palette = design_system::palette(ui);
|
let palette = design_system::palette(ui);
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(palette.recessed)
|
.fill(palette.panel)
|
||||||
.inner_margin(egui::Margin {
|
.inner_margin(egui::Margin {
|
||||||
// The parent slot stroke already contributes one pixel on each side. Reclaim it here
|
left: 19,
|
||||||
// so the parameter sections are the authored 569/369 px instead of 567/367 px.
|
right: 8,
|
||||||
left: 18,
|
top: 8,
|
||||||
right: 7,
|
|
||||||
// Own the exported PARAMETERS origin here so every primitive/static/skinned caller
|
|
||||||
// receives the same position independent of its surrounding component chrome.
|
|
||||||
top: 21,
|
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
})
|
})
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let mut measure = egui::text::LayoutJob::default();
|
ui.label(TypeRole::Caption.text("PARAMETERS").color(palette.accent));
|
||||||
measure.append(
|
let spine_x = ui.min_rect().left() - 7.0;
|
||||||
"PARAMETERS",
|
|
||||||
0.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: TypeRole::Eyebrow.font(),
|
|
||||||
extra_letter_spacing: 0.6,
|
|
||||||
color: egui::Color32::TRANSPARENT,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Penpot positions the 9 px eyebrow two raster pixels above egui's default label
|
|
||||||
// baseline. Keep the label's normal allocation so section flow remains unchanged,
|
|
||||||
// then paint the visible glyphs at the authored baseline.
|
|
||||||
let response = ui.label(measure);
|
|
||||||
let mut visible = egui::text::LayoutJob::default();
|
|
||||||
visible.append(
|
|
||||||
"PARAMETERS",
|
|
||||||
0.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: TypeRole::Eyebrow.font(),
|
|
||||||
extra_letter_spacing: 0.6,
|
|
||||||
color: palette.accent,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let galley = ui.painter().layout_job(visible);
|
|
||||||
ui.painter().galley(
|
|
||||||
response.rect.min - egui::vec2(0.0, 5.0),
|
|
||||||
galley,
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
let spine_x = ui.min_rect().left() - 11.0;
|
|
||||||
let spine_top = ui.cursor().top();
|
let spine_top = ui.cursor().top();
|
||||||
content_ui(ui);
|
content_ui(ui);
|
||||||
let spine_bottom = ui.min_rect().bottom().max(spine_top);
|
let spine_bottom = ui.min_rect().bottom().max(spine_top);
|
||||||
ui.painter().vline(
|
ui.painter().vline(
|
||||||
spine_x,
|
spine_x,
|
||||||
spine_top..=spine_bottom,
|
spine_top..=spine_bottom,
|
||||||
egui::Stroke::new(1.0_f32, palette.accent_dark),
|
egui::Stroke::new(1.0_f32, palette.accent),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -522,7 +458,7 @@ fn material_identity(
|
|||||||
let name = ui
|
let name = ui
|
||||||
.add(
|
.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
TypeRole::AssetTitle
|
TypeRole::Section
|
||||||
.text(&view.label)
|
.text(&view.label)
|
||||||
.color(palette.text_primary),
|
.color(palette.text_primary),
|
||||||
)
|
)
|
||||||
@ -530,12 +466,7 @@ fn material_identity(
|
|||||||
.sense(egui::Sense::click()),
|
.sense(egui::Sense::click()),
|
||||||
)
|
)
|
||||||
.on_hover_text(&view.label);
|
.on_hover_text(&view.label);
|
||||||
let unassigned = view.assigned.is_none() && view.effective.is_none();
|
let path = view.path.as_deref().unwrap_or("Engine built-in material");
|
||||||
let path = view.path.as_deref().unwrap_or(if unassigned {
|
|
||||||
"Drop a material here or choose from the browser"
|
|
||||||
} else {
|
|
||||||
"Engine built-in material"
|
|
||||||
});
|
|
||||||
let identity = view.inherited.map_or_else(
|
let identity = view.inherited.map_or_else(
|
||||||
|| path.to_string(),
|
|| path.to_string(),
|
||||||
|layer| format!("{path} · {}", layer.label()),
|
|layer| format!("{path} · {}", layer.label()),
|
||||||
@ -551,7 +482,7 @@ fn material_identity(
|
|||||||
if let Some(mut kind) = view.shader_kind {
|
if let Some(mut kind) = view.shader_kind {
|
||||||
egui::ComboBox::from_id_salt("material_identity_shader_kind")
|
egui::ComboBox::from_id_salt("material_identity_shader_kind")
|
||||||
.selected_text(&view.shader)
|
.selected_text(&view.shader)
|
||||||
.width(SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)))
|
.width(150.0_f32.min(ui.available_width().max(1.0)))
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit");
|
ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit");
|
||||||
ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit");
|
ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit");
|
||||||
@ -561,12 +492,9 @@ fn material_identity(
|
|||||||
actions.push(MaterialSlotAction::SetShader(kind));
|
actions.push(MaterialSlotAction::SetShader(kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !view.shader.is_empty() {
|
} else {
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[
|
[150.0_f32.min(ui.available_width().max(1.0)), 22.0],
|
||||||
SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)),
|
|
||||||
22.0,
|
|
||||||
],
|
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
TypeRole::Body
|
TypeRole::Body
|
||||||
.text(&view.shader)
|
.text(&view.shader)
|
||||||
@ -634,15 +562,6 @@ fn material_thumbnail_at(
|
|||||||
palette.error,
|
palette.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
MaterialThumbnailPresentation::Empty => {
|
|
||||||
ui.painter().text(
|
|
||||||
rect.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::PALETTE.as_str(),
|
|
||||||
egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
@ -653,6 +572,72 @@ fn material_thumbnail_at(
|
|||||||
response.on_hover_text(format!("Open {} material picker", view.label))
|
response.on_hover_text(format!("Open {} material picker", view.label))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn overflow_menu(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
view: &MaterialSlotPanelViewModel,
|
||||||
|
actions: &mut Vec<MaterialSlotAction>,
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -677,20 +662,15 @@ mod tests {
|
|||||||
can_clear: true,
|
can_clear: true,
|
||||||
can_extract: false,
|
can_extract: false,
|
||||||
can_create_instance: true,
|
can_create_instance: true,
|
||||||
parameters_initially_open: true,
|
|
||||||
expanded_header_when_parameters_closed: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slot_header_insets_match_the_current_penpot_export() {
|
fn slot_header_insets_match_the_current_penpot_export() {
|
||||||
const OUTER_FRAME_STROKE_INSET: f32 = 1.0;
|
assert_eq!(SLOT_ACCENT_X, 8.0);
|
||||||
assert_eq!(SLOT_ACCENT_X + OUTER_FRAME_STROKE_INSET, 8.0);
|
assert_eq!(SLOT_CARET_CENTER_X, 23.0);
|
||||||
assert_eq!(SLOT_CARET_CENTER_X + OUTER_FRAME_STROKE_INSET, 23.0);
|
assert_eq!(SLOT_PREVIEW_X, 35.0);
|
||||||
assert_eq!(SLOT_PREVIEW_X + OUTER_FRAME_STROKE_INSET, 35.0);
|
|
||||||
assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0);
|
assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0);
|
||||||
assert_eq!(SLOT_STATUS_ACTION_WIDTH, 140.0);
|
|
||||||
assert_eq!(SHADER_CONTROL_WIDTH, 220.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -756,32 +736,8 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let mut view = view();
|
let mut view = view();
|
||||||
view.health = health;
|
view.health = health;
|
||||||
let palette = DesignPalette::from(crate::theme::BLACKSITE_PALETTE);
|
let palette = DesignPalette::from(crate::ui::theme::BLACKSITE_PALETTE);
|
||||||
assert!(!view.health.presentation(palette).1.is_empty());
|
assert!(!view.health.presentation(palette).1.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn penpot_material_statuses_use_semantic_phosphor_icons() {
|
|
||||||
assert_eq!(
|
|
||||||
MaterialHealth::Healthy.icon().as_str(),
|
|
||||||
icons::CHECK_CIRCLE.as_str()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
MaterialHealth::Dirty.icon().as_str(),
|
|
||||||
icons::PENCIL_SIMPLE.as_str()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
MaterialHealth::Processing.icon().as_str(),
|
|
||||||
icons::CIRCLE_NOTCH.as_str()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
MaterialHealth::Broken.icon().as_str(),
|
|
||||||
icons::WARNING_CIRCLE.as_str()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
MaterialHealth::ReadOnly.icon().as_str(),
|
|
||||||
icons::LOCK_SIMPLE.as_str()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,14 +1,14 @@
|
|||||||
//! Penpot-styled material and texture asset pickers.
|
//! Penpot-styled material and texture asset pickers.
|
||||||
|
|
||||||
use egui;
|
use bevy_egui::egui;
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
use material_schema::EditorAssetRef;
|
use shared::EditorAssetRef;
|
||||||
|
|
||||||
use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel};
|
use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel};
|
||||||
use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse};
|
use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse};
|
||||||
use crate::design_system;
|
use crate::ui::design_system;
|
||||||
use crate::design_system::controls::icon_label;
|
use crate::ui::design_system::controls::icon_label;
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
|
|
||||||
const MATERIAL_PICKER_WIDTH: f32 = 244.0;
|
const MATERIAL_PICKER_WIDTH: f32 = 244.0;
|
||||||
const TEXTURE_PICKER_WIDTH: f32 = 420.0;
|
const TEXTURE_PICKER_WIDTH: f32 = 420.0;
|
||||||
@ -261,7 +261,7 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail
|
|||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
icons::CIRCLE_NOTCH.as_str(),
|
icons::CIRCLE_NOTCH.as_str(),
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
palette.text_muted,
|
palette.text_muted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -270,19 +270,10 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail
|
|||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
icons::WARNING.as_str(),
|
icons::WARNING.as_str(),
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
palette.error,
|
palette.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
MaterialThumbnailPresentation::Empty => {
|
|
||||||
ui.painter().text(
|
|
||||||
rect.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::PALETTE.as_str(),
|
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::design_system;
|
use crate::ui::design_system;
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
use egui_phosphor_icons::icons;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum TextureFieldAdornment {
|
enum TextureFieldAdornment {
|
||||||
@ -100,7 +99,7 @@ pub(super) fn texture_channel_label(channel: TextureChannel) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_material_texture_binding<'a>(
|
pub(super) fn ensure_material_texture_binding<'a>(
|
||||||
textures: &'a mut Vec<MaterialTextureBinding>,
|
textures: &'a mut Vec<MaterialTextureBinding>,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> &'a mut MaterialTextureBinding {
|
) -> &'a mut MaterialTextureBinding {
|
||||||
@ -228,9 +227,6 @@ pub(super) fn texture_slot_ui(
|
|||||||
Some(MaterialThumbnailPresentation::Failed) => {
|
Some(MaterialThumbnailPresentation::Failed) => {
|
||||||
paint_field_icon(ui, image_rect, icons::WARNING, palette.error);
|
paint_field_icon(ui, image_rect, icons::WARNING, palette.error);
|
||||||
}
|
}
|
||||||
Some(MaterialThumbnailPresentation::Empty) => {
|
|
||||||
paint_field_icon(ui, image_rect, icons::IMAGE, palette.text_muted);
|
|
||||||
}
|
|
||||||
None => paint_field_icon(
|
None => paint_field_icon(
|
||||||
ui,
|
ui,
|
||||||
image_rect,
|
image_rect,
|
||||||
@ -253,8 +249,7 @@ pub(super) fn texture_slot_ui(
|
|||||||
// `Label::truncate` centers its galley when forced into the full fixed field rectangle.
|
// `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
|
// 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.
|
// the asset name then form one stable left-aligned identity regardless of field width.
|
||||||
let galley =
|
let galley = egui::WidgetText::from(TypeRole::Body.text(display_label).color(label_color))
|
||||||
egui::WidgetText::from(TypeRole::AssetLabel.text(display_label).color(label_color))
|
|
||||||
.into_galley(
|
.into_galley(
|
||||||
ui,
|
ui,
|
||||||
Some(egui::TextWrapMode::Truncate),
|
Some(egui::TextWrapMode::Truncate),
|
||||||
@ -296,7 +291,7 @@ pub(super) fn texture_slot_ui(
|
|||||||
.stroke(egui::Stroke::new(1.0_f32, palette.border)),
|
.stroke(egui::Stroke::new(1.0_f32, palette.border)),
|
||||||
);
|
);
|
||||||
if selected_candidate.is_some() && locate.clicked() {
|
if selected_candidate.is_some() && locate.clicked() {
|
||||||
result.locate = selected_candidate.map(|candidate| candidate.locate.clone());
|
result.locate = selected_candidate.map(|candidate| candidate.selection.clone());
|
||||||
}
|
}
|
||||||
locate.on_hover_text(if selected_candidate.is_some() {
|
locate.on_hover_text(if selected_candidate.is_some() {
|
||||||
"Locate texture in Content Browser"
|
"Locate texture in Content Browser"
|
||||||
@ -351,7 +346,7 @@ fn transient_texture_actions(
|
|||||||
.add_enabled(selected_candidate.is_some(), egui::Button::new("Locate"))
|
.add_enabled(selected_candidate.is_some(), egui::Button::new("Locate"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
result.locate = selected_candidate.map(|candidate| candidate.locate.clone());
|
result.locate = selected_candidate.map(|candidate| candidate.selection.clone());
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
@ -377,7 +372,7 @@ fn paint_field_icon(
|
|||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
icon.as_str(),
|
icon.as_str(),
|
||||||
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
color,
|
color,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
//! Shared material UV-transform authoring used by Materials and Material Instances.
|
//! Shared material UV-transform authoring used by Materials and Material Instances.
|
||||||
|
|
||||||
use bevy_math::Vec2;
|
use bevy::prelude::Vec2;
|
||||||
use egui;
|
use bevy_egui::egui;
|
||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
use material_schema::{MaterialInputSet, MaterialParameter, MaterialParameterValue};
|
use shared::{MaterialInputSet, MaterialParameter, MaterialParameterValue};
|
||||||
|
|
||||||
use crate::design_system;
|
use crate::ui::design_system;
|
||||||
use crate::design_system::typography::TypeRole;
|
use crate::ui::design_system::typography::TypeRole;
|
||||||
|
|
||||||
pub(super) fn uv_transform_section(
|
pub(super) fn uv_transform_section(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@ -19,10 +19,10 @@ pub(super) fn uv_transform_section(
|
|||||||
let width = ui.available_width().max(1.0);
|
let width = ui.available_width().max(1.0);
|
||||||
let height = match super::layout::ResponsiveMaterialLayout::for_width(width) {
|
let height = match super::layout::ResponsiveMaterialLayout::for_width(width) {
|
||||||
super::layout::ResponsiveMaterialLayout::Reference => {
|
super::layout::ResponsiveMaterialLayout::Reference => {
|
||||||
crate::design_system::UV_SECTION_HEIGHT
|
crate::ui::design_system::UV_SECTION_HEIGHT
|
||||||
}
|
}
|
||||||
super::layout::ResponsiveMaterialLayout::Compact => {
|
super::layout::ResponsiveMaterialLayout::Compact => {
|
||||||
crate::design_system::COMPACT_UV_SECTION_HEIGHT
|
crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT
|
||||||
}
|
}
|
||||||
super::layout::ResponsiveMaterialLayout::Transient => 100.0,
|
super::layout::ResponsiveMaterialLayout::Transient => 100.0,
|
||||||
};
|
};
|
||||||
@ -30,7 +30,7 @@ pub(super) fn uv_transform_section(
|
|||||||
ui.painter().rect(
|
ui.painter().rect(
|
||||||
rect,
|
rect,
|
||||||
5.0,
|
5.0,
|
||||||
palette.panel,
|
palette.section,
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
egui::Stroke::new(1.0_f32, palette.border),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
@ -92,7 +92,7 @@ fn vec2_group(
|
|||||||
reset_rect,
|
reset_rect,
|
||||||
egui::Button::new(
|
egui::Button::new(
|
||||||
egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font(
|
egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font(
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
|
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -5,7 +5,7 @@ mod asset_card;
|
|||||||
mod audio_inspector;
|
mod audio_inspector;
|
||||||
mod build;
|
mod build;
|
||||||
pub mod component_registry;
|
pub mod component_registry;
|
||||||
pub(crate) use editor_ui::design_system;
|
pub(crate) mod design_system;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod dock_tabs;
|
mod dock_tabs;
|
||||||
pub(crate) mod document_status;
|
pub(crate) mod document_status;
|
||||||
@ -239,7 +239,6 @@ impl UiState {
|
|||||||
selected_entities: &mut self.selected_entities,
|
selected_entities: &mut self.selected_entities,
|
||||||
renaming_entity: &mut self.renaming_entity,
|
renaming_entity: &mut self.renaming_entity,
|
||||||
rename_buffer: &mut self.rename_buffer,
|
rename_buffer: &mut self.rename_buffer,
|
||||||
palette: editor_ui::theme::editor_palette(ctx),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
clamp_inspector_width(
|
clamp_inspector_width(
|
||||||
@ -336,33 +335,13 @@ fn reconcile_operator_selection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_BROWSER_TAB_BADGE_WIDTH: f32 = 74.0;
|
|
||||||
const CONTENT_BROWSER_TAB_BADGE_HEIGHT: f32 = 22.0;
|
|
||||||
const CONTENT_BROWSER_TAB_BADGE_GAP: f32 = 10.0;
|
|
||||||
const DOCK_TAB_CLOSE_RESERVE: f32 = 28.0;
|
|
||||||
|
|
||||||
fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText {
|
fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText {
|
||||||
tab_title_job(
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
editor_ui::design_system::controls::PhosphorIconStyle::Regular,
|
|
||||||
egui::FontId::new(13.0, egui::FontFamily::Proportional),
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tab_title_job(
|
|
||||||
icon: egui_phosphor_icons::Icon,
|
|
||||||
label: &str,
|
|
||||||
icon_style: editor_ui::design_system::controls::PhosphorIconStyle,
|
|
||||||
label_font: egui::FontId,
|
|
||||||
) -> egui::text::LayoutJob {
|
|
||||||
let mut job = egui::text::LayoutJob::default();
|
let mut job = egui::text::LayoutJob::default();
|
||||||
job.append(
|
job.append(
|
||||||
icon.as_str(),
|
icon.as_str(),
|
||||||
0.0,
|
0.0,
|
||||||
egui::TextFormat {
|
egui::TextFormat {
|
||||||
font_id: editor_ui::design_system::controls::phosphor_icon_font(icon_style, 15.0),
|
font_id: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||||
color: theme::TEXT,
|
color: theme::TEXT,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@ -371,48 +350,14 @@ fn tab_title_job(
|
|||||||
&format!(" {label}"),
|
&format!(" {label}"),
|
||||||
0.0,
|
0.0,
|
||||||
egui::TextFormat {
|
egui::TextFormat {
|
||||||
font_id: label_font,
|
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
|
||||||
color: theme::TEXT,
|
color: theme::TEXT,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content_browser_tab_title() -> egui::WidgetText {
|
|
||||||
let mut job = tab_title_job(
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Content Browser",
|
|
||||||
editor_ui::design_system::controls::PhosphorIconStyle::Fill,
|
|
||||||
editor_ui::design_system::typography::TypeRole::AssetTitle.font(),
|
|
||||||
);
|
|
||||||
// The rounded PROJECT chip is painted by `on_tab_button`; reserve its exact width in the
|
|
||||||
// title galley so it never collides with the close button or neighboring tabs.
|
|
||||||
job.append(
|
|
||||||
"",
|
|
||||||
CONTENT_BROWSER_TAB_BADGE_GAP + CONTENT_BROWSER_TAB_BADGE_WIDTH,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: editor_ui::design_system::typography::TypeRole::Eyebrow.font(),
|
|
||||||
color: theme::TEXT_DIM,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
job.into()
|
job.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content_browser_tab_badge_rect(tab_rect: egui::Rect) -> egui::Rect {
|
|
||||||
egui::Rect::from_center_size(
|
|
||||||
egui::pos2(
|
|
||||||
tab_rect.right() - DOCK_TAB_CLOSE_RESERVE - CONTENT_BROWSER_TAB_BADGE_WIDTH * 0.5,
|
|
||||||
tab_rect.center().y,
|
|
||||||
),
|
|
||||||
egui::vec2(
|
|
||||||
CONTENT_BROWSER_TAB_BADGE_WIDTH,
|
|
||||||
CONTENT_BROWSER_TAB_BADGE_HEIGHT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum EditorTab {
|
pub enum EditorTab {
|
||||||
Viewport,
|
Viewport,
|
||||||
@ -534,7 +479,6 @@ struct TabViewer<'a> {
|
|||||||
selected_entities: &'a mut SelectedEntities,
|
selected_entities: &'a mut SelectedEntities,
|
||||||
renaming_entity: &'a mut Option<Entity>,
|
renaming_entity: &'a mut Option<Entity>,
|
||||||
rename_buffer: &'a mut String,
|
rename_buffer: &'a mut String,
|
||||||
palette: editor_ui::theme::EditorVisualPalette,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl egui_dock::TabViewer for TabViewer<'_> {
|
impl egui_dock::TabViewer for TabViewer<'_> {
|
||||||
@ -591,77 +535,30 @@ impl egui_dock::TabViewer for TabViewer<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
|
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
|
||||||
if matches!(tab, EditorTab::AssetBrowser) {
|
|
||||||
return content_browser_tab_title();
|
|
||||||
}
|
|
||||||
let (icon, label) = match tab {
|
let (icon, label) = match tab {
|
||||||
EditorTab::Viewport | EditorTab::GameView => (icons::FILM_SLATE, "Viewport"),
|
EditorTab::Viewport | EditorTab::GameView => (icons::FILM_SLATE, "Viewport"),
|
||||||
EditorTab::Hierarchy => (icons::LIST_BULLETS, "Hierarchy"),
|
EditorTab::Hierarchy => (icons::LIST_BULLETS, "Hierarchy"),
|
||||||
EditorTab::Inspector => (icons::SLIDERS, "Inspector"),
|
EditorTab::Inspector => (icons::SLIDERS, "Inspector"),
|
||||||
EditorTab::Toolbar => (icons::WRENCH, "Toolbar"),
|
EditorTab::Toolbar => (icons::WRENCH, "Toolbar"),
|
||||||
EditorTab::AssetBrowser => unreachable!("handled above"),
|
EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"),
|
||||||
EditorTab::MaterialLibrary => (icons::PALETTE, "Material Library"),
|
EditorTab::MaterialLibrary => (icons::PALETTE, "Material Library"),
|
||||||
EditorTab::Build => (icons::HAMMER, "Build"),
|
EditorTab::Build => (icons::HAMMER, "Build"),
|
||||||
};
|
};
|
||||||
tab_title(icon, label)
|
tab_title(icon, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_tab_button(&mut self, tab: &mut Self::Tab, response: &egui::Response) {
|
|
||||||
if !matches!(tab, EditorTab::AssetBrowser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let badge = content_browser_tab_badge_rect(response.rect);
|
|
||||||
let painter = response.ctx.layer_painter(response.layer_id);
|
|
||||||
painter.rect_filled(
|
|
||||||
badge,
|
|
||||||
CONTENT_BROWSER_TAB_BADGE_HEIGHT * 0.5,
|
|
||||||
self.palette.selection_bg,
|
|
||||||
);
|
|
||||||
painter.text(
|
|
||||||
badge.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
"PROJECT",
|
|
||||||
editor_ui::design_system::typography::TypeRole::Eyebrow.font(),
|
|
||||||
self.palette.selection,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_background(&self, tab: &Self::Tab) -> bool {
|
fn clear_background(&self, tab: &Self::Tab) -> bool {
|
||||||
match tab {
|
match tab {
|
||||||
EditorTab::Viewport | EditorTab::GameView => true,
|
EditorTab::Viewport | EditorTab::GameView => true,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tab_style_override(
|
|
||||||
&self,
|
|
||||||
tab: &Self::Tab,
|
|
||||||
global_style: &egui_dock::TabStyle,
|
|
||||||
) -> Option<egui_dock::TabStyle> {
|
|
||||||
if !matches!(tab, EditorTab::AssetBrowser) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut style = global_style.clone();
|
|
||||||
// The Content Browser owns exact edge-to-edge toolbar/body/footer rectangles. The generic
|
|
||||||
// four-pixel dock-body inset was the production-only gutter absent from Gallery renders.
|
|
||||||
style.tab_body.inner_margin = egui::Margin::ZERO;
|
|
||||||
Some(style)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_browser_project_badge_stays_inside_the_dock_tab() {
|
|
||||||
let tab = egui::Rect::from_min_size(egui::pos2(4.0, 2.0), egui::vec2(250.0, 32.0));
|
|
||||||
let badge = content_browser_tab_badge_rect(tab);
|
|
||||||
assert_eq!(badge.size(), egui::vec2(74.0, 22.0));
|
|
||||||
assert_eq!(badge.right(), tab.right() - DOCK_TAB_CLOSE_RESERVE);
|
|
||||||
assert!(tab.contains_rect(badge));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn operator_selection_wins_when_ui_selection_did_not_change() {
|
fn operator_selection_wins_when_ui_selection_did_not_change() {
|
||||||
let before = Entity::from_bits(1);
|
let before = Entity::from_bits(1);
|
||||||
|
|||||||
@ -196,10 +196,7 @@ pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
|
|||||||
style.separator.color_dragged = palette.accent_hover;
|
style.separator.color_dragged = palette.accent_hover;
|
||||||
|
|
||||||
style.tab_bar.bg_fill = palette.panel_dark;
|
style.tab_bar.bg_fill = palette.panel_dark;
|
||||||
// Penpot's 36 px panel title row maps to the production dock tab. Keeping the dock row at the
|
style.tab_bar.height = 30.0;
|
||||||
// authored height lets docked and standalone Content Browser chrome share the same total
|
|
||||||
// 91/124 px geometry.
|
|
||||||
style.tab_bar.height = editor_ui::content_browser::DOCK_HEADER_HEIGHT;
|
|
||||||
style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1);
|
style.tab_bar.inner_margin = egui::Margin::symmetric(2, 1);
|
||||||
style.tab_bar.hline_color = palette.accent;
|
style.tab_bar.hline_color = palette.accent;
|
||||||
style.tab_bar.corner_radius = CornerRadius::ZERO;
|
style.tab_bar.corner_radius = CornerRadius::ZERO;
|
||||||
@ -293,17 +290,3 @@ pub fn toolbar_group_frame() -> egui::Frame {
|
|||||||
.inner_margin(egui::Margin::symmetric(5, 3))
|
.inner_margin(egui::Margin::symmetric(5, 3))
|
||||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_browser_dock_title_row_matches_the_penpot_header_height() {
|
|
||||||
let context = egui::Context::default();
|
|
||||||
assert_eq!(
|
|
||||||
editor_dock_style(&context).tab_bar.height,
|
|
||||||
editor_ui::content_browser::DOCK_HEADER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "editor_ui"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
description = "Lightweight production egui components and design system for Blacksite editor surfaces"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bevy_math = "0.19"
|
|
||||||
egui.workspace = true
|
|
||||||
egui_phosphor_icons.workspace = true
|
|
||||||
material_schema.workspace = true
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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.
|
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,234 +0,0 @@
|
|||||||
pub const DOCK_HEADER_HEIGHT: f32 = 36.0;
|
|
||||||
pub const WIDE_FULL_TOOLBAR_HEIGHT: f32 = 91.0;
|
|
||||||
pub const WIDE_DOCKED_TOOLBAR_HEIGHT: f32 = WIDE_FULL_TOOLBAR_HEIGHT - DOCK_HEADER_HEIGHT;
|
|
||||||
pub const COMPACT_FULL_TOOLBAR_HEIGHT: f32 = 124.0;
|
|
||||||
pub const COMPACT_DOCKED_TOOLBAR_HEIGHT: f32 = COMPACT_FULL_TOOLBAR_HEIGHT - DOCK_HEADER_HEIGHT;
|
|
||||||
pub const STATUS_BAR_HEIGHT: f32 = 32.0;
|
|
||||||
/// Width below which the 124 px compact reference replaces the one-row 91 px layout.
|
|
||||||
///
|
|
||||||
/// The wide row needs enough room for navigation, breadcrumbs, search, filtering, and view
|
|
||||||
/// controls without overlap. This is also the production breakpoint for Sources/Details panes.
|
|
||||||
pub const COMPACT_BREAKPOINT: f32 = 1000.0;
|
|
||||||
/// Width at which sort and thumbnail-scale controls move out of overflow and into the toolbar.
|
|
||||||
///
|
|
||||||
/// The 1240 px Penpot wide reference remains unchanged. Expanded workspaces gain the controls
|
|
||||||
/// without compressing navigation, breadcrumbs, search, or the Details action.
|
|
||||||
pub const EXPANDED_OPTIONS_BREAKPOINT: f32 = 1400.0;
|
|
||||||
pub const SOURCES_WIDTH: f32 = 216.0;
|
|
||||||
pub const DETAILS_WIDTH: f32 = 290.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ContentBrowserChrome {
|
|
||||||
/// Includes the 36 px title row used by the standalone design reference and UI gallery.
|
|
||||||
Standalone,
|
|
||||||
/// The editor dock tab already owns the title row, leaving 55 px wide or 88 px compact chrome.
|
|
||||||
Docked,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentBrowserChrome {
|
|
||||||
pub const fn height(self, width: f32) -> f32 {
|
|
||||||
match (self, width < COMPACT_BREAKPOINT) {
|
|
||||||
(Self::Standalone, false) => WIDE_FULL_TOOLBAR_HEIGHT,
|
|
||||||
(Self::Docked, false) => WIDE_DOCKED_TOOLBAR_HEIGHT,
|
|
||||||
(Self::Standalone, true) => COMPACT_FULL_TOOLBAR_HEIGHT,
|
|
||||||
(Self::Docked, true) => COMPACT_DOCKED_TOOLBAR_HEIGHT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) const fn y_offset(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::Standalone => DOCK_HEADER_HEIGHT,
|
|
||||||
Self::Docked => 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ContentBrowserViewMode {
|
|
||||||
Grid,
|
|
||||||
List,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable row-major presentation order shared by production and gallery fixtures.
|
|
||||||
///
|
|
||||||
/// Folders keep their navigation semantics but do not own a separate layout pass: assets fill any
|
|
||||||
/// remaining cells in the final folder row.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ContentBrowserGridEntry {
|
|
||||||
Folder(usize),
|
|
||||||
Asset(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn continuous_grid_entries(
|
|
||||||
folder_count: usize,
|
|
||||||
asset_count: usize,
|
|
||||||
) -> Vec<ContentBrowserGridEntry> {
|
|
||||||
(0..folder_count)
|
|
||||||
.map(ContentBrowserGridEntry::Folder)
|
|
||||||
.chain((0..asset_count).map(ContentBrowserGridEntry::Asset))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ContentBrowserKindFilter {
|
|
||||||
All,
|
|
||||||
Model,
|
|
||||||
Texture,
|
|
||||||
Material,
|
|
||||||
Audio,
|
|
||||||
Level,
|
|
||||||
Prefab,
|
|
||||||
Builtin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentBrowserKindFilter {
|
|
||||||
pub const ALL: [Self; 8] = [
|
|
||||||
Self::All,
|
|
||||||
Self::Model,
|
|
||||||
Self::Texture,
|
|
||||||
Self::Material,
|
|
||||||
Self::Audio,
|
|
||||||
Self::Level,
|
|
||||||
Self::Prefab,
|
|
||||||
Self::Builtin,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::All => "All assets",
|
|
||||||
Self::Model => "Models",
|
|
||||||
Self::Texture => "Textures",
|
|
||||||
Self::Material => "Materials",
|
|
||||||
Self::Audio => "Audio",
|
|
||||||
Self::Level => "Levels",
|
|
||||||
Self::Prefab => "Prefabs",
|
|
||||||
Self::Builtin => "Built-ins",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ContentBrowserSort {
|
|
||||||
Name,
|
|
||||||
Kind,
|
|
||||||
Modified,
|
|
||||||
Size,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentBrowserSort {
|
|
||||||
pub const ALL: [Self; 4] = [Self::Name, Self::Kind, Self::Modified, Self::Size];
|
|
||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Name => "Name",
|
|
||||||
Self::Kind => "Type",
|
|
||||||
Self::Modified => "Modified",
|
|
||||||
Self::Size => "Size",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ContentPathSegment {
|
|
||||||
pub path: String,
|
|
||||||
pub label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ContentBrowserToolbarViewModel {
|
|
||||||
pub chrome: ContentBrowserChrome,
|
|
||||||
pub path: Vec<ContentPathSegment>,
|
|
||||||
pub search: String,
|
|
||||||
pub kind_filter: ContentBrowserKindFilter,
|
|
||||||
pub sort: ContentBrowserSort,
|
|
||||||
pub view: ContentBrowserViewMode,
|
|
||||||
pub recursive: bool,
|
|
||||||
pub show_sources: bool,
|
|
||||||
pub show_details: bool,
|
|
||||||
pub thumbnail_size: f32,
|
|
||||||
pub can_go_parent: bool,
|
|
||||||
pub can_go_back: bool,
|
|
||||||
pub can_go_forward: bool,
|
|
||||||
pub can_edit_folder: bool,
|
|
||||||
pub can_rename: bool,
|
|
||||||
pub can_edit_selection: bool,
|
|
||||||
pub can_paste: bool,
|
|
||||||
pub can_undo: bool,
|
|
||||||
pub pending_repairs: usize,
|
|
||||||
pub direct_folder_count: usize,
|
|
||||||
pub direct_asset_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum ContentBrowserToolbarAction {
|
|
||||||
Back,
|
|
||||||
Forward,
|
|
||||||
Parent,
|
|
||||||
Refresh,
|
|
||||||
Navigate(String),
|
|
||||||
ImportHere,
|
|
||||||
ImportTo,
|
|
||||||
NewFolder,
|
|
||||||
Rename,
|
|
||||||
Duplicate,
|
|
||||||
Undo,
|
|
||||||
Cut,
|
|
||||||
Copy,
|
|
||||||
Paste,
|
|
||||||
OpenTrash,
|
|
||||||
ReviewMoves,
|
|
||||||
SetSearch(String),
|
|
||||||
SetKindFilter(ContentBrowserKindFilter),
|
|
||||||
SetSort(ContentBrowserSort),
|
|
||||||
SetView(ContentBrowserViewMode),
|
|
||||||
SetRecursive(bool),
|
|
||||||
SetShowSources(bool),
|
|
||||||
SetShowDetails(bool),
|
|
||||||
SetThumbnailSize(f32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn penpot_content_browser_geometry_is_stable() {
|
|
||||||
assert_eq!(DOCK_HEADER_HEIGHT, 36.0);
|
|
||||||
assert_eq!(WIDE_FULL_TOOLBAR_HEIGHT, 91.0);
|
|
||||||
assert_eq!(WIDE_DOCKED_TOOLBAR_HEIGHT, 55.0);
|
|
||||||
assert_eq!(COMPACT_FULL_TOOLBAR_HEIGHT, 124.0);
|
|
||||||
assert_eq!(COMPACT_DOCKED_TOOLBAR_HEIGHT, 88.0);
|
|
||||||
assert_eq!(STATUS_BAR_HEIGHT, 32.0);
|
|
||||||
assert_eq!(EXPANDED_OPTIONS_BREAKPOINT, 1400.0);
|
|
||||||
assert_eq!(SOURCES_WIDTH, 216.0);
|
|
||||||
assert_eq!(DETAILS_WIDTH, 290.0);
|
|
||||||
assert_eq!(ContentBrowserChrome::Standalone.height(1240.0), 91.0);
|
|
||||||
assert_eq!(ContentBrowserChrome::Docked.height(1240.0), 55.0);
|
|
||||||
assert_eq!(ContentBrowserChrome::Standalone.height(620.0), 124.0);
|
|
||||||
assert_eq!(ContentBrowserChrome::Docked.height(620.0), 88.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filters_and_sorts_have_stable_labels() {
|
|
||||||
assert_eq!(ContentBrowserKindFilter::ALL.len(), 8);
|
|
||||||
assert_eq!(ContentBrowserKindFilter::All.label(), "All assets");
|
|
||||||
assert_eq!(ContentBrowserSort::ALL.len(), 4);
|
|
||||||
assert_eq!(ContentBrowserSort::Kind.label(), "Type");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assets_fill_the_remaining_cells_in_a_folder_row() {
|
|
||||||
let entries = continuous_grid_entries(2, 3);
|
|
||||||
let rows = entries.chunks(4).collect::<Vec<_>>();
|
|
||||||
assert_eq!(
|
|
||||||
rows[0],
|
|
||||||
[
|
|
||||||
ContentBrowserGridEntry::Folder(0),
|
|
||||||
ContentBrowserGridEntry::Folder(1),
|
|
||||||
ContentBrowserGridEntry::Asset(0),
|
|
||||||
ContentBrowserGridEntry::Asset(1),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(rows[1], [ContentBrowserGridEntry::Asset(2)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,367 +0,0 @@
|
|||||||
//! Penpot 420×350 material color popup with live preview and reversible dismissal.
|
|
||||||
|
|
||||||
mod popup;
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
|
|
||||||
use super::typography::TypeRole;
|
|
||||||
|
|
||||||
pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0);
|
|
||||||
const COLOR_SWATCH_OUTER_RADIUS: u8 = 3;
|
|
||||||
|
|
||||||
#[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<f32>,
|
|
||||||
current_intensity: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct ColorPickerResponse {
|
|
||||||
pub changed: bool,
|
|
||||||
pub applied: bool,
|
|
||||||
pub cancelled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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 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 width = ui.available_width().max(1.0);
|
|
||||||
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click());
|
|
||||||
let swatch_width = 44.0_f32.min(width);
|
|
||||||
let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0));
|
|
||||||
let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 };
|
|
||||||
let hex_rect = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2(swatch_width + gap, 0.0),
|
|
||||||
egui::vec2((width - swatch_width - gap).max(0.0), 22.0),
|
|
||||||
);
|
|
||||||
popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated);
|
|
||||||
let (rgb_rect, alpha_rect) = color_swatch_halves(swatch_rect.shrink(1.0));
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
rgb_rect,
|
|
||||||
egui::CornerRadius {
|
|
||||||
nw: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
ne: 0,
|
|
||||||
sw: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
se: 0,
|
|
||||||
},
|
|
||||||
egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])),
|
|
||||||
);
|
|
||||||
ui.painter().rect_filled(
|
|
||||||
alpha_rect,
|
|
||||||
egui::CornerRadius {
|
|
||||||
nw: 0,
|
|
||||||
ne: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
sw: 0,
|
|
||||||
se: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
},
|
|
||||||
color32(*rgba),
|
|
||||||
);
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
swatch_rect,
|
|
||||||
4.0,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border_strong),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
if hex_rect.width() > 0.0 {
|
|
||||||
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::<PickerSession>(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::<PickerSession>(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::<PickerSession>(id));
|
|
||||||
} else {
|
|
||||||
ui.ctx().data_mut(|data| data.insert_temp(id, session));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.changed = *rgba != before;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_swatch_halves(inner: egui::Rect) -> (egui::Rect, egui::Rect) {
|
|
||||||
let split = inner.center().x;
|
|
||||||
(
|
|
||||||
egui::Rect::from_min_max(inner.min, egui::pos2(split, inner.max.y)),
|
|
||||||
egui::Rect::from_min_max(egui::pos2(split, inner.min.y), inner.max),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 inline_swatch_halves_share_one_unrounded_inner_edge() {
|
|
||||||
let inner = egui::Rect::from_min_size(egui::pos2(1.0, 1.0), egui::vec2(42.0, 20.0));
|
|
||||||
let (rgb, alpha) = color_swatch_halves(inner);
|
|
||||||
assert_eq!(rgb.right(), alpha.left());
|
|
||||||
assert_eq!(rgb.left(), inner.left());
|
|
||||||
assert_eq!(alpha.right(), inner.right());
|
|
||||||
assert_eq!(rgb.height(), alpha.height());
|
|
||||||
|
|
||||||
let left = egui::CornerRadius {
|
|
||||||
nw: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
ne: 0,
|
|
||||||
sw: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
se: 0,
|
|
||||||
};
|
|
||||||
let right = egui::CornerRadius {
|
|
||||||
nw: 0,
|
|
||||||
ne: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
sw: 0,
|
|
||||||
se: COLOR_SWATCH_OUTER_RADIUS,
|
|
||||||
};
|
|
||||||
assert_eq!((left.ne, left.se, right.nw, right.sw), (0, 0, 0, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,765 +0,0 @@
|
|||||||
//! Fixed-coordinate renderer for the exported Penpot color overlay.
|
|
||||||
|
|
||||||
mod sliders;
|
|
||||||
|
|
||||||
use 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::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<const N: usize>(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::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::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::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::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::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::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::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::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::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::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::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::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::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::design_system::palette(ui);
|
|
||||||
ui.push_id(("color_picker_footer_action", label), |ui| {
|
|
||||||
ui.put(
|
|
||||||
rect,
|
|
||||||
egui::Button::new(crate::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::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::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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
//! Fixed Penpot geometry for the color picker's RGBA and HSV channel modes.
|
|
||||||
|
|
||||||
use egui::{self, ecolor::Hsva};
|
|
||||||
|
|
||||||
use super::super::{byte, set_current, PickerSession};
|
|
||||||
use super::{caption, text, Geometry};
|
|
||||||
use crate::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::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<f32>,
|
|
||||||
suffix: &str,
|
|
||||||
) {
|
|
||||||
let palette = crate::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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,419 +0,0 @@
|
|||||||
//! Reusable Penpot-styled editor controls with no world or asset-store access.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::{icons, Icon};
|
|
||||||
use std::ops::RangeInclusive;
|
|
||||||
|
|
||||||
use super::palette;
|
|
||||||
use super::typography::TypeRole;
|
|
||||||
|
|
||||||
/// Phosphor font variant used by a design-system glyph.
|
|
||||||
///
|
|
||||||
/// Penpot names the intended variant for each icon. Keeping that choice explicit prevents a
|
|
||||||
/// correct codepoint from silently rendering with the wrong visual weight in production.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub enum PhosphorIconStyle {
|
|
||||||
Regular,
|
|
||||||
#[default]
|
|
||||||
Bold,
|
|
||||||
Fill,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PhosphorIconStyle {
|
|
||||||
const fn family(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Regular => "phosphor-regular",
|
|
||||||
Self::Bold => "phosphor-bold",
|
|
||||||
Self::Fill => "phosphor-fill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn phosphor_icon_font(style: PhosphorIconStyle, size: f32) -> egui::FontId {
|
|
||||||
egui::FontId::new(size, egui::FontFamily::Name(style.family().into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Penpot's scalar component keeps a six-pixel leading inset inside its value-group frame. The
|
|
||||||
// exported child geometry is authoritative here; the group's transparent parent frame is six
|
|
||||||
// pixels narrower than its visible children and must not be mistaken for the control width.
|
|
||||||
const SCALAR_TRACK_X: f32 = 6.0;
|
|
||||||
const SCALAR_NUMBER_GAP: f32 = 6.0;
|
|
||||||
const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0;
|
|
||||||
const SCALAR_NUMBER_WIDTH: f32 = 33.0;
|
|
||||||
const SCALAR_THUMB_RADIUS: f32 = 4.0;
|
|
||||||
|
|
||||||
pub 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 fn section_header(ui: &mut egui::Ui, title: &str, open: &mut bool) -> egui::Response {
|
|
||||||
section_header_with_summary(ui, title, None, open)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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-bold".into())),
|
|
||||||
palette.text_secondary,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(26.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 fn icon_label(
|
|
||||||
icon: Icon,
|
|
||||||
label: &str,
|
|
||||||
role: TypeRole,
|
|
||||||
icon_size: f32,
|
|
||||||
color: egui::Color32,
|
|
||||||
) -> egui::WidgetText {
|
|
||||||
icon_label_with_style(icon, label, role, icon_size, color, PhosphorIconStyle::Bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compose a Phosphor glyph in its authored visual weight with a Source Sans label.
|
|
||||||
pub fn icon_label_with_style(
|
|
||||||
icon: Icon,
|
|
||||||
label: &str,
|
|
||||||
role: TypeRole,
|
|
||||||
icon_size: f32,
|
|
||||||
color: egui::Color32,
|
|
||||||
style: PhosphorIconStyle,
|
|
||||||
) -> egui::WidgetText {
|
|
||||||
let mut job = egui::text::LayoutJob::default();
|
|
||||||
job.append(
|
|
||||||
icon.as_str(),
|
|
||||||
0.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: phosphor_icon_font(style, icon_size),
|
|
||||||
color,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !label.is_empty() {
|
|
||||||
job.append(
|
|
||||||
label,
|
|
||||||
6.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: role.font(),
|
|
||||||
color,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
job.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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-bold".into()),
|
|
||||||
))
|
|
||||||
.color(palette.text_secondary),
|
|
||||||
)
|
|
||||||
.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 fn status(ui: &mut egui::Ui, icon: Icon, color: egui::Color32, label: &str, tooltip: &str) {
|
|
||||||
let palette = palette(ui);
|
|
||||||
let mut job = egui::text::LayoutJob::default();
|
|
||||||
job.append(
|
|
||||||
icon.as_str(),
|
|
||||||
0.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
color,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
job.append(
|
|
||||||
label,
|
|
||||||
5.0,
|
|
||||||
egui::TextFormat {
|
|
||||||
font_id: TypeRole::Small.font(),
|
|
||||||
color: palette.text_secondary,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover()));
|
|
||||||
response.on_hover_text(tooltip);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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 fn two_option_segmented(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
left_label: &str,
|
|
||||||
right_label: &str,
|
|
||||||
right_selected: bool,
|
|
||||||
) -> Option<bool> {
|
|
||||||
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 fn scalar_control(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
salt: impl std::hash::Hash,
|
|
||||||
value: &mut f32,
|
|
||||||
range: RangeInclusive<f32>,
|
|
||||||
max_decimals: usize,
|
|
||||||
) -> egui::Response {
|
|
||||||
let palette = palette(ui);
|
|
||||||
let before = *value;
|
|
||||||
// The final Penpot component deliberately stretches only the track at the compact
|
|
||||||
// breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference.
|
|
||||||
// The numeric field remains a stable 33 px in both modes, after a six-pixel leading inset
|
|
||||||
// and a six-pixel track-to-number gap.
|
|
||||||
let width = 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 = (width - number_width).max(0.0);
|
|
||||||
let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).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() {
|
|
||||||
let wide_width = 138.8;
|
|
||||||
let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH;
|
|
||||||
let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
|
|
||||||
assert!((wide_track - 93.8).abs() < 0.001);
|
|
||||||
|
|
||||||
let compact_width = 205.3;
|
|
||||||
let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH;
|
|
||||||
let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
|
|
||||||
assert!((compact_track - 160.3).abs() < 0.001);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
//! 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 mod color_picker;
|
|
||||||
pub mod controls;
|
|
||||||
pub mod property_grid;
|
|
||||||
pub mod typography;
|
|
||||||
|
|
||||||
use egui::{self, Color32, CornerRadius, Stroke};
|
|
||||||
|
|
||||||
use crate::theme::{AssetTypePalette, EditorVisualPalette};
|
|
||||||
|
|
||||||
pub const REFERENCE_WIDTH: f32 = 620.0;
|
|
||||||
pub const INSPECTOR_MIN_WIDTH: f32 = 420.0;
|
|
||||||
pub const OUTER_PADDING: f32 = 12.0;
|
|
||||||
pub const SECTION_GAP: f32 = 8.0;
|
|
||||||
pub const MATERIALS_HEADING_HEIGHT: f32 = 32.0;
|
|
||||||
pub const HEADER_HEIGHT: f32 = 82.0;
|
|
||||||
pub const COLLAPSED_HEADER_HEIGHT: f32 = 52.0;
|
|
||||||
pub const MATERIAL_PREVIEW_SIZE: f32 = 64.0;
|
|
||||||
pub const MATERIAL_IDENTITY_WIDTH: f32 = 229.5;
|
|
||||||
pub const SURFACE_SECTION_HEIGHT: f32 = 46.0;
|
|
||||||
pub const COMPACT_SURFACE_SECTION_HEIGHT: f32 = 46.0;
|
|
||||||
pub const INPUT_SECTION_HEADER_HEIGHT: f32 = 24.0;
|
|
||||||
pub const PARAMETER_ROW_HEIGHT: f32 = 32.0;
|
|
||||||
pub const COMPACT_PARAMETER_ROW_HEIGHT: f32 = 58.0;
|
|
||||||
pub const UV_SECTION_HEIGHT: f32 = 54.0;
|
|
||||||
pub const COMPACT_UV_SECTION_HEIGHT: f32 = 94.0;
|
|
||||||
pub const ADVANCED_COLLAPSED_HEIGHT: f32 = 30.0;
|
|
||||||
pub const ADVANCED_EXPANDED_HEIGHT: f32 = 178.0;
|
|
||||||
pub const CONTROL_HEIGHT: f32 = 24.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub 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,
|
|
||||||
pub asset_types: AssetTypePalette,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<EditorVisualPalette> 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,
|
|
||||||
asset_types: value.asset_types,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn palette(ui: &egui::Ui) -> DesignPalette {
|
|
||||||
crate::theme::editor_palette(ui.ctx()).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies the Penpot material-panel visual language locally without restyling the whole editor.
|
|
||||||
pub fn scope<R>(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::theme::BLACKSITE_PALETTE);
|
|
||||||
assert_eq!(palette.accent, crate::theme::SELECTION);
|
|
||||||
assert_eq!(palette.panel, crate::theme::PANEL_BG);
|
|
||||||
assert_eq!(palette.text_primary, crate::theme::TEXT);
|
|
||||||
assert_eq!(palette.asset_types, crate::theme::ASSET_TYPES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
//! Testable geometry for dense Penpot material property rows.
|
|
||||||
|
|
||||||
use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT};
|
|
||||||
use egui;
|
|
||||||
|
|
||||||
/// Minimum live section width that can safely retain the exported wide-row geometry.
|
|
||||||
///
|
|
||||||
/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome,
|
|
||||||
/// component-card borders, and the floating scrollbar consume up to ten pixels before the
|
|
||||||
/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px
|
|
||||||
/// Inspector renders the authoritative wide reference instead of incorrectly switching to the
|
|
||||||
/// 420 px two-line rows.
|
|
||||||
pub const WIDE_SECTION_WIDTH: f32 = 559.0;
|
|
||||||
pub 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 struct PropertyGridGeometry {
|
|
||||||
pub stacked: bool,
|
|
||||||
pub row_height: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub struct PropertyRowRects {
|
|
||||||
pub row: egui::Rect,
|
|
||||||
pub label: egui::Rect,
|
|
||||||
pub value: egui::Rect,
|
|
||||||
pub color_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 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 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(103.35, 5.0),
|
|
||||||
egui::vec2(138.8, 22.0),
|
|
||||||
),
|
|
||||||
color_value: egui::Rect::from_min_size(
|
|
||||||
row.min + egui::vec2(107.95, 5.0),
|
|
||||||
egui::vec2(182.55, 22.0),
|
|
||||||
),
|
|
||||||
channel: egui::Rect::from_min_size(
|
|
||||||
row.min + egui::vec2(242.5, 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 + 91.35).min(channel_x);
|
|
||||||
// The scalar's visible children finish 0.35 px before the channel. Penpot's transparent
|
|
||||||
// parent frame is six pixels narrower and is not the actual clipping boundary.
|
|
||||||
let value_width = (channel_x - value_x - 0.35).max(1.0);
|
|
||||||
let color_value_x = (inset + 95.95).min(channel_x);
|
|
||||||
let color_value_width = (inset + inner_width - color_value_x).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),
|
|
||||||
),
|
|
||||||
color_value: egui::Rect::from_min_size(
|
|
||||||
row.min + egui::vec2(color_value_x, 5.0),
|
|
||||||
egui::vec2(color_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 native_wide_containment_threshold_keeps_reference_rows() {
|
|
||||||
let geometry = PropertyGridGeometry::for_width(559.0);
|
|
||||||
assert!(!geometry.stacked);
|
|
||||||
assert_eq!(geometry.row_height, 32.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn below_native_wide_containment_threshold_reflows_without_compression() {
|
|
||||||
let geometry = PropertyGridGeometry::for_width(558.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(103.35, 5.0));
|
|
||||||
assert!((rects.value.width() - 138.8).abs() < 0.001);
|
|
||||||
assert_eq!(rects.value.height(), 22.0);
|
|
||||||
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
|
|
||||||
assert!((rects.color_value.width() - 182.55).abs() < 0.001);
|
|
||||||
assert_eq!(
|
|
||||||
rects.channel,
|
|
||||||
egui::Rect::from_min_size(egui::pos2(242.5, 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(103.35, 5.0));
|
|
||||||
assert!((rects.value.width() - 205.3).abs() < 0.001);
|
|
||||||
assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0));
|
|
||||||
assert!((rects.color_value.width() - 249.05).abs() < 0.001);
|
|
||||||
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.color_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.color_value, reference.color_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
//! Source Sans typography roles used by the Penpot-led editor visual system.
|
|
||||||
|
|
||||||
use egui::{self, FontFamily, FontId, RichText, Style, TextStyle};
|
|
||||||
|
|
||||||
use crate::fonts::{SOURCE_SANS_BOLD, SOURCE_SANS_REGULAR};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum TypeRole {
|
|
||||||
Caption,
|
|
||||||
Eyebrow,
|
|
||||||
Small,
|
|
||||||
Body,
|
|
||||||
Control,
|
|
||||||
Section,
|
|
||||||
AssetLabel,
|
|
||||||
AssetTitle,
|
|
||||||
Title,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TypeRole {
|
|
||||||
pub const fn size(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::Caption | Self::Eyebrow => 9.0,
|
|
||||||
Self::Small => 10.0,
|
|
||||||
Self::Body => 11.0,
|
|
||||||
Self::Control | Self::AssetLabel => 10.0,
|
|
||||||
Self::Section => 11.0,
|
|
||||||
Self::AssetTitle => 13.0,
|
|
||||||
Self::Title => 14.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn bold(self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Eyebrow | Self::Section | Self::AssetLabel | Self::AssetTitle | Self::Title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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 fn text(self, value: impl Into<String>) -> RichText {
|
|
||||||
RichText::new(value).font(self.font())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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::Eyebrow.size(), 9.0);
|
|
||||||
assert_eq!(TypeRole::Small.size(), 10.0);
|
|
||||||
assert_eq!(TypeRole::Body.size(), 11.0);
|
|
||||||
assert_eq!(TypeRole::Control.size(), 10.0);
|
|
||||||
assert_eq!(TypeRole::Section.size(), 11.0);
|
|
||||||
assert_eq!(TypeRole::AssetLabel.size(), 10.0);
|
|
||||||
assert_eq!(TypeRole::AssetTitle.size(), 13.0);
|
|
||||||
assert_eq!(TypeRole::Title.size(), 14.0);
|
|
||||||
assert!(!TypeRole::Body.bold());
|
|
||||||
assert!(TypeRole::Eyebrow.bold());
|
|
||||||
assert!(TypeRole::Section.bold());
|
|
||||||
assert!(TypeRole::AssetLabel.bold());
|
|
||||||
assert!(TypeRole::AssetTitle.bold());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
//! Source Sans Pro and Phosphor font registration for every Blacksite egui host.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular";
|
|
||||||
pub const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold";
|
|
||||||
|
|
||||||
pub fn font_definitions() -> egui::FontDefinitions {
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
egui_phosphor_icons::add_fonts(&mut fonts);
|
|
||||||
fonts.font_data.insert(
|
|
||||||
SOURCE_SANS_REGULAR.to_owned(),
|
|
||||||
Arc::new(egui::FontData::from_static(include_bytes!(
|
|
||||||
"../assets/fonts/source-sans/SourceSansPro-Regular.ttf"
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
fonts.font_data.insert(
|
|
||||||
SOURCE_SANS_BOLD.to_owned(),
|
|
||||||
Arc::new(egui::FontData::from_static(include_bytes!(
|
|
||||||
"../assets/fonts/source-sans/SourceSansPro-Bold.ttf"
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
fonts
|
|
||||||
.families
|
|
||||||
.entry(egui::FontFamily::Proportional)
|
|
||||||
.or_default()
|
|
||||||
.insert(0, SOURCE_SANS_REGULAR.to_owned());
|
|
||||||
fonts.families.insert(
|
|
||||||
egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()),
|
|
||||||
vec![SOURCE_SANS_REGULAR.to_owned()],
|
|
||||||
);
|
|
||||||
fonts.families.insert(
|
|
||||||
egui::FontFamily::Name(SOURCE_SANS_BOLD.into()),
|
|
||||||
vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()],
|
|
||||||
);
|
|
||||||
let fallbacks = fonts
|
|
||||||
.families
|
|
||||||
.get(&egui::FontFamily::Proportional)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
for (family, font_names) in &mut fonts.families {
|
|
||||||
let egui::FontFamily::Name(name) = family else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if name.starts_with("phosphor-") {
|
|
||||||
for fallback in &fallbacks {
|
|
||||||
if !font_names.contains(fallback) {
|
|
||||||
font_names.push(fallback.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fonts
|
|
||||||
}
|
|
||||||
@ -1,262 +0,0 @@
|
|||||||
//! Action-returning Inspector component-card chrome shared by production and gallery fixtures.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::{icons, Icon};
|
|
||||||
|
|
||||||
use crate::design_system;
|
|
||||||
use crate::design_system::typography::TypeRole;
|
|
||||||
use crate::theme::{BORDER, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG};
|
|
||||||
|
|
||||||
pub 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;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct ComponentCardOptions {
|
|
||||||
pub type_name: &'static str,
|
|
||||||
pub title: &'static str,
|
|
||||||
pub icon: Icon,
|
|
||||||
pub active_toggle: bool,
|
|
||||||
pub removable: bool,
|
|
||||||
pub reorderable: bool,
|
|
||||||
pub resettable: bool,
|
|
||||||
pub copyable: bool,
|
|
||||||
pub summary: &'static str,
|
|
||||||
pub body_margin: i8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ComponentCardOptions {
|
|
||||||
pub const fn removable(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
|
|
||||||
Self {
|
|
||||||
type_name,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
active_toggle: true,
|
|
||||||
removable: true,
|
|
||||||
reorderable: true,
|
|
||||||
resettable: true,
|
|
||||||
copyable: true,
|
|
||||||
summary: "Authoring component",
|
|
||||||
body_margin: 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn fixed(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
|
|
||||||
Self {
|
|
||||||
type_name,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
active_toggle: false,
|
|
||||||
removable: false,
|
|
||||||
reorderable: false,
|
|
||||||
resettable: true,
|
|
||||||
copyable: false,
|
|
||||||
summary: "Authoring component",
|
|
||||||
body_margin: 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ComponentCardViewModel {
|
|
||||||
pub options: ComponentCardOptions,
|
|
||||||
pub active: bool,
|
|
||||||
pub collapsed: bool,
|
|
||||||
pub pasteable: bool,
|
|
||||||
pub can_move_up: bool,
|
|
||||||
pub can_move_down: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct ComponentCardAction {
|
|
||||||
pub type_name: &'static str,
|
|
||||||
pub collapsed: Option<bool>,
|
|
||||||
pub active: Option<bool>,
|
|
||||||
pub reset: bool,
|
|
||||||
pub copy: bool,
|
|
||||||
pub paste: bool,
|
|
||||||
pub move_up: bool,
|
|
||||||
pub move_down: bool,
|
|
||||||
pub remove: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn component_card(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
view: &ComponentCardViewModel,
|
|
||||||
add_contents: impl FnOnce(&mut egui::Ui),
|
|
||||||
) -> ComponentCardAction {
|
|
||||||
let mut action = ComponentCardAction {
|
|
||||||
type_name: view.options.type_name,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
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, 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,
|
|
||||||
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(icon_text(
|
|
||||||
if view.collapsed {
|
|
||||||
icons::CARET_RIGHT
|
|
||||||
} else {
|
|
||||||
icons::CARET_DOWN
|
|
||||||
},
|
|
||||||
12.0,
|
|
||||||
))
|
|
||||||
.frame(false),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
action.collapsed = Some(!view.collapsed);
|
|
||||||
}
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
view.options.icon.as_str(),
|
|
||||||
egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
TEXT_DIM,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
view.options.title,
|
|
||||||
TypeRole::Title.font(),
|
|
||||||
TEXT,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
view.options.summary,
|
|
||||||
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| {
|
|
||||||
actions_menu(ui, view, &mut action);
|
|
||||||
let color = if view.active { SUCCESS } else { TEXT_MUTED };
|
|
||||||
let status_rect = ui
|
|
||||||
.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover())
|
|
||||||
.0;
|
|
||||||
ui.painter().circle_filled(status_rect.center(), 4.0, color);
|
|
||||||
ui.label(
|
|
||||||
TypeRole::Small
|
|
||||||
.text(if view.active { "Active" } else { "Off" })
|
|
||||||
.color(TEXT_DIM),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if response.clicked()
|
|
||||||
&& ui
|
|
||||||
.input(|input| input.pointer.interact_pos())
|
|
||||||
.is_some_and(|point| {
|
|
||||||
!caret_rect.contains(point) && !actions_rect.contains(point)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
action.collapsed = Some(!view.collapsed);
|
|
||||||
}
|
|
||||||
if !view.collapsed {
|
|
||||||
egui::Frame::new()
|
|
||||||
.inner_margin(egui::Margin {
|
|
||||||
left: view.options.body_margin,
|
|
||||||
right: view.options.body_margin,
|
|
||||||
top: 10,
|
|
||||||
bottom: view.options.body_margin,
|
|
||||||
})
|
|
||||||
.show(ui, add_contents);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add_space(6.0);
|
|
||||||
action
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actions_menu(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
view: &ComponentCardViewModel,
|
|
||||||
action: &mut ComponentCardAction,
|
|
||||||
) {
|
|
||||||
ui.menu_button(
|
|
||||||
icon_text(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM),
|
|
||||||
|ui| {
|
|
||||||
ui.set_min_width(160.0);
|
|
||||||
action.reset |= action_button(ui, view.options.resettable, "Reset");
|
|
||||||
action.copy |= action_button(ui, view.options.copyable, "Copy Values");
|
|
||||||
action.paste |= action_button(ui, view.pasteable, "Paste Values");
|
|
||||||
ui.separator();
|
|
||||||
action.move_up |= action_button(ui, view.can_move_up, "Move Up");
|
|
||||||
action.move_down |= action_button(ui, view.can_move_down, "Move Down");
|
|
||||||
ui.separator();
|
|
||||||
ui.add_enabled(false, egui::Button::new("Open Documentation"));
|
|
||||||
if ui
|
|
||||||
.add_enabled(
|
|
||||||
view.options.removable,
|
|
||||||
egui::Button::new(egui::RichText::new("Remove").color(ERROR)),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
action.remove = true;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn action_button(ui: &mut egui::Ui, enabled: bool, label: &str) -> bool {
|
|
||||||
let clicked = ui.add_enabled(enabled, egui::Button::new(label)).clicked();
|
|
||||||
if clicked {
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
clicked
|
|
||||||
}
|
|
||||||
|
|
||||||
fn icon_text(icon: Icon, size: f32) -> egui::RichText {
|
|
||||||
egui::RichText::new(icon.as_str()).font(egui::FontId::new(
|
|
||||||
size,
|
|
||||||
egui::FontFamily::Name("phosphor-bold".into()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn penpot_component_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
//! Production egui presentation shared by the Blacksite editor and lightweight UI gallery.
|
|
||||||
//!
|
|
||||||
//! This crate owns rendering, deterministic geometry, and action-returning view contracts. It does
|
|
||||||
//! not own a Bevy world, project state, authored-document publication, scene mutations, or content
|
|
||||||
//! processing.
|
|
||||||
|
|
||||||
pub mod content_browser;
|
|
||||||
pub mod design_system;
|
|
||||||
pub mod fonts;
|
|
||||||
pub mod inspector;
|
|
||||||
pub mod materials;
|
|
||||||
pub mod renderer;
|
|
||||||
pub mod theme;
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
//! Material-slot and schema-driven parameter widgets shared by production and the gallery.
|
|
||||||
|
|
||||||
use bevy_math::{Vec2, Vec3};
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::Icon;
|
|
||||||
use material_schema::{
|
|
||||||
ColorDesc, EditorAssetRef, MaterialInputDesc, MaterialInputSchema, MaterialInputSet,
|
|
||||||
MaterialParameter, MaterialParameterValue, MaterialTextureBinding, ShaderPropertyType,
|
|
||||||
TextureChannel,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::theme::TEXT_DIM;
|
|
||||||
|
|
||||||
mod advanced_preview;
|
|
||||||
mod inputs;
|
|
||||||
mod layout;
|
|
||||||
mod panel;
|
|
||||||
mod pickers;
|
|
||||||
mod surface;
|
|
||||||
mod texture_inputs;
|
|
||||||
mod uv_transform;
|
|
||||||
|
|
||||||
pub use inputs::{
|
|
||||||
apply_arm_texture_preset, apply_separate_texture_preset, compact_text_edit,
|
|
||||||
default_value_for_shader_property, material_input_schema_editor,
|
|
||||||
material_input_schema_editor_with_inheritance, optional_path_edit, packed_map_mode,
|
|
||||||
parameter_value_matches_property, shader_kind_combo, shader_kind_picker, PackedMapMode,
|
|
||||||
};
|
|
||||||
pub use panel::{
|
|
||||||
material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge,
|
|
||||||
MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelAction,
|
|
||||||
MaterialSlotPanelResponse, MaterialSlotPanelViewModel, MaterialsSectionViewModel,
|
|
||||||
};
|
|
||||||
pub use surface::material_surface_options_ui;
|
|
||||||
pub use texture_inputs::ensure_material_texture_binding;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum MaterialLocateTarget {
|
|
||||||
File(String),
|
|
||||||
SubAsset {
|
|
||||||
parent_path: String,
|
|
||||||
sub_asset_id: String,
|
|
||||||
label: String,
|
|
||||||
source_path: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MaterialTextureCandidate {
|
|
||||||
pub label: String,
|
|
||||||
pub detail: String,
|
|
||||||
pub reference: EditorAssetRef,
|
|
||||||
pub locate: MaterialLocateTarget,
|
|
||||||
pub thumbnail: MaterialThumbnailPresentation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct MaterialInputEditorResponse {
|
|
||||||
pub accepted_drop: bool,
|
|
||||||
pub locate: Option<MaterialLocateTarget>,
|
|
||||||
pub browse_library: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub enum MaterialThumbnailPresentation {
|
|
||||||
Ready(egui::TextureId),
|
|
||||||
Pending,
|
|
||||||
Failed,
|
|
||||||
Empty,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum TextureSlotSelection<'a> {
|
|
||||||
Reference(Option<&'a EditorAssetRef>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TextureSlotResponse {
|
|
||||||
selected: Option<MaterialTextureCandidate>,
|
|
||||||
locate: Option<MaterialLocateTarget>,
|
|
||||||
clear: bool,
|
|
||||||
accepted_drop: bool,
|
|
||||||
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-bold".into()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@ -1,412 +0,0 @@
|
|||||||
//! Transient assignment and drag feedback for the complete material-slot header.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::icons;
|
|
||||||
|
|
||||||
use super::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel};
|
|
||||||
use crate::design_system::typography::TypeRole;
|
|
||||||
use crate::design_system::DesignPalette;
|
|
||||||
|
|
||||||
const ZONE_INSET: f32 = 1.0;
|
|
||||||
const CONTENT_INSET: f32 = 8.0;
|
|
||||||
const ICON_SIZE: f32 = 48.0;
|
|
||||||
const BUTTON_WIDTH: f32 = 130.0;
|
|
||||||
const COMPACT_BUTTON_WIDTH: f32 = 116.0;
|
|
||||||
const BUTTON_HEIGHT: f32 = 30.0;
|
|
||||||
const CONTENT_GAP: f32 = 10.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(super) enum MaterialDropVisualState {
|
|
||||||
Empty,
|
|
||||||
Compatible,
|
|
||||||
Incompatible,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterialDropVisualState {
|
|
||||||
pub(super) fn from_view(
|
|
||||||
view: &MaterialSlotPanelViewModel,
|
|
||||||
target_hovered: bool,
|
|
||||||
) -> Option<Self> {
|
|
||||||
if target_hovered && view.drop_candidate.is_some() {
|
|
||||||
Some(Self::Compatible)
|
|
||||||
} else if target_hovered && view.invalid_drop_reason.is_some() {
|
|
||||||
Some(Self::Incompatible)
|
|
||||||
} else if view.assigned.is_none() && view.effective.is_none() {
|
|
||||||
Some(Self::Empty)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy(self) -> (&'static str, &'static str, &'static str) {
|
|
||||||
match self {
|
|
||||||
Self::Empty => (
|
|
||||||
"No material assigned",
|
|
||||||
"Drop a material here or choose from the browser",
|
|
||||||
"Choose material",
|
|
||||||
),
|
|
||||||
Self::Compatible => (
|
|
||||||
"Relevant project material",
|
|
||||||
"Compatible Material asset",
|
|
||||||
"Drop material",
|
|
||||||
),
|
|
||||||
Self::Incompatible => (
|
|
||||||
"Incompatible asset",
|
|
||||||
"Expected a Material asset",
|
|
||||||
"Cannot assign",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn accepted_drop_action(
|
|
||||||
state: MaterialDropVisualState,
|
|
||||||
pointer_released: bool,
|
|
||||||
candidate: Option<&MaterialPickerCandidate>,
|
|
||||||
) -> Option<MaterialSlotAction> {
|
|
||||||
if state != MaterialDropVisualState::Compatible || !pointer_released {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
candidate.map(|candidate| MaterialSlotAction::Assign {
|
|
||||||
reference: candidate.reference.clone(),
|
|
||||||
from_drop: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub(super) struct MaterialDropFeedbackGeometry {
|
|
||||||
pub zone: egui::Rect,
|
|
||||||
pub icon: egui::Rect,
|
|
||||||
pub text: egui::Rect,
|
|
||||||
pub action: egui::Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterialDropFeedbackGeometry {
|
|
||||||
pub(super) fn from_header(header: egui::Rect) -> Self {
|
|
||||||
let zone = header.shrink(ZONE_INSET);
|
|
||||||
let icon = egui::Rect::from_center_size(
|
|
||||||
egui::pos2(
|
|
||||||
zone.left() + CONTENT_INSET + ICON_SIZE * 0.5,
|
|
||||||
zone.center().y,
|
|
||||||
),
|
|
||||||
egui::vec2(ICON_SIZE, ICON_SIZE),
|
|
||||||
);
|
|
||||||
let available_button_width = if zone.width() >= 430.0 {
|
|
||||||
BUTTON_WIDTH
|
|
||||||
} else {
|
|
||||||
COMPACT_BUTTON_WIDTH
|
|
||||||
};
|
|
||||||
let action = egui::Rect::from_center_size(
|
|
||||||
egui::pos2(
|
|
||||||
zone.right() - CONTENT_INSET - available_button_width * 0.5,
|
|
||||||
zone.center().y,
|
|
||||||
),
|
|
||||||
egui::vec2(available_button_width, BUTTON_HEIGHT),
|
|
||||||
);
|
|
||||||
let text = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(icon.right() + CONTENT_GAP, icon.top()),
|
|
||||||
egui::pos2(
|
|
||||||
(action.left() - CONTENT_GAP).max(icon.right() + CONTENT_GAP),
|
|
||||||
icon.bottom(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Self {
|
|
||||||
zone,
|
|
||||||
icon,
|
|
||||||
text,
|
|
||||||
action,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) fn is_finite_and_contained(self) -> bool {
|
|
||||||
[self.zone, self.icon, self.text, self.action]
|
|
||||||
.into_iter()
|
|
||||||
.all(|rect| rect.is_finite() && self.zone.contains_rect(rect))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn paint_material_drop_feedback(
|
|
||||||
ui: &egui::Ui,
|
|
||||||
header: egui::Rect,
|
|
||||||
state: MaterialDropVisualState,
|
|
||||||
palette: DesignPalette,
|
|
||||||
) {
|
|
||||||
let geometry = MaterialDropFeedbackGeometry::from_header(header);
|
|
||||||
let (title, detail, action_label) = state.copy();
|
|
||||||
let (fill, stroke, detail_color, icon_fill) = match state {
|
|
||||||
MaterialDropVisualState::Empty => (
|
|
||||||
palette.recessed,
|
|
||||||
palette.border_strong,
|
|
||||||
palette.text_muted,
|
|
||||||
palette.elevated,
|
|
||||||
),
|
|
||||||
MaterialDropVisualState::Compatible => (
|
|
||||||
palette.accent_dark,
|
|
||||||
palette.accent,
|
|
||||||
palette.accent,
|
|
||||||
palette.accent_dark,
|
|
||||||
),
|
|
||||||
MaterialDropVisualState::Incompatible => (
|
|
||||||
palette.elevated,
|
|
||||||
palette.error,
|
|
||||||
palette.error,
|
|
||||||
palette.control,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.painter().rect_filled(geometry.zone, 7.0, fill);
|
|
||||||
if state == MaterialDropVisualState::Empty {
|
|
||||||
paint_dashed_rect(ui.painter(), geometry.zone, 7.0, stroke);
|
|
||||||
} else {
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
geometry.zone,
|
|
||||||
7.0,
|
|
||||||
egui::Stroke::new(2.0_f32, stroke),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ui.painter().rect(
|
|
||||||
geometry.icon,
|
|
||||||
4.0,
|
|
||||||
icon_fill,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border_strong),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
geometry.icon.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::PALETTE.as_str(),
|
|
||||||
egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
||||||
if state == MaterialDropVisualState::Incompatible {
|
|
||||||
palette.text_muted
|
|
||||||
} else {
|
|
||||||
palette.accent
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let title_pos = egui::pos2(geometry.text.left(), geometry.text.center().y - 8.0);
|
|
||||||
let detail_pos = egui::pos2(geometry.text.left(), geometry.text.center().y + 8.0);
|
|
||||||
let text_painter = ui.painter().with_clip_rect(geometry.text);
|
|
||||||
text_painter.text(
|
|
||||||
title_pos,
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
title,
|
|
||||||
TypeRole::AssetTitle.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
text_painter.text(
|
|
||||||
detail_pos,
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
detail,
|
|
||||||
TypeRole::Small.font(),
|
|
||||||
detail_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
ui.painter().rect(
|
|
||||||
geometry.action,
|
|
||||||
5.0,
|
|
||||||
palette.control,
|
|
||||||
egui::Stroke::new(
|
|
||||||
1.0_f32,
|
|
||||||
if state == MaterialDropVisualState::Incompatible {
|
|
||||||
palette.error
|
|
||||||
} else {
|
|
||||||
palette.accent
|
|
||||||
},
|
|
||||||
),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
let label_width = ui
|
|
||||||
.painter()
|
|
||||||
.layout_no_wrap(
|
|
||||||
action_label.to_owned(),
|
|
||||||
TypeRole::Control.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
)
|
|
||||||
.size()
|
|
||||||
.x;
|
|
||||||
let group_width = 14.0 + 6.0 + label_width;
|
|
||||||
let group_left = geometry.action.center().x - group_width * 0.5;
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(group_left + 7.0, geometry.action.center().y),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::FOLDER_OPEN.as_str(),
|
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
||||||
if state == MaterialDropVisualState::Incompatible {
|
|
||||||
palette.error
|
|
||||||
} else {
|
|
||||||
palette.accent
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(group_left + 20.0, geometry.action.center().y),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
action_label,
|
|
||||||
TypeRole::Control.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_dashed_rect(painter: &egui::Painter, rect: egui::Rect, radius: f32, color: egui::Color32) {
|
|
||||||
let stroke = egui::Stroke::new(1.0_f32, color);
|
|
||||||
let dash = 9.0;
|
|
||||||
let gap = 7.0;
|
|
||||||
let inset = radius.max(2.0);
|
|
||||||
for (from, to) in [
|
|
||||||
(
|
|
||||||
egui::pos2(rect.left() + inset, rect.top()),
|
|
||||||
egui::pos2(rect.right() - inset, rect.top()),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
egui::pos2(rect.left() + inset, rect.bottom()),
|
|
||||||
egui::pos2(rect.right() - inset, rect.bottom()),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
egui::pos2(rect.left(), rect.top() + inset),
|
|
||||||
egui::pos2(rect.left(), rect.bottom() - inset),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
egui::pos2(rect.right(), rect.top() + inset),
|
|
||||||
egui::pos2(rect.right(), rect.bottom() - inset),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let vector = to - from;
|
|
||||||
let length = vector.length();
|
|
||||||
if length <= f32::EPSILON {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let direction = vector / length;
|
|
||||||
let mut offset = 0.0;
|
|
||||||
while offset < length {
|
|
||||||
let end = (offset + dash).min(length);
|
|
||||||
painter.line_segment([from + direction * offset, from + direction * end], stroke);
|
|
||||||
offset += dash + gap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::materials::{MaterialHealth, MaterialThumbnailPresentation};
|
|
||||||
|
|
||||||
fn view() -> MaterialSlotPanelViewModel {
|
|
||||||
MaterialSlotPanelViewModel {
|
|
||||||
slot_id: "slot:test".into(),
|
|
||||||
label: "No material assigned".into(),
|
|
||||||
path: None,
|
|
||||||
shader: String::new(),
|
|
||||||
shader_kind: None,
|
|
||||||
can_edit_shader: false,
|
|
||||||
thumbnail: MaterialThumbnailPresentation::Empty,
|
|
||||||
inherited: None,
|
|
||||||
health: MaterialHealth::Healthy,
|
|
||||||
assigned: None,
|
|
||||||
effective: None,
|
|
||||||
candidates: Vec::new(),
|
|
||||||
drop_candidate: None,
|
|
||||||
invalid_drop_reason: None,
|
|
||||||
can_locate: false,
|
|
||||||
can_clear: false,
|
|
||||||
can_extract: false,
|
|
||||||
can_create_instance: false,
|
|
||||||
parameters_initially_open: false,
|
|
||||||
expanded_header_when_parameters_closed: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drop_state_matrix_prioritizes_targeted_feedback() {
|
|
||||||
let mut view = view();
|
|
||||||
assert_eq!(
|
|
||||||
MaterialDropVisualState::from_view(&view, false),
|
|
||||||
Some(MaterialDropVisualState::Empty)
|
|
||||||
);
|
|
||||||
view.drop_candidate = Some(super::super::MaterialPickerCandidate {
|
|
||||||
reference: material_schema::EditorAssetRef::new("mat", "material:source", "M_Test"),
|
|
||||||
label: "M_Test".into(),
|
|
||||||
detail: "assets/materials/M_Test.ron".into(),
|
|
||||||
thumbnail: MaterialThumbnailPresentation::Empty,
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
MaterialDropVisualState::from_view(&view, true),
|
|
||||||
Some(MaterialDropVisualState::Compatible)
|
|
||||||
);
|
|
||||||
view.drop_candidate = None;
|
|
||||||
view.invalid_drop_reason = Some("Expected a Material asset".into());
|
|
||||||
assert_eq!(
|
|
||||||
MaterialDropVisualState::from_view(&view, true),
|
|
||||||
Some(MaterialDropVisualState::Incompatible)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
MaterialDropVisualState::from_view(&view, false),
|
|
||||||
Some(MaterialDropVisualState::Empty)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assigned_slots_only_replace_the_header_while_targeted() {
|
|
||||||
let mut view = view();
|
|
||||||
view.assigned = Some(material_schema::EditorAssetRef::new(
|
|
||||||
"mat",
|
|
||||||
"material:source",
|
|
||||||
"M_Test",
|
|
||||||
));
|
|
||||||
view.effective = view.assigned.clone();
|
|
||||||
assert_eq!(MaterialDropVisualState::from_view(&view, false), None);
|
|
||||||
view.invalid_drop_reason = Some("Expected a Material asset".into());
|
|
||||||
assert_eq!(
|
|
||||||
MaterialDropVisualState::from_view(&view, true),
|
|
||||||
Some(MaterialDropVisualState::Incompatible)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_a_released_compatible_candidate_emits_an_assignment() {
|
|
||||||
let candidate = MaterialPickerCandidate {
|
|
||||||
reference: material_schema::EditorAssetRef::new("mat", "material:source", "M_Test"),
|
|
||||||
label: "M_Test".into(),
|
|
||||||
detail: "assets/materials/M_Test.ron".into(),
|
|
||||||
thumbnail: MaterialThumbnailPresentation::Empty,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
accepted_drop_action(MaterialDropVisualState::Compatible, true, Some(&candidate)),
|
|
||||||
Some(MaterialSlotAction::Assign {
|
|
||||||
reference: candidate.reference.clone(),
|
|
||||||
from_drop: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
accepted_drop_action(MaterialDropVisualState::Compatible, false, Some(&candidate)),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
accepted_drop_action(
|
|
||||||
MaterialDropVisualState::Incompatible,
|
|
||||||
true,
|
|
||||||
Some(&candidate)
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
accepted_drop_action(MaterialDropVisualState::Compatible, true, None),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feedback_geometry_is_finite_and_bounded_at_wide_and_narrow_widths() {
|
|
||||||
for width in [396.0, 596.0] {
|
|
||||||
let header = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(12.0, 24.0),
|
|
||||||
egui::vec2(width, crate::design_system::HEADER_HEIGHT),
|
|
||||||
);
|
|
||||||
let geometry = MaterialDropFeedbackGeometry::from_header(header);
|
|
||||||
assert!(geometry.is_finite_and_contained());
|
|
||||||
assert_eq!(geometry.icon.size(), egui::vec2(48.0, 48.0));
|
|
||||||
assert_eq!(geometry.action.height(), 30.0);
|
|
||||||
assert!(geometry.text.right() <= geometry.action.left());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
//! Exact Penpot status/action geometry for an expanded material-slot header.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::icons;
|
|
||||||
|
|
||||||
use super::{MaterialSlotAction, MaterialSlotPanelViewModel};
|
|
||||||
use crate::design_system::controls::{icon_button, icon_label, status};
|
|
||||||
use crate::design_system::typography::TypeRole;
|
|
||||||
use crate::design_system::{self, DesignPalette};
|
|
||||||
|
|
||||||
pub(super) const SLOT_STATUS_ACTION_WIDTH: f32 = 140.0;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
struct ExpandedHeaderActionGeometry {
|
|
||||||
status: egui::Rect,
|
|
||||||
menu: egui::Rect,
|
|
||||||
browse: egui::Rect,
|
|
||||||
locate: egui::Rect,
|
|
||||||
clear: egui::Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn geometry(rect: egui::Rect) -> ExpandedHeaderActionGeometry {
|
|
||||||
let status_row = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2(0.0, 5.333),
|
|
||||||
egui::vec2(SLOT_STATUS_ACTION_WIDTH, 24.0),
|
|
||||||
);
|
|
||||||
let menu = egui::Rect::from_min_size(
|
|
||||||
status_row.min + egui::vec2(116.0, 0.0),
|
|
||||||
egui::vec2(24.0, 24.0),
|
|
||||||
);
|
|
||||||
let action_row = rect.min + egui::vec2(0.0, 34.667);
|
|
||||||
ExpandedHeaderActionGeometry {
|
|
||||||
status: egui::Rect::from_min_max(
|
|
||||||
status_row.min,
|
|
||||||
egui::pos2(menu.left() - 5.0, status_row.bottom()),
|
|
||||||
),
|
|
||||||
menu,
|
|
||||||
browse: egui::Rect::from_min_size(
|
|
||||||
action_row + egui::vec2(56.0, 0.0),
|
|
||||||
egui::vec2(24.0, 24.0),
|
|
||||||
),
|
|
||||||
locate: egui::Rect::from_min_size(
|
|
||||||
action_row + egui::vec2(86.0, 0.0),
|
|
||||||
egui::vec2(24.0, 24.0),
|
|
||||||
),
|
|
||||||
clear: egui::Rect::from_min_size(
|
|
||||||
action_row + egui::vec2(116.0, 0.0),
|
|
||||||
egui::vec2(24.0, 24.0),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn expanded_status_actions(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
rect: egui::Rect,
|
|
||||||
view: &MaterialSlotPanelViewModel,
|
|
||||||
actions: &mut Vec<MaterialSlotAction>,
|
|
||||||
diagnostics_ui: impl FnOnce(&mut egui::Ui),
|
|
||||||
palette: DesignPalette,
|
|
||||||
) {
|
|
||||||
if view.assigned.is_none() && view.effective.is_none() {
|
|
||||||
let button_rect = egui::Rect::from_min_size(
|
|
||||||
rect.right_bottom() - egui::vec2(116.0, 28.0),
|
|
||||||
egui::vec2(116.0, 28.0),
|
|
||||||
);
|
|
||||||
if rect_scope(ui, button_rect, |ui| {
|
|
||||||
ui.add(
|
|
||||||
egui::Button::new(icon_label(
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Choose material",
|
|
||||||
TypeRole::Control,
|
|
||||||
13.0,
|
|
||||||
palette.text_primary,
|
|
||||||
))
|
|
||||||
.min_size(button_rect.size()),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
}) {
|
|
||||||
actions.push(MaterialSlotAction::BrowseLibrary);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let geometry = geometry(rect);
|
|
||||||
rect_scope(ui, geometry.status, |ui| {
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
let (color, label, tooltip) = view.health.presentation(palette);
|
|
||||||
status(ui, view.health.icon(), color, label, tooltip);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
rect_scope(ui, geometry.menu, |ui| {
|
|
||||||
overflow_menu(ui, view, actions, diagnostics_ui);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (button_rect, action, enabled, icon, tooltip) in [
|
|
||||||
(
|
|
||||||
geometry.browse,
|
|
||||||
MaterialSlotAction::BrowseLibrary,
|
|
||||||
true,
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Browse project materials",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
geometry.locate,
|
|
||||||
MaterialSlotAction::Locate,
|
|
||||||
view.can_locate,
|
|
||||||
icons::CROSSHAIR,
|
|
||||||
"Locate in Content Browser",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
geometry.clear,
|
|
||||||
MaterialSlotAction::Clear,
|
|
||||||
view.can_clear,
|
|
||||||
icons::X,
|
|
||||||
"Clear actor assignment",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
if !enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let clicked = rect_scope(ui, button_rect, |ui| {
|
|
||||||
icon_button(ui, icon, tooltip, true).clicked()
|
|
||||||
});
|
|
||||||
if clicked {
|
|
||||||
actions.push(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn overflow_menu(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
view: &MaterialSlotPanelViewModel,
|
|
||||||
actions: &mut Vec<MaterialSlotAction>,
|
|
||||||
diagnostics_ui: impl FnOnce(&mut egui::Ui),
|
|
||||||
) {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
ui.menu_button(
|
|
||||||
egui::RichText::new(icons::DOTS_THREE.as_str()).font(egui::FontId::new(
|
|
||||||
16.0,
|
|
||||||
egui::FontFamily::Name("phosphor-bold".into()),
|
|
||||||
)),
|
|
||||||
|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()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rect_scope<R>(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expanded_status_and_actions_match_the_penpot_header_geometry() {
|
|
||||||
let rect = egui::Rect::from_min_size(egui::pos2(448.0, 9.0), egui::vec2(140.0, 64.0));
|
|
||||||
let geometry = geometry(rect);
|
|
||||||
assert_eq!(geometry.menu.min, egui::pos2(564.0, 14.333));
|
|
||||||
assert_eq!(geometry.browse.min, egui::pos2(504.0, 43.667));
|
|
||||||
assert_eq!(geometry.locate.min, egui::pos2(534.0, 43.667));
|
|
||||||
assert_eq!(geometry.clear.min, egui::pos2(564.0, 43.667));
|
|
||||||
assert_eq!(geometry.menu.size(), egui::vec2(24.0, 24.0));
|
|
||||||
assert_eq!(geometry.clear.size(), egui::vec2(24.0, 24.0));
|
|
||||||
for child in [
|
|
||||||
geometry.status,
|
|
||||||
geometry.menu,
|
|
||||||
geometry.browse,
|
|
||||||
geometry.locate,
|
|
||||||
geometry.clear,
|
|
||||||
] {
|
|
||||||
assert!(rect.contains_rect(child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
//! Penpot Surface section backed by shared material document values.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use material_schema::{MaterialAlphaMode, MaterialInputSet, MaterialRenderState};
|
|
||||||
|
|
||||||
use super::inputs::{
|
|
||||||
apply_arm_texture_preset, apply_separate_texture_preset, packed_map_mode, PackedMapMode,
|
|
||||||
};
|
|
||||||
use super::layout::{ResponsiveMaterialLayout, SurfaceSectionGeometry};
|
|
||||||
use crate::design_system;
|
|
||||||
|
|
||||||
pub fn material_surface_options_ui(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
state: &mut MaterialRenderState,
|
|
||||||
packed_inputs: Option<&mut MaterialInputSet>,
|
|
||||||
) {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
let width = ui.available_width().max(1.0);
|
|
||||||
let height = match ResponsiveMaterialLayout::for_width(width) {
|
|
||||||
ResponsiveMaterialLayout::Reference => design_system::SURFACE_SECTION_HEIGHT,
|
|
||||||
ResponsiveMaterialLayout::Compact => design_system::COMPACT_SURFACE_SECTION_HEIGHT,
|
|
||||||
ResponsiveMaterialLayout::Transient => 100.0,
|
|
||||||
};
|
|
||||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
|
||||||
ui.painter().rect(
|
|
||||||
rect,
|
|
||||||
5.0,
|
|
||||||
palette.panel,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
let geometry = SurfaceSectionGeometry::for_rect(rect);
|
|
||||||
ui.painter().text(
|
|
||||||
geometry.title.left_center(),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"Surface",
|
|
||||||
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",
|
|
||||||
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| {
|
|
||||||
design_system::controls::switch(ui, &mut state.double_sided)
|
|
||||||
.on_hover_text("Render both sides of the surface");
|
|
||||||
});
|
|
||||||
// Explicitly placed blend/packing/switch children do not own vertical flow. Reassert the
|
|
||||||
// Surface rectangle after they render so the following authored section gap starts at the
|
|
||||||
// true 46/100 px boundary.
|
|
||||||
ui.advance_cursor_after_rect(rect);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut MaterialInputSet) {
|
|
||||||
let arm = packed_map_mode(inputs) == PackedMapMode::Arm;
|
|
||||||
match design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) {
|
|
||||||
Some(false) => apply_separate_texture_preset(inputs),
|
|
||||||
Some(true) => apply_arm_texture_preset(inputs),
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn surface_blend_controls(ui: &mut egui::Ui, state: &mut 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<R>(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -1,412 +0,0 @@
|
|||||||
//! Static/skinned renderer-array presentation shared by production and the UI gallery.
|
|
||||||
|
|
||||||
use egui;
|
|
||||||
use egui_phosphor_icons::icons;
|
|
||||||
|
|
||||||
use crate::design_system;
|
|
||||||
use crate::design_system::typography::TypeRole;
|
|
||||||
use crate::theme::{BORDER, PANEL_BG_DARK, WIDGET_BG};
|
|
||||||
|
|
||||||
pub const RENDERER_ARRAY_HEADER_HEIGHT: f32 = 40.0;
|
|
||||||
pub const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0;
|
|
||||||
pub const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 60.0;
|
|
||||||
pub const RENDERER_PROPERTIES_HEADER_HEIGHT: f32 = 38.0;
|
|
||||||
pub const MATERIAL_SLOTS_HEADER_HEIGHT: f32 = 50.0;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct RendererPanelHeaderViewModel<'a> {
|
|
||||||
pub index: usize,
|
|
||||||
pub label: &'a str,
|
|
||||||
pub path: &'a str,
|
|
||||||
pub slot_count: usize,
|
|
||||||
pub texture_id: Option<egui::TextureId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
let (rect, _) = ui.allocate_exact_size(
|
|
||||||
egui::vec2(ui.available_width().max(1.0), RENDERER_ARRAY_HEADER_HEIGHT),
|
|
||||||
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(32.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"Mesh renderers",
|
|
||||||
TypeRole::Section.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
let badge = egui::Rect::from_center_size(
|
|
||||||
rect.left_center() + egui::vec2(124.0, 0.0),
|
|
||||||
egui::vec2(20.0, 20.0),
|
|
||||||
);
|
|
||||||
ui.painter()
|
|
||||||
.circle_filled(badge.center(), 10.0, palette.accent.linear_multiply(0.22));
|
|
||||||
ui.painter().text(
|
|
||||||
badge.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
format!("{count}"),
|
|
||||||
TypeRole::Small.font(),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(144.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"Each renderer owns the slots exposed by its mesh",
|
|
||||||
TypeRole::Caption.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(design_system::controls::icon_label(
|
|
||||||
icons::PLUS,
|
|
||||||
"Add renderer",
|
|
||||||
TypeRole::Body,
|
|
||||||
12.0,
|
|
||||||
palette.text_primary,
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub 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 fn renderer_panel_header(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
model: RendererPanelHeaderViewModel<'_>,
|
|
||||||
open: &mut bool,
|
|
||||||
add_actions: impl FnOnce(&mut egui::Ui),
|
|
||||||
) {
|
|
||||||
let palette = 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(24.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() + 68.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) = model.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,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ui.painter().text(
|
|
||||||
preview.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::CUBE.as_str(),
|
|
||||||
egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.text_secondary,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let label = if model.label.trim().is_empty() {
|
|
||||||
"Mesh renderer"
|
|
||||||
} else {
|
|
||||||
model.label.trim()
|
|
||||||
};
|
|
||||||
let identity_x = rect.left() + 105.0;
|
|
||||||
if *open {
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(identity_x, rect.top() + 28.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
format!("RENDERER {}", model.index),
|
|
||||||
TypeRole::Eyebrow.font(),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(
|
|
||||||
identity_x,
|
|
||||||
if *open {
|
|
||||||
rect.top() + 45.0
|
|
||||||
} else {
|
|
||||||
rect.center().y - 7.0
|
|
||||||
},
|
|
||||||
),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
label,
|
|
||||||
TypeRole::AssetTitle.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(
|
|
||||||
identity_x,
|
|
||||||
if *open {
|
|
||||||
rect.top() + 62.0
|
|
||||||
} else {
|
|
||||||
rect.center().y + 9.0
|
|
||||||
},
|
|
||||||
),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
if *open {
|
|
||||||
model.path.to_owned()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"Renderer {} · {} material slot{}",
|
|
||||||
model.index,
|
|
||||||
model.slot_count,
|
|
||||||
if model.slot_count == 1 { "" } else { "s" }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
TypeRole::Small.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
let slot_badge = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(
|
|
||||||
rect.right() - if *open { 60.0 } else { 78.0 },
|
|
||||||
rect.top() + if *open { 12.0 } else { 8.0 },
|
|
||||||
),
|
|
||||||
egui::vec2(40.0, 20.0),
|
|
||||||
);
|
|
||||||
ui.painter()
|
|
||||||
.rect_filled(slot_badge, 10.0, palette.accent.linear_multiply(0.22));
|
|
||||||
ui.painter().text(
|
|
||||||
slot_badge.center(),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
format!(
|
|
||||||
"{} slot{}",
|
|
||||||
model.slot_count,
|
|
||||||
if model.slot_count == 1 { "" } else { "s" }
|
|
||||||
),
|
|
||||||
TypeRole::Eyebrow.font(),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
let action_rect = if *open {
|
|
||||||
ui.painter().circle_filled(
|
|
||||||
egui::pos2(rect.right() - 10.0, rect.top() + 22.0),
|
|
||||||
4.0,
|
|
||||||
palette.healthy,
|
|
||||||
);
|
|
||||||
let action_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(rect.right() - 132.0, rect.bottom() - 38.0),
|
|
||||||
egui::vec2(122.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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
action_rect
|
|
||||||
} else {
|
|
||||||
let action_rect = egui::Rect::from_center_size(
|
|
||||||
egui::pos2(rect.right() - 20.0, rect.center().y),
|
|
||||||
egui::vec2(28.0, 28.0),
|
|
||||||
);
|
|
||||||
ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| {
|
|
||||||
design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::DOTS_THREE_VERTICAL,
|
|
||||||
"Renderer actions",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
action_rect
|
|
||||||
};
|
|
||||||
if header_response.clicked()
|
|
||||||
&& ui
|
|
||||||
.input(|input| input.pointer.interact_pos())
|
|
||||||
.is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer))
|
|
||||||
{
|
|
||||||
*open = !*open;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
let (rect, _) = ui.allocate_exact_size(
|
|
||||||
egui::vec2(ui.available_width().max(1.0), MATERIAL_SLOTS_HEADER_HEIGHT),
|
|
||||||
egui::Sense::hover(),
|
|
||||||
);
|
|
||||||
ui.painter().rect_filled(rect, 0.0, palette.elevated);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(15.0, 0.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::CARET_DOWN.as_str(),
|
|
||||||
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.text_secondary,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(35.0, 0.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::PALETTE.as_str(),
|
|
||||||
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(49.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"Material slots",
|
|
||||||
TypeRole::Section.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(122.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
format!("{count} exposed by mesh"),
|
|
||||||
TypeRole::Caption.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[
|
|
||||||
egui::pos2(rect.left() + 201.0, rect.center().y),
|
|
||||||
egui::pos2(rect.right() - 93.0, rect.center().y),
|
|
||||||
],
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.right_center() - egui::vec2(76.0, 0.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::LINK.as_str(),
|
|
||||||
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.right_center() - egui::vec2(10.0, 0.0),
|
|
||||||
egui::Align2::RIGHT_CENTER,
|
|
||||||
"Index mapped",
|
|
||||||
TypeRole::Eyebrow.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn renderer_properties_header(ui: &mut egui::Ui) {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
let (rect, _) = ui.allocate_exact_size(
|
|
||||||
egui::vec2(
|
|
||||||
ui.available_width().max(1.0),
|
|
||||||
RENDERER_PROPERTIES_HEADER_HEIGHT,
|
|
||||||
),
|
|
||||||
egui::Sense::hover(),
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(4.0, 0.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
icons::SLIDERS_HORIZONTAL.as_str(),
|
|
||||||
egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-bold".into())),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.left_center() + egui::vec2(20.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"Renderer properties",
|
|
||||||
TypeRole::AssetLabel.font(),
|
|
||||||
palette.accent,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
rect.right_center(),
|
|
||||||
egui::Align2::RIGHT_CENTER,
|
|
||||||
"Mesh + render flags",
|
|
||||||
TypeRole::Caption.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn renderer_material_slot_row<R>(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
index: usize,
|
|
||||||
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
|
||||||
) -> R {
|
|
||||||
let palette = design_system::palette(ui);
|
|
||||||
let row = ui.horizontal_top(|ui| {
|
|
||||||
let (index_rect, _) = ui.allocate_exact_size(egui::vec2(39.0, 60.0), egui::Sense::hover());
|
|
||||||
ui.painter().text(
|
|
||||||
index_rect.center_top() + egui::vec2(0.0, 8.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
"SLOT",
|
|
||||||
TypeRole::Caption.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
index_rect.center_top() + egui::vec2(0.0, 18.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
index.to_string(),
|
|
||||||
TypeRole::Body.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.set_width(ui.available_width().max(1.0));
|
|
||||||
add_contents(ui)
|
|
||||||
})
|
|
||||||
.inner
|
|
||||||
});
|
|
||||||
let rail_x = row.response.rect.left() + 23.0;
|
|
||||||
ui.painter().line_segment(
|
|
||||||
[
|
|
||||||
egui::pos2(rail_x, row.response.rect.top() + 35.0),
|
|
||||||
egui::pos2(rail_x, row.response.rect.bottom() - 8.0),
|
|
||||||
],
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border_strong),
|
|
||||||
);
|
|
||||||
row.inner
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renderer_headers_match_current_penpot_heights() {
|
|
||||||
assert_eq!(RENDERER_ARRAY_HEADER_HEIGHT, 40.0);
|
|
||||||
assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0);
|
|
||||||
assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 60.0);
|
|
||||||
assert_eq!(RENDERER_PROPERTIES_HEADER_HEIGHT, 38.0);
|
|
||||||
assert_eq!(MATERIAL_SLOTS_HEADER_HEIGHT, 50.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
//! Semantic Blacksite editor palette shared by production and the UI gallery.
|
|
||||||
|
|
||||||
use egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals};
|
|
||||||
|
|
||||||
/// Theme-owned colors for Content Browser asset categories.
|
|
||||||
///
|
|
||||||
/// These are classification colors, not health or selection states. Keeping them in the palette
|
|
||||||
/// lets a future Editor Settings theme change the vocabulary without teaching cards about theme
|
|
||||||
/// preferences.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct AssetTypePalette {
|
|
||||||
pub primitive: Color32,
|
|
||||||
pub light: Color32,
|
|
||||||
pub model: Color32,
|
|
||||||
pub texture: Color32,
|
|
||||||
pub material: Color32,
|
|
||||||
pub audio: Color32,
|
|
||||||
pub level: Color32,
|
|
||||||
pub prefab: Color32,
|
|
||||||
pub post_process: Color32,
|
|
||||||
pub rendering_profile: Color32,
|
|
||||||
pub shader: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 asset_types: AssetTypePalette,
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
pub const WIDGET_BG: Color32 = Color32::from_rgb(21, 24, 28);
|
|
||||||
pub const ELEVATED_BG: Color32 = Color32::from_rgb(31, 35, 40);
|
|
||||||
pub const ACCENT: Color32 = Color32::from_rgb(229, 164, 51);
|
|
||||||
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(249, 193, 79);
|
|
||||||
pub const SELECTION: Color32 = Color32::from_rgb(65, 198, 207);
|
|
||||||
pub const SELECTION_SECONDARY: Color32 = Color32::from_rgb(84, 139, 155);
|
|
||||||
pub const SELECTION_BG: Color32 = Color32::from_rgb(22, 72, 78);
|
|
||||||
pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(23, 48, 54);
|
|
||||||
pub const TEXT: Color32 = Color32::from_rgb(220, 225, 229);
|
|
||||||
pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 251, 252);
|
|
||||||
pub const TEXT_DIM: Color32 = Color32::from_rgb(155, 163, 171);
|
|
||||||
pub const TEXT_MUTED: Color32 = Color32::from_rgb(96, 105, 114);
|
|
||||||
pub const BORDER: Color32 = Color32::from_rgb(38, 43, 49);
|
|
||||||
pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 71, 79);
|
|
||||||
pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127);
|
|
||||||
pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72);
|
|
||||||
pub const ERROR: Color32 = Color32::from_rgb(226, 94, 94);
|
|
||||||
pub const ASSET_TYPES: AssetTypePalette = AssetTypePalette {
|
|
||||||
primitive: Color32::from_rgb(138, 155, 173),
|
|
||||||
light: Color32::from_rgb(244, 211, 94),
|
|
||||||
model: Color32::from_rgb(91, 143, 249),
|
|
||||||
texture: Color32::from_rgb(84, 190, 127),
|
|
||||||
material: Color32::from_rgb(235, 180, 72),
|
|
||||||
audio: Color32::from_rgb(208, 125, 232),
|
|
||||||
level: Color32::from_rgb(65, 198, 207),
|
|
||||||
prefab: Color32::from_rgb(79, 182, 166),
|
|
||||||
post_process: Color32::from_rgb(167, 139, 250),
|
|
||||||
rendering_profile: Color32::from_rgb(154, 167, 184),
|
|
||||||
shader: Color32::from_rgb(240, 124, 99),
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
asset_types: ASSET_TYPES,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette";
|
|
||||||
|
|
||||||
pub fn editor_palette(ctx: &egui::Context) -> EditorVisualPalette {
|
|
||||||
ctx.data_mut(|data| {
|
|
||||||
data.get_temp::<EditorVisualPalette>(Id::new(EDITOR_PALETTE_ID))
|
|
||||||
.unwrap_or(BLACKSITE_PALETTE)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_editor_theme(ctx: &egui::Context) {
|
|
||||||
apply_editor_theme_with_palette(ctx, editor_palette(ctx));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = 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 = visuals.widgets.hovered;
|
|
||||||
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.button_frame = true;
|
|
||||||
visuals.collapsing_header_frame = false;
|
|
||||||
visuals.indent_has_left_vline = true;
|
|
||||||
visuals.striped = true;
|
|
||||||
visuals.slider_trailing_fill = true;
|
|
||||||
visuals.interact_cursor = Some(egui::CursorIcon::PointingHand);
|
|
||||||
|
|
||||||
let mut style = (*ctx.global_style()).clone();
|
|
||||||
style.visuals = visuals;
|
|
||||||
style.spacing.item_spacing = egui::vec2(8.0, 7.0);
|
|
||||||
style.spacing.button_padding = egui::vec2(10.0, 5.0);
|
|
||||||
style.spacing.interact_size = egui::vec2(40.0, 28.0);
|
|
||||||
style.spacing.window_margin = egui::Margin::same(10);
|
|
||||||
style.spacing.menu_margin = egui::Margin::same(8);
|
|
||||||
style.spacing.indent = 17.0;
|
|
||||||
style.interaction.tooltip_delay = 0.32;
|
|
||||||
style.interaction.tooltip_grace_time = 0.18;
|
|
||||||
style.animation_time = 0.12;
|
|
||||||
style.compact_menu_style = true;
|
|
||||||
style.text_styles.insert(
|
|
||||||
egui::TextStyle::Body,
|
|
||||||
FontId::new(14.0, egui::FontFamily::Proportional),
|
|
||||||
);
|
|
||||||
style.text_styles.insert(
|
|
||||||
egui::TextStyle::Button,
|
|
||||||
FontId::new(13.0, egui::FontFamily::Proportional),
|
|
||||||
);
|
|
||||||
style.text_styles.insert(
|
|
||||||
egui::TextStyle::Heading,
|
|
||||||
FontId::new(16.0, egui::FontFamily::Proportional),
|
|
||||||
);
|
|
||||||
style.text_styles.insert(
|
|
||||||
egui::TextStyle::Small,
|
|
||||||
FontId::new(12.0, egui::FontFamily::Proportional),
|
|
||||||
);
|
|
||||||
ctx.set_global_style(style);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "material_schema"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
description = "Lightweight authored material schema and value types shared by Blacksite tools"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bevy_color = { version = "0.19", features = ["serialize"] }
|
|
||||||
bevy_math = { version = "0.19", features = ["bevy_reflect", "serialize"] }
|
|
||||||
bevy_reflect = { version = "0.19", features = ["glam"] }
|
|
||||||
serde.workspace = true
|
|
||||||
@ -1,546 +0,0 @@
|
|||||||
//! Lightweight authored material values and schemas.
|
|
||||||
//!
|
|
||||||
//! This crate intentionally excludes Bevy's app, asset, rendering, scene, and physics layers so
|
|
||||||
//! headless tooling and the production UI gallery can share the exact authoring contracts without
|
|
||||||
//! compiling the game runtime.
|
|
||||||
|
|
||||||
use bevy_color::Color;
|
|
||||||
use bevy_math::{Vec2, Vec3};
|
|
||||||
use bevy_reflect::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Reference to an imported content-browser asset or sub-asset.
|
|
||||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[type_path = "shared::components"]
|
|
||||||
pub struct EditorAssetRef {
|
|
||||||
/// Stable project asset registry UUID string.
|
|
||||||
pub asset_id: String,
|
|
||||||
/// Stable imported sub-asset ID inside the generated artifact.
|
|
||||||
#[serde(default, alias = "mesh_label")]
|
|
||||||
pub sub_asset_id: String,
|
|
||||||
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: String,
|
|
||||||
/// Optional loadable project path cached for runtime hydration.
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EditorAssetRef {
|
|
||||||
pub fn new(
|
|
||||||
asset_id: impl Into<String>,
|
|
||||||
sub_asset_id: impl Into<String>,
|
|
||||||
label: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
asset_id: asset_id.into(),
|
|
||||||
sub_asset_id: sub_asset_id.into(),
|
|
||||||
label: label.into(),
|
|
||||||
source_path: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
|
|
||||||
self.source_path = Some(source_path.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_resolved(&self) -> bool {
|
|
||||||
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How a material should be resolved at runtime.
|
|
||||||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[type_path = "shared::components"]
|
|
||||||
pub enum MaterialShaderKind {
|
|
||||||
#[default]
|
|
||||||
StandardLit,
|
|
||||||
Unlit,
|
|
||||||
Custom,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference to a built-in or custom shader schema.
|
|
||||||
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[type_path = "shared::components"]
|
|
||||||
pub struct ShaderRefDesc {
|
|
||||||
pub kind: MaterialShaderKind,
|
|
||||||
#[serde(default)]
|
|
||||||
pub schema_path: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub shader_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ShaderRefDesc {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
kind: MaterialShaderKind::StandardLit,
|
|
||||||
schema_path: None,
|
|
||||||
shader_path: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializable color description used by authored material documents.
|
|
||||||
#[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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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.
|
|
||||||
#[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<EditorAssetRef>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub channel: TextureChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum MaterialAlphaMode {
|
|
||||||
#[default]
|
|
||||||
Opaque,
|
|
||||||
Cutout,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct MaterialRenderState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub alpha_mode: MaterialAlphaMode,
|
|
||||||
#[serde(default = "default_alpha_cutoff")]
|
|
||||||
pub alpha_cutoff: f32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub double_sided: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn default_alpha_cutoff() -> f32 {
|
|
||||||
0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MaterialRenderState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
alpha_mode: MaterialAlphaMode::Opaque,
|
|
||||||
alpha_cutoff: default_alpha_cutoff(),
|
|
||||||
double_sided: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical values and texture bindings consumed by built-in and custom material schemas.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct MaterialInputSet {
|
|
||||||
#[serde(default)]
|
|
||||||
pub values: Vec<MaterialParameter>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub textures: Vec<MaterialTextureBinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterialInputSet {
|
|
||||||
pub fn from_material_desc<T: MaterialInputSource>(source: &T) -> Self {
|
|
||||||
source.to_material_inputs()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_to_material_desc<T: MaterialInputTarget>(&self, target: &mut T) {
|
|
||||||
target.apply_material_inputs(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime-owned descriptors implement this bridge without making the schema crate depend on the
|
|
||||||
/// renderer or scene representation.
|
|
||||||
pub trait MaterialInputSource {
|
|
||||||
fn to_material_inputs(&self) -> MaterialInputSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime-owned descriptors implement this bridge without making lightweight authoring tools
|
|
||||||
/// depend on those descriptors.
|
|
||||||
pub trait MaterialInputTarget {
|
|
||||||
fn apply_material_inputs(&mut self, inputs: &MaterialInputSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum ShaderPropertyType {
|
|
||||||
Bool,
|
|
||||||
Float { min: Option<f32>, max: Option<f32> },
|
|
||||||
Vec2,
|
|
||||||
Vec3,
|
|
||||||
Color,
|
|
||||||
Enum { options: Vec<String> },
|
|
||||||
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, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum MaterialInputPresentation {
|
|
||||||
#[default]
|
|
||||||
Row,
|
|
||||||
Companion {
|
|
||||||
owner: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
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<MaterialParameterValue>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub texture: Option<MaterialTextureInputDesc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tooltip: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub advanced: bool,
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compatibility source alias for code that has not yet adopted material-input terminology.
|
|
||||||
pub type ShaderPropertyDesc = MaterialInputDesc;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct MaterialInputSchema {
|
|
||||||
#[serde(default)]
|
|
||||||
pub groups: Vec<MaterialInputGroupDesc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub inputs: Vec<MaterialInputDesc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Engine-owned Standard Lit authoring schema. Custom Surface schemas use the same 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 standard_lit_has_six_primary_surface_rows() {
|
|
||||||
let schema = standard_lit_input_schema();
|
|
||||||
let surface = schema
|
|
||||||
.inputs
|
|
||||||
.iter()
|
|
||||||
.filter(|input| input.group == "surface_inputs")
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(surface.len(), 7);
|
|
||||||
assert_eq!(
|
|
||||||
surface
|
|
||||||
.iter()
|
|
||||||
.filter(|input| matches!(input.presentation, MaterialInputPresentation::Row))
|
|
||||||
.count(),
|
|
||||||
6
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@ description = "Shared reflectable authoring types and scene hydration for the FP
|
|||||||
avian3d.workspace = true
|
avian3d.workspace = true
|
||||||
bevy.workspace = true
|
bevy.workspace = true
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
material_schema.workspace = true
|
|
||||||
ron = "0.8"
|
ron = "0.8"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
settings.workspace = true
|
settings.workspace = true
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use avian3d::prelude::ColliderConstructor;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{ColorDesc, EditorAssetRef, MaterialDesc};
|
use crate::{ColorDesc, MaterialDesc};
|
||||||
|
|
||||||
/// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only.
|
/// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only.
|
||||||
#[derive(
|
#[derive(
|
||||||
@ -412,6 +412,48 @@ pub const COMPONENT_AUDIO_LISTENER_DESC: &str = "shared::components::AudioListen
|
|||||||
/// Stable imported sub-asset identifier used by authored audio clip references.
|
/// Stable imported sub-asset identifier used by authored audio clip references.
|
||||||
pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source";
|
pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source";
|
||||||
|
|
||||||
|
/// Reference to an imported content-browser asset or sub-asset.
|
||||||
|
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EditorAssetRef {
|
||||||
|
/// Stable project asset registry UUID string.
|
||||||
|
pub asset_id: String,
|
||||||
|
/// Stable imported sub-asset ID inside the generated artifact.
|
||||||
|
#[serde(default, alias = "mesh_label")]
|
||||||
|
pub sub_asset_id: String,
|
||||||
|
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: String,
|
||||||
|
/// Optional loadable project path cached for runtime hydration when the
|
||||||
|
/// referenced asset is not available through an imported artifact lookup.
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorAssetRef {
|
||||||
|
pub fn new(
|
||||||
|
asset_id: impl Into<String>,
|
||||||
|
sub_asset_id: impl Into<String>,
|
||||||
|
label: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
asset_id: asset_id.into(),
|
||||||
|
sub_asset_id: sub_asset_id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
source_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
|
||||||
|
self.source_path = Some(source_path.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_resolved(&self) -> bool {
|
||||||
|
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Distance curve used by an authored spatial audio source.
|
/// Distance curve used by an authored spatial audio source.
|
||||||
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@ -1195,6 +1237,37 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a material should be resolved at runtime.
|
||||||
|
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum MaterialShaderKind {
|
||||||
|
#[default]
|
||||||
|
StandardLit,
|
||||||
|
Unlit,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference to a built-in or custom shader schema.
|
||||||
|
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ShaderRefDesc {
|
||||||
|
pub kind: MaterialShaderKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub schema_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shader_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShaderRefDesc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: MaterialShaderKind::StandardLit,
|
||||||
|
schema_path: None,
|
||||||
|
shader_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Typed material parameter value exposed by a shader schema.
|
/// Typed material parameter value exposed by a shader schema.
|
||||||
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
|
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
|
||||||
/// Hydration turns this into a `WorldAssetRoot` on the entity.
|
/// Hydration turns this into a `WorldAssetRoot` on the entity.
|
||||||
|
|||||||
@ -19,14 +19,6 @@ mod prefab_overrides;
|
|||||||
mod renderer_material;
|
mod renderer_material;
|
||||||
mod rendering_profile_asset;
|
mod rendering_profile_asset;
|
||||||
|
|
||||||
pub use material_schema::{
|
|
||||||
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialInputDesc,
|
|
||||||
MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, MaterialInputSet,
|
|
||||||
MaterialInputSource, MaterialInputTarget, MaterialParameter, MaterialParameterValue,
|
|
||||||
MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, MaterialTextureInputDesc,
|
|
||||||
ShaderPropertyDesc, ShaderPropertyType, ShaderRefDesc, TextureChannel, TextureSemantic,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
|
pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
|
||||||
pub use animation::*;
|
pub use animation::*;
|
||||||
pub use asset_fingerprint::AssetSourceFingerprint;
|
pub use asset_fingerprint::AssetSourceFingerprint;
|
||||||
@ -41,9 +33,12 @@ pub use hydration::{
|
|||||||
StaticMeshArtifactCache,
|
StaticMeshArtifactCache,
|
||||||
};
|
};
|
||||||
pub use material_asset::{
|
pub use material_asset::{
|
||||||
apply_material_inputs_to_desc, load_resolved_material_from_path, material_inputs_from_desc,
|
load_resolved_material_from_path, standard_lit_input_schema, MaterialAlphaMode, MaterialAsset,
|
||||||
MaterialAsset, MaterialInstanceAsset, MaterialProvenance, ShaderSchemaAsset,
|
MaterialInputDesc, MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema,
|
||||||
MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION,
|
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 material_desc::*;
|
||||||
pub use navigation::*;
|
pub use navigation::*;
|
||||||
|
|||||||
@ -4,9 +4,8 @@ use bevy::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
EditorAssetRef, MaterialDesc, MaterialInputSchema, MaterialInputSet, MaterialInputSource,
|
ColorDesc, EditorAssetRef, MaterialDesc, MaterialParameter, MaterialParameterValue,
|
||||||
MaterialInputTarget, MaterialParameter, MaterialParameterValue, MaterialRef,
|
MaterialRef, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel,
|
||||||
MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2;
|
pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2;
|
||||||
@ -25,8 +24,51 @@ const fn default_surface_shader_schema_version() -> u32 {
|
|||||||
SURFACE_SHADER_SCHEMA_VERSION
|
SURFACE_SHADER_SCHEMA_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts the runtime/legacy descriptor into the lightweight authored input representation.
|
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet {
|
pub enum MaterialAlphaMode {
|
||||||
|
#[default]
|
||||||
|
Opaque,
|
||||||
|
Cutout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct MaterialRenderState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub alpha_mode: MaterialAlphaMode,
|
||||||
|
#[serde(default = "default_alpha_cutoff")]
|
||||||
|
pub alpha_cutoff: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub double_sided: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_alpha_cutoff() -> f32 {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MaterialRenderState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
alpha_mode: MaterialAlphaMode::Opaque,
|
||||||
|
alpha_cutoff: default_alpha_cutoff(),
|
||||||
|
double_sided: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<MaterialParameter>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub textures: Vec<MaterialTextureBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterialInputSet {
|
||||||
|
pub fn from_material_desc(material: &MaterialDesc) -> Self {
|
||||||
let mut values = vec![
|
let mut values = vec![
|
||||||
MaterialParameter {
|
MaterialParameter {
|
||||||
name: "base_color".into(),
|
name: "base_color".into(),
|
||||||
@ -93,16 +135,17 @@ pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet {
|
|||||||
upsert_texture(&mut textures, texture.clone());
|
upsert_texture(&mut textures, texture.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MaterialInputSet { values, textures }
|
Self { values, textures }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies authored values to the runtime/legacy material representation.
|
pub fn apply_to_material_desc(&self, material: &mut MaterialDesc) {
|
||||||
pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut MaterialDesc) {
|
|
||||||
material.parameters.clear();
|
material.parameters.clear();
|
||||||
material.textures.clear();
|
material.textures.clear();
|
||||||
for parameter in &inputs.values {
|
for parameter in &self.values {
|
||||||
match (parameter.name.as_str(), ¶meter.value) {
|
match (parameter.name.as_str(), ¶meter.value) {
|
||||||
("base_color", MaterialParameterValue::Color(value)) => material.base_color = *value,
|
("base_color", MaterialParameterValue::Color(value)) => {
|
||||||
|
material.base_color = *value
|
||||||
|
}
|
||||||
("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value,
|
("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value,
|
||||||
("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value,
|
("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value,
|
||||||
("emissive_color", MaterialParameterValue::Color(value)) => {
|
("emissive_color", MaterialParameterValue::Color(value)) => {
|
||||||
@ -116,33 +159,47 @@ pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut M
|
|||||||
_ => upsert_parameter(&mut material.parameters, parameter.clone()),
|
_ => upsert_parameter(&mut material.parameters, parameter.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
material.base_color_texture = binding_path(inputs.texture("base_color"));
|
material.base_color_texture = binding_path(self.texture("base_color"));
|
||||||
material.normal_map_texture = binding_path(inputs.texture("normal"));
|
material.normal_map_texture = binding_path(self.texture("normal"));
|
||||||
material.emissive_texture = binding_path(inputs.texture("emissive"));
|
material.emissive_texture = binding_path(self.texture("emissive"));
|
||||||
let roughness = binding_path(inputs.texture("roughness"));
|
let roughness = binding_path(self.texture("roughness"));
|
||||||
let metallic = binding_path(inputs.texture("metallic"));
|
let metallic = binding_path(self.texture("metallic"));
|
||||||
material.metallic_roughness_texture = match (roughness, metallic) {
|
material.metallic_roughness_texture = match (roughness, metallic) {
|
||||||
(Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness),
|
(Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness),
|
||||||
(Some(path), None) | (None, Some(path)) => Some(path),
|
(Some(path), None) | (None, Some(path)) => Some(path),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
material.occlusion_texture = binding_path(inputs.texture("occlusion"));
|
material.occlusion_texture = binding_path(self.texture("occlusion"));
|
||||||
for texture in &inputs.textures {
|
for texture in &self.textures {
|
||||||
if !is_standard_texture_name(&texture.name) {
|
if !is_standard_texture_name(&texture.name) {
|
||||||
upsert_texture(&mut material.textures, texture.clone());
|
upsert_texture(&mut material.textures, texture.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MaterialInputSource for MaterialDesc {
|
pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> {
|
||||||
fn to_material_inputs(&self) -> MaterialInputSet {
|
self.textures.iter().find(|binding| binding.name == name)
|
||||||
material_inputs_from_desc(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MaterialInputTarget for MaterialDesc {
|
pub fn validate_unique(&self) -> Result<(), String> {
|
||||||
fn apply_material_inputs(&mut self, inputs: &MaterialInputSet) {
|
let mut names = std::collections::HashSet::new();
|
||||||
apply_material_inputs_to_desc(inputs, self);
|
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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,6 +420,115 @@ fn upsert_texture(values: &mut Vec<MaterialTextureBinding>, value: MaterialTextu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ShaderPropertyType {
|
||||||
|
Bool,
|
||||||
|
Float { min: Option<f32>, max: Option<f32> },
|
||||||
|
Vec2,
|
||||||
|
Vec3,
|
||||||
|
Color,
|
||||||
|
Enum { options: Vec<String> },
|
||||||
|
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 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<MaterialParameterValue>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub texture: Option<MaterialTextureInputDesc>,
|
||||||
|
#[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<MaterialInputGroupDesc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub inputs: Vec<MaterialInputDesc>,
|
||||||
|
}
|
||||||
|
|
||||||
/// RON shader schema describing material inspector parameters.
|
/// RON shader schema describing material inspector parameters.
|
||||||
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@ -386,12 +552,181 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
|
||||||
standard_lit_input_schema, MaterialAlphaMode, MaterialInputPresentation, ShaderPropertyType,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn material_v2_defaults_to_opaque() {
|
fn material_v2_defaults_to_opaque() {
|
||||||
|
|||||||
@ -3,10 +3,58 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{ComponentInstanceId, EditorAssetRef, ShaderRefDesc};
|
||||||
ColorDesc, ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding,
|
|
||||||
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<EditorAssetRef>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub channel: TextureChannel,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-render-slot material override stored on scene actors.
|
/// Per-render-slot material override stored on scene actors.
|
||||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@ -28,6 +76,34 @@ pub struct MaterialOverride {
|
|||||||
pub slots: Vec<MaterialSlotOverride>,
|
pub slots: Vec<MaterialSlotOverride>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Resolved runtime and legacy-compatible material representation. Project authoring uses
|
||||||
/// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials.
|
/// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials.
|
||||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ui_gallery"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
description = "Focused Blacksite editor UI gallery and Penpot comparison harness"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
eframe.workspace = true
|
|
||||||
editor_ui.workspace = true
|
|
||||||
egui.workspace = true
|
|
||||||
egui_phosphor_icons.workspace = true
|
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
|
||||||
material_schema.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json = "1"
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"material_label": "M_Wood_Planks",
|
|
||||||
"material_path": "materials/wood/M_Wood_Planks.mat",
|
|
||||||
"metallic": 0.72,
|
|
||||||
"roughness": 0.72,
|
|
||||||
"occlusion": 0.72,
|
|
||||||
"normal_strength": 0.72,
|
|
||||||
"emissive_intensity": 0.0,
|
|
||||||
"uv_tiling": [1.0, 1.0]
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"accent": "#E5A433",
|
|
||||||
"selection": "#41C6CF",
|
|
||||||
"panel": "#0D0F12",
|
|
||||||
"panel_dark": "#090B0D",
|
|
||||||
"widget": "#15181C",
|
|
||||||
"elevated": "#1F2328",
|
|
||||||
"text": "#DCE1E5",
|
|
||||||
"text_dim": "#9BA3AB",
|
|
||||||
"text_muted": "#606972",
|
|
||||||
"border": "#262B31",
|
|
||||||
"border_strong": "#40474F",
|
|
||||||
"success": "#54BE7F",
|
|
||||||
"warning": "#EBB448",
|
|
||||||
"error": "#E25E5E",
|
|
||||||
"asset_types": {
|
|
||||||
"primitive": "#8A9BAD",
|
|
||||||
"light": "#F4D35E",
|
|
||||||
"model": "#5B8FF9",
|
|
||||||
"texture": "#54BE7F",
|
|
||||||
"material": "#EBB448",
|
|
||||||
"audio": "#D07DE8",
|
|
||||||
"level": "#41C6CF",
|
|
||||||
"prefab": "#4FB6A6",
|
|
||||||
"post_process": "#A78BFA",
|
|
||||||
"rendering_profile": "#9AA7B8",
|
|
||||||
"shader": "#F07C63"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,835 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use editor_ui::inspector::{ComponentCardOptions, ComponentCardViewModel};
|
|
||||||
use editor_ui::materials::{
|
|
||||||
material_input_schema_editor, material_slot_panel, material_surface_options_ui,
|
|
||||||
materials_section, MaterialHealth, MaterialSlotAction, MaterialThumbnailPresentation,
|
|
||||||
MaterialsSectionViewModel,
|
|
||||||
};
|
|
||||||
use editor_ui::renderer::{
|
|
||||||
renderer_array_header, renderer_material_slot_row, renderer_material_slots_header,
|
|
||||||
renderer_panel, renderer_panel_header, renderer_properties_header,
|
|
||||||
RendererPanelHeaderViewModel,
|
|
||||||
};
|
|
||||||
use egui_phosphor_icons::icons;
|
|
||||||
use material_schema::{standard_lit_input_schema, MaterialShaderKind};
|
|
||||||
|
|
||||||
use crate::fixtures::{FixtureId, FixtureModel};
|
|
||||||
use crate::reference::{
|
|
||||||
difference_image, ComparisonMode, DifferenceStats, GalleryPreset, ReferenceImage,
|
|
||||||
};
|
|
||||||
use crate::reload::RuntimeDocuments;
|
|
||||||
|
|
||||||
pub struct UiGalleryApp {
|
|
||||||
preset: GalleryPreset,
|
|
||||||
comparison: ComparisonMode,
|
|
||||||
overlay_opacity: f32,
|
|
||||||
fixture_id: FixtureId,
|
|
||||||
fixture: FixtureModel,
|
|
||||||
fixture_textures: Vec<egui::TextureHandle>,
|
|
||||||
documents: RuntimeDocuments,
|
|
||||||
references: Vec<(GalleryPreset, ReferenceImage)>,
|
|
||||||
reference_error: Option<String>,
|
|
||||||
last_live_rect: Option<egui::Rect>,
|
|
||||||
last_reference_rect: Option<egui::Rect>,
|
|
||||||
difference_texture: Option<egui::TextureHandle>,
|
|
||||||
difference_stats: Option<DifferenceStats>,
|
|
||||||
difference_dirty: bool,
|
|
||||||
capture: Option<CaptureRequest>,
|
|
||||||
renderer_open: [bool; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct CaptureRequest {
|
|
||||||
preset: GalleryPreset,
|
|
||||||
live_rect: egui::Rect,
|
|
||||||
reference_rect: egui::Rect,
|
|
||||||
pixels_per_point: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UiGalleryApp {
|
|
||||||
pub fn new(creation: &eframe::CreationContext<'_>) -> Self {
|
|
||||||
creation
|
|
||||||
.egui_ctx
|
|
||||||
.set_fonts(editor_ui::fonts::font_definitions());
|
|
||||||
editor_ui::theme::apply_editor_theme(&creation.egui_ctx);
|
|
||||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("../..")
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."));
|
|
||||||
let mut documents = RuntimeDocuments::new(&root);
|
|
||||||
documents.poll(0.0);
|
|
||||||
if let Ok(palette) = documents.theme_value.palette() {
|
|
||||||
editor_ui::theme::apply_editor_theme_with_palette(&creation.egui_ctx, palette);
|
|
||||||
}
|
|
||||||
let fixture_textures = make_fixture_textures(&creation.egui_ctx);
|
|
||||||
let (material_thumbnail, texture_thumbnails) = fixture_thumbnail_ids(&fixture_textures);
|
|
||||||
let fixture_id = FixtureId::Reference;
|
|
||||||
let fixture = FixtureModel::new(
|
|
||||||
fixture_id,
|
|
||||||
&documents.fixture_value,
|
|
||||||
material_thumbnail,
|
|
||||||
texture_thumbnails,
|
|
||||||
);
|
|
||||||
let mut references = Vec::new();
|
|
||||||
let mut reference_error = None;
|
|
||||||
for preset in GalleryPreset::ALL {
|
|
||||||
match ReferenceImage::load(&creation.egui_ctx, &root, preset) {
|
|
||||||
Ok(reference) => references.push((preset, reference)),
|
|
||||||
Err(error) => reference_error = Some(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
preset: GalleryPreset::ContentBrowser1240,
|
|
||||||
comparison: ComparisonMode::SideBySide,
|
|
||||||
overlay_opacity: 0.5,
|
|
||||||
fixture_id,
|
|
||||||
fixture,
|
|
||||||
fixture_textures,
|
|
||||||
documents,
|
|
||||||
references,
|
|
||||||
reference_error,
|
|
||||||
last_live_rect: None,
|
|
||||||
last_reference_rect: None,
|
|
||||||
difference_texture: None,
|
|
||||||
difference_stats: None,
|
|
||||||
difference_dirty: true,
|
|
||||||
capture: None,
|
|
||||||
renderer_open: [true, false, false],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_fixture(&mut self, ctx: &egui::Context) {
|
|
||||||
let (material_thumbnail, texture_thumbnails) =
|
|
||||||
fixture_thumbnail_ids(&self.fixture_textures);
|
|
||||||
self.fixture = FixtureModel::new(
|
|
||||||
self.fixture_id,
|
|
||||||
&self.documents.fixture_value,
|
|
||||||
material_thumbnail,
|
|
||||||
texture_thumbnails,
|
|
||||||
);
|
|
||||||
ctx.request_repaint();
|
|
||||||
self.difference_dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_preset(&mut self, ctx: &egui::Context, preset: GalleryPreset) {
|
|
||||||
if self.preset == preset {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.preset = preset;
|
|
||||||
if preset.is_renderer() && !self.fixture_id.is_renderer() {
|
|
||||||
self.fixture_id = FixtureId::StaticMesh;
|
|
||||||
self.rebuild_fixture(ctx);
|
|
||||||
}
|
|
||||||
self.difference_dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_fixture(&mut self, ctx: &egui::Context, fixture: FixtureId) {
|
|
||||||
if self.fixture_id == fixture {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.fixture_id = fixture;
|
|
||||||
self.rebuild_fixture(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_comparison(&mut self, comparison: ComparisonMode) {
|
|
||||||
if self.comparison == comparison {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.comparison = comparison;
|
|
||||||
self.difference_dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
|
|
||||||
let (function_key, shift, previous_fixture, next_fixture) = ctx.input(|input| {
|
|
||||||
let function_key = [
|
|
||||||
egui::Key::F1,
|
|
||||||
egui::Key::F2,
|
|
||||||
egui::Key::F3,
|
|
||||||
egui::Key::F4,
|
|
||||||
egui::Key::F5,
|
|
||||||
egui::Key::F6,
|
|
||||||
egui::Key::F7,
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.position(|key| input.key_pressed(key));
|
|
||||||
(
|
|
||||||
function_key,
|
|
||||||
input.modifiers.shift,
|
|
||||||
input.key_pressed(egui::Key::PageUp),
|
|
||||||
input.key_pressed(egui::Key::PageDown),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
if let Some(index) = function_key {
|
|
||||||
if shift {
|
|
||||||
if let Some(comparison) = ComparisonMode::ALL.get(index).copied() {
|
|
||||||
self.select_comparison(comparison);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.select_preset(ctx, GalleryPreset::ALL[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if previous_fixture || next_fixture {
|
|
||||||
let current = FixtureId::ALL
|
|
||||||
.iter()
|
|
||||||
.position(|fixture| *fixture == self.fixture_id)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let next = if previous_fixture {
|
|
||||||
current.checked_sub(1).unwrap_or(FixtureId::ALL.len() - 1)
|
|
||||||
} else {
|
|
||||||
(current + 1) % FixtureId::ALL.len()
|
|
||||||
};
|
|
||||||
self.select_fixture(ctx, FixtureId::ALL[next]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reference(&self) -> Option<&ReferenceImage> {
|
|
||||||
self.references
|
|
||||||
.iter()
|
|
||||||
.find_map(|(preset, image)| (*preset == self.preset).then_some(image))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_screenshot_events(&mut self, ctx: &egui::Context) {
|
|
||||||
let screenshots = ctx.input(|input| {
|
|
||||||
input
|
|
||||||
.raw
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| match event {
|
|
||||||
egui::Event::Screenshot { image, .. } => Some(image.clone()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
});
|
|
||||||
let Some(request) = self.capture.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(screenshot) = screenshots.last() else {
|
|
||||||
self.capture = Some(request);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (difference, stats) = difference_image(
|
|
||||||
screenshot,
|
|
||||||
request.live_rect,
|
|
||||||
request.reference_rect,
|
|
||||||
request.pixels_per_point,
|
|
||||||
request.preset,
|
|
||||||
);
|
|
||||||
if let Some(texture) = &mut self.difference_texture {
|
|
||||||
texture.set(difference, egui::TextureOptions::NEAREST);
|
|
||||||
} else {
|
|
||||||
self.difference_texture = Some(ctx.load_texture(
|
|
||||||
"blacksite-ui-gallery-difference",
|
|
||||||
difference,
|
|
||||||
egui::TextureOptions::NEAREST,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.difference_stats = Some(stats);
|
|
||||||
self.difference_dirty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
ui.strong("Blacksite UI Gallery");
|
|
||||||
ui.separator();
|
|
||||||
egui::ComboBox::from_id_salt("gallery_preset")
|
|
||||||
.selected_text(self.preset.label())
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for preset in GalleryPreset::ALL {
|
|
||||||
let selected = self.preset == preset;
|
|
||||||
if ui.selectable_label(selected, preset.label()).clicked() {
|
|
||||||
self.select_preset(ctx, preset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
egui::ComboBox::from_id_salt("gallery_fixture")
|
|
||||||
.selected_text(self.fixture_id.label())
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for fixture in FixtureId::ALL {
|
|
||||||
let selected = self.fixture_id == fixture;
|
|
||||||
if ui.selectable_label(selected, fixture.label()).clicked() {
|
|
||||||
self.select_fixture(ctx, fixture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for mode in ComparisonMode::ALL {
|
|
||||||
let selected = self.comparison == mode;
|
|
||||||
if ui.selectable_label(selected, mode.label()).clicked() {
|
|
||||||
self.select_comparison(mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.comparison == ComparisonMode::Overlay {
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(&mut self.overlay_opacity, 0.0..=1.0)
|
|
||||||
.text("Reference opacity")
|
|
||||||
.show_value(false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if self.comparison == ComparisonMode::Difference
|
|
||||||
&& ui.button("Refresh difference").clicked()
|
|
||||||
{
|
|
||||||
self.difference_dirty = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
ui.small(format!(
|
|
||||||
"Canvas: {} px · reference: {}",
|
|
||||||
self.preset.width() as u32,
|
|
||||||
self.preset.file_name()
|
|
||||||
));
|
|
||||||
ui.separator();
|
|
||||||
ui.small("Theme and fixture JSON reload without rebuilding");
|
|
||||||
ui.separator();
|
|
||||||
ui.small("F1–F7 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures");
|
|
||||||
if let Some(stats) = self.difference_stats {
|
|
||||||
ui.separator();
|
|
||||||
ui.small(format!(
|
|
||||||
"Δ mean {:.2} · max {} · {:.1}% pixels > 8",
|
|
||||||
stats.mean_channel_delta, stats.max_channel_delta, stats.changed_percent
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(error) = self
|
|
||||||
.documents
|
|
||||||
.error
|
|
||||||
.as_deref()
|
|
||||||
.or(self.reference_error.as_deref())
|
|
||||||
{
|
|
||||||
ui.colored_label(editor_ui::theme::ERROR, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_comparison(&mut self, ui: &mut egui::Ui) {
|
|
||||||
let preset = self.preset;
|
|
||||||
match self.comparison {
|
|
||||||
ComparisonMode::Live => {
|
|
||||||
self.last_live_rect = Some(self.render_live_canvas(ui));
|
|
||||||
}
|
|
||||||
ComparisonMode::SideBySide => {
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.strong("Penpot reference");
|
|
||||||
if let Some(reference) = self.reference() {
|
|
||||||
reference.paint(ui, preset, 1.0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.add_space(16.0);
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.strong("Production component");
|
|
||||||
self.last_live_rect = Some(self.render_live_canvas(ui));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ComparisonMode::Overlay => {
|
|
||||||
self.last_live_rect = Some(self.render_live_canvas(ui));
|
|
||||||
if let (Some(rect), Some(reference)) = (self.last_live_rect, self.reference()) {
|
|
||||||
let mut overlay_ui = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(rect)
|
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
reference.paint(&mut overlay_ui, preset, self.overlay_opacity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ComparisonMode::Difference => {
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
self.last_live_rect = Some(self.render_live_canvas(ui));
|
|
||||||
ui.add_space(16.0);
|
|
||||||
if self.difference_dirty || self.capture.is_some() {
|
|
||||||
self.last_reference_rect = self
|
|
||||||
.reference()
|
|
||||||
.map(|reference| reference.paint(ui, preset, 1.0).rect);
|
|
||||||
} else if let Some(texture) = &self.difference_texture {
|
|
||||||
self.last_reference_rect = None;
|
|
||||||
ui.image((
|
|
||||||
texture.id(),
|
|
||||||
egui::vec2(preset.width(), preset.target_height()),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ui.allocate_ui(egui::vec2(preset.width(), preset.target_height()), |ui| {
|
|
||||||
ui.centered_and_justified(|ui| {
|
|
||||||
ui.label("Difference capture pending…");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_live_canvas(&mut self, ui: &mut egui::Ui) -> egui::Rect {
|
|
||||||
let size = egui::vec2(self.preset.width(), self.preset.target_height());
|
|
||||||
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
|
||||||
let palette = editor_ui::design_system::palette(ui);
|
|
||||||
let (canvas_fill, canvas_radius) =
|
|
||||||
if self.preset.is_content_browser() || self.preset.is_renderer() {
|
|
||||||
(palette.recessed, 4.0)
|
|
||||||
} else {
|
|
||||||
(palette.panel, 7.0)
|
|
||||||
};
|
|
||||||
ui.painter().rect(
|
|
||||||
rect,
|
|
||||||
canvas_radius,
|
|
||||||
canvas_fill,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border_strong),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
// Penpot material boards are complete Inspector canvases, while renderer boards include a
|
|
||||||
// authored top inset before the component card. The preset owns both cases.
|
|
||||||
let content_rect = self.preset.content_rect(rect);
|
|
||||||
let mut child = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(content_rect)
|
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
let previous_inputs = self.fixture.inputs.clone();
|
|
||||||
let previous_renderer_inputs = self
|
|
||||||
.fixture
|
|
||||||
.renderer_slots
|
|
||||||
.iter()
|
|
||||||
.map(|fixture| fixture.inputs.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if self.preset.is_content_browser() {
|
|
||||||
crate::content_browser::render_content_browser_fixture(
|
|
||||||
&mut child,
|
|
||||||
self.preset.width(),
|
|
||||||
self.preset.target_height(),
|
|
||||||
);
|
|
||||||
} else if self.preset.is_renderer() {
|
|
||||||
self.render_renderer_fixture(&mut child);
|
|
||||||
} else {
|
|
||||||
self.render_material_fixture(&mut child);
|
|
||||||
}
|
|
||||||
if previous_inputs != self.fixture.inputs
|
|
||||||
|| previous_renderer_inputs
|
|
||||||
!= self
|
|
||||||
.fixture
|
|
||||||
.renderer_slots
|
|
||||||
.iter()
|
|
||||||
.map(|fixture| fixture.inputs.clone())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
{
|
|
||||||
self.difference_dirty = true;
|
|
||||||
}
|
|
||||||
rect
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_renderer_fixture(&mut self, ui: &mut egui::Ui) {
|
|
||||||
let title = if self.fixture_id == FixtureId::SkinnedMesh {
|
|
||||||
"Skinned Mesh Renderer"
|
|
||||||
} else {
|
|
||||||
"Static Mesh Renderer"
|
|
||||||
};
|
|
||||||
let component = ComponentCardViewModel {
|
|
||||||
options: ComponentCardOptions {
|
|
||||||
type_name: "gallery::Renderer",
|
|
||||||
title,
|
|
||||||
icon: icons::CUBE,
|
|
||||||
active_toggle: true,
|
|
||||||
removable: true,
|
|
||||||
reorderable: true,
|
|
||||||
resettable: true,
|
|
||||||
copyable: true,
|
|
||||||
summary: "3 renderers · 5 material slots",
|
|
||||||
body_margin: 0,
|
|
||||||
},
|
|
||||||
active: true,
|
|
||||||
collapsed: false,
|
|
||||||
pasteable: false,
|
|
||||||
can_move_up: false,
|
|
||||||
can_move_down: true,
|
|
||||||
};
|
|
||||||
editor_ui::inspector::component_card(ui, &component, |ui| {
|
|
||||||
let _ = renderer_array_header(ui, self.renderer_open.len());
|
|
||||||
ui.add_space(26.0);
|
|
||||||
for renderer_index in 0..self.renderer_open.len() {
|
|
||||||
let renderer_label = match renderer_index {
|
|
||||||
0 => "metal_office_desk / Primitive 0",
|
|
||||||
1 => "metal_office_desk_drawer_06 / Primitive 0",
|
|
||||||
_ => "metal_office_desk_tray_01 / Primitive 0",
|
|
||||||
};
|
|
||||||
renderer_panel(ui, |ui| {
|
|
||||||
renderer_panel_header(
|
|
||||||
ui,
|
|
||||||
RendererPanelHeaderViewModel {
|
|
||||||
index: renderer_index,
|
|
||||||
label: renderer_label,
|
|
||||||
path: "draw:scene0:node2:mesh2:primitive0",
|
|
||||||
slot_count: if renderer_index == 0 { 3 } else { 1 },
|
|
||||||
texture_id: None,
|
|
||||||
},
|
|
||||||
&mut self.renderer_open[renderer_index],
|
|
||||||
|ui| {
|
|
||||||
editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::DOTS_THREE_VERTICAL,
|
|
||||||
"Renderer actions",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::X,
|
|
||||||
"Remove renderer",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::CROSSHAIR,
|
|
||||||
"Locate mesh",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
editor_ui::design_system::controls::icon_button(
|
|
||||||
ui,
|
|
||||||
icons::FOLDER_OPEN,
|
|
||||||
"Browse mesh",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !self.renderer_open[renderer_index] {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderer_properties_fixture(ui, renderer_label);
|
|
||||||
let slot_count = if renderer_index == 0 { 3 } else { 1 };
|
|
||||||
renderer_material_slots_header(ui, slot_count);
|
|
||||||
if renderer_index == 0 {
|
|
||||||
for slot_index in 0..self.fixture.renderer_slots.len() {
|
|
||||||
renderer_material_slot_row(ui, slot_index, |ui| {
|
|
||||||
self.render_renderer_material_slot(ui, slot_index);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_material_fixture(&mut self, ui: &mut egui::Ui) {
|
|
||||||
let section = MaterialsSectionViewModel {
|
|
||||||
slot_ids: vec![self.fixture.slot.slot_id.clone()],
|
|
||||||
};
|
|
||||||
materials_section(ui, §ion, |ui| {
|
|
||||||
let slot = &mut self.fixture.slot;
|
|
||||||
let render_state = &mut self.fixture.render_state;
|
|
||||||
let inputs = &mut self.fixture.inputs;
|
|
||||||
let textures = &self.fixture.textures;
|
|
||||||
let response = material_slot_panel(
|
|
||||||
ui,
|
|
||||||
("gallery-slot", &slot.slot_id),
|
|
||||||
slot,
|
|
||||||
|ui| {
|
|
||||||
material_surface_options_ui(ui, render_state, Some(inputs));
|
|
||||||
ui.add_space(4.0);
|
|
||||||
material_input_schema_editor(
|
|
||||||
ui,
|
|
||||||
&standard_lit_input_schema(),
|
|
||||||
inputs,
|
|
||||||
textures,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|ui| {
|
|
||||||
ui.label(format!("Stable slot: {}", slot.slot_id));
|
|
||||||
ui.label("Gallery fixture — no project state is loaded");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for action in response.actions {
|
|
||||||
apply_slot_action(slot, action.action);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_renderer_material_slot(&mut self, ui: &mut egui::Ui, slot_index: usize) {
|
|
||||||
let preset_label = self.preset.label();
|
|
||||||
let fixture = &mut self.fixture.renderer_slots[slot_index];
|
|
||||||
let slot_id = fixture.slot.slot_id.clone();
|
|
||||||
fixture.slot.expanded_header_when_parameters_closed = true;
|
|
||||||
fixture.slot.parameters_initially_open =
|
|
||||||
slot_index == 0 && self.preset == GalleryPreset::Renderer760;
|
|
||||||
let textures = &self.fixture.textures;
|
|
||||||
ui.push_id(
|
|
||||||
(
|
|
||||||
"gallery-renderer-slot-scope",
|
|
||||||
preset_label,
|
|
||||||
slot_id.as_str(),
|
|
||||||
),
|
|
||||||
|ui| {
|
|
||||||
let response = material_slot_panel(
|
|
||||||
ui,
|
|
||||||
"material-panel",
|
|
||||||
&fixture.slot,
|
|
||||||
|ui| {
|
|
||||||
material_surface_options_ui(
|
|
||||||
ui,
|
|
||||||
&mut fixture.render_state,
|
|
||||||
Some(&mut fixture.inputs),
|
|
||||||
);
|
|
||||||
ui.add_space(4.0);
|
|
||||||
material_input_schema_editor(
|
|
||||||
ui,
|
|
||||||
&standard_lit_input_schema(),
|
|
||||||
&mut fixture.inputs,
|
|
||||||
textures,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|ui| {
|
|
||||||
ui.label(format!("Stable slot: {}", fixture.slot.slot_id));
|
|
||||||
ui.label("Gallery renderer fixture — no project state is loaded");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
for action in response.actions {
|
|
||||||
apply_slot_action(&mut fixture.slot, action.action);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for UiGalleryApp {
|
|
||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.handle_screenshot_events(ctx);
|
|
||||||
self.handle_shortcuts(ctx);
|
|
||||||
let now = ctx.input(|input| input.time);
|
|
||||||
if self.documents.poll(now) {
|
|
||||||
if let Ok(palette) = self.documents.theme_value.palette() {
|
|
||||||
editor_ui::theme::apply_editor_theme_with_palette(ctx, palette);
|
|
||||||
}
|
|
||||||
self.rebuild_fixture(ctx);
|
|
||||||
}
|
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
||||||
let ctx = root.ctx().clone();
|
|
||||||
egui::CentralPanel::default().show_inside(root, |ui| {
|
|
||||||
self.toolbar(&ctx, ui);
|
|
||||||
ui.separator();
|
|
||||||
egui::ScrollArea::both()
|
|
||||||
.id_salt("gallery_canvas_scroll")
|
|
||||||
.auto_shrink([false, false])
|
|
||||||
.show(ui, |ui| self.render_comparison(ui));
|
|
||||||
if self.comparison == ComparisonMode::Difference
|
|
||||||
&& self.difference_dirty
|
|
||||||
&& self.capture.is_none()
|
|
||||||
{
|
|
||||||
if let (Some(live_rect), Some(reference_rect)) =
|
|
||||||
(self.last_live_rect, self.last_reference_rect)
|
|
||||||
{
|
|
||||||
self.capture = Some(CaptureRequest {
|
|
||||||
preset: self.preset,
|
|
||||||
live_rect,
|
|
||||||
reference_rect,
|
|
||||||
pixels_per_point: ctx.pixels_per_point(),
|
|
||||||
});
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(
|
|
||||||
"blacksite-ui-gallery-difference",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderer_properties_fixture(ui: &mut egui::Ui, name: &str) {
|
|
||||||
let palette = editor_ui::design_system::palette(ui);
|
|
||||||
let mut name_buffer = name.to_owned();
|
|
||||||
egui::Frame::new()
|
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
renderer_properties_header(ui);
|
|
||||||
ui.add_space(2.0);
|
|
||||||
property_fixture_row(ui, "Name", |ui| {
|
|
||||||
ui.add_sized(
|
|
||||||
[ui.available_width().max(1.0), 24.0],
|
|
||||||
egui::TextEdit::singleline(&mut name_buffer),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
|
||||||
property_fixture_row(ui, "Mesh", |ui| {
|
|
||||||
let field = ui.available_rect_before_wrap();
|
|
||||||
let field =
|
|
||||||
egui::Rect::from_min_size(field.min, egui::vec2(field.width().max(1.0), 38.0));
|
|
||||||
ui.allocate_rect(field, egui::Sense::hover());
|
|
||||||
ui.painter().rect(
|
|
||||||
field,
|
|
||||||
4.0,
|
|
||||||
palette.control,
|
|
||||||
egui::Stroke::new(1.0_f32, palette.border),
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
field.left_center() + egui::vec2(10.0, -7.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
name,
|
|
||||||
editor_ui::design_system::typography::TypeRole::Body.font(),
|
|
||||||
palette.text_primary,
|
|
||||||
);
|
|
||||||
ui.painter().text(
|
|
||||||
field.left_center() + egui::vec2(10.0, 8.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
"assets/Furniture/Office/metal_office_desk.mesh",
|
|
||||||
editor_ui::design_system::typography::TypeRole::Small.font(),
|
|
||||||
palette.text_muted,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
|
||||||
property_fixture_row(ui, "Flags", |ui| {
|
|
||||||
let mut visible = true;
|
|
||||||
let mut cast_shadows = true;
|
|
||||||
let mut receive_shadows = true;
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
ui.checkbox(&mut visible, "Visible");
|
|
||||||
ui.checkbox(&mut cast_shadows, "Cast shadows");
|
|
||||||
ui.checkbox(&mut receive_shadows, "Receive shadows");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn property_fixture_row(ui: &mut egui::Ui, label: &str, add_value: impl FnOnce(&mut egui::Ui)) {
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
ui.allocate_ui_with_layout(
|
|
||||||
egui::vec2(72.0, 28.0),
|
|
||||||
egui::Layout::left_to_right(egui::Align::Center),
|
|
||||||
|ui| {
|
|
||||||
ui.label(label);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ui.vertical(add_value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_slot_action(
|
|
||||||
slot: &mut editor_ui::materials::MaterialSlotPanelViewModel,
|
|
||||||
action: MaterialSlotAction,
|
|
||||||
) {
|
|
||||||
match action {
|
|
||||||
MaterialSlotAction::Assign { reference, .. } => {
|
|
||||||
slot.label = reference.label.clone();
|
|
||||||
slot.path = reference.source_path.clone();
|
|
||||||
slot.assigned = Some(reference);
|
|
||||||
slot.health = MaterialHealth::Dirty;
|
|
||||||
}
|
|
||||||
MaterialSlotAction::Clear => {
|
|
||||||
slot.assigned = None;
|
|
||||||
slot.health = MaterialHealth::Dirty;
|
|
||||||
}
|
|
||||||
MaterialSlotAction::SetShader(kind) => {
|
|
||||||
slot.shader_kind = Some(kind);
|
|
||||||
slot.shader = match kind {
|
|
||||||
MaterialShaderKind::StandardLit => "Standard Lit",
|
|
||||||
MaterialShaderKind::Unlit => "Unlit",
|
|
||||||
MaterialShaderKind::Custom => "Custom Surface",
|
|
||||||
}
|
|
||||||
.to_owned();
|
|
||||||
slot.health = MaterialHealth::Dirty;
|
|
||||||
}
|
|
||||||
MaterialSlotAction::BrowseLibrary
|
|
||||||
| MaterialSlotAction::Locate
|
|
||||||
| MaterialSlotAction::ExtractEditable
|
|
||||||
| MaterialSlotAction::CreateInstance => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_fixture_textures(ctx: &egui::Context) -> Vec<egui::TextureHandle> {
|
|
||||||
let mut handles = vec![make_material_thumbnail(ctx)];
|
|
||||||
handles.extend([
|
|
||||||
checker_thumbnail(ctx, "gallery-base-color", [126, 100, 74], [87, 69, 51]),
|
|
||||||
checker_thumbnail(ctx, "gallery-orm", [198, 238, 18], [103, 164, 8]),
|
|
||||||
checker_thumbnail(ctx, "gallery-normal", [116, 92, 232], [73, 145, 242]),
|
|
||||||
]);
|
|
||||||
handles
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_thumbnail_ids(
|
|
||||||
handles: &[egui::TextureHandle],
|
|
||||||
) -> (
|
|
||||||
MaterialThumbnailPresentation,
|
|
||||||
[MaterialThumbnailPresentation; 3],
|
|
||||||
) {
|
|
||||||
(
|
|
||||||
MaterialThumbnailPresentation::Ready(handles[0].id()),
|
|
||||||
[
|
|
||||||
MaterialThumbnailPresentation::Ready(handles[1].id()),
|
|
||||||
MaterialThumbnailPresentation::Ready(handles[2].id()),
|
|
||||||
MaterialThumbnailPresentation::Ready(handles[3].id()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_material_thumbnail(ctx: &egui::Context) -> egui::TextureHandle {
|
|
||||||
let size = 64;
|
|
||||||
let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]);
|
|
||||||
for y in 0..size {
|
|
||||||
for x in 0..size {
|
|
||||||
image[(x, y)] = if (x / 16 + y / 16) % 2 == 0 {
|
|
||||||
egui::Color32::from_rgb(32, 37, 43)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(43, 50, 58)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paint_fixture_ellipse(&mut image, [37.0, 42.0], [19.0, 19.0], [17, 22, 27], 0.8);
|
|
||||||
paint_fixture_ellipse(&mut image, [34.0, 38.0], [20.0, 20.0], [109, 123, 138], 1.0);
|
|
||||||
paint_fixture_ellipse(&mut image, [27.0, 28.5], [6.0, 4.5], [199, 211, 221], 0.85);
|
|
||||||
paint_fixture_ellipse(&mut image, [38.0, 42.0], [9.0, 9.0], [63, 74, 86], 1.0);
|
|
||||||
ctx.load_texture(
|
|
||||||
"gallery-material-sphere",
|
|
||||||
image,
|
|
||||||
egui::TextureOptions::NEAREST,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_fixture_ellipse(
|
|
||||||
image: &mut egui::ColorImage,
|
|
||||||
center: [f32; 2],
|
|
||||||
radius: [f32; 2],
|
|
||||||
color: [u8; 3],
|
|
||||||
opacity: f32,
|
|
||||||
) {
|
|
||||||
for y in 0..image.size[1] {
|
|
||||||
for x in 0..image.size[0] {
|
|
||||||
let dx = (x as f32 + 0.5 - center[0]) / radius[0];
|
|
||||||
let dy = (y as f32 + 0.5 - center[1]) / radius[1];
|
|
||||||
let distance = (dx * dx + dy * dy).sqrt();
|
|
||||||
let coverage = ((1.0 - distance) * radius[0].min(radius[1])).clamp(0.0, 1.0);
|
|
||||||
let alpha = coverage * opacity;
|
|
||||||
if alpha <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let previous = image[(x, y)];
|
|
||||||
let blend = |source: u8, destination: u8| {
|
|
||||||
(source as f32 * alpha + destination as f32 * (1.0 - alpha)).round() as u8
|
|
||||||
};
|
|
||||||
image[(x, y)] = egui::Color32::from_rgb(
|
|
||||||
blend(color[0], previous.r()),
|
|
||||||
blend(color[1], previous.g()),
|
|
||||||
blend(color[2], previous.b()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checker_thumbnail(
|
|
||||||
ctx: &egui::Context,
|
|
||||||
name: &str,
|
|
||||||
first: [u8; 3],
|
|
||||||
second: [u8; 3],
|
|
||||||
) -> egui::TextureHandle {
|
|
||||||
let size = 18;
|
|
||||||
let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]);
|
|
||||||
for y in 0..size {
|
|
||||||
for x in 0..size {
|
|
||||||
let color = if (x / 3 + y / 3) % 2 == 0 {
|
|
||||||
first
|
|
||||||
} else {
|
|
||||||
second
|
|
||||||
};
|
|
||||||
image[(x, y)] = egui::Color32::from_rgb(color[0], color[1], color[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.load_texture(name, image, egui::TextureOptions::NEAREST)
|
|
||||||
}
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
use editor_ui::content_browser::{
|
|
||||||
content_browser_asset_card, content_browser_content_header, content_browser_detail_path_row,
|
|
||||||
content_browser_detail_row, content_browser_details_header,
|
|
||||||
content_browser_details_pane_heading, content_browser_details_section,
|
|
||||||
content_browser_grid_inset, content_browser_grid_surface_background,
|
|
||||||
content_browser_pane_background, content_browser_pane_heading, content_browser_source_row,
|
|
||||||
content_browser_status_bar, content_browser_toolbar, ContentBrowserAssetTone,
|
|
||||||
ContentBrowserBadgeTone, ContentBrowserCardStatus, ContentBrowserCardViewModel,
|
|
||||||
ContentBrowserChrome, ContentBrowserContentHeaderViewModel,
|
|
||||||
ContentBrowserDetailsHeaderViewModel, ContentBrowserKindFilter, ContentBrowserSort,
|
|
||||||
ContentBrowserSourceRowViewModel, ContentBrowserStatusViewModel, ContentBrowserThumbnail,
|
|
||||||
ContentBrowserToolbarViewModel, ContentBrowserViewMode, ContentPathSegment, ASSET_CARD_GAP,
|
|
||||||
DETAILS_WIDTH, SOURCES_WIDTH, STATUS_BAR_HEIGHT,
|
|
||||||
};
|
|
||||||
use egui_phosphor_icons::{icons, Icon};
|
|
||||||
|
|
||||||
pub fn render_content_browser_fixture(ui: &mut egui::Ui, width: f32, height: f32) {
|
|
||||||
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
let compact = width < editor_ui::content_browser::COMPACT_BREAKPOINT;
|
|
||||||
let model = toolbar_model();
|
|
||||||
let _ = content_browser_toolbar(ui, &model);
|
|
||||||
let toolbar_height = model.chrome.height(width);
|
|
||||||
let body_height = (height - toolbar_height - STATUS_BAR_HEIGHT).max(1.0);
|
|
||||||
let (body, _) = ui.allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover());
|
|
||||||
let mut body_ui = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(body)
|
|
||||||
.layout(egui::Layout::left_to_right(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
body_ui.set_clip_rect(body.intersect(ui.clip_rect()));
|
|
||||||
body_ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
if compact {
|
|
||||||
draw_grid(&mut body_ui, width, body_height);
|
|
||||||
} else {
|
|
||||||
draw_sources(&mut body_ui, body_height);
|
|
||||||
draw_grid(
|
|
||||||
&mut body_ui,
|
|
||||||
(width - SOURCES_WIDTH - DETAILS_WIDTH).max(1.0),
|
|
||||||
body_height,
|
|
||||||
);
|
|
||||||
draw_details(&mut body_ui, body_height);
|
|
||||||
}
|
|
||||||
content_browser_status_bar(
|
|
||||||
ui,
|
|
||||||
ContentBrowserStatusViewModel {
|
|
||||||
catalog: "Catalog ready",
|
|
||||||
processing: "2 thumbnails processing",
|
|
||||||
selection: "metal_office_desk selected",
|
|
||||||
healthy: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_model() -> ContentBrowserToolbarViewModel {
|
|
||||||
ContentBrowserToolbarViewModel {
|
|
||||||
chrome: ContentBrowserChrome::Standalone,
|
|
||||||
path: ["Project", "assets", "Props", "Office"]
|
|
||||||
.into_iter()
|
|
||||||
.scan(String::new(), |path, label| {
|
|
||||||
if !path.is_empty() {
|
|
||||||
path.push('/');
|
|
||||||
}
|
|
||||||
path.push_str(label);
|
|
||||||
Some(ContentPathSegment {
|
|
||||||
path: path.clone(),
|
|
||||||
label: label.to_owned(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
search: String::new(),
|
|
||||||
kind_filter: ContentBrowserKindFilter::All,
|
|
||||||
sort: ContentBrowserSort::Name,
|
|
||||||
view: ContentBrowserViewMode::Grid,
|
|
||||||
recursive: false,
|
|
||||||
show_sources: false,
|
|
||||||
show_details: false,
|
|
||||||
thumbnail_size: 96.0,
|
|
||||||
can_go_parent: true,
|
|
||||||
can_go_back: true,
|
|
||||||
can_go_forward: true,
|
|
||||||
can_edit_folder: true,
|
|
||||||
can_rename: true,
|
|
||||||
can_edit_selection: true,
|
|
||||||
can_paste: true,
|
|
||||||
can_undo: true,
|
|
||||||
pending_repairs: 0,
|
|
||||||
direct_folder_count: 3,
|
|
||||||
direct_asset_count: 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_sources(ui: &mut egui::Ui, height: f32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(SOURCES_WIDTH, height), egui::Sense::hover());
|
|
||||||
let mut sources = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(rect)
|
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
sources.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
sources.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
content_browser_pane_background(&sources);
|
|
||||||
content_browser_pane_heading(&mut sources, "SOURCES", false);
|
|
||||||
for (index, (label, count, depth, children, expanded)) in [
|
|
||||||
("Built-ins", 14, 0, false, false),
|
|
||||||
("assets", 42, 0, true, true),
|
|
||||||
("Furniture", 8, 1, true, true),
|
|
||||||
("Office", 12, 1, false, false),
|
|
||||||
("Materials", 9, 1, false, false),
|
|
||||||
("Textures", 16, 1, false, false),
|
|
||||||
("Levels", 3, 1, false, false),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let id = format!("gallery-source-{index}");
|
|
||||||
let _ = content_browser_source_row(
|
|
||||||
&mut sources,
|
|
||||||
ContentBrowserSourceRowViewModel {
|
|
||||||
id: &id,
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
depth,
|
|
||||||
has_children: children,
|
|
||||||
expanded,
|
|
||||||
selected: label == "Office",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_grid(ui: &mut egui::Ui, width: f32, height: f32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
|
||||||
let mut grid = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(rect)
|
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
grid.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
grid.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
content_browser_pane_background(&grid);
|
|
||||||
content_browser_content_header(
|
|
||||||
&mut grid,
|
|
||||||
ContentBrowserContentHeaderViewModel {
|
|
||||||
folder: "Office",
|
|
||||||
item_count: 12,
|
|
||||||
sort: "Name",
|
|
||||||
selection_count: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
content_browser_grid_surface_background(&grid);
|
|
||||||
content_browser_grid_inset(&mut grid, |ui| {
|
|
||||||
ui.spacing_mut().item_spacing = egui::vec2(ASSET_CARD_GAP, ASSET_CARD_GAP);
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
for card in fixture_cards() {
|
|
||||||
let _ = content_browser_asset_card(ui, card.view_model(), 96.0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum CardState {
|
|
||||||
Normal,
|
|
||||||
Dirty,
|
|
||||||
Failed,
|
|
||||||
Processing,
|
|
||||||
Selected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct FixtureCard {
|
|
||||||
label: &'static str,
|
|
||||||
kind: &'static str,
|
|
||||||
icon: Icon,
|
|
||||||
state: CardState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FixtureCard {
|
|
||||||
fn view_model(self) -> ContentBrowserCardViewModel<'static> {
|
|
||||||
ContentBrowserCardViewModel {
|
|
||||||
label: self.label,
|
|
||||||
kind: self.kind,
|
|
||||||
icon: self.icon,
|
|
||||||
icon_style: if self.kind == "Folder" {
|
|
||||||
editor_ui::design_system::controls::PhosphorIconStyle::Fill
|
|
||||||
} else {
|
|
||||||
editor_ui::design_system::controls::PhosphorIconStyle::Regular
|
|
||||||
},
|
|
||||||
thumbnail: if matches!(self.state, CardState::Failed) {
|
|
||||||
ContentBrowserThumbnail::Failed
|
|
||||||
} else if matches!(self.state, CardState::Processing) {
|
|
||||||
ContentBrowserThumbnail::Pending
|
|
||||||
} else {
|
|
||||||
ContentBrowserThumbnail::Placeholder
|
|
||||||
},
|
|
||||||
asset_tone: fixture_asset_tone(self.kind),
|
|
||||||
selected: matches!(self.state, CardState::Selected),
|
|
||||||
status: ContentBrowserCardStatus {
|
|
||||||
dirty: matches!(self.state, CardState::Dirty),
|
|
||||||
processing: matches!(self.state, CardState::Processing),
|
|
||||||
error: matches!(self.state, CardState::Failed),
|
|
||||||
},
|
|
||||||
failure_tooltip: matches!(self.state, CardState::Failed)
|
|
||||||
.then_some("Thumbnail render failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_asset_tone(kind: &str) -> ContentBrowserAssetTone {
|
|
||||||
match kind {
|
|
||||||
"Primitive" => ContentBrowserAssetTone::Primitive,
|
|
||||||
"Light" => ContentBrowserAssetTone::Light,
|
|
||||||
"Model" => ContentBrowserAssetTone::Model,
|
|
||||||
"Texture" => ContentBrowserAssetTone::Texture,
|
|
||||||
"Material" => ContentBrowserAssetTone::Material,
|
|
||||||
"Audio Clip" => ContentBrowserAssetTone::Audio,
|
|
||||||
"Level" => ContentBrowserAssetTone::Level,
|
|
||||||
"Prefab" => ContentBrowserAssetTone::Prefab,
|
|
||||||
"Post Process Volume" | "Post FX" => ContentBrowserAssetTone::PostProcess,
|
|
||||||
"Rendering Profile" => ContentBrowserAssetTone::RenderingProfile,
|
|
||||||
"Shader Schema" => ContentBrowserAssetTone::Shader,
|
|
||||||
_ => ContentBrowserAssetTone::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_cards() -> [FixtureCard; 8] {
|
|
||||||
[
|
|
||||||
FixtureCard {
|
|
||||||
label: "office_lamp",
|
|
||||||
kind: "Model",
|
|
||||||
icon: icons::CUBE,
|
|
||||||
state: CardState::Normal,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "pebble_bricks",
|
|
||||||
kind: "Material",
|
|
||||||
icon: icons::PALETTE,
|
|
||||||
state: CardState::Dirty,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "office_lamp",
|
|
||||||
kind: "Model",
|
|
||||||
icon: icons::CUBE,
|
|
||||||
state: CardState::Normal,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "damaged_fixture",
|
|
||||||
kind: "Model",
|
|
||||||
icon: icons::CUBE,
|
|
||||||
state: CardState::Failed,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "metal_arm_2k",
|
|
||||||
kind: "Texture",
|
|
||||||
icon: icons::IMAGE,
|
|
||||||
state: CardState::Processing,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "pebble_bricks",
|
|
||||||
kind: "Material",
|
|
||||||
icon: icons::PALETTE,
|
|
||||||
state: CardState::Dirty,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "metal_office_desk",
|
|
||||||
kind: "Model",
|
|
||||||
icon: icons::CUBE,
|
|
||||||
state: CardState::Selected,
|
|
||||||
},
|
|
||||||
FixtureCard {
|
|
||||||
label: "textures",
|
|
||||||
kind: "Folder",
|
|
||||||
icon: icons::FOLDER_OPEN,
|
|
||||||
state: CardState::Normal,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_details(ui: &mut egui::Ui, height: f32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(DETAILS_WIDTH, height), egui::Sense::hover());
|
|
||||||
let mut details = ui.new_child(
|
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(rect)
|
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
|
||||||
);
|
|
||||||
details.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
||||||
details.spacing_mut().item_spacing = egui::Vec2::ZERO;
|
|
||||||
content_browser_pane_background(&details);
|
|
||||||
content_browser_details_pane_heading(&mut details, "DETAILS", true);
|
|
||||||
let _ = content_browser_details_header(
|
|
||||||
&mut details,
|
|
||||||
ContentBrowserDetailsHeaderViewModel {
|
|
||||||
label: "metal_office_desk_2k",
|
|
||||||
kind: "Model",
|
|
||||||
icon: icons::CUBE,
|
|
||||||
thumbnail: ContentBrowserThumbnail::Placeholder,
|
|
||||||
badge: Some(("UNTRACKED", ContentBrowserBadgeTone::Neutral)),
|
|
||||||
badge_tooltip: Some("Git: untracked"),
|
|
||||||
unsaved: false,
|
|
||||||
can_locate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
content_browser_details_section(&mut details, "IDENTITY");
|
|
||||||
content_browser_detail_path_row(
|
|
||||||
&mut details,
|
|
||||||
"Path",
|
|
||||||
"assets/Props/Office/metal_office_desk_2k.gltf",
|
|
||||||
);
|
|
||||||
content_browser_detail_path_row(&mut details, "Folder", "assets/Props/Office");
|
|
||||||
content_browser_detail_row(
|
|
||||||
&mut details,
|
|
||||||
"Asset ID",
|
|
||||||
"c07a5b05-f27d-46c6-8b8c-9de9cdd14720",
|
|
||||||
);
|
|
||||||
content_browser_details_section(&mut details, "DEPENDENCIES");
|
|
||||||
content_browser_detail_path_row(
|
|
||||||
&mut details,
|
|
||||||
"Ready",
|
|
||||||
"textures/metal_office_desk_diff_2k.png",
|
|
||||||
);
|
|
||||||
content_browser_detail_path_row(
|
|
||||||
&mut details,
|
|
||||||
"Ready",
|
|
||||||
"textures/metal_office_desk_arm_2k.png",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,457 +0,0 @@
|
|||||||
use editor_ui::materials::{
|
|
||||||
MaterialHealth, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate,
|
|
||||||
MaterialSlotPanelViewModel, MaterialTextureCandidate, MaterialThumbnailPresentation,
|
|
||||||
};
|
|
||||||
use material_schema::{
|
|
||||||
standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialInputSet, MaterialParameter,
|
|
||||||
MaterialParameterValue, MaterialRenderState, MaterialShaderKind, MaterialTextureBinding,
|
|
||||||
TextureChannel,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum FixtureId {
|
|
||||||
Reference,
|
|
||||||
Primitive,
|
|
||||||
StaticMesh,
|
|
||||||
SkinnedMesh,
|
|
||||||
Inherited,
|
|
||||||
Imported,
|
|
||||||
Dirty,
|
|
||||||
Processing,
|
|
||||||
Failed,
|
|
||||||
ReadOnly,
|
|
||||||
BuiltIn,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FixtureId {
|
|
||||||
pub const ALL: [Self; 11] = [
|
|
||||||
Self::Reference,
|
|
||||||
Self::Primitive,
|
|
||||||
Self::StaticMesh,
|
|
||||||
Self::SkinnedMesh,
|
|
||||||
Self::Inherited,
|
|
||||||
Self::Imported,
|
|
||||||
Self::Dirty,
|
|
||||||
Self::Processing,
|
|
||||||
Self::Failed,
|
|
||||||
Self::ReadOnly,
|
|
||||||
Self::BuiltIn,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Reference => "Penpot Layout",
|
|
||||||
Self::Primitive => "Primitive",
|
|
||||||
Self::StaticMesh => "Static Mesh",
|
|
||||||
Self::SkinnedMesh => "Skinned Mesh",
|
|
||||||
Self::Inherited => "Inherited",
|
|
||||||
Self::Imported => "Imported Source",
|
|
||||||
Self::Dirty => "Dirty",
|
|
||||||
Self::Processing => "Processing",
|
|
||||||
Self::Failed => "Failed",
|
|
||||||
Self::ReadOnly => "Read only",
|
|
||||||
Self::BuiltIn => "Built-in DefaultGrid",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn is_renderer(self) -> bool {
|
|
||||||
matches!(self, Self::StaticMesh | Self::SkinnedMesh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct FixtureOverrides {
|
|
||||||
pub material_label: Option<String>,
|
|
||||||
pub material_path: Option<String>,
|
|
||||||
pub metallic: Option<f32>,
|
|
||||||
pub roughness: Option<f32>,
|
|
||||||
pub occlusion: Option<f32>,
|
|
||||||
pub normal_strength: Option<f32>,
|
|
||||||
pub emissive_intensity: Option<f32>,
|
|
||||||
pub uv_tiling: Option<[f32; 2]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FixtureModel {
|
|
||||||
pub slot: MaterialSlotPanelViewModel,
|
|
||||||
pub render_state: MaterialRenderState,
|
|
||||||
pub inputs: MaterialInputSet,
|
|
||||||
pub renderer_slots: Vec<RendererMaterialFixture>,
|
|
||||||
pub textures: Vec<MaterialTextureCandidate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RendererMaterialFixture {
|
|
||||||
pub slot: MaterialSlotPanelViewModel,
|
|
||||||
pub render_state: MaterialRenderState,
|
|
||||||
pub inputs: MaterialInputSet,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FixtureModel {
|
|
||||||
pub fn new(
|
|
||||||
id: FixtureId,
|
|
||||||
overrides: &FixtureOverrides,
|
|
||||||
material_thumbnail: MaterialThumbnailPresentation,
|
|
||||||
texture_thumbnails: [MaterialThumbnailPresentation; 3],
|
|
||||||
) -> Self {
|
|
||||||
let material_ref = EditorAssetRef::new(
|
|
||||||
"c07a5b05-f27d-46c6-8b8c-9de9cdd1472c",
|
|
||||||
"material:source",
|
|
||||||
"M_Wood_Planks",
|
|
||||||
)
|
|
||||||
.with_source_path("materials/wood/M_Wood_Planks.mat");
|
|
||||||
let label = overrides
|
|
||||||
.material_label
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| fixture_material_label(id).to_owned());
|
|
||||||
let path = overrides
|
|
||||||
.material_path
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| fixture_material_path(id).to_owned());
|
|
||||||
let (health, inherited, can_edit_shader, can_clear) = fixture_state(id);
|
|
||||||
let slot = MaterialSlotPanelViewModel {
|
|
||||||
slot_id: fixture_slot_id(id).to_owned(),
|
|
||||||
label,
|
|
||||||
path: Some(path),
|
|
||||||
shader: "Standard Lit".to_owned(),
|
|
||||||
shader_kind: Some(MaterialShaderKind::StandardLit),
|
|
||||||
can_edit_shader,
|
|
||||||
thumbnail: material_thumbnail,
|
|
||||||
inherited,
|
|
||||||
health,
|
|
||||||
assigned: (!matches!(id, FixtureId::Inherited | FixtureId::BuiltIn))
|
|
||||||
.then_some(material_ref.clone()),
|
|
||||||
effective: Some(material_ref),
|
|
||||||
candidates: material_candidates(material_thumbnail),
|
|
||||||
drop_candidate: None,
|
|
||||||
invalid_drop_reason: None,
|
|
||||||
can_locate: !matches!(id, FixtureId::BuiltIn),
|
|
||||||
can_clear,
|
|
||||||
can_extract: matches!(id, FixtureId::Imported),
|
|
||||||
can_create_instance: !matches!(id, FixtureId::Imported | FixtureId::BuiltIn),
|
|
||||||
parameters_initially_open: true,
|
|
||||||
expanded_header_when_parameters_closed: false,
|
|
||||||
};
|
|
||||||
let textures = texture_candidates(texture_thumbnails);
|
|
||||||
let inputs = fixture_inputs(id, overrides, &textures);
|
|
||||||
let renderer_slots = renderer_material_fixtures(&slot, &inputs);
|
|
||||||
Self {
|
|
||||||
slot,
|
|
||||||
render_state: MaterialRenderState::default(),
|
|
||||||
inputs,
|
|
||||||
renderer_slots,
|
|
||||||
textures,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderer_material_fixtures(
|
|
||||||
primary: &MaterialSlotPanelViewModel,
|
|
||||||
inputs: &MaterialInputSet,
|
|
||||||
) -> Vec<RendererMaterialFixture> {
|
|
||||||
let mut painted = primary.clone();
|
|
||||||
painted.slot_id = "slot:draw:scene0:node2:mesh2:primitive1".to_owned();
|
|
||||||
painted.label = "M_Painted_Metal".to_owned();
|
|
||||||
painted.path = Some("assets/materials/office/M_Painted_Metal.mat".to_owned());
|
|
||||||
painted.assigned = painted.assigned.as_ref().map(|_| {
|
|
||||||
EditorAssetRef::new(
|
|
||||||
"gallery-painted-metal",
|
|
||||||
"material:source",
|
|
||||||
"M_Painted_Metal",
|
|
||||||
)
|
|
||||||
.with_source_path("assets/materials/office/M_Painted_Metal.mat")
|
|
||||||
});
|
|
||||||
painted.effective = painted.assigned.clone();
|
|
||||||
painted.parameters_initially_open = false;
|
|
||||||
|
|
||||||
let mut empty = primary.clone();
|
|
||||||
empty.slot_id = "slot:draw:scene0:node2:mesh2:primitive2".to_owned();
|
|
||||||
empty.label = "No material assigned".to_owned();
|
|
||||||
empty.path = None;
|
|
||||||
empty.shader.clear();
|
|
||||||
empty.shader_kind = None;
|
|
||||||
empty.can_edit_shader = false;
|
|
||||||
empty.thumbnail = MaterialThumbnailPresentation::Empty;
|
|
||||||
empty.health = MaterialHealth::Healthy;
|
|
||||||
empty.assigned = None;
|
|
||||||
empty.effective = None;
|
|
||||||
empty.can_locate = false;
|
|
||||||
empty.can_clear = false;
|
|
||||||
empty.can_create_instance = false;
|
|
||||||
empty.parameters_initially_open = false;
|
|
||||||
|
|
||||||
[primary.clone(), painted, empty]
|
|
||||||
.into_iter()
|
|
||||||
.map(|slot| RendererMaterialFixture {
|
|
||||||
slot,
|
|
||||||
render_state: MaterialRenderState::default(),
|
|
||||||
inputs: inputs.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_state(id: FixtureId) -> (MaterialHealth, Option<MaterialLayerBadge>, bool, bool) {
|
|
||||||
match id {
|
|
||||||
FixtureId::Inherited => (
|
|
||||||
MaterialHealth::Healthy,
|
|
||||||
Some(MaterialLayerBadge::ModelDefault),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
FixtureId::Imported => (
|
|
||||||
MaterialHealth::ReadOnly,
|
|
||||||
Some(MaterialLayerBadge::ModelSource),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
FixtureId::Dirty => (MaterialHealth::Dirty, None, true, true),
|
|
||||||
FixtureId::Processing => (MaterialHealth::Processing, None, true, true),
|
|
||||||
FixtureId::Failed => (MaterialHealth::Broken, None, false, true),
|
|
||||||
FixtureId::ReadOnly => (MaterialHealth::ReadOnly, None, false, false),
|
|
||||||
FixtureId::BuiltIn => (
|
|
||||||
MaterialHealth::ReadOnly,
|
|
||||||
Some(MaterialLayerBadge::DefaultGrid),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
FixtureId::Reference
|
|
||||||
| FixtureId::Primitive
|
|
||||||
| FixtureId::StaticMesh
|
|
||||||
| FixtureId::SkinnedMesh => (MaterialHealth::Healthy, None, true, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_slot_id(id: FixtureId) -> &'static str {
|
|
||||||
match id {
|
|
||||||
FixtureId::Reference => "slot:gallery:penpot-reference",
|
|
||||||
FixtureId::Primitive => "slot:primitive:surface",
|
|
||||||
FixtureId::StaticMesh => "slot:draw:scene0:node2:mesh2:primitive0",
|
|
||||||
FixtureId::SkinnedMesh => "slot:skin:scene0:node1:mesh0:primitive0",
|
|
||||||
_ => "slot:gallery:surface",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_material_label(id: FixtureId) -> &'static str {
|
|
||||||
match id {
|
|
||||||
FixtureId::Imported => "metal_office_desk",
|
|
||||||
FixtureId::BuiltIn => "Default Grid — Engine Built-in",
|
|
||||||
FixtureId::Failed => "Missing explicit material",
|
|
||||||
_ => "M_Wood_Planks",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_material_path(id: FixtureId) -> &'static str {
|
|
||||||
match id {
|
|
||||||
FixtureId::Imported => "assets/Furniture/Office/metal_office_desk_2k.gltf#material:0",
|
|
||||||
FixtureId::BuiltIn => "builtin://materials/default-grid",
|
|
||||||
FixtureId::Failed => "assets/materials/missing.material.ron",
|
|
||||||
_ => "materials/wood/M_Wood_Planks.mat",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn material_candidates(thumbnail: MaterialThumbnailPresentation) -> Vec<MaterialPickerCandidate> {
|
|
||||||
["M_Wood_Planks", "M_Painted_Metal", "M_Concrete"]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, label)| MaterialPickerCandidate {
|
|
||||||
reference: EditorAssetRef::new(
|
|
||||||
format!("gallery-material-{index}"),
|
|
||||||
"material:source",
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
.with_source_path(format!("assets/materials/{label}.material.ron")),
|
|
||||||
label: label.to_owned(),
|
|
||||||
detail: format!("assets/materials/{label}.material.ron"),
|
|
||||||
thumbnail,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn texture_candidates(
|
|
||||||
thumbnails: [MaterialThumbnailPresentation; 3],
|
|
||||||
) -> Vec<MaterialTextureCandidate> {
|
|
||||||
[
|
|
||||||
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
|
|
||||||
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
|
|
||||||
("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, (label, path))| MaterialTextureCandidate {
|
|
||||||
label: label.to_owned(),
|
|
||||||
detail: path.to_owned(),
|
|
||||||
reference: EditorAssetRef::new(format!("gallery-texture-{index}"), "texture:source", label)
|
|
||||||
.with_source_path(path),
|
|
||||||
locate: MaterialLocateTarget::File(path.to_owned()),
|
|
||||||
thumbnail: thumbnails[index],
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture_inputs(
|
|
||||||
id: FixtureId,
|
|
||||||
overrides: &FixtureOverrides,
|
|
||||||
textures: &[MaterialTextureCandidate],
|
|
||||||
) -> MaterialInputSet {
|
|
||||||
let schema = standard_lit_input_schema();
|
|
||||||
let mut set = MaterialInputSet::default();
|
|
||||||
for input in &schema.inputs {
|
|
||||||
if let Some(value) = input.default_value.clone() {
|
|
||||||
set.values.push(MaterialParameter {
|
|
||||||
name: input.name.clone(),
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(texture) = input.texture.as_ref() {
|
|
||||||
set.textures.push(MaterialTextureBinding {
|
|
||||||
name: input.name.clone(),
|
|
||||||
texture: None,
|
|
||||||
channel: texture.default_channel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_value(
|
|
||||||
&mut set,
|
|
||||||
"base_color",
|
|
||||||
MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)),
|
|
||||||
);
|
|
||||||
set_value(
|
|
||||||
&mut set,
|
|
||||||
"emissive_color",
|
|
||||||
MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)),
|
|
||||||
);
|
|
||||||
set_float(&mut set, "metallic", overrides.metallic.unwrap_or(0.72));
|
|
||||||
set_float(&mut set, "roughness", overrides.roughness.unwrap_or(0.72));
|
|
||||||
set_float(&mut set, "occlusion", overrides.occlusion.unwrap_or(0.72));
|
|
||||||
set_float(
|
|
||||||
&mut set,
|
|
||||||
"normal",
|
|
||||||
overrides.normal_strength.unwrap_or(0.72),
|
|
||||||
);
|
|
||||||
set_float(
|
|
||||||
&mut set,
|
|
||||||
"emissive_intensity",
|
|
||||||
overrides.emissive_intensity.unwrap_or(0.0),
|
|
||||||
);
|
|
||||||
if let Some([x, y]) = overrides.uv_tiling {
|
|
||||||
if let Some(parameter) = set
|
|
||||||
.values
|
|
||||||
.iter_mut()
|
|
||||||
.find(|value| value.name == "uv_tiling")
|
|
||||||
{
|
|
||||||
if let MaterialParameterValue::Vec2(value) = &mut parameter.value {
|
|
||||||
value.x = x;
|
|
||||||
value.y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bind(&mut set, "base_color", &textures[0], TextureChannel::Rgba);
|
|
||||||
for (name, channel) in [
|
|
||||||
("occlusion", TextureChannel::R),
|
|
||||||
("roughness", TextureChannel::G),
|
|
||||||
("metallic", TextureChannel::B),
|
|
||||||
] {
|
|
||||||
bind(&mut set, name, &textures[1], channel);
|
|
||||||
}
|
|
||||||
bind(&mut set, "normal", &textures[2], TextureChannel::Rgb);
|
|
||||||
if id == FixtureId::Reference {
|
|
||||||
// Penpot's sample binds the same mock texture to Emissive. Its all-G scalar labels conflict
|
|
||||||
// with the simultaneously selected ORM mode; keep Blacksite's valid R/G/B contract and
|
|
||||||
// document those three sample glyphs as an intentional handoff delta.
|
|
||||||
bind(
|
|
||||||
&mut set,
|
|
||||||
"emissive_color",
|
|
||||||
&textures[1],
|
|
||||||
TextureChannel::Rgb,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
set
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_float(inputs: &mut MaterialInputSet, name: &str, value: f32) {
|
|
||||||
set_value(inputs, name, MaterialParameterValue::Float(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_value(inputs: &mut MaterialInputSet, name: &str, value: MaterialParameterValue) {
|
|
||||||
if let Some(parameter) = inputs.values.iter_mut().find(|item| item.name == name) {
|
|
||||||
parameter.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind(
|
|
||||||
inputs: &mut MaterialInputSet,
|
|
||||||
name: &str,
|
|
||||||
candidate: &MaterialTextureCandidate,
|
|
||||||
channel: TextureChannel,
|
|
||||||
) {
|
|
||||||
if let Some(binding) = inputs.textures.iter_mut().find(|item| item.name == name) {
|
|
||||||
binding.texture = Some(candidate.reference.clone());
|
|
||||||
binding.channel = channel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn test_textures() -> Vec<MaterialTextureCandidate> {
|
|
||||||
texture_candidates([MaterialThumbnailPresentation::Pending; 3])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reference_fixture_keeps_valid_orm_channels_and_adds_penpot_emissive_sample() {
|
|
||||||
let textures = test_textures();
|
|
||||||
let reference = fixture_inputs(
|
|
||||||
FixtureId::Reference,
|
|
||||||
&FixtureOverrides::default(),
|
|
||||||
&textures,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference
|
|
||||||
.texture("occlusion")
|
|
||||||
.map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::R)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference
|
|
||||||
.texture("roughness")
|
|
||||||
.map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::G)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference.texture("metallic").map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::B)
|
|
||||||
);
|
|
||||||
assert!(reference
|
|
||||||
.texture("emissive_color")
|
|
||||||
.and_then(|binding| binding.texture.as_ref())
|
|
||||||
.is_some());
|
|
||||||
|
|
||||||
let primitive = fixture_inputs(
|
|
||||||
FixtureId::Primitive,
|
|
||||||
&FixtureOverrides::default(),
|
|
||||||
&textures,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
primitive
|
|
||||||
.texture("occlusion")
|
|
||||||
.map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::R)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
primitive
|
|
||||||
.texture("roughness")
|
|
||||||
.map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::G)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
primitive.texture("metallic").map(|binding| binding.channel),
|
|
||||||
Some(TextureChannel::B)
|
|
||||||
);
|
|
||||||
assert!(primitive
|
|
||||||
.texture("emissive_color")
|
|
||||||
.and_then(|binding| binding.texture.as_ref())
|
|
||||||
.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
mod app;
|
|
||||||
mod content_browser;
|
|
||||||
mod fixtures;
|
|
||||||
mod reference;
|
|
||||||
mod reload;
|
|
||||||
|
|
||||||
fn main() -> eframe::Result {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("Blacksite UI Gallery")
|
|
||||||
.with_inner_size([1420.0, 940.0])
|
|
||||||
.with_min_inner_size([900.0, 620.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
eframe::run_native(
|
|
||||||
"Blacksite UI Gallery",
|
|
||||||
options,
|
|
||||||
Box::new(|creation| Ok(Box::new(app::UiGalleryApp::new(creation)))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,343 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum GalleryPreset {
|
|
||||||
ContentBrowser1240,
|
|
||||||
ContentBrowser1600,
|
|
||||||
ContentBrowser620,
|
|
||||||
Materials620,
|
|
||||||
Materials420,
|
|
||||||
Renderer760,
|
|
||||||
Renderer480,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GalleryPreset {
|
|
||||||
pub const ALL: [Self; 7] = [
|
|
||||||
Self::ContentBrowser1240,
|
|
||||||
Self::ContentBrowser620,
|
|
||||||
Self::Materials620,
|
|
||||||
Self::Materials420,
|
|
||||||
Self::Renderer760,
|
|
||||||
Self::Renderer480,
|
|
||||||
Self::ContentBrowser1600,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::ContentBrowser1240 => "Content Browser · 1240",
|
|
||||||
Self::ContentBrowser1600 => "Content Browser · 1600 expanded",
|
|
||||||
Self::ContentBrowser620 => "Content Browser · 620",
|
|
||||||
Self::Materials620 => "Materials · 620",
|
|
||||||
Self::Materials420 => "Materials · 420",
|
|
||||||
Self::Renderer760 => "Renderer · 760",
|
|
||||||
Self::Renderer480 => "Renderer · 480",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn width(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::ContentBrowser1240 => 1240.0,
|
|
||||||
Self::ContentBrowser1600 => 1600.0,
|
|
||||||
Self::ContentBrowser620 => 620.0,
|
|
||||||
Self::Materials620 => 620.0,
|
|
||||||
Self::Materials420 => 420.0,
|
|
||||||
Self::Renderer760 => 760.0,
|
|
||||||
Self::Renderer480 => 480.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn target_height(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 => 650.0,
|
|
||||||
Self::Materials620 => 539.0,
|
|
||||||
Self::Materials420 => 735.0,
|
|
||||||
Self::Renderer760 => 1233.0,
|
|
||||||
Self::Renderer480 => 828.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn file_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::ContentBrowser1240 => "content-browser-wide-1240.png",
|
|
||||||
Self::ContentBrowser1600 => "content-browser-expanded-1600.png",
|
|
||||||
Self::ContentBrowser620 => "content-browser-compact-620.png",
|
|
||||||
Self::Materials620 => "materials-wide-620.png",
|
|
||||||
Self::Materials420 => "materials-narrow-420.png",
|
|
||||||
Self::Renderer760 => "static-mesh-renderer-wide-760.png",
|
|
||||||
Self::Renderer480 => "static-mesh-renderer-narrow-480.png",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn is_renderer(self) -> bool {
|
|
||||||
matches!(self, Self::Renderer760 | Self::Renderer480)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn is_content_browser(self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Production-content canvas inside the exported Penpot reference.
|
|
||||||
///
|
|
||||||
/// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset;
|
|
||||||
/// renderer boards are already complete component canvases.
|
|
||||||
pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect {
|
|
||||||
if self.is_content_browser() {
|
|
||||||
canvas
|
|
||||||
} else if self.is_renderer() {
|
|
||||||
egui::Rect::from_min_max(
|
|
||||||
canvas.min + egui::vec2(0.0, 12.0),
|
|
||||||
egui::pos2(canvas.right(), canvas.bottom()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
egui::Rect::from_min_max(
|
|
||||||
canvas.min + egui::vec2(12.0, 10.0),
|
|
||||||
egui::pos2(canvas.right() - 12.0, canvas.bottom()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ComparisonMode {
|
|
||||||
Live,
|
|
||||||
SideBySide,
|
|
||||||
Overlay,
|
|
||||||
Difference,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ComparisonMode {
|
|
||||||
pub const ALL: [Self; 4] = [
|
|
||||||
Self::Live,
|
|
||||||
Self::SideBySide,
|
|
||||||
Self::Overlay,
|
|
||||||
Self::Difference,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Live => "Live",
|
|
||||||
Self::SideBySide => "Side by side",
|
|
||||||
Self::Overlay => "Overlay",
|
|
||||||
Self::Difference => "Difference",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReferenceImage {
|
|
||||||
pub source_size: [usize; 2],
|
|
||||||
pub texture: egui::TextureHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct ReferenceCrop {
|
|
||||||
origin: [usize; 2],
|
|
||||||
size: [usize; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reference_crop(source_size: [usize; 2], preset: GalleryPreset) -> ReferenceCrop {
|
|
||||||
let target_width = preset.width().round().max(1.0) as usize;
|
|
||||||
let target_height = preset.target_height().round().max(1.0) as usize;
|
|
||||||
// Penpot's material exports include the frame's one-pixel outer stroke in their viewBox,
|
|
||||||
// producing 622/422 px PNGs for 620/420 px boards. That stroke is export overscan, not part of
|
|
||||||
// the authored canvas. Cropping it preserves direct 1:1 pixels; scaling it into the canvas
|
|
||||||
// blurs text and introduces a false global geometry delta.
|
|
||||||
let overscan = source_size[0].saturating_sub(target_width);
|
|
||||||
let inset = usize::from(overscan == 2);
|
|
||||||
ReferenceCrop {
|
|
||||||
origin: [inset, inset],
|
|
||||||
size: [
|
|
||||||
target_width.min(source_size[0].saturating_sub(inset)),
|
|
||||||
target_height.min(source_size[1].saturating_sub(inset)),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReferenceImage {
|
|
||||||
pub fn load(ctx: &egui::Context, root: &Path, preset: GalleryPreset) -> Result<Self, String> {
|
|
||||||
let path = reference_path(root, preset);
|
|
||||||
let bytes = std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
|
|
||||||
let decoded = image::load_from_memory(&bytes)
|
|
||||||
.map_err(|error| format!("{}: {error}", path.display()))?
|
|
||||||
.to_rgba8();
|
|
||||||
let source_size = [decoded.width() as usize, decoded.height() as usize];
|
|
||||||
let pixels = egui::ColorImage::from_rgba_unmultiplied(source_size, decoded.as_raw());
|
|
||||||
let texture = ctx.load_texture(
|
|
||||||
format!("penpot-reference-{}", preset.label()),
|
|
||||||
pixels.clone(),
|
|
||||||
egui::TextureOptions::NEAREST,
|
|
||||||
);
|
|
||||||
Ok(Self {
|
|
||||||
source_size,
|
|
||||||
texture,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn paint(&self, ui: &mut egui::Ui, preset: GalleryPreset, opacity: f32) -> egui::Response {
|
|
||||||
let size = egui::vec2(preset.width(), preset.target_height());
|
|
||||||
let crop = reference_crop(self.source_size, preset);
|
|
||||||
let uv_min = egui::pos2(
|
|
||||||
crop.origin[0] as f32 / self.source_size[0] as f32,
|
|
||||||
crop.origin[1] as f32 / self.source_size[1] as f32,
|
|
||||||
);
|
|
||||||
let uv_max = egui::pos2(
|
|
||||||
(crop.origin[0] + crop.size[0]) as f32 / self.source_size[0] as f32,
|
|
||||||
(crop.origin[1] + crop.size[1]) as f32 / self.source_size[1] as f32,
|
|
||||||
);
|
|
||||||
ui.add(
|
|
||||||
egui::Image::new((self.texture.id(), size))
|
|
||||||
.uv(egui::Rect::from_min_max(uv_min, uv_max))
|
|
||||||
.tint(egui::Color32::from_white_alpha((opacity * 255.0) as u8)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn difference_image(
|
|
||||||
screenshot: &egui::ColorImage,
|
|
||||||
live_rect: egui::Rect,
|
|
||||||
reference_rect: egui::Rect,
|
|
||||||
pixels_per_point: f32,
|
|
||||||
preset: GalleryPreset,
|
|
||||||
) -> (egui::ColorImage, DifferenceStats) {
|
|
||||||
let width = preset.width().round().max(1.0) as usize;
|
|
||||||
let height = preset.target_height().round().max(1.0) as usize;
|
|
||||||
let mut output =
|
|
||||||
egui::ColorImage::new([width, height], vec![egui::Color32::BLACK; width * height]);
|
|
||||||
let mut total = 0_u64;
|
|
||||||
let mut max_delta = 0_u8;
|
|
||||||
let mut changed = 0_u64;
|
|
||||||
for y in 0..height {
|
|
||||||
for x in 0..width {
|
|
||||||
let live =
|
|
||||||
sample_screenshot(screenshot, live_rect, pixels_per_point, x, y, width, height);
|
|
||||||
let reference_color = sample_screenshot(
|
|
||||||
screenshot,
|
|
||||||
reference_rect,
|
|
||||||
pixels_per_point,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
let delta = [
|
|
||||||
live.r().abs_diff(reference_color.r()),
|
|
||||||
live.g().abs_diff(reference_color.g()),
|
|
||||||
live.b().abs_diff(reference_color.b()),
|
|
||||||
];
|
|
||||||
let peak = *delta.iter().max().unwrap_or(&0);
|
|
||||||
max_delta = max_delta.max(peak);
|
|
||||||
total += delta.into_iter().map(u64::from).sum::<u64>();
|
|
||||||
changed += u64::from(peak > 8);
|
|
||||||
let amplified = |value: u8| value.saturating_mul(4);
|
|
||||||
output[(x, y)] = egui::Color32::from_rgb(
|
|
||||||
amplified(delta[0]),
|
|
||||||
amplified(delta[1]),
|
|
||||||
amplified(delta[2]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let samples = (width * height * 3).max(1) as f32;
|
|
||||||
let pixels = (width * height).max(1) as f32;
|
|
||||||
(
|
|
||||||
output,
|
|
||||||
DifferenceStats {
|
|
||||||
mean_channel_delta: total as f32 / samples,
|
|
||||||
max_channel_delta: max_delta,
|
|
||||||
changed_percent: changed as f32 * 100.0 / pixels,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct DifferenceStats {
|
|
||||||
pub mean_channel_delta: f32,
|
|
||||||
pub max_channel_delta: u8,
|
|
||||||
pub changed_percent: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reference_path(root: &Path, preset: GalleryPreset) -> PathBuf {
|
|
||||||
root.join("docs/editor/evaluations/content-workspace-m2/penpot")
|
|
||||||
.join(preset.file_name())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_screenshot(
|
|
||||||
image: &egui::ColorImage,
|
|
||||||
rect: egui::Rect,
|
|
||||||
pixels_per_point: f32,
|
|
||||||
x: usize,
|
|
||||||
y: usize,
|
|
||||||
width: usize,
|
|
||||||
height: usize,
|
|
||||||
) -> egui::Color32 {
|
|
||||||
let px = ((rect.left() + (x as f32 + 0.5) * rect.width() / width as f32) * pixels_per_point)
|
|
||||||
.floor() as isize;
|
|
||||||
let py = ((rect.top() + (y as f32 + 0.5) * rect.height() / height as f32) * pixels_per_point)
|
|
||||||
.floor() as isize;
|
|
||||||
if px < 0 || py < 0 || px as usize >= image.size[0] || py as usize >= image.size[1] {
|
|
||||||
return egui::Color32::BLACK;
|
|
||||||
}
|
|
||||||
image[(px as usize, py as usize)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn material_presets_supply_exported_slot_widths() {
|
|
||||||
for (preset, expected_width) in [
|
|
||||||
(GalleryPreset::Materials620, 596.0),
|
|
||||||
(GalleryPreset::Materials420, 396.0),
|
|
||||||
] {
|
|
||||||
let canvas = egui::Rect::from_min_size(
|
|
||||||
egui::Pos2::ZERO,
|
|
||||||
egui::vec2(preset.width(), preset.target_height()),
|
|
||||||
);
|
|
||||||
let content = preset.content_rect(canvas);
|
|
||||||
assert_eq!(content.left(), 12.0);
|
|
||||||
assert_eq!(content.top(), 10.0);
|
|
||||||
assert_eq!(content.width(), expected_width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renderer_presets_preserve_the_exported_top_inset() {
|
|
||||||
for preset in [GalleryPreset::Renderer760, GalleryPreset::Renderer480] {
|
|
||||||
let canvas = egui::Rect::from_min_size(
|
|
||||||
egui::Pos2::ZERO,
|
|
||||||
egui::vec2(preset.width(), preset.target_height()),
|
|
||||||
);
|
|
||||||
let content = preset.content_rect(canvas);
|
|
||||||
assert_eq!(content.left(), 0.0);
|
|
||||||
assert_eq!(content.top(), 12.0);
|
|
||||||
assert_eq!(content.width(), preset.width());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn material_export_overscan_is_cropped_instead_of_scaled() {
|
|
||||||
assert_eq!(
|
|
||||||
reference_crop([622, 661], GalleryPreset::Materials620),
|
|
||||||
ReferenceCrop {
|
|
||||||
origin: [1, 1],
|
|
||||||
size: [620, 539],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference_crop([422, 857], GalleryPreset::Materials420),
|
|
||||||
ReferenceCrop {
|
|
||||||
origin: [1, 1],
|
|
||||||
size: [420, 735],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference_crop([760, 1233], GalleryPreset::Renderer760),
|
|
||||||
ReferenceCrop {
|
|
||||||
origin: [0, 0],
|
|
||||||
size: [760, 1233],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::{Duration, SystemTime};
|
|
||||||
|
|
||||||
use editor_ui::theme::{EditorVisualPalette, BLACKSITE_PALETTE};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::fixtures::FixtureOverrides;
|
|
||||||
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_millis(300);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct ThemeOverrides {
|
|
||||||
pub canvas: Option<String>,
|
|
||||||
pub panel: Option<String>,
|
|
||||||
pub panel_dark: Option<String>,
|
|
||||||
pub widget: Option<String>,
|
|
||||||
pub elevated: Option<String>,
|
|
||||||
pub accent: Option<String>,
|
|
||||||
pub selection: Option<String>,
|
|
||||||
pub text: Option<String>,
|
|
||||||
pub text_dim: Option<String>,
|
|
||||||
pub text_muted: Option<String>,
|
|
||||||
pub border: Option<String>,
|
|
||||||
pub border_strong: Option<String>,
|
|
||||||
pub success: Option<String>,
|
|
||||||
pub warning: Option<String>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub asset_types: AssetTypeThemeOverrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct AssetTypeThemeOverrides {
|
|
||||||
pub primitive: Option<String>,
|
|
||||||
pub light: Option<String>,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub texture: Option<String>,
|
|
||||||
pub material: Option<String>,
|
|
||||||
pub audio: Option<String>,
|
|
||||||
pub level: Option<String>,
|
|
||||||
pub prefab: Option<String>,
|
|
||||||
pub post_process: Option<String>,
|
|
||||||
pub rendering_profile: Option<String>,
|
|
||||||
pub shader: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ThemeOverrides {
|
|
||||||
pub fn palette(&self) -> Result<EditorVisualPalette, String> {
|
|
||||||
let mut palette = BLACKSITE_PALETTE;
|
|
||||||
macro_rules! apply {
|
|
||||||
($field:ident) => {
|
|
||||||
if let Some(value) = self.$field.as_deref() {
|
|
||||||
palette.$field = parse_color(value)?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
apply!(canvas);
|
|
||||||
apply!(panel);
|
|
||||||
apply!(panel_dark);
|
|
||||||
apply!(widget);
|
|
||||||
apply!(elevated);
|
|
||||||
apply!(accent);
|
|
||||||
apply!(selection);
|
|
||||||
apply!(text);
|
|
||||||
apply!(text_dim);
|
|
||||||
apply!(text_muted);
|
|
||||||
apply!(border);
|
|
||||||
apply!(border_strong);
|
|
||||||
apply!(success);
|
|
||||||
apply!(warning);
|
|
||||||
apply!(error);
|
|
||||||
macro_rules! apply_asset_type {
|
|
||||||
($field:ident) => {
|
|
||||||
if let Some(value) = self.asset_types.$field.as_deref() {
|
|
||||||
palette.asset_types.$field = parse_color(value)?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
apply_asset_type!(primitive);
|
|
||||||
apply_asset_type!(light);
|
|
||||||
apply_asset_type!(model);
|
|
||||||
apply_asset_type!(texture);
|
|
||||||
apply_asset_type!(material);
|
|
||||||
apply_asset_type!(audio);
|
|
||||||
apply_asset_type!(level);
|
|
||||||
apply_asset_type!(prefab);
|
|
||||||
apply_asset_type!(post_process);
|
|
||||||
apply_asset_type!(rendering_profile);
|
|
||||||
apply_asset_type!(shader);
|
|
||||||
Ok(palette)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RuntimeDocuments {
|
|
||||||
theme: ReloadableDocument<ThemeOverrides>,
|
|
||||||
fixture: ReloadableDocument<FixtureOverrides>,
|
|
||||||
pub theme_value: ThemeOverrides,
|
|
||||||
pub fixture_value: FixtureOverrides,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeDocuments {
|
|
||||||
pub fn new(root: &Path) -> Self {
|
|
||||||
Self::from_directory(root.join("crates/ui_gallery/fixtures"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_directory(directory: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
theme: ReloadableDocument::new(directory.join("theme.json")),
|
|
||||||
fixture: ReloadableDocument::new(directory.join("material.json")),
|
|
||||||
theme_value: ThemeOverrides::default(),
|
|
||||||
fixture_value: FixtureOverrides::default(),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn poll(&mut self, now: f64) -> bool {
|
|
||||||
let mut changed = false;
|
|
||||||
match self.theme.poll(now) {
|
|
||||||
Ok(Some(value)) => {
|
|
||||||
self.theme_value = value;
|
|
||||||
self.error = None;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
Err(error) => self.error = Some(error),
|
|
||||||
Ok(None) => {}
|
|
||||||
}
|
|
||||||
match self.fixture.poll(now) {
|
|
||||||
Ok(Some(value)) => {
|
|
||||||
self.fixture_value = value;
|
|
||||||
self.error = None;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
Err(error) => self.error = Some(error),
|
|
||||||
Ok(None) => {}
|
|
||||||
}
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReloadableDocument<T> {
|
|
||||||
path: PathBuf,
|
|
||||||
modified: Option<SystemTime>,
|
|
||||||
next_poll: f64,
|
|
||||||
marker: std::marker::PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> ReloadableDocument<T>
|
|
||||||
where
|
|
||||||
T: for<'de> Deserialize<'de>,
|
|
||||||
{
|
|
||||||
fn new(path: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
modified: None,
|
|
||||||
next_poll: 0.0,
|
|
||||||
marker: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll(&mut self, now: f64) -> Result<Option<T>, String> {
|
|
||||||
if now < self.next_poll {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
self.next_poll = now + POLL_INTERVAL.as_secs_f64();
|
|
||||||
let metadata = fs::metadata(&self.path)
|
|
||||||
.map_err(|error| format!("{}: {error}", self.path.display()))?;
|
|
||||||
let modified = metadata.modified().ok();
|
|
||||||
if self.modified == modified {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let bytes =
|
|
||||||
fs::read(&self.path).map_err(|error| format!("{}: {error}", self.path.display()))?;
|
|
||||||
let value = serde_json::from_slice(&bytes)
|
|
||||||
.map_err(|error| format!("{}: {error}", self.path.display()))?;
|
|
||||||
self.modified = modified;
|
|
||||||
Ok(Some(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_color(value: &str) -> Result<egui::Color32, String> {
|
|
||||||
let value = value.trim().trim_start_matches('#');
|
|
||||||
if value.len() != 6 && value.len() != 8 {
|
|
||||||
return Err(format!("expected #RRGGBB or #RRGGBBAA, got {value}"));
|
|
||||||
}
|
|
||||||
let byte = |offset| {
|
|
||||||
u8::from_str_radix(&value[offset..offset + 2], 16)
|
|
||||||
.map_err(|error| format!("invalid color {value}: {error}"))
|
|
||||||
};
|
|
||||||
Ok(egui::Color32::from_rgba_unmultiplied(
|
|
||||||
byte(0)?,
|
|
||||||
byte(2)?,
|
|
||||||
byte(4)?,
|
|
||||||
if value.len() == 8 { byte(6)? } else { 255 },
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn temporary_directory() -> PathBuf {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
|
||||||
.expect("system clock after epoch")
|
|
||||||
.as_nanos();
|
|
||||||
let directory = std::env::temp_dir().join(format!(
|
|
||||||
"blacksite-ui-gallery-reload-{}-{nonce}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
fs::create_dir_all(&directory).expect("create temporary fixture directory");
|
|
||||||
directory
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_documents_reload_fixture_without_reconstruction() {
|
|
||||||
let directory = temporary_directory();
|
|
||||||
fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
|
|
||||||
fs::write(directory.join("material.json"), "{}\n").expect("write material fixture");
|
|
||||||
let mut documents = RuntimeDocuments::from_directory(directory.clone());
|
|
||||||
assert!(documents.poll(0.0));
|
|
||||||
assert_eq!(documents.fixture_value.material_label, None);
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
directory.join("material.json"),
|
|
||||||
r#"{"material_label":"LIVE_RELOAD_PROOF"}"#,
|
|
||||||
)
|
|
||||||
.expect("update material fixture");
|
|
||||||
assert!(documents.poll(1.0));
|
|
||||||
assert_eq!(
|
|
||||||
documents.fixture_value.material_label.as_deref(),
|
|
||||||
Some("LIVE_RELOAD_PROOF")
|
|
||||||
);
|
|
||||||
assert_eq!(documents.error, None);
|
|
||||||
fs::remove_dir_all(directory).expect("remove temporary fixture directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_reload_keeps_the_last_valid_document_and_reports_the_error() {
|
|
||||||
let directory = temporary_directory();
|
|
||||||
fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
|
|
||||||
fs::write(
|
|
||||||
directory.join("material.json"),
|
|
||||||
r#"{"material_label":"baseline"}"#,
|
|
||||||
)
|
|
||||||
.expect("write material fixture");
|
|
||||||
let mut documents = RuntimeDocuments::from_directory(directory.clone());
|
|
||||||
assert!(documents.poll(0.0));
|
|
||||||
|
|
||||||
fs::write(directory.join("material.json"), "{ invalid json\n")
|
|
||||||
.expect("write invalid material fixture");
|
|
||||||
assert!(!documents.poll(1.0));
|
|
||||||
assert_eq!(
|
|
||||||
documents.fixture_value.material_label.as_deref(),
|
|
||||||
Some("baseline")
|
|
||||||
);
|
|
||||||
assert!(documents
|
|
||||||
.error
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|error| error.contains("material.json")));
|
|
||||||
fs::remove_dir_all(directory).expect("remove temporary fixture directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn theme_colors_accept_rgb_and_rgba_hex() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_color("#41C6CF").expect("valid RGB"),
|
|
||||||
egui::Color32::from_rgb(65, 198, 207)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_color("E25E5E80").expect("valid RGBA"),
|
|
||||||
egui::Color32::from_rgba_unmultiplied(226, 94, 94, 128)
|
|
||||||
);
|
|
||||||
assert!(parse_color("#not-a-color").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn asset_category_colors_are_runtime_theme_overrides() {
|
|
||||||
let overrides: ThemeOverrides =
|
|
||||||
serde_json::from_str(r##"{"asset_types":{"model":"#010203","shader":"#A0B0C0"}}"##)
|
|
||||||
.expect("asset type theme overrides");
|
|
||||||
let palette = overrides.palette().expect("resolved theme palette");
|
|
||||||
assert_eq!(palette.asset_types.model, egui::Color32::from_rgb(1, 2, 3));
|
|
||||||
assert_eq!(
|
|
||||||
palette.asset_types.shader,
|
|
||||||
egui::Color32::from_rgb(160, 176, 192)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
palette.asset_types.material,
|
|
||||||
editor_ui::theme::ASSET_TYPES.material
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -65,12 +65,6 @@ result.
|
|||||||
base. Promotion requires a resolved project or imported Material base.
|
base. Promotion requires a resolved project or imported Material base.
|
||||||
- Ordinary editor/runtime loading is read-only. Registry, project-settings, and scene migrations
|
- Ordinary editor/runtime loading is read-only. Registry, project-settings, and scene migrations
|
||||||
run only through the explicit transactional project upgrader.
|
run only through the explicit transactional project upgrader.
|
||||||
- Editor thumbnails use typed content-addressed artifacts under managed
|
|
||||||
`assets/.thumbnails/<version>/` storage. Their visual signatures cover source/dependency bytes,
|
|
||||||
resolved Material inputs and textures, custom shader files, subasset identity, and the studio
|
|
||||||
renderer version. Normal catalog invalidation revalidates the signature while retaining the
|
|
||||||
last-good GPU image; replacement publishes atomically after a valid render/readback. These
|
|
||||||
artifacts are editor-only, untracked, watcher-excluded, and absent from packages.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
@ -85,10 +79,9 @@ result.
|
|||||||
imported source because failure uses the project/engine fallback deliberately.
|
imported source because failure uses the project/engine fallback deliberately.
|
||||||
- One cached DefaultGrid/emergency handle covers repeated primitive and mesh hydration, so missing
|
- One cached DefaultGrid/emergency handle covers repeated primitive and mesh hydration, so missing
|
||||||
assignments do not grow the material asset stores.
|
assignments do not grow the material asset stores.
|
||||||
- Thumbnail cache ownership includes the backing Egui image registration. Replacement releases the
|
- Thumbnail cache ownership includes the backing Egui image registration. Replacement, retry, and
|
||||||
superseded registration only after the new artifact is usable; manual retry/full teardown release
|
catalog invalidation explicitly release the last registration before a new studio render owns
|
||||||
it immediately. Catalog refresh retains it as last-good while revalidating visual signatures,
|
the key, preventing refresh-driven GPU resource growth.
|
||||||
preventing both refresh flicker and hidden GPU resource growth.
|
|
||||||
- File operations and imports must be staged transactions that update authored references,
|
- File operations and imports must be staged transactions that update authored references,
|
||||||
registry state, derived manifests, and runtime catalogs together.
|
registry state, derived manifests, and runtime catalogs together.
|
||||||
- Ordinary asset-property edits use the explicit dirty-document and two-phase publication boundary
|
- Ordinary asset-property edits use the explicit dirty-document and two-phase publication boundary
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user