Compare commits

...

3 Commits

Author SHA1 Message Date
634ec16c5e test(editor): satisfy strict thumbnail cache lint
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled
2026-07-22 17:23:45 -04:00
cadb37341d test(editor): complete content browser fixtures 2026-07-22 17:20:54 -04:00
106d947c98 feat(editor): finalize M2 content workspace UX 2026-07-22 17:14:07 -04:00
69 changed files with 5897 additions and 4343 deletions

View File

@ -1,20 +1,30 @@
name: content-browser-card-layout name: content-browser-card-layout
purpose: Verify production-quality grid/list card layout and model-thumbnail identity at wide and narrow browser sizes. purpose: Verify that the production Content Browser uses the shared Penpot body presentation at wide and compact sizes.
preconditions: The running editor has the project Content Browser available with long asset names and the Poly Haven desk imported. preconditions: The focused shared-UI checks pass and the project Content Browser contains folders, long asset names, and the Poly Haven desk.
build_lane: dev build_lane: dev
fixture: fixture:
steps: steps:
- Open the Content Browser grid and navigate to the Office model folder. - Open the Content Browser grid at the project assets root with Sources and Details visible.
- 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:
- Grid labels stay inside clipped two-line footers with ellipsis and no status overlap. - Toolbar, body, and footer form one continuous stack with no unowned horizontal gutter.
- 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 grid-wide, grid-narrow, list, and desk geometry states plus hashes. evidence: Target-window captures for root-grid wide, Office-grid wide, compact grid, 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.

View File

@ -7,13 +7,14 @@ binary: ui_gallery
fixture: fixture:
steps: steps:
- Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime. - Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime.
- Inspect the default Materials 620 side-by-side reference and production component canvases. - Inspect the default Content Browser 1240 side-by-side reference and production component canvases.
- Switch through Materials 420, Renderer 760, and Renderer 480 presets. - 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. - 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. - Switch through direct, inherited, imported, dirty, processing, failed, read-only, and built-in fixtures.
assertions: assertions:
- The gallery opens as one responsive native window without launching the Blacksite editor or game runtime. - The gallery opens as one responsive native window without launching the Blacksite editor or game runtime.
- All four exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome. - 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. - 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. - 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. - Theme and material fixture JSON changes appear without rebuilding or restarting the gallery.

View File

@ -100,9 +100,10 @@ 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-15] Thumbnail cache replacement, retry, and catalog invalidation release the - [Implemented 2026-07-19] Typed texture/model/mesh/material previews persist as content-addressed
last Egui image registration for each key. Repeated studio renders no longer retain hidden GPU 256 px artifacts under the managed `.thumbnails` cache. Visual source/dependency hashes and a
render targets after the visible cache entry is gone. renderer version invalidate only affected entries; catalog refresh retains last-good GPU images,
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
@ -172,6 +173,23 @@ loader.
overlay, and Difference modes, all material ownership/health fixtures, runtime token/fixture 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 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. 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
View File

@ -17,6 +17,7 @@
# 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

1
Cargo.lock generated
View File

@ -3242,6 +3242,7 @@ dependencies = [
"game_hot", "game_hot",
"gltf", "gltf",
"hot-lib-reloader", "hot-lib-reloader",
"image",
"notify", "notify",
"polyanya", "polyanya",
"protocol", "protocol",

View File

@ -51,7 +51,7 @@ egui = "0.34.3"
eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } 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"] } egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold", "fill"] }
editor_ui = { path = "crates/editor_ui" } editor_ui = { path = "crates/editor_ui" }
material_schema = { path = "crates/material_schema" } material_schema = { path = "crates/material_schema" }
transform-gizmo-bevy = "0.9" transform-gizmo-bevy = "0.9"

View File

@ -89,10 +89,15 @@ 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 launches the current managed development binary process with that root. A normal desktop click incrementally builds the editor and project browser
without invoking Cargo. This keeps editor startup deterministic and prevents build-planning work through Blacksite's persistent managed development lane before launching, so saved Rust changes are
from consuming memory in the desktop session. When an explicit rebuild is required, run always represented by the opened binary. The splash reports exact resolved-crate progress while
`~/.local/bin/blacksite-editor --build-only`; that command uses Blacksite's managed Cargo lane. Cargo reuses warm artifacts; a build or storage-preflight failure leaves the splash open with an
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)
@ -211,9 +216,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 |
| 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 | | 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 |
| 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 |
| 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 | | 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 |
| 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 |
@ -534,6 +539,7 @@ 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))
@ -557,7 +563,9 @@ 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, search/filter/sort controls, expandable model material sphere thumbnails, and a managed content-addressed 256 px disk cache that reuses valid
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

View File

@ -54,3 +54,4 @@ 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"] }

View File

@ -8,6 +8,7 @@ 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;
@ -28,21 +29,66 @@ struct ThumbnailFailure {
retryable: bool, retryable: bool,
} }
#[derive(Resource, Default)] struct DecodedThumbnail {
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) {
app.init_resource::<AssetThumbnailCache>() let project_root = app
.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);
} }
} }
@ -59,7 +105,10 @@ impl AssetThumbnailCache {
retryable: failure.retryable, retryable: failure.retryable,
}); });
} }
if self.pending.contains_key(key) || self.studio_pending.contains(key) { if self.pending.contains_key(key)
|| self.decoded_pending.contains_key(key)
|| self.studio_pending.contains(key)
{
return Some(ThumbnailState::Pending); return Some(ThumbnailState::Pending);
} }
None None
@ -69,46 +118,48 @@ 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 matches!( if self.request_is_current(&key) {
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 matches!( if self.request_is_current(&key) {
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 };
if studio.enqueue(key.clone(), model_path) { 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);
} }
} }
@ -122,11 +173,7 @@ impl AssetThumbnailCache {
requires_scene_instance: bool, requires_scene_instance: bool,
studio: &mut ThumbnailStudio, studio: &mut ThumbnailStudio,
) { ) {
if matches!( if self.request_is_current(&key) {
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) {
@ -141,7 +188,10 @@ impl AssetThumbnailCache {
material_label, material_label,
} }
}; };
if studio.enqueue_source(key.clone(), source) { 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);
} }
} }
@ -153,23 +203,20 @@ impl AssetThumbnailCache {
material_label: String, material_label: String,
studio: &mut ThumbnailStudio, studio: &mut ThumbnailStudio,
) { ) {
if matches!( if self.request_is_current(&key) {
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;
} }
if studio.enqueue_source( let source = ThumbnailJobSource::SourceMaterial {
key.clone(), model_path,
ThumbnailJobSource::SourceMaterial { material_label,
model_path, };
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);
} }
} }
@ -180,11 +227,7 @@ impl AssetThumbnailCache {
path: String, path: String,
studio: &mut ThumbnailStudio, studio: &mut ThumbnailStudio,
) { ) {
if matches!( if self.request_is_current(&key) {
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) {
@ -200,24 +243,118 @@ 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());
if studio.enqueue_source( let source = ThumbnailJobSource::MaterialAsset {
key.clone(), source_path: path,
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
@ -228,6 +365,7 @@ 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
} }
@ -243,6 +381,7 @@ 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;
} }
} }
@ -253,16 +392,23 @@ 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 {
@ -277,8 +423,12 @@ 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()
@ -322,6 +472,7 @@ 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(),
@ -333,13 +484,40 @@ 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()
@ -362,6 +540,9 @@ 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);
} }
} }
@ -518,12 +699,9 @@ 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) {
let registrations = world.resource_mut::<AssetThumbnailCache>().invalidate_all(); world
if let Some(mut textures) = world.get_resource_mut::<EguiUserTextures>() { .resource_mut::<AssetThumbnailCache>()
for registration in registrations { .invalidate_catalog_epoch();
textures.remove_image(registration);
}
}
if !world.contains_resource::<ThumbnailStudio>() { if !world.contains_resource::<ThumbnailStudio>() {
return; return;
} }
@ -542,6 +720,14 @@ 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"))
@ -593,7 +779,11 @@ 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 mut cache = AssetThumbnailCache::default(); let cache_root = std::env::temp_dir().join(format!(
"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);
@ -617,7 +807,12 @@ 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("material:shared", image, &mut textures); cache.complete_studio_thumbnail(
"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);
} }
@ -639,8 +834,18 @@ 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("first", image.clone(), &mut textures); cache.complete_studio_thumbnail(
cache.complete_studio_thumbnail("second", image, &mut textures); "first",
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");
@ -648,4 +853,71 @@ 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();
}
} }

View File

@ -0,0 +1,652 @@
//! 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();
}
}

View File

@ -2,14 +2,21 @@
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,
}, },
@ -23,6 +30,7 @@ pub enum ThumbnailJobSource {
material_label: String, material_label: String,
}, },
MaterialAsset { MaterialAsset {
source_path: String,
label: String, label: String,
material: Box<MaterialDesc>, material: Box<MaterialDesc>,
}, },
@ -31,6 +39,7 @@ 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,

View File

@ -3,6 +3,7 @@
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;

View File

@ -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; use std::path::{Path, PathBuf};
/// 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,6 +10,23 @@ 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() {
@ -24,18 +41,16 @@ pub fn validate_gltf_dependencies(model_asset_path: &str) -> Result<(), String>
} }
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;
} }
let dependency = model_dir.join(uri); dependencies.push(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}"));
}
} }
Ok(()) dependencies.sort();
dependencies.dedup();
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.

View File

@ -9,17 +9,19 @@ 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::render_resource::TextureFormat; use bevy::render::gpu_readback::{Readback, ReadbackComplete};
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 = 128; pub(crate) const THUMB_SIZE: u32 = THUMBNAIL_EDGE;
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;
@ -31,6 +33,7 @@ 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;
@ -44,6 +47,11 @@ 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,
@ -58,6 +66,9 @@ 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,
@ -79,6 +90,7 @@ 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 { .. } => {
@ -99,11 +111,12 @@ impl Plugin for ThumbnailStudioPlugin {
} }
impl ThumbnailStudio { impl ThumbnailStudio {
pub fn enqueue(&mut self, cache_key: String, model_path: String) -> bool { pub(crate) fn enqueue_source(
self.enqueue_source(cache_key, ThumbnailJobSource::Model { model_path }) &mut self,
} cache_key: String,
source: ThumbnailJobSource,
pub fn enqueue_source(&mut self, cache_key: String, source: ThumbnailJobSource) -> bool { artifact: ThumbnailArtifact,
) -> bool {
if self if self
.active .active
.as_ref() .as_ref()
@ -114,7 +127,12 @@ 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 { cache_key, source }); self.queue.push_back(ThumbnailJob {
cache_key,
source,
artifact,
attempt: 0,
});
true true
} }
@ -324,8 +342,14 @@ 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,
@ -388,6 +412,9 @@ 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,
@ -408,6 +435,9 @@ 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,
@ -430,6 +460,7 @@ 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(
@ -551,7 +582,45 @@ fn labeled_asset_path(path: &str, label: &str) -> String {
} }
fn studio_render_image() -> Image { fn studio_render_image() -> Image {
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None) let mut image =
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 {
@ -591,6 +660,21 @@ 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);
} }

View File

@ -6,6 +6,13 @@ 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 {

View File

@ -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::{file_status_indicator_ui, CollaborationState}; use crate::project::collaboration::{CollaborationState, FileStatusTone};
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use super::state::{ use super::state::{
@ -50,18 +50,16 @@ 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::theme::{ use crate::ui::theme::{
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
WARNING, WIDGET_BG,
}; };
const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT; const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT;
const TOOLBAR_HEIGHT: f32 = editor_ui::content_browser::TOOLBAR_HEIGHT;
const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0; const DETAILS_MIN_PANEL_WIDTH: f32 = 980.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 = editor_ui::content_browser::COMPACT_BREAKPOINT;
const TREE_MIN_WIDTH: f32 = 180.0; const TREE_MIN_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH;
const TREE_MAX_WIDTH: f32 = 216.0; const TREE_MAX_WIDTH: f32 = editor_ui::content_browser::SOURCES_WIDTH;
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";
@ -100,88 +98,6 @@ 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,
@ -189,7 +105,9 @@ 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 body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0); let toolbar_height =
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(
@ -197,12 +115,15 @@ 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);
@ -254,23 +175,40 @@ pub fn asset_browser_ui(
prefetch_folder_thumbnails(world, &current_folder); prefetch_folder_thumbnails(world, &current_folder);
let show_tree = available_width >= TREE_MIN_PANEL_WIDTH; let compact = available_width < TREE_MIN_PANEL_WIDTH;
let show_details = let show_tree = if compact {
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH; state_snapshot.show_sources
} 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 {
(available_width * 0.18).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH) TREE_MAX_WIDTH.min((available_width - MIN_CONTENT_WIDTH).max(TREE_MIN_WIDTH))
} else { } else {
0.0 0.0
}; };
let details_width = if show_details { let details_width = if show_details {
details_width_for_layout(available_width, tree_width, state_snapshot.details_width) if compact {
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
}; };
let separator_budget = (show_tree as u8 as f32) * 10.0 // Penpot separators and the Details resize target overlay their owning panes. They do
+ (show_details as u8 as f32) * DETAILS_RESIZE_HANDLE_WIDTH; // not consume layout width: 216 + 734 + 290 must close exactly at the 1240 px reference.
let content_width = (available_width - tree_width - details_width - separator_budget) let content_width =
.max(MIN_CONTENT_WIDTH.min(available_width)); content_width_for_layout(available_width, tree_width, details_width);
exact_region( exact_region(
ui, ui,
@ -283,11 +221,9 @@ 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| {
ui.label( editor_ui::content_browser::content_browser_pane_background(ui);
egui::RichText::new("SOURCES") editor_ui::content_browser::content_browser_pane_heading(
.small() ui, "SOURCES", false,
.strong()
.color(TEXT_MUTED),
); );
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("asset_folder_tree") .id_salt("asset_folder_tree")
@ -307,7 +243,6 @@ 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(
@ -315,7 +250,16 @@ 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| {
content_header(world, ui, &current_folder, &folders, &assets); editor_ui::content_browser::content_browser_pane_background(ui);
content_header(
world,
ui,
&current_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())
@ -344,16 +288,21 @@ 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));
asset_grid( editor_ui::content_browser::content_browser_grid_inset(
world,
ui, ui,
selected_entities, |ui| {
&selected, asset_grid(
&child_folders, world,
&visible_assets, ui,
&cache_snapshot, selected_entities,
thumbnail_size, &selected,
&visible_order, &child_folders,
&visible_assets,
&cache_snapshot,
thumbnail_size,
&visible_order,
);
},
); );
} else { } else {
asset_list( asset_list(
@ -375,27 +324,16 @@ pub fn asset_browser_ui(
content_empty_space(world, content_response, &current_folder, &visible_order); content_empty_space(world, content_response, &current_folder, &visible_order);
if show_details { if show_details {
let resize_response = details_resize_handle(ui, body_height); let details_boundary = ui.next_widget_position();
let requested_width = if resize_response.double_clicked() {
Some(ASSET_DETAILS_DEFAULT_WIDTH)
} else if resize_response.dragged() {
Some(details_width - resize_response.drag_delta().x)
} else {
None
};
if let Some(requested_width) = requested_width {
world.resource_mut::<AssetBrowserUiState>().details_width =
details_width_for_layout(
available_width,
tree_width,
requested_width,
);
}
exact_region( exact_region(
ui, ui,
egui::vec2(details_width, body_height), egui::vec2(details_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);
editor_ui::content_browser::content_browser_details_pane_heading(
ui, "DETAILS", true,
);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("asset_details_area") .id_salt("asset_details_area")
.max_height(ui.available_height()) .max_height(ui.available_height())
@ -405,6 +343,27 @@ pub fn asset_browser_ui(
}); });
}, },
); );
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() {
Some(ASSET_DETAILS_DEFAULT_WIDTH)
} else if resize_response.dragged() {
Some(details_width - resize_response.drag_delta().x)
} else {
None
};
if let Some(requested_width) = requested_width {
world.resource_mut::<AssetBrowserUiState>().details_width =
details_width_for_layout(
available_width,
tree_width,
requested_width,
);
}
}
} }
}, },
); );
@ -422,17 +381,45 @@ pub fn asset_browser_ui(
.iter() .iter()
.filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str())) .filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
.count(); .count();
let summary = if selection_count > 0 { let selection_summary = if selection_count == 1 {
format!("{selection_count} selected") 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 { } else {
format!("{direct_folders} folders · {direct_assets} assets") 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( editor_ui::content_browser::content_browser_status_bar(
ui, ui,
editor_ui::content_browser::ContentBrowserStatusViewModel { editor_ui::content_browser::ContentBrowserStatusViewModel {
summary: &summary, catalog,
location: &current_folder, processing: &processing,
status: &browser_status, selection: &selection_summary,
healthy,
}, },
); );
}, },
@ -460,6 +447,8 @@ 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"]
@ -486,6 +475,7 @@ 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::*;

View File

@ -1,10 +1,11 @@
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) {
ui.horizontal_wrapped(|ui| { editor_ui::content_browser::content_browser_detail_row(ui, label, value);
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(
@ -12,8 +13,6 @@ 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();
@ -61,7 +60,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_row(ui, "Path", &path); detail_path_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);
@ -71,7 +70,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 {
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); editor_ui::content_browser::content_browser_details_empty_state(ui, "No asset selected");
} }
} }
@ -82,9 +81,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);
ui.add_space(8.0); editor_ui::content_browser::content_browser_details_section(ui, "IDENTITY");
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); detail_path_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
detail_row(ui, "Folder", asset.folder_path.as_str()); detail_path_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();
@ -101,66 +100,43 @@ 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();
ui.separator(); editor_ui::content_browser::content_browser_details_section(ui, "IMPORT SETTINGS");
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_row(ui, "Static mesh artifact", manifest); detail_path_row(ui, "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() {
ui.separator(); editor_ui::content_browser::content_browser_details_section(
ui.label(panel_heading("Texture Properties")); ui,
"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() {
ui.separator(); editor_ui::content_browser::content_browser_details_section(ui, "DEPENDENCIES");
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| { if missing_count > 0 {
ui.label(panel_heading("Dependencies")); let optional = !model_uses_source_materials(&record);
if missing_count > 0 { detail_row(
let optional = !model_uses_source_materials(&record); ui,
ui.small( "Status",
egui::RichText::new(if optional { &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();
ui.horizontal(|ui| { detail_path_row(ui, if exists { "Ready" } else { "Missing" }, dep);
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);
});
} }
} }
} }
@ -178,8 +154,7 @@ pub(crate) fn top_level_asset_details_panel(
.unwrap_or("-"), .unwrap_or("-"),
); );
if matches!(asset.kind, EditorAssetKind::Material) { if matches!(asset.kind, EditorAssetKind::Material) {
ui.separator(); editor_ui::content_browser::content_browser_details_section(ui, "MATERIAL");
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);
@ -227,59 +202,81 @@ 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: &World, ui: &mut egui::Ui, asset: &EditorAsset) { pub(super) fn asset_details_header(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset) {
let thumbnail = world let cache = world
.get_resource::<AssetThumbnailCache>() .get_resource::<AssetThumbnailCache>()
.and_then(|cache| cache.snapshot().texture_for(asset)); .map(AssetThumbnailCache::snapshot);
ui.horizontal_top(|ui| { let thumbnail = if let Some(texture) = cache.as_ref().and_then(|cache| cache.texture_for(asset))
let (preview, _) = ui.allocate_exact_size(egui::vec2(96.0, 96.0), egui::Sense::hover()); {
ui.painter().rect( editor_ui::content_browser::ContentBrowserThumbnail::Ready(texture)
preview, } else if cache.as_ref().is_some_and(|cache| cache.is_pending(asset)) {
5.0, editor_ui::content_browser::ContentBrowserThumbnail::Pending
WIDGET_BG, } else if cache
egui::Stroke::new(1.0_f32, BORDER), .as_ref()
egui::StrokeKind::Inside, .and_then(|cache| cache.failure_reason(asset))
); .is_some()
if let Some(texture) = thumbnail { {
ui.painter().image( editor_ui::content_browser::ContentBrowserThumbnail::Failed
texture, } else {
preview.shrink(4.0), editor_ui::content_browser::ContentBrowserThumbnail::Placeholder
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), };
egui::Color32::WHITE, let dirty = asset.path.as_deref().is_some_and(|path| {
); world
} else { .get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
ui.painter().text( .and_then(|store| store.snapshot_for_path(path))
preview.center(), .is_some_and(|snapshot| snapshot.dirty)
egui::Align2::CENTER_CENTER,
kind_icon(&asset.kind).as_str(),
egui::FontId::new(30.0, egui::FontFamily::Name(PHOSPHOR.into())),
TEXT_DIM,
);
}
ui.vertical(|ui| {
ui.add(egui::Label::new(egui::RichText::new(&asset.label).strong()).truncate())
.on_hover_text(&asset.label);
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
if let (Some(state), Some(path)) = (
world.get_resource::<CollaborationState>(),
asset.path.as_deref(),
) {
let status = state.file_status(Path::new(path));
let _ = file_status_indicator_ui(ui, &status, Path::new(path));
}
if let Some(snapshot) = asset.path.as_deref().and_then(|path| {
world
.get_resource::<crate::asset_documents::AuthoredAssetDocumentStore>()
.and_then(|store| store.snapshot_for_path(path))
}) {
if snapshot.dirty {
ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong());
}
}
ui.add_space(4.0);
ui.small(egui::RichText::new("Asset identity and import settings").color(TEXT_MUTED));
});
}); });
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(

View File

@ -8,7 +8,9 @@ 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,
} }
@ -20,7 +22,9 @@ 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,
} }
} }

View File

@ -1,4 +1,5 @@
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,
@ -29,57 +30,60 @@ pub(super) fn asset_grid(
thumbnail_size: f32, thumbnail_size: f32,
visible_order: &[AssetSelection], visible_order: &[AssetSelection],
) { ) {
let thumb_size = thumbnail_size.clamp(48.0, 112.0); let cell_width = editor_ui::content_browser::content_browser_asset_card_size(thumbnail_size).x;
let cell_width = thumb_size + 20.0; let gap = editor_ui::content_browser::ASSET_CARD_GAP;
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;
for folder_row in folders.chunks(columns) { let entries = continuous_grid_entries(folders.len(), assets.len());
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 folder in folder_row { for entry in entry_row {
let selection = AssetSelection::Folder(folder.path.clone()); if let ContentBrowserGridEntry::Folder(folder_index) = *entry {
let is_selected = world.resource::<EditorAssets>().is_selected(&selection); let folder = &folders[folder_index];
let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected); let selection = AssetSelection::Folder(folder.path.clone());
handle_asset_drop_to_folder(world, ui, &response, &folder.path); let is_selected = world.resource::<EditorAssets>().is_selected(&selection);
if response.secondary_clicked() { let response = draw_folder_cell(ui, folder, thumbnail_size, is_selected);
prepare_context_menu_selection(world, selection.clone()); handle_asset_drop_to_folder(world, ui, &response, &folder.path);
if response.secondary_clicked() {
prepare_context_menu_selection(world, selection.clone());
}
if response.double_clicked() {
navigate_content_folder(world, folder.path.clone());
} else if response.clicked() {
apply_content_click_selection(world, ui, selection.clone(), visible_order);
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.start_drag(selection.clone());
}
response.context_menu(|ui| folder_context_menu(world, ui, folder));
} else {
let ContentBrowserGridEntry::Asset(asset_index) = *entry else {
unreachable!("folder entries are handled above")
};
draw_asset_grid_item(
world,
ui,
selected_entities,
selected,
&assets[asset_index],
cache_snapshot,
thumbnail_size,
visible_order,
);
} }
if response.double_clicked() {
navigate_content_folder(world, folder.path.clone());
} else if response.clicked() {
apply_content_click_selection(world, ui, selection.clone(), visible_order);
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.start_drag(selection.clone());
}
response.context_menu(|ui| folder_context_menu(world, ui, folder));
}
});
}
for asset_row in assets.chunks(columns) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(gap, gap);
for row in asset_row {
draw_asset_grid_item(
world,
ui,
selected_entities,
selected,
row,
cache_snapshot,
thumbnail_size,
visible_order,
);
} }
}); });
for row in asset_row { for entry in entry_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>()
@ -166,19 +170,22 @@ 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),
cache_snapshot.is_pending(asset), thumbnail_pending,
failure, failure,
is_selected, is_selected,
thumbnail_size, thumbnail_size,
AssetCardStatus::for_document( card_status,
document_status.as_ref(),
document_status.as_ref().is_some_and(|status| status.dirty),
failure.is_some(),
),
); );
if has_children { if has_children {

View File

@ -0,0 +1,94 @@
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")
}

View File

@ -368,39 +368,19 @@ pub(super) fn draw_folder_cell(
thumbnail_size: f32, thumbnail_size: f32,
is_selected: bool, is_selected: bool,
) -> egui::Response { ) -> egui::Response {
let thumb_size = thumbnail_size.clamp(48.0, 112.0); editor_ui::content_browser::content_browser_asset_card(
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0); ui,
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); editor_ui::content_browser::ContentBrowserCardViewModel {
let fill = if is_selected { label: &folder.name,
crate::ui::theme::SELECTION_BG_MUTED kind: "Folder",
} else if response.hovered() { icon: icons::FOLDER_OPEN,
ELEVATED_BG icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Fill,
} else { thumbnail: editor_ui::content_browser::ContentBrowserThumbnail::Placeholder,
WIDGET_BG asset_tone: editor_ui::content_browser::ContentBrowserAssetTone::None,
}; selected: is_selected,
ui.painter().rect( status: editor_ui::content_browser::ContentBrowserCardStatus::default(),
rect, failure_tooltip: None,
4.0, },
fill, thumbnail_size,
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
} }

View File

@ -6,6 +6,7 @@ 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 { let folder_label = if current_folder == BUILTINS_FOLDER {
"Built-ins" "Built-ins"
@ -15,7 +16,6 @@ pub(super) fn content_header(
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.unwrap_or(current_folder) .unwrap_or(current_folder)
}; };
ui.label(panel_heading(folder_label));
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)
@ -25,13 +25,14 @@ pub(super) fn content_header(
.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; let sort = world.resource::<AssetBrowserUiState>().sort;
ui.small( editor_ui::content_browser::content_browser_content_header(
egui::RichText::new(format!( ui,
"{} items · sorted by {}", editor_ui::content_browser::ContentBrowserContentHeaderViewModel {
direct_folders + direct_assets, folder: folder_label,
sort_label(sort) item_count: direct_folders + direct_assets,
)) sort: sort_label(sort),
.color(TEXT_DIM), selection_count,
},
); );
} }
@ -100,48 +101,41 @@ pub(super) fn folder_tree_branch(
.contains(path); .contains(path);
let selected = current_folder == *path; let selected = current_folder == *path;
ui.horizontal(|ui| { let folder_count = world
ui.add_space(depth as f32 * 12.0); .resource::<EditorAssets>()
if children.is_empty() { .assets
ui.add_sized( .iter()
[18.0, 20.0], .filter(|asset| asset.folder_path == *path)
egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)), .count();
); 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 {
let mut state = world.resource_mut::<AssetBrowserUiState>();
if expanded {
state.expanded_folders.remove(path);
} else { } else {
let icon = if expanded { state.expanded_folders.insert(path.clone());
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>();
if expanded {
state.expanded_folders.remove(path);
} else {
state.expanded_folders.insert(path.clone());
}
}
} }
ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); }
let folder_response = ui.add_sized( let folder_response = row.row;
[fit_width(ui, 72.0, f32::INFINITY), 20.0], if folder_response.clicked() {
egui::Button::selectable(selected, name.as_str()), navigate_content_folder(world, path.clone());
); }
if folder_response.clicked() { if folder_response.secondary_clicked() {
navigate_content_folder(world, path.clone()); prepare_context_menu_selection(world, AssetSelection::Folder(path.clone()));
} }
if folder_response.secondary_clicked() { folder_response.context_menu(|ui| folder_context_menu(world, ui, folder));
prepare_context_menu_selection(world, AssetSelection::Folder(path.clone())); handle_asset_drop_to_folder(world, ui, &folder_response, path);
}
folder_response.context_menu(|ui| folder_context_menu(world, ui, folder));
handle_asset_drop_to_folder(world, ui, &folder_response, path);
});
if expanded { if expanded {
for child in children { for child in children {

View File

@ -28,6 +28,7 @@
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());
@ -274,6 +275,7 @@
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");
@ -394,15 +396,27 @@
} }
#[test] #[test]
fn details_width_respects_content_and_pane_minimums() { fn content_browser_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), 600.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, 420.0), 420.0); assert_eq!(details_width_for_layout(1_000.0, 220.0, 420.0), 420.0);
} }
#[test] #[test]
fn details_width_uses_reclaimed_tree_space() { fn content_browser_details_width_uses_reclaimed_tree_space() {
assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 830.0); assert_eq!(details_width_for_layout(1_000.0, 0.0, 900.0), 840.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]

View File

@ -129,8 +129,10 @@
#[test] #[test]
fn content_navigation_history_round_trips_like_a_file_manager() { fn content_navigation_history_round_trips_like_a_file_manager() {
let mut world = World::new(); let mut world = World::new();
let mut assets = EditorAssets::default(); let assets = EditorAssets {
assets.current_folder = "assets".into(); current_folder: "assets".into(),
..Default::default()
};
world.insert_resource(assets); world.insert_resource(assets);
world.insert_resource(AssetBrowserUiState::default()); world.insert_resource(AssetBrowserUiState::default());
@ -178,7 +180,6 @@
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]

View File

@ -16,7 +16,9 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
kind_filter, kind_filter,
thumbnail_size, thumbnail_size,
recursive, recursive,
show_sources,
show_details, show_details,
show_compact_details,
can_undo, can_undo,
can_paste, can_paste,
can_go_back, can_go_back,
@ -30,21 +32,45 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
state.kind_filter, state.kind_filter,
state.thumbnail_size, state.thumbnail_size,
state.recursive, state.recursive,
state.show_sources,
state.show_details, state.show_details,
state.show_compact_details,
!state.content_undo.is_empty(), !state.content_undo.is_empty(),
state.clipboard.is_some() && current_folder != BUILTINS_FOLDER, state.clipboard.is_some() && current_folder != BUILTINS_FOLDER,
!state.navigation_back.is_empty(), !state.navigation_back.is_empty(),
!state.navigation_forward.is_empty(), !state.navigation_forward.is_empty(),
) )
}; };
let (direct_folder_count, direct_asset_count) = {
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 { let model = editor_ui::content_browser::ContentBrowserToolbarViewModel {
chrome: editor_ui::content_browser::ContentBrowserChrome::Docked,
path: content_path_segments(&current_folder), path: content_path_segments(&current_folder),
search, search,
kind_filter: kind_filter.into(), kind_filter: kind_filter.into(),
sort: sort.into(), sort: sort.into(),
view: view.into(), view: view.into(),
recursive, recursive,
show_details, show_sources,
show_details: if compact {
show_compact_details
} else {
show_details
},
thumbnail_size, thumbnail_size,
can_go_parent: current_folder.contains('/'), can_go_parent: current_folder.contains('/'),
can_go_back, can_go_back,
@ -55,6 +81,8 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
can_paste, can_paste,
can_undo, can_undo,
pending_repairs, pending_repairs,
direct_folder_count,
direct_asset_count,
}; };
let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model); let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model);
let mut visible_results_changed = false; let mut visible_results_changed = false;
@ -99,8 +127,23 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
world.resource_mut::<AssetBrowserUiState>().recursive = recursive; world.resource_mut::<AssetBrowserUiState>().recursive = recursive;
visible_results_changed = true; visible_results_changed = true;
} }
Action::SetShowSources(show) => {
let mut state = world.resource_mut::<AssetBrowserUiState>();
state.show_sources = show;
if show {
state.show_compact_details = false;
}
}
Action::SetShowDetails(show) => { Action::SetShowDetails(show) => {
world.resource_mut::<AssetBrowserUiState>().show_details = show; let mut state = world.resource_mut::<AssetBrowserUiState>();
if compact {
state.show_compact_details = show;
if show {
state.show_sources = false;
}
} else {
state.show_details = show;
}
} }
Action::SetThumbnailSize(size) => { Action::SetThumbnailSize(size) => {
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size; world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;

View File

@ -114,19 +114,6 @@ 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",

View File

@ -43,7 +43,9 @@ 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. /// File-manager navigation history. Content topology operations do not rewrite these stacks.
pub(crate) navigation_back: Vec<String>, pub(crate) navigation_back: Vec<String>,
pub(crate) navigation_forward: Vec<String>, pub(crate) navigation_forward: Vec<String>,
@ -188,7 +190,9 @@ impl Default for AssetBrowserUiState {
kind_filter: AssetKindFilter::All, kind_filter: AssetKindFilter::All,
thumbnail_size: 96.0, thumbnail_size: 96.0,
recursive: false, recursive: false,
show_sources: false,
show_details: true, show_details: true,
show_compact_details: false,
navigation_back: Vec::new(), navigation_back: Vec::new(),
navigation_forward: Vec::new(), navigation_forward: Vec::new(),
details_width: ASSET_DETAILS_DEFAULT_WIDTH, details_width: ASSET_DETAILS_DEFAULT_WIDTH,

View File

@ -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}; use crate::assets::{kind_icon, EditorAsset, EditorAssetKind};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AssetCardStatus { pub struct AssetCardStatus {
@ -50,17 +50,54 @@ pub fn draw_asset_card(
thumbnail_size: f32, thumbnail_size: f32,
status: AssetCardStatus, status: AssetCardStatus,
) -> egui::Response { ) -> egui::Response {
let response = draw_asset_cell( let thumbnail = if let Some(texture) = texture_id {
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,
asset, editor_ui::content_browser::ContentBrowserCardViewModel {
texture_id, label: &asset.label,
pending, kind: asset_kind_label(&asset.kind),
failure, icon: kind_icon(&asset.kind),
selected, icon_style: editor_ui::design_system::controls::PhosphorIconStyle::Regular,
thumbnail,
asset_tone: asset_kind_tone(&asset.kind),
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) {
@ -124,102 +161,19 @@ pub fn draw_asset_status_marker(ui: &mut egui::Ui, status: AssetCardStatus) {
}); });
} }
#[allow(clippy::too_many_arguments)] fn asset_kind_label(kind: &EditorAssetKind) -> &'static str {
fn draw_asset_cell( match kind {
ui: &mut egui::Ui, EditorAssetKind::Primitive(_) => "Primitive",
asset: &EditorAsset, EditorAssetKind::Light(_) => "Light",
texture_id: Option<egui::TextureId>, EditorAssetKind::Model => "Model",
pending: bool, EditorAssetKind::Texture => "Texture",
failed: Option<&str>, EditorAssetKind::Material => "Material",
selected: bool, EditorAssetKind::AudioClip => "Audio Clip",
thumbnail_size: f32, EditorAssetKind::Level => "Level",
) -> egui::Response { EditorAssetKind::Prefab => "Prefab",
let thumb_size = thumbnail_size.clamp(48.0, 112.0); EditorAssetKind::PostProcessVolume => "Post Process Volume",
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 42.0); EditorAssetKind::PostProcessEffect => "Post FX",
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag()); EditorAssetKind::RenderingProfile => "Rendering Profile",
let fill = if selected { EditorAssetKind::ShaderSchema => "Shader Schema",
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
} }
} }

View File

@ -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 => "Asset Browser", EditorTab::AssetBrowser => "Content Browser",
EditorTab::MaterialLibrary => "Material Library", EditorTab::MaterialLibrary => "Material Library",
EditorTab::Build => "Build", EditorTab::Build => "Build",
EditorTab::Toolbar => "Toolbar (legacy)", EditorTab::Toolbar => "Toolbar (legacy)",

View File

@ -280,24 +280,6 @@ 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,

View File

@ -1,281 +0,0 @@
//! Non-interactive Penpot v2.3.1 renderer-control preview.
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use crate::ui::design_system::typography::TypeRole;
use crate::ui::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT};
const PLANNED_TOOLTIP: &str = "Planned — renderer support pending";
pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) {
let open_id = ui.make_persistent_id("material_unsupported_advanced_open");
let reveal_id = open_id.with("reveal_complete_body");
let mut open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(open_id))
.unwrap_or(false);
let width = ui.available_width().max(1.0);
let mode = super::layout::ResponsiveMaterialLayout::for_width(width);
let height = if open {
if mode == super::layout::ResponsiveMaterialLayout::Transient {
254.0
} else {
ADVANCED_EXPANDED_HEIGHT
}
} else {
ADVANCED_COLLAPSED_HEIGHT
};
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
ui.painter().rect(
rect,
5.0,
palette.section,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 30.0));
ui.painter().rect_filled(header, 4.0, palette.elevated);
let header_response = ui.interact(header, open_id.with("header"), egui::Sense::click());
if header_response.clicked() {
open = !open;
ui.ctx().data_mut(|data| data.insert_temp(reveal_id, open));
}
header_response.on_hover_text(if open {
"Collapse Advanced preview"
} else {
"Expand Advanced preview"
});
ui.painter().text(
header.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
if open {
icons::CARET_DOWN.as_str()
} else {
icons::CARET_RIGHT.as_str()
},
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary,
);
ui.painter().text(
header.left_center() + egui::vec2(30.0, 0.0),
egui::Align2::LEFT_CENTER,
"Advanced",
TypeRole::Section.font(),
palette.text_primary,
);
if !open && rect.width() >= 300.0 {
ui.painter().text(
header.right_center() - egui::vec2(12.0, 0.0),
egui::Align2::RIGHT_CENTER,
PLANNED_TOOLTIP,
TypeRole::Small.font(),
palette.text_muted,
);
}
if open {
match mode {
super::layout::ResponsiveMaterialLayout::Reference => reference_controls(ui, rect),
super::layout::ResponsiveMaterialLayout::Compact => {
compact_reference_controls(ui, rect)
}
super::layout::ResponsiveMaterialLayout::Transient => compact_controls(ui, rect),
}
}
let reveal_complete_body = ui
.ctx()
.data_mut(|data| data.get_temp::<bool>(reveal_id))
.unwrap_or(false);
if open && reveal_complete_body && rect.height() > ADVANCED_COLLAPSED_HEIGHT {
ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM));
ui.ctx().data_mut(|data| data.remove::<bool>(reveal_id));
}
ui.ctx()
.data_mut(|data| data.insert_persisted(open_id, open));
}
fn reference_controls(ui: &mut egui::Ui, rect: egui::Rect) {
disabled_field(ui, rect, 28.0, 42.0, "Culling", "Back");
disabled_field(ui, rect, 28.0, 76.0, "Render Queue", "From Shader");
disabled_toggle(ui, rect, 328.0, 42.0, "Depth Write", true);
disabled_toggle(ui, rect, 328.0, 76.0, "Receive Shadows", true);
planned_note(ui, rect, 28.0, 124.0);
}
fn compact_reference_controls(ui: &mut egui::Ui, rect: egui::Rect) {
disabled_field_at(ui, rect, 20.0, 90.0, 90.0, 42.0, "Culling", "Back");
disabled_field_at(
ui,
rect,
20.0,
90.0,
90.0,
76.0,
"Render Queue",
"From Shader",
);
disabled_toggle_at(
ui,
rect,
196.0,
rect.width() - 38.0,
42.0,
"Depth Write",
true,
);
disabled_toggle_at(
ui,
rect,
196.0,
rect.width() - 38.0,
76.0,
"Receive Shadows",
true,
);
planned_note(ui, rect, 20.0, 124.0);
}
fn compact_controls(ui: &mut egui::Ui, rect: egui::Rect) {
disabled_field(ui, rect, 20.0, 42.0, "Culling", "Back");
disabled_field(ui, rect, 20.0, 78.0, "Render Queue", "From Shader");
disabled_toggle(ui, rect, 20.0, 124.0, "Depth Write", true);
disabled_toggle(ui, rect, 20.0, 160.0, "Receive Shadows", true);
planned_note(ui, rect, 20.0, 210.0);
}
fn disabled_field(ui: &mut egui::Ui, rect: egui::Rect, x: f32, y: f32, label: &str, value: &str) {
disabled_field_at(ui, rect, x, x + 106.0, 150.0, y, label, value);
}
#[expect(
clippy::too_many_arguments,
reason = "explicit Penpot preview geometry"
)]
fn disabled_field_at(
ui: &mut egui::Ui,
rect: egui::Rect,
label_x: f32,
field_x: f32,
field_width: f32,
y: f32,
label: &str,
value: &str,
) {
let palette = design_system::palette(ui);
ui.painter().text(
rect.min + egui::vec2(label_x, y),
egui::Align2::LEFT_CENTER,
label,
TypeRole::Body.font(),
palette.text_muted,
);
let field = egui::Rect::from_min_size(
egui::pos2(rect.left() + field_x, rect.top() + y - 7.0),
egui::vec2(field_width.min(rect.width() - field_x - 8.0).max(1.0), 24.0),
);
paint_disabled_field(ui, field, value);
}
fn disabled_toggle(
ui: &mut egui::Ui,
rect: egui::Rect,
x: f32,
y: f32,
label: &str,
enabled: bool,
) {
disabled_toggle_at(ui, rect, x, x + 106.0, y, label, enabled);
}
fn disabled_toggle_at(
ui: &mut egui::Ui,
rect: egui::Rect,
label_x: f32,
toggle_x: f32,
y: f32,
label: &str,
enabled: bool,
) {
let palette = design_system::palette(ui);
ui.painter().text(
rect.min + egui::vec2(label_x, y),
egui::Align2::LEFT_CENTER,
label,
TypeRole::Body.font(),
palette.text_muted,
);
let toggle = egui::Rect::from_min_size(
egui::pos2(rect.left() + toggle_x, rect.top() + y - 8.0),
egui::vec2(30.0, 16.0),
);
ui.painter().rect(
toggle,
8.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let thumb_x = if enabled {
toggle.right() - 8.0
} else {
toggle.left() + 8.0
};
ui.painter().circle_filled(
egui::pos2(thumb_x, toggle.center().y),
5.0,
palette.text_muted,
);
ui.interact(
toggle,
ui.make_persistent_id(("advanced_toggle", label)),
egui::Sense::hover(),
)
.on_hover_text(PLANNED_TOOLTIP);
}
fn paint_disabled_field(ui: &mut egui::Ui, rect: egui::Rect, value: &str) {
let palette = design_system::palette(ui);
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.left_center() + egui::vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
value,
TypeRole::Body.font(),
palette.text_muted,
);
ui.interact(
rect,
ui.make_persistent_id(("advanced_field", value)),
egui::Sense::hover(),
)
.on_hover_text(PLANNED_TOOLTIP);
}
fn planned_note(ui: &egui::Ui, rect: egui::Rect, x: f32, y: f32) {
let palette = design_system::palette(ui);
ui.painter().text(
rect.min + egui::vec2(x, y),
egui::Align2::LEFT_CENTER,
PLANNED_TOOLTIP,
TypeRole::Small.font(),
palette.text_muted,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_advanced_preview_matches_v231_heights() {
assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0);
assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0);
}
}

View File

@ -1,657 +0,0 @@
use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section};
use super::*;
use crate::ui::design_system;
use crate::ui::design_system::color_picker::{color_control, color_control_with_intensity};
use crate::ui::design_system::controls::{
scalar_control, section_frame, section_header, section_header_with_summary, switch,
};
use crate::ui::design_system::property_grid::PropertyGridGeometry;
use crate::ui::design_system::typography::TypeRole;
pub(crate) fn material_input_schema_editor(
ui: &mut egui::Ui,
schema: &MaterialInputSchema,
inputs: &mut MaterialInputSet,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
) -> MaterialInputEditorResponse {
material_input_schema_editor_with_inheritance(
ui,
schema,
inputs,
None,
texture_assets,
drop_candidate,
)
}
pub(super) fn material_input_schema_editor_with_inheritance(
ui: &mut egui::Ui,
schema: &MaterialInputSchema,
inputs: &mut MaterialInputSet,
inherited: Option<&MaterialInputSet>,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
) -> MaterialInputEditorResponse {
let mut response = MaterialInputEditorResponse::default();
let has_standard_packed_inputs = ["occlusion", "roughness", "metallic"]
.iter()
.all(|name| schema.inputs.iter().any(|input| input.name == *name));
let mut groups = schema.groups.clone();
if groups.is_empty() {
groups.push(shared::MaterialInputGroupDesc {
id: String::new(),
display_name: "Properties".into(),
order: 0,
advanced: false,
});
}
groups.sort_by_key(|group| group.order);
for group in groups {
let mut properties = schema
.inputs
.iter()
.filter(|property| {
property.group == group.id
&& matches!(
property.presentation,
shared::MaterialInputPresentation::Row
)
})
.collect::<Vec<_>>();
let companions = schema
.inputs
.iter()
.filter(|property| property.group == group.id)
.filter_map(|property| match &property.presentation {
shared::MaterialInputPresentation::Companion { owner } => {
Some((owner.as_str(), property))
}
shared::MaterialInputPresentation::Row => None,
})
.collect::<Vec<_>>();
properties.sort_by_key(|property| property.order);
if properties.is_empty() {
continue;
}
if group.id == "uv_transform" {
material_uv_transform_ui(ui, inputs, inherited);
ui.add_space(8.0);
continue;
}
let open_id = ui.make_persistent_id(("material_input_group_open", &group.id));
let mut open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(open_id))
.unwrap_or(!group.advanced);
section_frame(ui).show(ui, |ui| {
if group.id == "surface_inputs" {
let summary = format!("{} bindings", properties.len());
section_header_with_summary(ui, "Inputs", Some(&summary), &mut open);
} else {
section_header(ui, &group.display_name, &mut open);
}
if open {
let rows = material_input_rows(
ui,
&properties,
&companions,
inputs,
texture_assets,
drop_candidate,
);
response.accepted_drop |= rows.accepted_drop;
response.locate = response.locate.take().or(rows.locate);
response.browse_library |= rows.browse_library;
}
});
ui.ctx()
.data_mut(|data| data.insert_persisted(open_id, open));
ui.add_space(8.0);
}
if has_standard_packed_inputs {
super::advanced_preview::unsupported_advanced_preview(ui);
}
response
}
fn material_uv_transform_ui(
ui: &mut egui::Ui,
inputs: &mut MaterialInputSet,
inherited: Option<&MaterialInputSet>,
) {
let mut offset = material_vec2_value(inputs, "uv_offset", Vec2::ZERO);
let mut tiling = material_vec2_value(inputs, "uv_tiling", Vec2::ONE);
let reset_offset = inherited.map_or(Vec2::ZERO, |inputs| {
material_vec2_value(inputs, "uv_offset", Vec2::ZERO)
});
let reset_tiling = inherited.map_or(Vec2::ONE, |inputs| {
material_vec2_value(inputs, "uv_tiling", Vec2::ONE)
});
let original = (offset, tiling);
uv_transform_section(ui, &mut offset, &mut tiling, reset_offset, reset_tiling);
if (offset, tiling) != original {
set_material_vec2_value(inputs, "uv_offset", offset);
set_material_vec2_value(inputs, "uv_tiling", tiling);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PackedMapMode {
Arm,
Separate,
}
pub(super) fn packed_map_mode(inputs: &MaterialInputSet) -> PackedMapMode {
let channels = [
("occlusion", TextureChannel::R),
("roughness", TextureChannel::G),
("metallic", TextureChannel::B),
];
if channels.iter().all(|(name, channel)| {
inputs
.texture(name)
.is_some_and(|binding| binding.channel == *channel)
}) {
PackedMapMode::Arm
} else {
PackedMapMode::Separate
}
}
pub(super) fn apply_arm_texture_preset(inputs: &mut MaterialInputSet) {
let shared_texture = ["occlusion", "roughness", "metallic"]
.iter()
.find_map(|name| {
inputs
.texture(name)
.and_then(|binding| binding.texture.clone())
});
for (name, channel) in [
("occlusion", TextureChannel::R),
("roughness", TextureChannel::G),
("metallic", TextureChannel::B),
] {
let binding = ensure_material_texture_binding(&mut inputs.textures, name);
binding.channel = channel;
if let Some(texture) = shared_texture.clone() {
binding.texture = Some(texture);
}
}
}
pub(super) fn apply_separate_texture_preset(inputs: &mut MaterialInputSet) {
for name in ["occlusion", "roughness", "metallic"] {
ensure_material_texture_binding(&mut inputs.textures, name).channel = TextureChannel::R;
}
}
pub(super) fn material_input_rows(
ui: &mut egui::Ui,
properties: &[&MaterialInputDesc],
companions: &[(&str, &MaterialInputDesc)],
inputs: &mut MaterialInputSet,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
) -> MaterialInputEditorResponse {
let palette = design_system::palette(ui);
let mut response = MaterialInputEditorResponse::default();
let geometry = PropertyGridGeometry::for_width(ui.available_width());
// Property rows are a contiguous Penpot table. Default egui item spacing otherwise adds four
// pixels between every nominally 31 px row and breaks the reference geometry.
ui.spacing_mut().item_spacing.y = 0.0;
for (row, property) in properties.iter().enumerate() {
let companion = companions
.iter()
.find_map(|(owner, companion)| (*owner == property.name).then_some(*companion));
ui.push_id(("material_input_row", &property.name), |ui| {
let width = ui.available_width().max(1.0);
let (row_rect, _) = ui
.allocate_exact_size(egui::vec2(width, geometry.row_height), egui::Sense::hover());
ui.painter().rect_filled(
row_rect,
0.0,
if row.is_multiple_of(2) {
palette.row_even
} else {
palette.row_odd
},
);
let rects = geometry.row_rects(row_rect);
rect_ui(ui, rects.label, |ui| material_input_label(ui, property));
let is_color = matches!(property.property_type, ShaderPropertyType::Color);
rect_ui(
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| {
material_input_channel_ui(ui, property, inputs)
});
}
if material_input_uses_texture(property) {
let texture = rect_ui(ui, rects.texture_group, |ui| {
material_input_texture_ui(
ui,
property,
inputs,
texture_assets,
drop_candidate,
rects.inline_texture_actions,
)
});
response.accepted_drop |= texture.accepted_drop;
response.locate = response.locate.take().or(texture.locate);
response.browse_library |= texture.browse_library;
}
});
}
response
}
fn rect_ui<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
}
pub(super) fn material_input_label(ui: &mut egui::Ui, property: &MaterialInputDesc) {
let palette = design_system::palette(ui);
ui.add(
egui::Label::new(
TypeRole::Body
.text(&property.display_name)
.color(palette.text_secondary),
)
.truncate(),
)
.on_hover_text(&property.tooltip);
}
pub(super) fn material_input_uses_texture(property: &MaterialInputDesc) -> bool {
property.texture.is_some() || matches!(property.property_type, ShaderPropertyType::Texture)
}
pub(super) fn material_input_value_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
companion: Option<&MaterialInputDesc>,
inputs: &mut MaterialInputSet,
) {
let MaterialInputSet { values, .. } = inputs;
ui.horizontal(|ui| {
if matches!(property.property_type, ShaderPropertyType::Texture) {
ui.label(egui::RichText::new("Texture").small().color(TEXT_DIM));
} else {
let default = property
.default_value
.clone()
.unwrap_or_else(|| default_value_for_shader_property(&property.property_type));
let mut value = values
.iter()
.find(|value| value.name == property.name)
.map(|parameter| parameter.value.clone())
.unwrap_or(default);
if !parameter_value_matches_property(&value, &property.property_type) {
value = default_value_for_shader_property(&property.property_type);
}
let original = value.clone();
if matches!(property.property_type, ShaderPropertyType::Color)
&& companion.is_some_and(|input| input.name == "emissive_intensity")
{
let companion = companion.expect("checked companion");
let companion_default = companion
.default_value
.clone()
.unwrap_or(MaterialParameterValue::Float(0.0));
let mut companion_value = values
.iter()
.find(|value| value.name == companion.name)
.map(|parameter| parameter.value.clone())
.unwrap_or(companion_default);
if !parameter_value_matches_property(&companion_value, &companion.property_type) {
companion_value = default_value_for_shader_property(&companion.property_type);
}
let original_companion = companion_value.clone();
if let (
MaterialParameterValue::Color(color),
MaterialParameterValue::Float(intensity),
) = (&mut value, &mut companion_value)
{
let mut rgba = [color.r, color.g, color.b, color.a];
let picker = color_control_with_intensity(
ui,
"material_emissive_color",
&property.display_name,
&mut rgba,
intensity,
);
if picker.changed {
*color = ColorDesc {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
};
}
}
if value != original {
upsert_material_parameter(values, &property.name, value);
}
if companion_value != original_companion {
upsert_material_parameter(values, &companion.name, companion_value);
}
} else {
material_parameter_control_ui(
ui,
&property.display_name,
&property.property_type,
&mut value,
);
if value != original {
upsert_material_parameter(values, &property.name, value);
}
}
}
});
}
fn upsert_material_parameter(
values: &mut Vec<MaterialParameter>,
name: &str,
value: MaterialParameterValue,
) {
if let Some(parameter) = values.iter_mut().find(|parameter| parameter.name == name) {
parameter.value = value;
} else {
values.push(MaterialParameter {
name: name.to_string(),
value,
});
}
}
fn material_input_channel_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
inputs: &mut MaterialInputSet,
) {
let palette = design_system::palette(ui);
if property
.texture
.as_ref()
.is_some_and(|texture| texture.allow_channel_override)
{
let original = material_property_texture_binding(&inputs.textures, property);
let mut binding = original.clone();
material_texture_channel_ui(ui, property, &mut binding);
if binding != original {
upsert_material_texture_binding(&mut inputs.textures, binding);
}
} else {
let channel = property.texture.as_ref().map(|texture| {
if matches!(texture.semantic, shared::TextureSemantic::Color) {
"RGB"
} else {
texture_channel_label(texture.default_channel)
}
});
if let Some(channel) = channel {
readonly_channel(ui, channel);
} else {
ui.label(TypeRole::Body.text("").color(palette.text_muted));
}
}
}
pub(super) fn material_input_texture_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
inputs: &mut MaterialInputSet,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
inline_actions: bool,
) -> TextureSlotResponse {
if !material_input_uses_texture(property) {
return TextureSlotResponse::default();
}
let original = material_property_texture_binding(&inputs.textures, property);
let mut binding = original.clone();
let response = texture_binding_ui(
ui,
&mut binding,
texture_assets,
drop_candidate,
inline_actions,
);
if binding != original {
upsert_material_texture_binding(&mut inputs.textures, binding);
}
response
}
pub(super) fn material_parameter_control_ui(
ui: &mut egui::Ui,
label: &str,
property_type: &ShaderPropertyType,
value: &mut MaterialParameterValue,
) {
if !parameter_value_matches_property(value, property_type) {
*value = default_value_for_shader_property(property_type);
}
match value {
MaterialParameterValue::Bool(value) => {
switch(ui, value);
}
MaterialParameterValue::Float(value) => {
let (min, max) = match property_type {
ShaderPropertyType::Float { min, max } => (*min, *max),
_ => (None, None),
};
match (min, max) {
(Some(min), Some(max)) => {
scalar_control(ui, label, value, min..=max, usize::from(max <= 1_000.0) * 2);
}
_ => {
ui.add(egui::DragValue::new(value).speed(0.01));
}
}
}
MaterialParameterValue::Vec2(value) => {
ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "));
ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "));
}
MaterialParameterValue::Vec3(value) => {
ui.add(egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "));
ui.add(egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "));
ui.add(egui::DragValue::new(&mut value.z).speed(0.01).prefix("Z "));
}
MaterialParameterValue::Color(value) => {
let mut rgba = [value.r, value.g, value.b, value.a];
let response = color_control(ui, "material_color", label, &mut rgba);
if response.changed {
*value = ColorDesc {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
};
}
}
MaterialParameterValue::Enum(value) => {
let options = match property_type {
ShaderPropertyType::Enum { options } => options.as_slice(),
_ => &[],
};
egui::ComboBox::from_id_salt(("material_enum", value.as_str()))
.selected_text(value.as_str())
.show_ui(ui, |ui| {
for option in options {
ui.selectable_value(value, option.clone(), option);
}
});
}
}
}
fn readonly_channel(ui: &mut egui::Ui, channel: &str) {
let palette = design_system::palette(ui);
let (rect, response) = ui.allocate_exact_size(egui::vec2(48.0, 22.0), egui::Sense::hover());
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.left_center() + egui::vec2(9.0, 0.0),
egui::Align2::LEFT_CENTER,
channel,
TypeRole::Body.font(),
palette.text_secondary,
);
ui.painter().text(
rect.right_center() - egui::vec2(8.0, 0.0),
egui::Align2::CENTER_CENTER,
egui_phosphor_icons::icons::CARET_DOWN.as_str(),
egui::FontId::new(9.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted,
);
response.on_hover_text("Channel is fixed by this material input");
}
pub(super) fn default_value_for_shader_property(
property_type: &ShaderPropertyType,
) -> MaterialParameterValue {
match property_type {
ShaderPropertyType::Bool => MaterialParameterValue::Bool(false),
ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0),
ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO),
ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO),
ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()),
ShaderPropertyType::Enum { options } => {
MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default())
}
ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0),
}
}
pub(super) fn parameter_value_matches_property(
value: &MaterialParameterValue,
property_type: &ShaderPropertyType,
) -> bool {
matches!(
(value, property_type),
(MaterialParameterValue::Bool(_), ShaderPropertyType::Bool)
| (
MaterialParameterValue::Float(_),
ShaderPropertyType::Float { .. }
)
| (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2)
| (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3)
| (MaterialParameterValue::Color(_), ShaderPropertyType::Color)
| (
MaterialParameterValue::Enum(_),
ShaderPropertyType::Enum { .. }
)
)
}
pub(super) fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new(label));
ui.add_sized(
[fit_width(ui, 100.0, f32::INFINITY), 22.0],
egui::TextEdit::singleline(value),
);
});
}
pub(super) fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new("Shader"));
shader_kind_combo(ui, kind, "asset_material_shader_kind");
});
}
pub(super) fn shader_kind_combo(
ui: &mut egui::Ui,
kind: &mut MaterialShaderKind,
salt: impl std::hash::Hash,
) {
egui::ComboBox::from_id_salt(salt)
.selected_text(match kind {
MaterialShaderKind::StandardLit => "Standard Lit",
MaterialShaderKind::Unlit => "Unlit",
MaterialShaderKind::Custom => "Custom Surface",
})
.width(150.0_f32.min(ui.available_width().max(1.0)))
.show_ui(ui, |ui| {
ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit");
ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit");
ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom Surface");
});
}
pub(super) fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) {
ui.horizontal(|ui| {
ui.add_sized([92.0, 20.0], egui::Label::new(label));
let mut text = value.clone().unwrap_or_default();
if ui
.add_sized(
[fit_width(ui, 80.0, f32::INFINITY), 22.0],
egui::TextEdit::singleline(&mut text),
)
.changed()
{
*value = if text.trim().is_empty() {
None
} else {
Some(text)
};
}
if ui.button("Clear").clicked() {
*value = None;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() {
let context = egui::Context::default();
context.set_fonts(crate::ui::fonts::editor_font_definitions());
let mut inputs = MaterialInputSet::default();
let original = inputs.clone();
let schema = shared::standard_lit_input_schema();
let _ = context.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(700.0, 900.0),
)),
..Default::default()
},
|ui| {
material_input_schema_editor(ui, &schema, &mut inputs, &[], None);
},
);
assert_eq!(inputs, original);
}
}

View File

@ -1,343 +0,0 @@
//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests.
use bevy_egui::egui;
use crate::ui::design_system::{
ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT, COLLAPSED_HEADER_HEIGHT, HEADER_HEIGHT,
INPUT_SECTION_HEADER_HEIGHT, MATERIAL_IDENTITY_WIDTH, MATERIAL_PREVIEW_SIZE, OUTER_PADDING,
PARAMETER_ROW_HEIGHT, SECTION_GAP, SURFACE_SECTION_HEIGHT, UV_SECTION_HEIGHT,
};
/// Smallest canvas that can still contain the exported compact Surface controls.
///
/// The Penpot board owns a 372 px parameter section. In the actor Inspector, component-card
/// chrome can temporarily reduce that canvas by a few pixels even while the Inspector itself is
/// at its supported 420 px floor. The fixed compact controls only require 362 px, so keep the
/// intended two-line layout until that real containment limit and reserve `Transient` for actual
/// sub-minimum docking frames.
pub(super) const COMPACT_SECTION_WIDTH: f32 = 357.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResponsiveMaterialLayout {
Reference,
Compact,
Transient,
}
impl ResponsiveMaterialLayout {
pub(super) fn for_width(width: f32) -> Self {
if width + f32::EPSILON >= crate::ui::design_system::property_grid::WIDE_SECTION_WIDTH {
Self::Reference
} else if width + f32::EPSILON >= COMPACT_SECTION_WIDTH {
Self::Compact
} else {
Self::Transient
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct SurfaceSectionGeometry {
pub height: f32,
pub title: egui::Rect,
pub blend: egui::Rect,
pub packed: egui::Rect,
pub double_sided: egui::Rect,
}
impl SurfaceSectionGeometry {
pub(super) fn for_rect(rect: egui::Rect) -> Self {
let local = |x: f32, y: f32, width: f32, height: f32| {
let x = x.clamp(0.0, rect.width());
let y = y.clamp(0.0, rect.height());
egui::Rect::from_min_size(
rect.min + egui::vec2(x, y),
egui::vec2(
width.max(0.0).min(rect.width() - x),
height.max(0.0).min(rect.height() - y),
),
)
};
match ResponsiveMaterialLayout::for_width(rect.width()) {
ResponsiveMaterialLayout::Reference => Self {
height: SURFACE_SECTION_HEIGHT,
title: local(10.0, 10.0, 48.0, 26.0),
blend: local(66.0, 10.0, 250.0, 26.0),
packed: local(324.0, 11.0, 128.0, 24.0),
double_sided: local(460.0, 12.0, 99.0, 22.0),
},
ResponsiveMaterialLayout::Compact => Self {
height: crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT,
title: local(10.0, 10.0, 48.0, 26.0),
blend: local(66.0, 10.0, 50.0, 26.0),
packed: local(124.0, 11.0, 128.0, 24.0),
double_sided: local(260.0, 12.0, 99.0, 22.0),
},
ResponsiveMaterialLayout::Transient => Self {
height: 100.0,
title: local(10.0, 10.0, 48.0, 26.0),
blend: local(66.0, 10.0, (rect.width() - 76.0).clamp(1.0, 250.0), 26.0),
packed: local(10.0, 42.0, (rect.width() - 20.0).clamp(0.0, 128.0), 24.0),
double_sided: local(146.0, 43.0, (rect.width() - 156.0).clamp(0.0, 99.0), 22.0),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct UvGroupGeometry {
pub label: egui::Rect,
pub x: egui::Rect,
pub y: egui::Rect,
pub reset: Option<egui::Rect>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct UvSectionGeometry {
pub height: f32,
pub title: egui::Rect,
pub offset: UvGroupGeometry,
pub tiling: UvGroupGeometry,
}
impl UvSectionGeometry {
pub(super) fn for_rect(rect: egui::Rect) -> Self {
let mode = ResponsiveMaterialLayout::for_width(rect.width());
let title = egui::Rect::from_min_size(
rect.min + egui::vec2(12.0, 7.0),
egui::vec2(20.0_f32.min(rect.width()), 40.0_f32.min(rect.height())),
);
if mode == ResponsiveMaterialLayout::Transient {
let group_width = (rect.width() - 20.0).max(1.0);
return Self {
height: 100.0,
title,
offset: uv_group(rect.min + egui::vec2(42.0, 7.0), group_width),
tiling: uv_group(rect.min + egui::vec2(42.0, 47.0), group_width),
};
}
if mode == ResponsiveMaterialLayout::Reference {
Self {
height: UV_SECTION_HEIGHT,
title,
offset: uv_group(rect.min + egui::vec2(104.667, 7.0), 165.0),
tiling: uv_group(rect.min + egui::vec2(332.333, 7.0), 164.0),
}
} else {
Self {
height: crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT,
title,
offset: uv_group(rect.min + egui::vec2(118.0, 7.0), 165.0),
tiling: uv_group(rect.min + egui::vec2(118.5, 47.0), 164.0),
}
}
}
}
fn uv_group(origin: egui::Pos2, group_width: f32) -> UvGroupGeometry {
let local = |x: f32, y: f32, control_width: f32, height: f32| {
let x = x.clamp(0.0, group_width.max(0.0));
egui::Rect::from_min_size(
origin + egui::vec2(x, y),
egui::vec2(control_width.max(0.0).min(group_width - x), height),
)
};
let reset = (group_width >= 164.0).then(|| local(group_width - 24.0, 8.0, 24.0, 24.0));
UvGroupGeometry {
label: local(0.0, 10.0, 25.0, 20.0),
x: local(30.0, 8.0, 50.0, 24.0),
y: local(86.0, 8.0, 50.0, 24.0),
reset,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct MaterialPanelGeometry {
pub header: f32,
pub collapsed_header: f32,
pub asset_zone: f32,
pub surface: f32,
pub input_header: f32,
pub input_row: f32,
pub uv: f32,
pub advanced_collapsed: f32,
pub advanced_expanded: f32,
pub preview: f32,
pub identity: f32,
pub outer_padding: f32,
pub section_gap: f32,
}
impl MaterialPanelGeometry {
pub(super) const REFERENCE: Self = Self {
header: HEADER_HEIGHT,
collapsed_header: COLLAPSED_HEADER_HEIGHT,
asset_zone: HEADER_HEIGHT,
surface: SURFACE_SECTION_HEIGHT,
input_header: INPUT_SECTION_HEADER_HEIGHT,
input_row: PARAMETER_ROW_HEIGHT,
uv: UV_SECTION_HEIGHT,
advanced_collapsed: ADVANCED_COLLAPSED_HEIGHT,
advanced_expanded: ADVANCED_EXPANDED_HEIGHT,
preview: MATERIAL_PREVIEW_SIZE,
identity: MATERIAL_IDENTITY_WIDTH,
outer_padding: OUTER_PADDING,
section_gap: SECTION_GAP,
};
#[cfg(test)]
pub(super) const fn slot_width(inspector_width: f32) -> f32 {
inspector_width - OUTER_PADDING * 2.0
}
#[cfg(test)]
pub(super) const fn parameter_width(slot_width: f32) -> f32 {
slot_width - 27.0
}
#[cfg(test)]
pub(super) const fn inputs_height(compact: bool, primary_rows: usize) -> f32 {
INPUT_SECTION_HEADER_HEIGHT
+ primary_rows as f32
* if compact {
crate::ui::design_system::COMPACT_PARAMETER_ROW_HEIGHT
} else {
PARAMETER_ROW_HEIGHT
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_penpot_reference_geometry_is_exact() {
let geometry = MaterialPanelGeometry::REFERENCE;
assert_eq!(geometry.header, 82.0);
assert_eq!(geometry.collapsed_header, 52.0);
assert_eq!(geometry.asset_zone, 82.0);
assert_eq!(geometry.surface, 46.0);
assert_eq!(geometry.input_header, 24.0);
assert_eq!(geometry.input_row, 32.0);
assert_eq!(geometry.uv, 54.0);
assert_eq!(geometry.advanced_collapsed, 30.0);
assert_eq!(geometry.advanced_expanded, 178.0);
assert_eq!(geometry.preview, 64.0);
assert_eq!(geometry.identity, 229.5);
assert_eq!(620.0 - OUTER_PADDING * 2.0, 596.0);
assert_eq!(420.0 - OUTER_PADDING * 2.0, 396.0);
assert_eq!(MaterialPanelGeometry::slot_width(620.0), 596.0);
assert_eq!(MaterialPanelGeometry::slot_width(420.0), 396.0);
assert_eq!(MaterialPanelGeometry::parameter_width(596.0), 569.0);
assert_eq!(MaterialPanelGeometry::parameter_width(396.0), 369.0);
assert_eq!(MaterialPanelGeometry::inputs_height(false, 6), 216.0);
assert_eq!(MaterialPanelGeometry::inputs_height(true, 6), 372.0);
}
#[test]
fn surface_geometry_matches_both_exported_references() {
let wide = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(569.0, 46.0),
));
assert_eq!(
wide.title,
egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(48.0, 26.0))
);
assert_eq!(
wide.blend,
egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(250.0, 26.0))
);
assert_eq!(
wide.packed,
egui::Rect::from_min_size(egui::pos2(324.0, 11.0), egui::vec2(128.0, 24.0))
);
assert_eq!(
wide.double_sided,
egui::Rect::from_min_size(egui::pos2(460.0, 12.0), egui::vec2(99.0, 22.0))
);
let narrow = SurfaceSectionGeometry::for_rect(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(369.0, 46.0),
));
assert_eq!(
narrow.blend,
egui::Rect::from_min_size(egui::pos2(66.0, 10.0), egui::vec2(50.0, 26.0))
);
assert_eq!(
narrow.packed,
egui::Rect::from_min_size(egui::pos2(124.0, 11.0), egui::vec2(128.0, 24.0))
);
assert_eq!(
narrow.double_sided,
egui::Rect::from_min_size(egui::pos2(260.0, 12.0), egui::vec2(99.0, 22.0))
);
}
#[test]
fn uv_geometry_centers_title_and_keeps_independent_resets() {
for (width, height) in [(569.0, 54.0), (369.0, 94.0)] {
let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
let geometry = UvSectionGeometry::for_rect(section);
assert_eq!(geometry.title.min, egui::pos2(12.0, 7.0));
assert_eq!(geometry.offset.x.size(), egui::vec2(50.0, 24.0));
assert_eq!(geometry.offset.y.size(), egui::vec2(50.0, 24.0));
assert!(geometry.offset.reset.is_some());
assert!(geometry.tiling.reset.is_some());
for rect in [
geometry.title,
geometry.offset.label,
geometry.offset.x,
geometry.offset.y,
geometry.offset.reset.expect("reference reset"),
geometry.tiling.label,
geometry.tiling.x,
geometry.tiling.y,
geometry.tiling.reset.expect("reference reset"),
] {
assert!(section.contains_rect(rect));
assert!(rect.is_finite());
}
}
}
#[test]
fn transient_geometry_stays_finite_and_bounded() {
let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 100.0));
let surface = SurfaceSectionGeometry::for_rect(section);
let uv = UvSectionGeometry::for_rect(section);
for rect in [
surface.title,
surface.blend,
surface.packed,
surface.double_sided,
] {
assert!(rect.is_finite());
assert!(section.contains_rect(rect));
}
for rect in [
uv.title,
uv.offset.label,
uv.offset.x,
uv.offset.y,
uv.tiling.label,
uv.tiling.x,
uv.tiling.y,
] {
assert!(rect.is_finite());
assert!(section.contains_rect(rect));
}
}
#[test]
fn inspector_chrome_reduced_floor_keeps_the_penpot_compact_surface() {
let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 46.0));
let surface = SurfaceSectionGeometry::for_rect(section);
assert_eq!(surface.height, 46.0);
assert_eq!(surface.packed.min, egui::pos2(124.0, 11.0));
assert_eq!(surface.packed.max, egui::pos2(252.0, 35.0));
assert_eq!(surface.double_sided.min, egui::pos2(260.0, 12.0));
}
}

View File

@ -1,686 +0,0 @@
//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors.
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use shared::{EditorAssetRef, MaterialShaderKind};
use super::layout::MaterialPanelGeometry;
use super::MaterialThumbnailPresentation;
use crate::ui::design_system::controls::status;
use crate::ui::design_system::typography::TypeRole;
use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT};
mod header_actions;
use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH};
const SLOT_ACCENT_X: f32 = 8.0;
const SLOT_CARET_CENTER_X: f32 = 23.0;
const SLOT_PREVIEW_X: f32 = 35.0;
const SHADER_CONTROL_WIDTH: f32 = 220.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MaterialLayerBadge {
ModelSource,
ModelDefault,
ProjectDefault,
DefaultGrid,
}
impl MaterialLayerBadge {
pub(crate) const fn label(self) -> &'static str {
match self {
Self::ModelSource => "Model Source",
Self::ModelDefault => "Model Default",
Self::ProjectDefault => "Project Default",
Self::DefaultGrid => "Default Grid",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MaterialHealth {
Healthy,
Dirty,
Processing,
Broken,
ReadOnly,
}
impl MaterialHealth {
fn presentation(self, palette: DesignPalette) -> (egui::Color32, &'static str, &'static str) {
match self {
Self::Healthy => (
palette.healthy,
"Compiled",
"Material is resolved and up to date",
),
Self::Dirty => (
palette.warning,
"Unsaved",
"Material has unsaved in-memory changes",
),
Self::Processing => (
palette.accent,
"Processing",
"Derived material data is processing",
),
Self::Broken => (
palette.error,
"Failed",
"Material reference or processing failed",
),
Self::ReadOnly => (
palette.text_muted,
"Read only",
"This material cannot be edited in place",
),
}
}
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)]
pub(crate) struct MaterialPickerCandidate {
pub reference: EditorAssetRef,
pub label: String,
pub detail: String,
pub thumbnail: MaterialThumbnailPresentation,
}
#[derive(Debug, Clone)]
pub(crate) struct MaterialSlotPanelViewModel {
pub slot_id: String,
pub label: String,
pub path: Option<String>,
pub shader: String,
pub shader_kind: Option<MaterialShaderKind>,
pub can_edit_shader: bool,
pub thumbnail: MaterialThumbnailPresentation,
pub inherited: Option<MaterialLayerBadge>,
pub health: MaterialHealth,
pub assigned: Option<EditorAssetRef>,
pub effective: Option<EditorAssetRef>,
pub candidates: Vec<MaterialPickerCandidate>,
pub drop_candidate: Option<MaterialPickerCandidate>,
pub invalid_drop_reason: Option<String>,
pub can_locate: bool,
pub can_clear: bool,
pub can_extract: bool,
pub can_create_instance: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum MaterialSlotAction {
Assign {
reference: EditorAssetRef,
from_drop: bool,
},
BrowseLibrary,
Locate,
Clear,
ExtractEditable,
CreateInstance,
SetShader(MaterialShaderKind),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct MaterialSlotPanelAction {
pub slot_id: String,
pub action: MaterialSlotAction,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct MaterialsSectionViewModel {
pub slot_ids: Vec<String>,
}
pub(crate) struct MaterialSlotPanelResponse {
pub actions: Vec<MaterialSlotPanelAction>,
}
pub(crate) fn materials_section<R>(
ui: &mut egui::Ui,
_view: &MaterialsSectionViewModel,
add_slots: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let start = ui.next_widget_position();
let expanded = actor_materials_rect(ui.max_rect(), start.y);
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(expanded)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_clip_rect(ui.clip_rect());
let result = design_system::scope(&mut child, |ui| {
let palette = design_system::palette(ui);
ui.allocate_ui_with_layout(
egui::vec2(
ui.available_width().max(1.0),
design_system::MATERIALS_HEADING_HEIGHT,
),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(
TypeRole::Title
.text("Materials")
.color(palette.text_primary),
);
},
);
ui.add_space(8.0);
add_slots(ui)
});
// `min_rect` is not a safe content-height signal for a child hosted by a ScrollArea: nested
// frames may temporarily inherit the parent's virtual extent even though their layout cursor
// has only advanced through finite authored rows. Advancing from that rect creates a phantom
// tail after multi-slot renderers and lets the Inspector scrollbar enter an empty region.
// The cursor is the authoritative end of this top-down section.
let used_bottom = materials_used_bottom(child.next_widget_position().y, start.y);
ui.advance_cursor_after_rect(egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), start.y),
egui::pos2(ui.max_rect().right(), used_bottom),
));
result
}
fn actor_materials_rect(body: egui::Rect, start_y: f32) -> egui::Rect {
// A ScrollArea content Ui has an effectively unbounded max-rect in its scrolling direction.
// Carrying that bottom edge into the child gives the Materials section a phantom multi-screen
// height even though its painted contents are finite. Seed only the exported width here; egui
// grows the zero-height child as headings and slots are actually allocated.
egui::Rect::from_min_size(
egui::pos2(body.left(), start_y),
egui::vec2(body.width(), 0.0),
)
}
fn materials_used_bottom(cursor_y: f32, start_y: f32) -> f32 {
if cursor_y.is_finite() {
cursor_y.max(start_y)
} else {
start_y
}
}
pub(crate) fn material_slot_panel(
ui: &mut egui::Ui,
id: impl std::hash::Hash,
view: &MaterialSlotPanelViewModel,
content_ui: impl FnOnce(&mut egui::Ui),
diagnostics_ui: impl FnOnce(&mut egui::Ui),
) -> MaterialSlotPanelResponse {
design_system::scope(ui, |ui| {
let palette = design_system::palette(ui);
let id = ui.make_persistent_id(id);
let open_id = id.with("parameters_open");
let mut parameters_open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(open_id))
.unwrap_or(true);
let mut slot_actions = Vec::new();
egui::Frame::new()
.fill(palette.panel)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(egui::CornerRadius::same(8))
.show(ui, |ui| {
slot_header(
ui,
id,
view,
&mut parameters_open,
&mut slot_actions,
diagnostics_ui,
);
if parameters_open {
parameter_body(ui, content_ui);
}
ui.ctx()
.data_mut(|data| data.insert_persisted(open_id, parameters_open));
});
MaterialSlotPanelResponse {
actions: slot_actions
.into_iter()
.map(|action| MaterialSlotPanelAction {
slot_id: view.slot_id.clone(),
action,
})
.collect(),
}
})
}
fn slot_header(
ui: &mut egui::Ui,
id: egui::Id,
view: &MaterialSlotPanelViewModel,
open: &mut bool,
actions: &mut Vec<MaterialSlotAction>,
diagnostics_ui: impl FnOnce(&mut egui::Ui),
) {
let palette = design_system::palette(ui);
let height = if *open {
HEADER_HEIGHT
} else {
MaterialPanelGeometry::REFERENCE.collapsed_header
};
let width = ui.available_width().max(1.0);
let (rect, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::click());
ui.painter().rect_filled(rect, 7.0, palette.recessed);
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.min + egui::vec2(SLOT_ACCENT_X, 8.0),
egui::vec2(3.0, height - 16.0),
),
2.0,
palette.accent,
);
let caret_rect = egui::Rect::from_center_size(
egui::pos2(rect.left() + SLOT_CARET_CENTER_X, rect.center().y),
egui::vec2(18.0, 28.0),
);
let caret_icon = if *open {
icons::CARET_DOWN
} else {
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,
egui::FontFamily::Name("phosphor-bold".into()),
)),
)
.frame(false),
);
if caret.clicked() {
*open = !*open;
}
let preview_size = if *open { 64.0 } else { 32.0 };
let preview_rect = egui::Rect::from_min_size(
egui::pos2(
rect.left() + SLOT_PREVIEW_X,
rect.center().y - preview_size * 0.5,
),
egui::vec2(preview_size, preview_size),
);
let preview = material_thumbnail_at(ui, view, preview_rect);
let action_width = SLOT_STATUS_ACTION_WIDTH;
let identity_left = preview_rect.right() + 6.0;
let actions_left = rect.right() - action_width - 8.0;
let identity_right = (identity_left + design_system::MATERIAL_IDENTITY_WIDTH)
.min(actions_left)
.max(identity_left + 1.0);
let identity_rect = egui::Rect::from_min_max(
egui::pos2(identity_left, rect.top() + if *open { 8.0 } else { 7.0 }),
egui::pos2(
identity_right,
rect.bottom() - if *open { 8.0 } else { 7.0 },
),
);
let identity = ui
.scope_builder(egui::UiBuilder::new().max_rect(identity_rect), |ui| {
ui.set_clip_rect(identity_rect.intersect(ui.clip_rect()));
if *open {
material_identity(ui, view, actions)
} else {
compact_material_identity(ui, view)
}
})
.inner;
let actions_rect = egui::Rect::from_min_size(
egui::pos2(actions_left, rect.top() + if *open { 9.0 } else { 7.0 }),
egui::vec2(action_width, if *open { 64.0 } else { height - 14.0 }),
);
if *open {
expanded_status_actions(ui, actions_rect, view, actions, diagnostics_ui, palette);
} else {
ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
overflow_menu(ui, view, actions, diagnostics_ui);
let (color, label, tooltip) = view.health.presentation(palette);
ui.horizontal(|ui| status(ui, view.health.icon(), color, label, tooltip));
});
});
}
if response.clicked() {
if let Some(pointer) = ui.input(|input| input.pointer.interact_pos()) {
if !caret_rect.contains(pointer)
&& !preview_rect.contains(pointer)
&& !identity_rect.contains(pointer)
&& !actions_rect.contains(pointer)
{
*open = !*open;
}
}
}
super::pickers::material_picker_popup(
ui,
id.with("material_picker"),
&preview.union(identity),
view,
actions,
);
if response.hovered()
&& view.drop_candidate.is_some()
&& ui.input(|input| input.pointer.any_released())
{
if let Some(candidate) = view.drop_candidate.as_ref() {
actions.push(MaterialSlotAction::Assign {
reference: candidate.reference.clone(),
from_drop: true,
});
}
}
if response.hovered() {
if let Some(reason) = view.invalid_drop_reason.as_deref() {
response.on_hover_text(reason);
}
}
}
fn compact_material_identity(
ui: &mut egui::Ui,
view: &MaterialSlotPanelViewModel,
) -> egui::Response {
let palette = design_system::palette(ui);
let name = ui
.add(
egui::Label::new(
TypeRole::Section
.text(&view.label)
.color(palette.text_primary),
)
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(&view.label);
let detail_text = view
.inherited
.map(MaterialLayerBadge::label)
.unwrap_or(view.shader.as_str());
let detail = ui
.add(
egui::Label::new(TypeRole::Small.text(detail_text).color(palette.text_muted))
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(detail_text);
name.union(detail)
}
fn parameter_body(ui: &mut egui::Ui, content_ui: impl FnOnce(&mut egui::Ui)) {
let palette = design_system::palette(ui);
egui::Frame::new()
.fill(palette.panel)
.inner_margin(egui::Margin {
left: 19,
right: 8,
top: 8,
bottom: 8,
})
.show(ui, |ui| {
ui.label(TypeRole::Caption.text("PARAMETERS").color(palette.accent));
let spine_x = ui.min_rect().left() - 7.0;
let spine_top = ui.cursor().top();
content_ui(ui);
let spine_bottom = ui.min_rect().bottom().max(spine_top);
ui.painter().vline(
spine_x,
spine_top..=spine_bottom,
egui::Stroke::new(1.0_f32, palette.accent),
);
});
}
fn material_identity(
ui: &mut egui::Ui,
view: &MaterialSlotPanelViewModel,
actions: &mut Vec<MaterialSlotAction>,
) -> egui::Response {
let palette = design_system::palette(ui);
let name = ui
.add(
egui::Label::new(
TypeRole::Section
.text(&view.label)
.color(palette.text_primary),
)
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(&view.label);
let path = view.path.as_deref().unwrap_or("Engine built-in material");
let identity = view.inherited.map_or_else(
|| path.to_string(),
|layer| format!("{path} · {}", layer.label()),
);
let detail = ui
.add(
egui::Label::new(TypeRole::Small.text(&identity).color(palette.text_muted))
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(&identity);
if view.can_edit_shader {
if let Some(mut kind) = view.shader_kind {
egui::ComboBox::from_id_salt("material_identity_shader_kind")
.selected_text(&view.shader)
.width(SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)))
.show_ui(ui, |ui| {
ui.selectable_value(&mut kind, MaterialShaderKind::StandardLit, "Standard Lit");
ui.selectable_value(&mut kind, MaterialShaderKind::Unlit, "Unlit");
ui.selectable_value(&mut kind, MaterialShaderKind::Custom, "Custom Surface");
});
if Some(kind) != view.shader_kind {
actions.push(MaterialSlotAction::SetShader(kind));
}
}
} else {
ui.add_sized(
[
SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)),
22.0,
],
egui::Label::new(
TypeRole::Body
.text(&view.shader)
.color(palette.text_secondary),
),
);
}
name.union(detail)
}
fn material_thumbnail_at(
ui: &mut egui::Ui,
view: &MaterialSlotPanelViewModel,
rect: egui::Rect,
) -> egui::Response {
let palette = design_system::palette(ui);
let response = ui.interact(
rect,
ui.make_persistent_id(("material_preview", &view.slot_id)),
egui::Sense::click(),
);
let size = rect.width();
let cell = size / 4.0;
for row in 0..4 {
for column in 0..4 {
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.min + egui::vec2(column as f32 * cell, row as f32 * cell),
egui::vec2(cell, cell),
)
.intersect(rect),
0.0,
if (row + column) % 2 == 0 {
palette.checker
} else {
palette.recessed
},
);
}
}
match view.thumbnail {
MaterialThumbnailPresentation::Ready(texture) => {
ui.painter().image(
texture,
rect.shrink(2.0),
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
MaterialThumbnailPresentation::Pending => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::CIRCLE_NOTCH.as_str(),
egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.text_secondary,
);
}
MaterialThumbnailPresentation::Failed => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::WARNING.as_str(),
egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())),
palette.error,
);
}
}
ui.painter().rect_stroke(
rect,
4.0,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
response.on_hover_text(format!("Open {} material picker", view.label))
}
#[cfg(test)]
mod tests {
use super::*;
fn view() -> MaterialSlotPanelViewModel {
MaterialSlotPanelViewModel {
slot_id: "slot:test".into(),
label: "M_Wood_Planks".into(),
path: Some("materials/wood/M_Wood_Planks.mat".into()),
shader: "Standard Lit".into(),
shader_kind: Some(MaterialShaderKind::StandardLit),
can_edit_shader: true,
thumbnail: MaterialThumbnailPresentation::Pending,
inherited: None,
health: MaterialHealth::Healthy,
assigned: None,
effective: None,
candidates: Vec::new(),
drop_candidate: None,
invalid_drop_reason: None,
can_locate: true,
can_clear: true,
can_extract: false,
can_create_instance: true,
}
}
#[test]
fn slot_header_insets_match_the_current_penpot_export() {
assert_eq!(SLOT_ACCENT_X, 8.0);
assert_eq!(SLOT_CARET_CENTER_X, 23.0);
assert_eq!(SLOT_PREVIEW_X, 35.0);
assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0);
assert_eq!(SLOT_STATUS_ACTION_WIDTH, 140.0);
assert_eq!(SHADER_CONTROL_WIDTH, 220.0);
}
#[test]
fn actor_component_body_preserves_penpot_slot_widths() {
let wide_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(596.0, 700.0));
let compact_body = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(396.0, 700.0));
let wide = actor_materials_rect(wide_body, 32.0);
let compact = actor_materials_rect(compact_body, 32.0);
assert_eq!(wide.width(), 596.0);
assert_eq!(compact.width(), 396.0);
assert_eq!(wide.left(), 0.0);
assert_eq!(compact.left(), 0.0);
assert_eq!(wide.top(), 32.0);
assert_eq!(compact.top(), 32.0);
assert_eq!(wide.height(), 0.0);
assert_eq!(compact.height(), 0.0);
}
#[test]
fn materials_height_uses_the_finite_layout_cursor_not_a_virtual_scroll_extent() {
assert_eq!(materials_used_bottom(948.0, 320.0), 948.0);
assert_eq!(materials_used_bottom(240.0, 320.0), 320.0);
assert_eq!(materials_used_bottom(f32::INFINITY, 320.0), 320.0);
}
#[test]
fn penpot_direct_assignment_has_no_redundant_explicit_badge() {
assert!(view().inherited.is_none());
}
#[test]
fn penpot_assignment_action_carries_exact_stable_reference() {
let reference = EditorAssetRef::new("asset-id", "material:source", "M_Wood");
let action = MaterialSlotPanelAction {
slot_id: "slot:test".into(),
action: MaterialSlotAction::Assign {
reference: reference.clone(),
from_drop: false,
},
};
assert_eq!(
action,
MaterialSlotPanelAction {
slot_id: "slot:test".into(),
action: MaterialSlotAction::Assign {
reference,
from_drop: false,
},
}
);
}
#[test]
fn penpot_supported_material_states_share_one_view_model() {
for health in [
MaterialHealth::Healthy,
MaterialHealth::Dirty,
MaterialHealth::Processing,
MaterialHealth::Broken,
MaterialHealth::ReadOnly,
] {
let mut view = view();
view.health = health;
let palette = DesignPalette::from(crate::ui::theme::BLACKSITE_PALETTE);
assert!(!view.health.presentation(palette).1.is_empty());
}
}
#[test]
fn penpot_material_statuses_use_semantic_phosphor_icons() {
assert_eq!(MaterialHealth::Healthy.icon(), icons::CHECK_CIRCLE);
assert_eq!(MaterialHealth::Dirty.icon(), icons::PENCIL_SIMPLE);
assert_eq!(MaterialHealth::Processing.icon(), icons::CIRCLE_NOTCH);
assert_eq!(MaterialHealth::Broken.icon(), icons::WARNING_CIRCLE);
assert_eq!(MaterialHealth::ReadOnly.icon(), icons::LOCK_SIMPLE);
}
}

View File

@ -1,216 +0,0 @@
//! Exact Penpot status/action geometry for an expanded material-slot header.
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use super::{MaterialSlotAction, MaterialSlotPanelViewModel};
use crate::ui::design_system::controls::{icon_button, icon_label, status};
use crate::ui::design_system::typography::TypeRole;
use crate::ui::design_system::{self, DesignPalette};
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,
) {
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));
}
}
}

View File

@ -1,411 +0,0 @@
//! Penpot-styled material and texture asset pickers.
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use shared::EditorAssetRef;
use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel};
use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse};
use crate::ui::design_system;
use crate::ui::design_system::controls::icon_label;
use crate::ui::design_system::typography::TypeRole;
const MATERIAL_PICKER_WIDTH: f32 = 244.0;
const TEXTURE_PICKER_WIDTH: f32 = 420.0;
const PICKER_ROW_HEIGHT: f32 = 44.0;
const PICKER_THUMBNAIL: f32 = 36.0;
pub(super) fn material_picker_popup(
_ui: &mut egui::Ui,
id: egui::Id,
anchor: &egui::Response,
view: &MaterialSlotPanelViewModel,
actions: &mut Vec<MaterialSlotAction>,
) {
egui::Popup::menu(anchor)
.id(id)
.width(MATERIAL_PICKER_WIDTH)
.show(|ui| {
ui.label(
TypeRole::Section
.text("Material Assets")
.color(design_system::palette(ui).text_primary),
);
if let Some(current) = view.effective.as_ref() {
if let Some(candidate) = find_material_candidate(&view.candidates, current) {
material_menu_row(ui, candidate, true, actions);
} else {
unresolved_material_menu_row(ui, current);
}
}
let recent_keys = recent_keys(ui, "material_picker_recents");
let recent = candidates_for_recent_keys(&view.candidates, &recent_keys);
for candidate in recent.into_iter().take(4) {
let selected = view
.effective
.as_ref()
.is_some_and(|current| same_reference(current, &candidate.reference));
if !selected {
material_menu_row(ui, candidate, false, actions);
}
}
picker_footer(ui, view.assigned.is_some(), actions);
});
}
pub(super) fn texture_picker_popup(
_ui: &mut egui::Ui,
id: egui::Id,
anchor: &egui::Response,
candidates: &[MaterialTextureCandidate],
selected: Option<&MaterialTextureCandidate>,
response: &mut TextureSlotResponse,
) {
egui::Popup::menu(anchor)
.id(id)
.width(TEXTURE_PICKER_WIDTH)
.show(|ui| {
picker_heading(
ui,
"Select Texture",
"Project textures and imported subassets",
);
if let Some(current) = selected {
picker_section_label(ui, "CURRENT");
texture_candidate_row(ui, current, true, response);
}
let recent_keys = recent_keys(ui, "texture_picker_recents");
let recent = candidates_for_recent_texture_keys(candidates, &recent_keys);
if !recent.is_empty() {
picker_section_label(ui, "RECENT");
for candidate in recent.into_iter().take(4) {
texture_candidate_row(ui, candidate, false, response);
}
}
picker_section_label(ui, "PROJECT TEXTURES");
egui::ScrollArea::vertical()
.id_salt(id.with("project_textures"))
.max_height(240.0)
.auto_shrink([false, true])
.show(ui, |ui| {
if candidates.is_empty() {
empty_picker_row(ui, "No project textures");
}
for candidate in candidates {
let selected = selected.is_some_and(|current| {
same_reference(&current.reference, &candidate.reference)
});
texture_candidate_row(ui, candidate, selected, response);
}
});
let palette = design_system::palette(ui);
ui.separator();
if ui
.add(egui::Button::new(icon_label(
icons::FOLDER_OPEN,
"Browse Asset Library",
TypeRole::Control,
14.0,
palette.text_primary,
)))
.clicked()
{
response.browse_library = true;
ui.close();
}
if selected.is_some()
&& ui
.add(egui::Button::new(icon_label(
icons::X,
"Clear Texture",
TypeRole::Control,
14.0,
palette.text_primary,
)))
.clicked()
{
response.clear = true;
ui.close();
}
});
}
fn picker_heading(ui: &mut egui::Ui, title: &str, subtitle: &str) {
let palette = design_system::palette(ui);
ui.label(TypeRole::Title.text(title).color(palette.text_primary));
ui.label(TypeRole::Small.text(subtitle).color(palette.text_muted));
ui.separator();
}
fn picker_section_label(ui: &mut egui::Ui, label: &str) {
let palette = design_system::palette(ui);
ui.add_space(4.0);
ui.label(TypeRole::Caption.text(label).color(palette.text_muted));
}
fn material_menu_row(
ui: &mut egui::Ui,
candidate: &MaterialPickerCandidate,
selected: bool,
actions: &mut Vec<MaterialSlotAction>,
) {
let response = picker_asset_row(
ui,
&candidate.label,
&candidate.detail,
candidate.thumbnail,
selected,
);
if response.clicked() {
remember_recent(ui, "material_picker_recents", &candidate.reference);
actions.push(MaterialSlotAction::Assign {
reference: candidate.reference.clone(),
from_drop: false,
});
ui.close();
}
}
fn texture_candidate_row(
ui: &mut egui::Ui,
candidate: &MaterialTextureCandidate,
selected: bool,
response: &mut TextureSlotResponse,
) {
let row = picker_asset_row(
ui,
&candidate.label,
&candidate.detail,
candidate.thumbnail,
selected,
);
if row.clicked() {
remember_recent(ui, "texture_picker_recents", &candidate.reference);
response.selected = Some(candidate.clone());
ui.close();
}
}
fn picker_asset_row(
ui: &mut egui::Ui,
label: &str,
detail: &str,
thumbnail: MaterialThumbnailPresentation,
selected: bool,
) -> egui::Response {
let palette = design_system::palette(ui);
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), PICKER_ROW_HEIGHT),
egui::Sense::click(),
);
let fill = if selected {
palette.accent_dark
} else if response.hovered() {
palette.elevated
} else {
palette.recessed
};
ui.painter().rect(
rect,
5.0,
fill,
egui::Stroke::new(
1.0_f32,
if selected {
palette.accent
} else {
palette.border
},
),
egui::StrokeKind::Inside,
);
let thumb = egui::Rect::from_min_size(
rect.left_top() + egui::vec2(4.0, 4.0),
egui::vec2(PICKER_THUMBNAIL, PICKER_THUMBNAIL),
);
paint_thumbnail(ui, thumb, thumbnail);
let text_rect = egui::Rect::from_min_max(
egui::pos2(thumb.right() + 8.0, rect.top() + 5.0),
egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0),
);
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(text_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_clip_rect(rect.intersect(ui.clip_rect()));
child
.add(egui::Label::new(TypeRole::Body.text(label).color(palette.text_primary)).truncate())
.on_hover_text(label);
child
.add(egui::Label::new(TypeRole::Small.text(detail).color(palette.text_muted)).truncate())
.on_hover_text(detail);
response
}
fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnailPresentation) {
let palette = design_system::palette(ui);
ui.painter().rect_filled(rect, 4.0, palette.control);
match thumbnail {
MaterialThumbnailPresentation::Ready(texture) => {
ui.painter().image(
texture,
rect,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
MaterialThumbnailPresentation::Pending => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::CIRCLE_NOTCH.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_muted,
);
}
MaterialThumbnailPresentation::Failed => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::WARNING.as_str(),
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.error,
);
}
}
ui.painter().rect_stroke(
rect,
4.0,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
}
fn picker_footer(ui: &mut egui::Ui, can_clear: bool, actions: &mut Vec<MaterialSlotAction>) {
let palette = design_system::palette(ui);
ui.separator();
if ui
.add(egui::Button::new(icon_label(
icons::FOLDER_OPEN,
"Browse Asset Library",
TypeRole::Control,
14.0,
palette.text_primary,
)))
.clicked()
{
actions.push(MaterialSlotAction::BrowseLibrary);
ui.close();
}
if can_clear
&& ui
.add(egui::Button::new(icon_label(
icons::X,
"Clear Material",
TypeRole::Control,
14.0,
palette.text_primary,
)))
.clicked()
{
actions.push(MaterialSlotAction::Clear);
ui.close();
}
}
fn unresolved_material_menu_row(ui: &mut egui::Ui, current: &EditorAssetRef) {
let palette = design_system::palette(ui);
ui.add_sized(
[ui.available_width().max(1.0), 28.0],
egui::Button::new(TypeRole::Body.text(&current.label).color(palette.error)).selected(true),
)
.on_hover_text("The currently assigned material could not be resolved");
}
fn empty_picker_row(ui: &mut egui::Ui, message: &str) {
let palette = design_system::palette(ui);
ui.add_sized(
[ui.available_width().max(1.0), 32.0],
egui::Label::new(TypeRole::Body.text(message).color(palette.text_muted)),
);
}
fn same_reference(left: &EditorAssetRef, right: &EditorAssetRef) -> bool {
left.asset_id == right.asset_id && left.sub_asset_id == right.sub_asset_id
}
fn find_material_candidate<'a>(
candidates: &'a [MaterialPickerCandidate],
reference: &EditorAssetRef,
) -> Option<&'a MaterialPickerCandidate> {
candidates
.iter()
.find(|candidate| same_reference(&candidate.reference, reference))
}
fn recent_keys(ui: &egui::Ui, salt: &'static str) -> Vec<(String, String)> {
let id = ui.make_persistent_id(salt);
ui.ctx()
.data_mut(|data| data.get_persisted::<Vec<(String, String)>>(id))
.unwrap_or_default()
}
fn remember_recent(ui: &egui::Ui, salt: &'static str, reference: &EditorAssetRef) {
let id = ui.make_persistent_id(salt);
let mut keys = recent_keys(ui, salt);
let key = (reference.asset_id.clone(), reference.sub_asset_id.clone());
keys.retain(|current| current != &key);
keys.insert(0, key);
keys.truncate(8);
ui.ctx().data_mut(|data| data.insert_persisted(id, keys));
}
fn candidates_for_recent_keys<'a>(
candidates: &'a [MaterialPickerCandidate],
keys: &[(String, String)],
) -> Vec<&'a MaterialPickerCandidate> {
keys.iter()
.filter_map(|(asset_id, sub_asset_id)| {
candidates.iter().find(|candidate| {
candidate.reference.asset_id == *asset_id
&& candidate.reference.sub_asset_id == *sub_asset_id
})
})
.collect()
}
fn candidates_for_recent_texture_keys<'a>(
candidates: &'a [MaterialTextureCandidate],
keys: &[(String, String)],
) -> Vec<&'a MaterialTextureCandidate> {
keys.iter()
.filter_map(|(asset_id, sub_asset_id)| {
candidates.iter().find(|candidate| {
candidate.reference.asset_id == *asset_id
&& candidate.reference.sub_asset_id == *sub_asset_id
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_picker_reference_comparison_uses_stable_identity_not_label() {
let left = EditorAssetRef::new("asset", "material:source", "Left");
let right = EditorAssetRef::new("asset", "material:source", "Renamed");
assert!(same_reference(&left, &right));
}
#[test]
fn penpot_picker_geometry_matches_penpot_assets() {
assert_eq!(MATERIAL_PICKER_WIDTH, 244.0);
assert_eq!(TEXTURE_PICKER_WIDTH, 420.0);
assert_eq!(PICKER_ROW_HEIGHT, 44.0);
assert_eq!(PICKER_THUMBNAIL, 36.0);
}
}

View File

@ -1,408 +0,0 @@
//! Texture/channel controls shared by Material and Material Instance input tables.
use super::*;
use crate::ui::design_system;
use crate::ui::design_system::typography::TypeRole;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextureFieldAdornment {
TypedThumbnail,
BrokenReference,
Empty,
}
fn texture_field_adornment(
presentation: Option<MaterialThumbnailPresentation>,
current_present: bool,
) -> TextureFieldAdornment {
if presentation.is_some() {
TextureFieldAdornment::TypedThumbnail
} else if current_present {
TextureFieldAdornment::BrokenReference
} else {
TextureFieldAdornment::Empty
}
}
pub(super) fn material_property_texture_binding(
textures: &[MaterialTextureBinding],
property: &MaterialInputDesc,
) -> MaterialTextureBinding {
textures
.iter()
.find(|texture| texture.name == property.name)
.cloned()
.unwrap_or_else(|| MaterialTextureBinding {
name: property.name.clone(),
texture: None,
channel: property
.texture
.as_ref()
.map_or(TextureChannel::Rgba, |desc| desc.default_channel),
})
}
pub(super) fn upsert_material_texture_binding(
textures: &mut Vec<MaterialTextureBinding>,
binding: MaterialTextureBinding,
) {
if let Some(existing) = textures
.iter_mut()
.find(|texture| texture.name == binding.name)
{
*existing = binding;
} else {
textures.push(binding);
}
}
pub(super) fn material_texture_channel_ui(
ui: &mut egui::Ui,
property: &MaterialInputDesc,
binding: &mut MaterialTextureBinding,
) {
let allow_override = property
.texture
.as_ref()
.is_some_and(|texture| texture.allow_channel_override);
if !allow_override {
return;
}
egui::ComboBox::from_id_salt(("texture_channel", &property.name))
.selected_text(texture_channel_label(binding.channel))
.width(48.0)
.show_ui(ui, |ui| {
for channel in [
TextureChannel::R,
TextureChannel::G,
TextureChannel::B,
TextureChannel::A,
] {
ui.selectable_value(
&mut binding.channel,
channel,
texture_channel_label(channel),
);
}
});
}
pub(super) fn texture_channel_label(channel: TextureChannel) -> &'static str {
match channel {
TextureChannel::R => "R",
TextureChannel::G => "G",
TextureChannel::B => "B",
TextureChannel::A => "A",
TextureChannel::Rgb => "RGB",
TextureChannel::Rgba => "RGBA",
}
}
pub(super) fn ensure_material_texture_binding<'a>(
textures: &'a mut Vec<MaterialTextureBinding>,
name: &str,
) -> &'a mut MaterialTextureBinding {
let index = textures
.iter()
.position(|texture| texture.name == name)
.unwrap_or_else(|| {
textures.push(MaterialTextureBinding {
name: name.to_string(),
texture: None,
channel: TextureChannel::Rgba,
});
textures.len() - 1
});
&mut textures[index]
}
pub(super) fn texture_binding_ui(
ui: &mut egui::Ui,
binding: &mut MaterialTextureBinding,
texture_assets: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
inline_actions: bool,
) -> TextureSlotResponse {
let response = texture_slot_ui(
ui,
format!("binding_{}", binding.name),
TextureSlotSelection::Reference(binding.texture.as_ref()),
"(none)",
texture_assets,
drop_candidate,
inline_actions,
);
if response.clear {
binding.texture = None;
} else if let Some(candidate) = response.selected.as_ref() {
binding.texture = Some(candidate.reference.clone());
}
response
}
pub(super) fn texture_slot_ui(
ui: &mut egui::Ui,
slot_id: impl std::hash::Hash,
current: TextureSlotSelection<'_>,
empty_label: &str,
candidates: &[MaterialTextureCandidate],
drop_candidate: Option<&MaterialTextureCandidate>,
inline_actions: bool,
) -> TextureSlotResponse {
let palette = design_system::palette(ui);
let mut result = TextureSlotResponse::default();
ui.push_id(slot_id, |ui| {
let width = ui.available_width().max(1.0);
let (outer, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
let group = outer;
let field_width = if inline_actions {
(group.width() - 60.0).max(1.0)
} else {
(group.width() - 30.0).max(1.0)
};
let field_rect = egui::Rect::from_min_size(group.min, egui::vec2(field_width, 22.0));
let locate_rect = egui::Rect::from_min_size(
group.min + egui::vec2(field_width + 6.0, 0.0),
egui::vec2(24.0, 22.0),
);
let clear_rect = inline_actions.then(|| {
egui::Rect::from_min_size(
group.min + egui::vec2(field_width + 36.0, 0.0),
egui::vec2(24.0, 22.0),
)
});
let selected_candidate = candidates.iter().find(|candidate| match current {
TextureSlotSelection::Reference(reference) => reference.is_some_and(|reference| {
reference.asset_id == candidate.reference.asset_id
&& reference.sub_asset_id == candidate.reference.sub_asset_id
}),
});
let current_present = match current {
TextureSlotSelection::Reference(reference) => reference.is_some(),
};
let display_label = selected_candidate
.map(|candidate| candidate.label.as_str())
.or_else(|| match current {
TextureSlotSelection::Reference(reference) => {
reference.map(|reference| reference.label.as_str())
}
})
.unwrap_or(empty_label);
let presentation = selected_candidate.map(|candidate| candidate.thumbnail);
let adornment = texture_field_adornment(presentation, current_present);
let label_color = if current_present {
palette.text_primary
} else {
palette.text_muted
};
ui.painter().rect(
field_rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let field = ui.interact(
field_rect,
ui.make_persistent_id("texture_field"),
egui::Sense::click(),
);
let image_rect = egui::Rect::from_center_size(
egui::pos2(field_rect.left() + 14.0, field_rect.center().y),
egui::vec2(16.0, 16.0),
);
match selected_candidate.map(|candidate| candidate.thumbnail) {
Some(MaterialThumbnailPresentation::Ready(texture)) => {
ui.painter().image(
texture,
image_rect,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
Some(MaterialThumbnailPresentation::Pending) => {
paint_field_icon(ui, image_rect, icons::CIRCLE_NOTCH, palette.text_muted);
}
Some(MaterialThumbnailPresentation::Failed) => {
paint_field_icon(ui, image_rect, icons::WARNING, palette.error);
}
None => paint_field_icon(
ui,
image_rect,
if adornment == TextureFieldAdornment::BrokenReference {
icons::WARNING
} else {
icons::IMAGE
},
if adornment == TextureFieldAdornment::BrokenReference {
palette.error
} else {
palette.text_muted
},
),
}
let text_rect = egui::Rect::from_min_max(
egui::pos2(field_rect.left() + 27.0, field_rect.top()),
egui::pos2(field_rect.right() - 6.0, field_rect.bottom()),
);
// `Label::truncate` centers its galley when forced into the full fixed field rectangle.
// Paint the truncated galley at the Penpot text origin instead: the typed thumbnail and
// the asset name then form one stable left-aligned identity regardless of field width.
let galley = egui::WidgetText::from(TypeRole::Body.text(display_label).color(label_color))
.into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
text_rect.width(),
egui::FontSelection::Default,
);
let text_pos = egui::pos2(
text_rect.left(),
text_rect.center().y - galley.size().y * 0.5,
);
let text_painter = ui
.painter()
.with_clip_rect(text_rect.intersect(ui.clip_rect()));
text_painter.galley(text_pos, galley, label_color);
field.clone().on_hover_text(display_label);
let drop_hovered = drop_candidate.is_some() && field.hovered();
if drop_hovered {
ui.painter().rect_stroke(
field_rect,
4.0,
egui::Stroke::new(2.0_f32, palette.accent),
egui::StrokeKind::Inside,
);
}
super::pickers::texture_picker_popup(
ui,
field.id.with("texture_picker"),
&field,
candidates,
selected_candidate,
&mut result,
);
if let Some(clear_rect) = clear_rect {
let locate = ui.put(
locate_rect,
egui::Button::new(icon_text(icons::CROSSHAIR, 14.0))
.min_size(locate_rect.size())
.fill(palette.control)
.stroke(egui::Stroke::new(1.0_f32, palette.border)),
);
if selected_candidate.is_some() && locate.clicked() {
result.locate = selected_candidate.map(|candidate| candidate.selection.clone());
}
locate.on_hover_text(if selected_candidate.is_some() {
"Locate texture in Content Browser"
} else {
"No texture to locate"
});
let clear = ui.put(
clear_rect,
egui::Button::new(icon_text(icons::X, 14.0))
.min_size(clear_rect.size())
.fill(palette.control)
.stroke(egui::Stroke::new(1.0_f32, palette.border)),
);
if current_present && clear.clicked() {
result.clear = true;
}
clear.on_hover_text(if current_present {
"Clear texture"
} else {
"No texture to clear"
});
} else {
transient_texture_actions(
ui,
locate_rect,
selected_candidate,
current_present,
&mut result,
);
}
if drop_hovered && ui.input(|input| input.pointer.any_released()) {
if let Some(candidate) = drop_candidate {
result.selected = Some(candidate.clone());
result.accepted_drop = true;
}
}
});
result
}
fn transient_texture_actions(
ui: &mut egui::Ui,
rect: egui::Rect,
selected_candidate: Option<&MaterialTextureCandidate>,
current_present: bool,
result: &mut TextureSlotResponse,
) {
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
ui.set_clip_rect(rect.intersect(ui.clip_rect()));
ui.menu_button(icon_text(icons::DOTS_THREE, 14.0), |ui| {
if ui
.add_enabled(selected_candidate.is_some(), egui::Button::new("Locate"))
.clicked()
{
result.locate = selected_candidate.map(|candidate| candidate.selection.clone());
ui.close();
}
if ui
.add_enabled(current_present, egui::Button::new("Clear"))
.clicked()
{
result.clear = true;
ui.close();
}
})
.response
.on_hover_text("Texture actions");
});
}
fn paint_field_icon(
ui: &egui::Ui,
rect: egui::Rect,
icon: egui_phosphor_icons::Icon,
color: egui::Color32,
) {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
color,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ready_texture_uses_its_thumbnail_without_a_redundant_asset_glyph() {
assert_eq!(
texture_field_adornment(
Some(MaterialThumbnailPresentation::Ready(
egui::TextureId::Managed(7)
)),
true,
),
TextureFieldAdornment::TypedThumbnail
);
}
#[test]
fn unresolved_and_empty_texture_fields_keep_distinct_fallbacks() {
assert_eq!(
texture_field_adornment(None, true),
TextureFieldAdornment::BrokenReference
);
assert_eq!(
texture_field_adornment(None, false),
TextureFieldAdornment::Empty
);
}
}

View File

@ -1,131 +0,0 @@
//! Shared material UV-transform authoring used by Materials and Material Instances.
use bevy::prelude::Vec2;
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use shared::{MaterialInputSet, MaterialParameter, MaterialParameterValue};
use crate::ui::design_system;
use crate::ui::design_system::typography::TypeRole;
pub(super) fn uv_transform_section(
ui: &mut egui::Ui,
offset: &mut Vec2,
tiling: &mut Vec2,
reset_offset: Vec2,
reset_tiling: Vec2,
) {
let palette = design_system::palette(ui);
let width = ui.available_width().max(1.0);
let height = match super::layout::ResponsiveMaterialLayout::for_width(width) {
super::layout::ResponsiveMaterialLayout::Reference => {
crate::ui::design_system::UV_SECTION_HEIGHT
}
super::layout::ResponsiveMaterialLayout::Compact => {
crate::ui::design_system::COMPACT_UV_SECTION_HEIGHT
}
super::layout::ResponsiveMaterialLayout::Transient => 100.0,
};
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
ui.painter().rect(
rect,
5.0,
palette.section,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
let geometry = super::layout::UvSectionGeometry::for_rect(rect);
child.painter().text(
geometry.title.left_center(),
egui::Align2::LEFT_CENTER,
"UV",
TypeRole::Section.font(),
palette.text_primary,
);
vec2_group(
&mut child,
geometry.offset,
"Offset",
offset,
reset_offset,
"Reset UV offset",
);
vec2_group(
&mut child,
geometry.tiling,
"Tiling",
tiling,
reset_tiling,
"Reset UV tiling",
);
}
fn vec2_group(
ui: &mut egui::Ui,
geometry: super::layout::UvGroupGeometry,
label: &str,
value: &mut Vec2,
reset: Vec2,
tooltip: &str,
) {
let palette = design_system::palette(ui);
ui.painter().text(
geometry.label.left_center(),
egui::Align2::LEFT_CENTER,
label,
TypeRole::Small.font(),
palette.text_muted,
);
ui.put(
geometry.x,
egui::DragValue::new(&mut value.x).speed(0.01).prefix("X "),
);
ui.put(
geometry.y,
egui::DragValue::new(&mut value.y).speed(0.01).prefix("Y "),
);
if let Some(reset_rect) = geometry.reset {
let enabled = *value != reset;
let response = ui.put(
reset_rect,
egui::Button::new(
egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font(
egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())),
),
),
);
if enabled && response.clicked() {
*value = reset;
}
response.on_hover_text(tooltip);
}
}
pub(super) fn material_vec2_value(inputs: &MaterialInputSet, name: &str, default: Vec2) -> Vec2 {
inputs
.values
.iter()
.find(|parameter| parameter.name == name)
.and_then(|parameter| match parameter.value {
MaterialParameterValue::Vec2(value) => Some(value),
_ => None,
})
.unwrap_or(default)
}
pub(super) fn set_material_vec2_value(inputs: &mut MaterialInputSet, name: &str, value: Vec2) {
if let Some(parameter) = inputs
.values
.iter_mut()
.find(|parameter| parameter.name == name)
{
parameter.value = MaterialParameterValue::Vec2(value);
} else {
inputs.values.push(MaterialParameter {
name: name.into(),
value: MaterialParameterValue::Vec2(value),
});
}
}

View File

@ -239,6 +239,7 @@ 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(
@ -335,13 +336,33 @@ 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: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())), font_id: editor_ui::design_system::controls::phosphor_icon_font(icon_style, 15.0),
color: theme::TEXT, color: theme::TEXT,
..Default::default() ..Default::default()
}, },
@ -350,14 +371,48 @@ fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText {
&format!(" {label}"), &format!(" {label}"),
0.0, 0.0,
egui::TextFormat { egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional), font_id: label_font,
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,
@ -479,6 +534,7 @@ 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<'_> {
@ -535,30 +591,77 @@ 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 => (icons::FOLDER, "Asset Browser"), EditorTab::AssetBrowser => unreachable!("handled above"),
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);

View File

@ -196,7 +196,10 @@ 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;
style.tab_bar.height = 30.0; // Penpot's 36 px panel title row maps to the production dock tab. Keeping the dock row at the
// 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;
@ -290,3 +293,17 @@ 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
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,234 @@
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)]);
}
}

View File

@ -7,6 +7,7 @@ use egui;
use super::typography::TypeRole; use super::typography::TypeRole;
pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0); 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PickerMode { pub(super) enum PickerMode {
@ -72,21 +73,27 @@ fn color_control_impl(
egui::vec2((width - swatch_width - gap).max(0.0), 22.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); let (rgb_rect, alpha_rect) = color_swatch_halves(swatch_rect.shrink(1.0));
let rgb_rect = egui::Rect::from_min_max(
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( ui.painter().rect_filled(
rgb_rect, rgb_rect,
3.0, 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])), 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_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( ui.painter().rect_stroke(
swatch_rect, swatch_rect,
4.0, 4.0,
@ -194,6 +201,14 @@ fn color_control_impl(
result 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 { fn centered_popup_rect(owner: egui::Rect, screen: egui::Rect) -> egui::Rect {
const SCREEN_MARGIN: f32 = 12.0; const SCREEN_MARGIN: f32 = 12.0;
let desired = owner.center() - POPUP_SIZE * 0.5; let desired = owner.center() - POPUP_SIZE * 0.5;
@ -307,6 +322,30 @@ mod tests {
} }
} }
#[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] #[test]
fn cancel_restores_opening_value_and_apply_keeps_live_preview() { fn cancel_restores_opening_value_and_apply_keeps_live_preview() {
let original = [0.1, 0.2, 0.3, 1.0]; let original = [0.1, 0.2, 0.3, 1.0];

View File

@ -7,6 +7,32 @@ use std::ops::RangeInclusive;
use super::palette; use super::palette;
use super::typography::TypeRole; 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 // 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 // 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. // pixels narrower than its visible children and must not be mistaken for the control width.
@ -85,13 +111,25 @@ pub fn icon_label(
role: TypeRole, role: TypeRole,
icon_size: f32, icon_size: f32,
color: egui::Color32, 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 { ) -> egui::WidgetText {
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: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())), font_id: phosphor_icon_font(style, icon_size),
color, color,
..Default::default() ..Default::default()
}, },

View File

@ -10,7 +10,7 @@ pub mod typography;
use egui::{self, Color32, CornerRadius, Stroke}; use egui::{self, Color32, CornerRadius, Stroke};
use crate::theme::EditorVisualPalette; use crate::theme::{AssetTypePalette, EditorVisualPalette};
pub const REFERENCE_WIDTH: f32 = 620.0; pub const REFERENCE_WIDTH: f32 = 620.0;
pub const INSPECTOR_MIN_WIDTH: f32 = 420.0; pub const INSPECTOR_MIN_WIDTH: f32 = 420.0;
@ -52,6 +52,7 @@ pub struct DesignPalette {
pub healthy: Color32, pub healthy: Color32,
pub warning: Color32, pub warning: Color32,
pub error: Color32, pub error: Color32,
pub asset_types: AssetTypePalette,
} }
impl From<EditorVisualPalette> for DesignPalette { impl From<EditorVisualPalette> for DesignPalette {
@ -75,6 +76,7 @@ impl From<EditorVisualPalette> for DesignPalette {
healthy: value.success, healthy: value.success,
warning: value.warning, warning: value.warning,
error: value.error, error: value.error,
asset_types: value.asset_types,
} }
} }
} }
@ -149,5 +151,6 @@ mod tests {
assert_eq!(palette.accent, crate::theme::SELECTION); assert_eq!(palette.accent, crate::theme::SELECTION);
assert_eq!(palette.panel, crate::theme::PANEL_BG); assert_eq!(palette.panel, crate::theme::PANEL_BG);
assert_eq!(palette.text_primary, crate::theme::TEXT); assert_eq!(palette.text_primary, crate::theme::TEXT);
assert_eq!(palette.asset_types, crate::theme::ASSET_TYPES);
} }
} }

View File

@ -10,8 +10,10 @@ use crate::design_system::controls::status;
use crate::design_system::typography::TypeRole; use crate::design_system::typography::TypeRole;
use crate::design_system::{self, DesignPalette, HEADER_HEIGHT}; use crate::design_system::{self, DesignPalette, HEADER_HEIGHT};
mod drop_feedback;
mod header_actions; mod header_actions;
use drop_feedback::{accepted_drop_action, paint_material_drop_feedback, MaterialDropVisualState};
use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH}; 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 // `Frame` reserves one pixel for the slot stroke before handing its child Ui to us. These local
@ -252,7 +254,7 @@ pub fn material_slot_panel(
&mut slot_actions, &mut slot_actions,
diagnostics_ui, diagnostics_ui,
); );
if parameters_open { if parameters_open && (view.assigned.is_some() || view.effective.is_some()) {
parameter_body(ui, content_ui); parameter_body(ui, content_ui);
} }
ui.ctx() ui.ctx()
@ -287,6 +289,34 @@ fn slot_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(
@ -392,22 +422,6 @@ 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(

View File

@ -0,0 +1,412 @@
//! 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());
}
}
}

View File

@ -2,6 +2,26 @@
use egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditorVisualPalette { pub struct EditorVisualPalette {
pub canvas: Color32, pub canvas: Color32,
@ -24,6 +44,7 @@ pub struct EditorVisualPalette {
pub success: Color32, pub success: Color32,
pub warning: Color32, pub warning: Color32,
pub error: Color32, pub error: Color32,
pub asset_types: AssetTypePalette,
} }
pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10); pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10);
@ -46,6 +67,19 @@ pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 71, 79);
pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127); pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127);
pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72); pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72);
pub const ERROR: Color32 = Color32::from_rgb(226, 94, 94); 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 { pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette {
canvas: CANVAS_BG, canvas: CANVAS_BG,
@ -68,6 +102,7 @@ pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette {
success: SUCCESS, success: SUCCESS,
warning: WARNING, warning: WARNING,
error: ERROR, error: ERROR,
asset_types: ASSET_TYPES,
}; };
const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette"; const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette";

View File

@ -12,5 +12,18 @@
"border_strong": "#40474F", "border_strong": "#40474F",
"success": "#54BE7F", "success": "#54BE7F",
"warning": "#EBB448", "warning": "#EBB448",
"error": "#E25E5E" "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"
}
} }

View File

@ -80,7 +80,7 @@ impl UiGalleryApp {
} }
} }
Self { Self {
preset: GalleryPreset::Materials620, preset: GalleryPreset::ContentBrowser1240,
comparison: ComparisonMode::SideBySide, comparison: ComparisonMode::SideBySide,
overlay_opacity: 0.5, overlay_opacity: 0.5,
fixture_id, fixture_id,
@ -142,9 +142,17 @@ impl UiGalleryApp {
fn handle_shortcuts(&mut self, ctx: &egui::Context) { fn handle_shortcuts(&mut self, ctx: &egui::Context) {
let (function_key, shift, previous_fixture, next_fixture) = ctx.input(|input| { 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] let function_key = [
.into_iter() egui::Key::F1,
.position(|key| input.key_pressed(key)); 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, function_key,
input.modifiers.shift, input.modifiers.shift,
@ -154,7 +162,9 @@ impl UiGalleryApp {
}); });
if let Some(index) = function_key { if let Some(index) = function_key {
if shift { if shift {
self.select_comparison(ComparisonMode::ALL[index]); if let Some(comparison) = ComparisonMode::ALL.get(index).copied() {
self.select_comparison(comparison);
}
} else { } else {
self.select_preset(ctx, GalleryPreset::ALL[index]); self.select_preset(ctx, GalleryPreset::ALL[index]);
} }
@ -270,7 +280,7 @@ impl UiGalleryApp {
ui.separator(); ui.separator();
ui.small("Theme and fixture JSON reload without rebuilding"); ui.small("Theme and fixture JSON reload without rebuilding");
ui.separator(); ui.separator();
ui.small("F1F4 presets · Shift+F1F4 modes · PgUp/PgDn fixtures"); ui.small("F1F7 presets · Shift+F1F4 modes · PgUp/PgDn fixtures");
if let Some(stats) = self.difference_stats { if let Some(stats) = self.difference_stats {
ui.separator(); ui.separator();
ui.small(format!( ui.small(format!(
@ -351,11 +361,12 @@ impl UiGalleryApp {
let size = egui::vec2(self.preset.width(), self.preset.target_height()); let size = egui::vec2(self.preset.width(), self.preset.target_height());
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let palette = editor_ui::design_system::palette(ui); let palette = editor_ui::design_system::palette(ui);
let (canvas_fill, canvas_radius) = if self.preset.is_renderer() { let (canvas_fill, canvas_radius) =
(palette.recessed, 4.0) if self.preset.is_content_browser() || self.preset.is_renderer() {
} else { (palette.recessed, 4.0)
(palette.panel, 7.0) } else {
}; (palette.panel, 7.0)
};
ui.painter().rect( ui.painter().rect(
rect, rect,
canvas_radius, canvas_radius,
@ -379,7 +390,13 @@ impl UiGalleryApp {
.iter() .iter()
.map(|fixture| fixture.inputs.clone()) .map(|fixture| fixture.inputs.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if self.preset.is_renderer() { 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); self.render_renderer_fixture(&mut child);
} else { } else {
self.render_material_fixture(&mut child); self.render_material_fixture(&mut child);

View File

@ -0,0 +1,326 @@
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",
);
}

View File

@ -1,4 +1,5 @@
mod app; mod app;
mod content_browser;
mod fixtures; mod fixtures;
mod reference; mod reference;
mod reload; mod reload;

View File

@ -2,6 +2,9 @@ use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GalleryPreset { pub enum GalleryPreset {
ContentBrowser1240,
ContentBrowser1600,
ContentBrowser620,
Materials620, Materials620,
Materials420, Materials420,
Renderer760, Renderer760,
@ -9,15 +12,21 @@ pub enum GalleryPreset {
} }
impl GalleryPreset { impl GalleryPreset {
pub const ALL: [Self; 4] = [ pub const ALL: [Self; 7] = [
Self::ContentBrowser1240,
Self::ContentBrowser620,
Self::Materials620, Self::Materials620,
Self::Materials420, Self::Materials420,
Self::Renderer760, Self::Renderer760,
Self::Renderer480, Self::Renderer480,
Self::ContentBrowser1600,
]; ];
pub const fn label(self) -> &'static str { pub const fn label(self) -> &'static str {
match self { match self {
Self::ContentBrowser1240 => "Content Browser · 1240",
Self::ContentBrowser1600 => "Content Browser · 1600 expanded",
Self::ContentBrowser620 => "Content Browser · 620",
Self::Materials620 => "Materials · 620", Self::Materials620 => "Materials · 620",
Self::Materials420 => "Materials · 420", Self::Materials420 => "Materials · 420",
Self::Renderer760 => "Renderer · 760", Self::Renderer760 => "Renderer · 760",
@ -27,6 +36,9 @@ impl GalleryPreset {
pub const fn width(self) -> f32 { pub const fn width(self) -> f32 {
match self { match self {
Self::ContentBrowser1240 => 1240.0,
Self::ContentBrowser1600 => 1600.0,
Self::ContentBrowser620 => 620.0,
Self::Materials620 => 620.0, Self::Materials620 => 620.0,
Self::Materials420 => 420.0, Self::Materials420 => 420.0,
Self::Renderer760 => 760.0, Self::Renderer760 => 760.0,
@ -36,6 +48,7 @@ impl GalleryPreset {
pub const fn target_height(self) -> f32 { pub const fn target_height(self) -> f32 {
match self { match self {
Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 => 650.0,
Self::Materials620 => 539.0, Self::Materials620 => 539.0,
Self::Materials420 => 735.0, Self::Materials420 => 735.0,
Self::Renderer760 => 1233.0, Self::Renderer760 => 1233.0,
@ -45,6 +58,9 @@ impl GalleryPreset {
pub const fn file_name(self) -> &'static str { pub const fn file_name(self) -> &'static str {
match self { 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::Materials620 => "materials-wide-620.png",
Self::Materials420 => "materials-narrow-420.png", Self::Materials420 => "materials-narrow-420.png",
Self::Renderer760 => "static-mesh-renderer-wide-760.png", Self::Renderer760 => "static-mesh-renderer-wide-760.png",
@ -56,12 +72,21 @@ impl GalleryPreset {
matches!(self, Self::Renderer760 | Self::Renderer480) 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. /// Production-content canvas inside the exported Penpot reference.
/// ///
/// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset; /// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset;
/// renderer boards are already complete component canvases. /// renderer boards are already complete component canvases.
pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect { pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect {
if self.is_renderer() { if self.is_content_browser() {
canvas
} else if self.is_renderer() {
egui::Rect::from_min_max( egui::Rect::from_min_max(
canvas.min + egui::vec2(0.0, 12.0), canvas.min + egui::vec2(0.0, 12.0),
egui::pos2(canvas.right(), canvas.bottom()), egui::pos2(canvas.right(), canvas.bottom()),

View File

@ -27,6 +27,23 @@ pub struct ThemeOverrides {
pub success: Option<String>, pub success: Option<String>,
pub warning: Option<String>, pub warning: Option<String>,
pub error: 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 { impl ThemeOverrides {
@ -54,6 +71,24 @@ impl ThemeOverrides {
apply!(success); apply!(success);
apply!(warning); apply!(warning);
apply!(error); 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) Ok(palette)
} }
} }
@ -240,4 +275,21 @@ mod tests {
); );
assert!(parse_color("#not-a-color").is_err()); 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
);
}
} }

View File

@ -65,6 +65,12 @@ 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
@ -79,9 +85,10 @@ 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, retry, and - Thumbnail cache ownership includes the backing Egui image registration. Replacement releases the
catalog invalidation explicitly release the last registration before a new studio render owns superseded registration only after the new artifact is usable; manual retry/full teardown release
the key, preventing refresh-driven GPU resource growth. it immediately. Catalog refresh retains it as last-good while revalidating visual signatures,
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

View File

@ -114,9 +114,34 @@ field, vector, selection, and overlay components across the Inspector. Built-in
dispatch through the registry and share one clipped actor-body scroll owner; domain behavior stays dispatch through the registry and share one clipped actor-body scroll owner; domain behavior stays
outside the visual primitives. outside the visual primitives.
The Content Browser is the next migrated workspace. Its Penpot reference deliberately includes a
36 px standalone title row, but the production dock tab already owns that identity; docked content
therefore renders the remaining 55 px single-row toolbar at wide widths rather than duplicating
chrome. Compact composition uses the exported 88 px two-row toolbar, starts with the content grid,
and exposes mutually exclusive Sources and Details drawers from explicit actions. Wide composition
uses fixed 216 px Sources and a 290 px default resizable Details pane around a flexible content
lane. The 56 px content heading precedes one continuous row-major card stream in which folders and
assets share the same bounded card footprint. Toolbar, breadcrumb, content header, cards, Details
identity, and the catalog/processing/selection footer consume the same semantic tokens and return
actions to the owning editor module. Long identity and dependency paths use the exported bounded
two-line Details geometry rather than unbounded egui wrapping or a single unreadable clipped line.
Those presentation functions live together in
`editor_ui::content_browser` and are invoked by both the production panel and the UI Gallery;
`editor::ui::asset_browser` remains the sole owner of project queries, selection, transactions,
thumbnail scheduling, and action resolution. A fixture-only Gallery body is not valid evidence for
production conformance.
The 1240 px toolbar remains the standard-wide reference. A separate 1600 px expanded reference
exposes sort and thumbnail scale in the toolbar at the 1400 px production breakpoint; standard and
compact layouts retain those controls in overflow. Card scaling preserves a fixed 40 px identity
footer and changes only the preview footprint, with square cached artifacts aspect-fitted rather
than stretched. Sorting and scaling are presentation operations and never mutate authored content.
## Consequences ## Consequences
- New editor UI has one reviewed visual vocabulary rather than panel-local approximations. - New editor UI has one reviewed visual vocabulary rather than panel-local approximations.
- Standalone design/gallery boards and docked production panels share one component without
duplicating dock-owned title chrome.
- Theme colors have one runtime owner and one design-side asset vocabulary; the material panel does - Theme colors have one runtime owner and one design-side asset vocabulary; the material panel does
not carry a private blue/teal palette. not carry a private blue/teal palette.
- The 620 px and 420 px geometries are deterministic and unit-testable. The Inspector has a 420 px - The 620 px and 420 px geometries are deterministic and unit-testable. The Inspector has a 420 px

View File

@ -84,7 +84,7 @@ records below are dated evidence, not product specifications.
| `ui/mod.rs` | Dock layout, unified viewport, menus | README controls | | `ui/mod.rs` | Dock layout, unified viewport, menus | README controls |
| `ui/scene_tabs.rs` | Main-toolbar scene tabs and composition controls | multi-scene-composition.md, ADR 0026 | | `ui/scene_tabs.rs` | Main-toolbar scene tabs and composition controls | multi-scene-composition.md, ADR 0026 |
| `ui/component_registry.rs` | Stable authoring component identities, lifecycle metadata, save policy, and inspector callbacks | extensibility.md, ADR 0034 | | `ui/component_registry.rs` | Stable authoring component identities, lifecycle metadata, save policy, and inspector callbacks | extensibility.md, ADR 0034 |
| `ui/asset_browser/` | Asset Browser tree, grid/list, details | roadmap Phase 4b | | `editor_ui::content_browser` / `ui/asset_browser/` | Shared responsive Content Browser chrome/body presentation plus production project state, list behavior, and content transactions | content-workspace.md, ADR 0045, ADR 0049 |
| `history/` | Undo/redo command stack | README controls | | `history/` | Undo/redo command stack | README controls |
| `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 | | `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 |
| `ext/extensibility.rs` | Command registry, `ActorInspectorSection`, palette | Phase 6 | | `ext/extensibility.rs` | Command registry, `ActorInspectorSection`, palette | Phase 6 |
@ -98,7 +98,7 @@ records below are dated evidence, not product specifications.
- **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic). - **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic).
- **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping. - **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping.
- **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera.
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions. It uses file-manager selection semantics across files and folders (click, Ctrl/Cmd toggle, Shift range, Ctrl/Cmd+A), batch clipboard/duplicate/trash/drag operations, keyboard shortcuts, and empty-space workspace menus. The details divider is draggable so asset and import settings can use more horizontal space, and double-clicking it restores the default width. Narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. Materials and shaders are classified by document schema anywhere under `assets/`; the browser renders material thumbnails on a sphere and exposes shader-schema-driven parameters/textures in Details. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references. glTF/GLB/FBX rows expand into normalized embedded mesh, material, and texture subassets. Model and Texture Details edit asset-keyed **UNSAVED** documents; `Ctrl+S` saves the active context and `Ctrl+Shift+S` saves all before affected-only background processing. FBX previews use the same sandboxed dependency resolver as import and validation; missing textures are required when any slot uses Source and informational when every slot uses Project/Default. Bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [content-workspace.md](content-workspace.md) and [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md). - **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions. It uses file-manager selection semantics across files and folders (click, Ctrl/Cmd toggle, Shift range, Ctrl/Cmd+A), batch clipboard/duplicate/trash/drag operations, keyboard shortcuts, and empty-space workspace menus. The details divider is draggable so asset and import settings can use more horizontal space, and double-clicking it restores the default width. Narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. Materials and shaders are classified by document schema anywhere under `assets/`; the browser renders material thumbnails on a sphere and exposes shader-schema-driven parameters/textures in Details. Typed 256 px previews persist in the managed `.thumbnails` cache, revalidate against visual dependency hashes, retain the last-good image during regeneration, and self-heal corrupt artifacts across restarts. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references. glTF/GLB/FBX rows expand into normalized embedded mesh, material, and texture subassets. Model and Texture Details edit asset-keyed **UNSAVED** documents; `Ctrl+S` saves the active context and `Ctrl+Shift+S` saves all before affected-only background processing. FBX previews use the same sandboxed dependency resolver as import and validation; missing textures are required when any slot uses Source and informational when every slot uses Project/Default. Bundle import preserves referenced sibling or `.fbm/` layout transactionally. See [content-workspace.md](content-workspace.md) and [ADR 0044](../adr/0044-sandboxed-fbx-external-texture-dependencies.md).
- **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/). - **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/).
- **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release. - **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release.
- **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md). - **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md).

View File

@ -254,9 +254,14 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
poses. Material subassets expose source defaults, and texture dependencies can be applied to poses. Material subassets expose source defaults, and texture dependencies can be applied to
selected actors. The thumbnail studio renders each generated model/subasset/material preview into selected actors. The thumbnail studio renders each generated model/subasset/material preview into
its own render target before registering it in the cache so later thumbnails cannot overwrite its own render target before registering it in the cache so later thumbnails cannot overwrite
earlier cache entries. FBX model and mesh previews use neutral direct geometry; source-material earlier cache entries. Completed renders are asynchronously read back into typed,
previews preflight external textures and retain one stable hover-visible failure reason instead content-addressed 256 px PNG artifacts under `assets/.thumbnails/v3/`. Visual signatures include
of enqueueing a known-missing path. source/dependency bytes, resolved Material inputs, textures, shader files, and the studio version.
Catalog refresh increments a validation epoch without discarding the last-good GPU registration;
only a changed signature schedules replacement. Corrupt artifacts regenerate, publication is
atomic, and retryable studio failures receive one bounded retry. FBX model and mesh previews use
neutral direct geometry; source-material previews preflight external textures and retain one
stable hover-visible failure reason instead of enqueueing a known-missing path.
- **Default placement:** **Renderable Asset (Auto)** creates - **Default placement:** **Renderable Asset (Auto)** creates
`ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and `ActorKind::StaticMesh + StaticMeshRenderer` for unrigged, non-animated models and
`ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains `ActorKind::SkinnedMesh + SkinnedMeshRenderer` when any part is skin-bound or the source contains

View File

@ -14,8 +14,32 @@ is mutable metadata that moves, validation, and packaging repair through the sha
## Content Browser workflow ## Content Browser workflow
The Content Browser toolbar provides **New Folder**, **Rename**, **Duplicate**, **Cut**, **Copy**, The docked Content Browser maps the standalone Penpot title row to the editor dock tab instead of
and **Paste**. The selected folder is the destination for paste and **File > Import Assets**. These rendering a second title inside the panel. At wide widths it uses the Penpot 55 px single-row
content toolbar: navigation, import, folder creation, breadcrumbs, search, type filtering, and view
controls share one dense row. At 1400 px and wider, sort order and the grid thumbnail-size slider
also become first-class controls in that row; narrower wide and compact layouts keep those
lower-frequency controls in overflow. Sorting preserves selection, while thumbnail scaling is a
presentation-only change and is disabled in list view. Below the 1000 px responsive breakpoint,
the compact 88 px two-row
toolbar keeps search in the flexible center lane, Sources and Details become
explicit mutually exclusive drawers, Import/New remain on the breadcrumb row, and infrequent
operations move to overflow. The default compact view is the content grid; no hidden pane silently
consumes its width. Wide mode uses a 216 px Sources pane and a 290 px default Details pane; the
Details divider remains resizable and double-click restores 290 px.
Grid mode uses one continuous row-major stream for folders and assets. Folders retain navigation
and drop-target behavior but use the same bounded card contract as files; they never force
the first asset onto a new row. The 56 px content heading owns the folder name, sort summary, and
selection chip before that single card stream begins. Each card clips a single bounded name and a
separate asset-kind line; long or Unicode names expose their full value by tooltip instead of
wrapping across card bounds. Sources, that shared card presentation, and bounded Details rows live
in `editor_ui::content_browser`; production supplies real project state and handles the actions,
while the UI Gallery supplies fixtures to the same functions. Details paths and dependency
identities receive the exported 32 px two-line treatment; other scalar values stay one line.
The Content Browser provides **New Folder**, **Rename**, **Duplicate**, **Cut**, **Copy**, and
**Paste**. The selected folder is the destination for paste and **File > Import Assets**. These
operations use the shared content transaction engine: it previews collisions, rejects managed or operations use the shared content transaction engine: it previews collisions, rejects managed or
symlink paths, stages filesystem and cached-reference changes together, preserves IDs on moves, symlink paths, stages filesystem and cached-reference changes together, preserves IDs on moves,
assigns new IDs to copies, and rolls back on failure. Successful move, rename, create-folder, copy, assigns new IDs to copies, and rolls back on failure. Successful move, rename, create-folder, copy,
@ -80,7 +104,9 @@ unused content space clears any old selection and opens workspace actions for **
current folder; the Material Library's create button retains `assets/materials/` as its convenient current folder; the Material Library's create button retains `assets/materials/` as its convenient
default. default.
Double-clicking a scene opens it, a model toggles its embedded contents, and other asset types reveal Double-clicking a scene opens it, a model toggles its embedded contents, and other asset types reveal
their editable Details. The footer and Details pane summarize multi-selections; single-selection their editable Details. The fixed footer reports catalog health, thumbnail/derived processing, and
the current selection without moving card labels. The content header reports folder/item count,
active sort, and a bounded selection chip. The footer and Details pane summarize multi-selections; single-selection
Details remains the authoritative editor for import and material settings. Drag the Details divider Details remains the authoritative editor for import and material settings. Drag the Details divider
to resize it for long slot/shader forms; double-click the divider to restore its default width. to resize it for long slot/shader forms; double-click the divider to restore its default width.
Texture Details uses the same pane for semantic/color-space intent, mip generation, UASTC or Texture Details uses the same pane for semantic/color-space intent, mip generation, UASTC or
@ -92,9 +118,23 @@ Thumbnail cache keys are typed by asset category. Texture cards may use direct i
model cards always queue the offscreen geometry studio—even when a glTF has a base-color texture. model cards always queue the offscreen geometry studio—even when a glTF has a base-color texture.
Expanded mesh rows request their exact mesh-subasset render, and source materials use a separate Expanded mesh rows request their exact mesh-subasset render, and source materials use a separate
material-sphere key. Missing dependencies produce an explicit failed thumbnail; a model or mesh material-sphere key. Missing dependencies produce an explicit failed thumbnail; a model or mesh
card never substitutes an albedo texture. Replacing, retrying, or invalidating a thumbnail also card never substitutes an albedo texture.
unregisters its last Egui image handle, so repeated catalog refreshes release the prior GPU image
instead of accumulating hidden render targets. Successful 256 px previews are persisted beneath the managed, untracked
`assets/.thumbnails/v3/` cache. The artifact key hashes the typed thumbnail identity, renderer
version, source bytes, model bundle dependencies, resolved Material values, referenced textures,
and custom shader files. A restart decodes that small validated PNG instead of reloading a 4K
texture or re-rendering geometry. Catalog refreshes revalidate signatures, and retained prior
generations restore the last-good image even after a restart while a changed preview is rebuilt or
its broken source is diagnosed; corrupt/missing artifacts self-heal, retryable studio
failures receive one bounded automatic retry, and **Regenerate Thumbnail** explicitly evicts the
selected key. Publication is atomic, retains one prior generation until the replacement is valid,
and trims only old derived entries after the cache exceeds its 512 MiB soft limit.
The grid slider scales cards from 68×88 through the 116×136 default to 132×152. Only the preview
region scales: every size reserves the same 40 px identity footer, keeps the name and kind bounded,
and paints the category keyline inside the card bottom. Canonical 256 px thumbnail artifacts are
aspect-fitted as squares rather than stretched to the card's changing preview rectangle.
**Import Here** pins the current/selected folder before the native picker opens. **Import To...** **Import Here** pins the current/selected folder before the native picker opens. **Import To...**
first opens a project-folder chooser without navigating away from the current browser location. first opens a project-folder chooser without navigating away from the current browser location.
@ -173,8 +213,14 @@ shown separately from Git state. `Ctrl+S` saves the last edited context; `Ctrl+S
never discard a dirty asset. Source publication is guarded and atomic, then affected-only derived never discard a dirty asset. Source publication is guarded and atomic, then affected-only derived
processing runs in the background while the previous valid artifact remains active. processing runs in the background while the previous valid artifact remains active.
Material scalar/color/render-state saves do not show `PROCESSING`; only packed-map binding changes Material scalar/color/render-state saves do not show `PROCESSING`; only packed-map binding changes
do. Content Browser and Material Library cards share clipped two-line labels, a top-right grid-card do. Content Browser cards use a clipped one-line identity plus a separate kind line, a top-right
status rail, and fixed-width list markers, so state never paints over or shifts an asset name. grid-card status rail, and fixed-width list markers, so state never paints over or shifts an asset
name. Every registered asset category retains its theme-owned classification color as a narrow
keyline along the bottom of the complete card, including when a rendered thumbnail is ready. The
keyline never tints or otherwise alters preview content; folders remain neutral, while loading,
failure, dirty, and selection indicators remain independent states. The canonical category palette
is documented in the [editor design system](design-system.md#asset-category-colors). Material
Library cards consume the same authored-document status model.
## Headless processing ## Headless processing

View File

@ -47,6 +47,48 @@ The material implementation is split as follows:
- `ui/materials/inputs.rs` renders schema-declared material controls. - `ui/materials/inputs.rs` renders schema-declared material controls.
- Inspector and asset editors provide state and apply returned actions through authored documents. - Inspector and asset editors provide state and apply returned actions through authored documents.
The Content Browser follows the same boundary. `editor_ui::content_browser` owns the standalone or
docked chrome mode plus the shared Sources rows, content heading, bounded folder/asset cards,
Details identity/rows, status presentation, and returned actions. The production
`ui/asset_browser` modules supply project state and own navigation history, selection, transactions,
thumbnail requests, and action handling; they do not carry a second body renderer. The UI Gallery
uses the same presentation functions with fixture view models, so a Gallery correction reaches the
production panel. The dock tab supplies the 36 px title row represented in the standalone Penpot
board. Production therefore renders the remaining 55 px single-row toolbar at wide widths or the
88 px two-row compact toolbar below 1000 px. An expanded composition at 1400 px and wider adds
foundation Combo Box and Slider instances for sort and thumbnail scale without changing the
standard-wide anchors; those controls return the same actions as their overflow counterparts.
### Asset category colors
Asset classification is a theme role, independent from thumbnail health, Git state, processing,
selection, or hover. Every `EditorAssetKind` maps exhaustively to the shared `AssetTypePalette` and
renders a 3 px keyline inside the bottom edge of its grid card. Primitive, Light, Model, Texture,
Material, Audio, Level, Prefab, Post Process, Rendering Profile, and Shader each have a distinct
default color; folders have no category keyline. The keyline remains stable for placeholder,
pending, ready, and failed thumbnails, so a rendered image never hides the asset type and a failure
never replaces it. The UI Gallery `theme.json` exposes the same roles for live iteration.
At the 1240 px Penpot reference, Sources is 216 px, Details is 290 px, and the grid owns the flexible
middle lane. At the 620 px reference, Sources and Details are absent by default and are opened from
dedicated toolbar actions. The compact toolbar preserves Back/Forward/Up, a 292 px reference search
field, Grid/List, Sources, Details, Import, New, and overflow. Status geometry is invariant: catalog
health is left, active processing is shown when space permits, and selection is right-aligned.
The content heading is 56 px, and grid cards form one continuous row-major stream across folder and
asset kinds rather than separate folder and file rows. Each body lane uses the semantic panel fill,
5 px radius, and strong inside border from the Penpot board. The grid's recessed surface begins 16
px inside that lane and cards begin another 6 px inside the surface; the resulting 22 px card
origin is shared by production and Gallery. The Details heading stays outside its scrolling content
body, and its identity stack owns the same fixed vertical geometry in both consumers. Ordinary
Details values use one clipped line; project-relative paths and dependency identities use the
Penpot 32 px two-line value region with ellipsis and a full-value tooltip, so a resized Details pane
never paints text across its boundary.
At the 1600 px expanded reference, only the flexible toolbar gap and grid lane grow. Sort and
thumbnail scale appear between the type filter and view controls. Grid cards scale from 68×88 to
132×152 around a fixed 40 px identity footer, and ready 256 px thumbnails remain square and
aspect-fitted at every size.
## Reference layout ## Reference layout
At the current 620 px wide reference, 12 px outer padding produces a 596 px slot and a 569 px parameter At the current 620 px wide reference, 12 px outer padding produces a 596 px slot and a 569 px parameter
@ -154,8 +196,9 @@ python scripts/editor/ui_gallery.py watch
``` ```
The gallery links `editor_ui` and `material_schema` directly and does not open a project, scene, The gallery links `editor_ui` and `material_schema` directly and does not open a project, scene,
renderer, or gameplay runtime. F1F4 select Materials 620, Materials 420, Renderer 760, and Renderer renderer, or gameplay runtime. F1F7 select Content Browser 1240/620, Materials 620/420,
480; Shift+F1F4 select Live, Side by side, Overlay, and Difference; Page Up/Page Down cycle the Renderer 760/480, and Content Browser 1600 expanded; Shift+F1F4 select Live, Side by side,
Overlay, and Difference; Page Up/Page Down cycle the
ownership and health fixtures. `crates/ui_gallery/fixtures/theme.json` and `material.json` reload in ownership and health fixtures. `crates/ui_gallery/fixtures/theme.json` and `material.json` reload in
the running process. Rust changes rebuild only the focused dependency closure, report a bounded the running process. Rust changes rebuild only the focused dependency closure, report a bounded
`resolved/rebuilt` counter, launch the replacement, and then stop the last-good gallery; a failed `resolved/rebuilt` counter, launch the replacement, and then stop the last-good gallery; a failed

View File

@ -17,6 +17,14 @@ runtime invariant or substitutes for the paired full-editor acceptance capture.
| [Static Mesh Renderer — wide](static-mesh-renderer-wide-760.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5315abf0a` | Full 760 px renderer composition, stable slot ordering, shared component chrome, and repeated material slots. | | [Static Mesh Renderer — wide](static-mesh-renderer-wide-760.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5315abf0a` | Full 760 px renderer composition, stable slot ordering, shared component chrome, and repeated material slots. |
| [Static Mesh Renderer — narrow](static-mesh-renderer-narrow-480.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5419afad2` | Full 480 px renderer composition and bounded narrow reflow. | | [Static Mesh Renderer — narrow](static-mesh-renderer-narrow-480.svg) | Page 02, `6cb78075-fbd0-8045-8008-57f5419afad2` | Full 480 px renderer composition and bounded narrow reflow. |
## Content Browser targets
| Export | Penpot page and shape | Required match |
|---|---|---|
| [Content Browser — wide](content-browser-wide-1240.png) | Page 03, `e755994a-6c1d-80bd-8008-58e2e8668f78` | 1240×650 standalone reference; 36 px dock/title row, 55 px single-row content toolbar, 216 px Sources, 56 px content heading, one mixed folder/asset card stream, flexible grid, 290 px Details, and 32 px status bar. Production maps the title row to its dock tab. |
| [Content Browser — expanded](content-browser-expanded-1600.png) | Page 18, `e4366b2b-77bd-8058-8008-5a57e6c9a02f` | 1600×650 expanded reference; the standard toolbar gains first-class Name sort and thumbnail-scale controls composed from the foundation Combo Box and Slider. Cards retain their identity footer while previews scale. |
| [Content Browser — compact](content-browser-compact-620.png) | Page 03, `e755994a-6c1d-80bd-8008-58e2e8dbf894` | 620×650 compact reference; 88 px two-row content toolbar, grid-only default body, explicit Sources/Details access, preserved search/navigation/grid-list controls, Import/New on the breadcrumb row, the same mixed card stream, and no clipped actions. |
## Shared component targets ## Shared component targets
| Export | Penpot page and shape | Required match | | Export | Penpot page and shape | Required match |
@ -39,6 +47,7 @@ runtime invariant or substitutes for the paired full-editor acceptance capture.
## Issue ownership ## Issue ownership
- **#68** owns exact Inspector/component/material/color-picker geometry and responsive behavior against these exports. - **#68** owns exact Inspector/component/material/color-picker geometry and responsive behavior against these exports.
- **#68** also owns Content Browser visual-system conformance against the current Page 03 wide and compact boards.
- **#67** owns the live authored-document and save-state behavior represented by the material-state and adjusted-color exports: interaction remains memory-only until explicit save, with distinct unsaved, processing, failed, and conflict state. - **#67** owns the live authored-document and save-state behavior represented by the material-state and adjusted-color exports: interaction remains memory-only until explicit save, with distinct unsaved, processing, failed, and conflict state.
- **#65** owns consolidated native comparison evidence across wide, narrow, short-height, multi-slot, overlay, save, restart, and failure scenarios. - **#65** owns consolidated native comparison evidence across wide, narrow, short-height, multi-slot, overlay, save, restart, and failure scenarios.
- **#59** indexes the design authority and requires the relevant child acceptance targets to be satisfied before epic closure. - **#59** indexes the design authority and requires the relevant child acceptance targets to be satisfied before epic closure.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -53,9 +53,15 @@ This keeps renderer, asset server, import registry, and project settings rooted
On the supported Linux workstation, the **Blacksite Editor** desktop entry provides an **Open On the supported Linux workstation, the **Blacksite Editor** desktop entry provides an **Open
Project Browser** action. Its wrapper launches the current managed development binary directly, Project Browser** action. Its wrapper launches the current managed development binary directly,
shows the normal startup splash, avoids duplicate processes, and preserves the Wayland, session-bus, shows the normal startup splash, avoids duplicate processes, and preserves the Wayland, session-bus,
graphics, and XDG environment required by the detached process. A desktop click never invokes Cargo. graphics, and XDG environment required by the detached process. Before launch, the wrapper runs an
An explicit `~/.local/bin/blacksite-editor --build-only` rebuild uses the repository's managed Cargo incremental build of both editor binaries through the persistent managed Cargo lane and its storage
lane; a missing binary produces an actionable splash error instead of starting an implicit build. pre/postflight. The splash reports exact resolved-crate progress, and any build or storage-preflight
failure remains visible with an actionable log instead of launching a stale executable. An explicit
`~/.local/bin/blacksite-editor --build-only` performs the same managed update without opening a GUI.
If an editor is already running, the wrapper still performs that update check before focusing it.
When Cargo replaced the executable, the running process is deliberately left untouched to protect
unsaved authored documents; the splash and desktop notification explicitly ask for a normal
close-and-reopen instead of implying that the old process already contains the rebuilt UI.
The browser's **New Sandbox** workflow requires a missing or empty folder and a non-empty name. The browser's **New Sandbox** workflow requires a missing or empty folder and a non-empty name.
Creation failures remain in the browser as status text; a successful scaffold is validated and Creation failures remain in the browser as status text; a successful scaffold is validated and

View File

@ -10,7 +10,8 @@ readonly STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blacksite-editor"
readonly LOG_FILE="$STATE_DIR/launch.log" readonly LOG_FILE="$STATE_DIR/launch.log"
readonly STATUS_FILE="$STATE_DIR/splash.status" readonly STATUS_FILE="$STATE_DIR/splash.status"
readonly SPLASH="$REPO/scripts/editor/splash.py" readonly SPLASH="$REPO/scripts/editor/splash.py"
readonly CARGO_LANE="$REPO/scripts/codex/cargo_lane.py" readonly BUILD_PROGRESS="$REPO/scripts/editor/build_progress.py"
readonly BUILD_STORAGE="$REPO/scripts/codex/build_storage.py"
build_only=false build_only=false
project_browser=false project_browser=false
@ -78,27 +79,55 @@ close_splash() {
} }
trap close_splash EXIT trap close_splash EXIT
focus_existing() { find_existing() {
local pid executable local pid executable
while read -r pid; do while read -r pid; do
[[ -n "$pid" ]] || continue [[ -n "$pid" ]] || continue
executable="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" executable="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)"
if [[ "$executable" == "$binary" ]]; then if [[ "$executable" == "$binary" ]]; then
if command -v hyprctl >/dev/null 2>&1; then printf '%s\n' "$pid"
hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true
fi
return 0 return 0
fi fi
done < <(pgrep -x "$process_name" 2>/dev/null || true) done < <(pgrep -x "$process_name" 2>/dev/null || true)
return 1 return 1
} }
if ! $build_only && focus_existing; then focus_process() {
exit 0 local pid="$1"
fi if command -v hyprctl >/dev/null 2>&1; then
hyprctl dispatch focuswindow "pid:$pid" >/dev/null 2>&1 || true
fi
}
running_binary_is_current() {
local pid="$1"
local running_identity current_identity
running_identity="$(stat -Lc '%d:%i' "/proc/$pid/exe" 2>/dev/null || true)"
current_identity="$(stat -Lc '%d:%i' "$binary" 2>/dev/null || true)"
[[ -n "$running_identity" && "$running_identity" == "$current_identity" ]]
}
build_current_editor() {
write_status "Checking build storage" "Validating the managed development lane" 6
if ! python3 "$BUILD_STORAGE" enforce --phase pre >>"$LOG_FILE" 2>&1; then
write_status "Build storage limit reached" \
"Open the launch log for the checked storage report" 6 error
return 1
fi
python3 "$BUILD_PROGRESS" "$STATUS_FILE" "$LOG_FILE"
local build_result=$?
if ! python3 "$BUILD_STORAGE" enforce --phase post >>"$LOG_FILE" 2>&1; then
printf '%s\n' \
"warning: build-storage postflight failed; see the report above" \
>>"$LOG_FILE"
fi
return "$build_result"
}
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
exec 9>"$STATE_DIR/launch.lock" exec 9>"$STATE_DIR/build.lock"
if ! flock -n 9; then if ! flock -n 9; then
exit 0 exit 0
fi fi
@ -117,13 +146,50 @@ if ! cd "$REPO"; then
exit 1 exit 1
fi fi
# Capture the process before Cargo may atomically replace the on-disk executable. A launcher click
# always checks/builds source updates first; it never silently focuses a stale process and makes the
# user believe the running editor already contains the rebuilt UI.
existing_pid=""
if ! $build_only; then
existing_pid="$(find_existing || true)"
fi
if ! build_current_editor; then
if $build_only; then
tail -n 120 "$LOG_FILE" >&2
else
keep_splash=true
fi
exit 1
fi
if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
focus_process "$existing_pid"
if running_binary_is_current "$existing_pid"; then
write_status "Editor already open" "Focused the current development build" 100 close
else
write_status "Update ready" "Close and reopen the editor to activate it" 100 close
if command -v notify-send >/dev/null 2>&1; then
notify-send --app-name="Blacksite Editor" \
"Blacksite update ready" \
"Close the running editor normally, then reopen it to activate the rebuilt UI." \
>/dev/null 2>&1 || true
fi
fi
# The status above already asked the splash process to close. Prevent the EXIT trap from
# replacing the meaningful "already open" or "update ready" result with a startup message.
splash_pid=""
exit 0
fi
if $build_only; then if $build_only; then
exec python3 "$CARGO_LANE" exec dev -- cargo build -p editor --bin editor printf 'Blacksite editor and project browser are current.\n'
exit 0
fi fi
if [[ ! -x "$binary" ]]; then if [[ ! -x "$binary" ]]; then
write_status "Editor build unavailable" \ write_status "Editor build unavailable" \
"Run blacksite-editor --build-only from a terminal" 100 error "The managed build completed without the requested binary" 100 error
keep_splash=true keep_splash=true
exit 1 exit 1
fi fi
@ -175,6 +241,11 @@ watch_editor_window() {
"The process is still running; check the launch log" 100 error "The process is still running; check the launch log" 100 error
} }
# The lock protects the managed build and startup handoff, not the editor runtime. Leaving this
# descriptor inherited across `exec` prevents later build-only updates for as long as the editor is
# open.
flock -u 9
exec 9>&-
watch_editor_window "$$" & watch_editor_window "$$" &
exec "$binary" >>"$LOG_FILE" 2>&1 exec "$binary" >>"$LOG_FILE" 2>&1

View File

@ -8,9 +8,14 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys
from typing import Iterable from typing import Iterable
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CARGO_LANE = ROOT / "scripts/codex/cargo_lane.py"
def write_status( def write_status(
path: Path, path: Path,
phase: str, phase: str,
@ -27,10 +32,6 @@ def write_status(
os.replace(temporary, path) os.replace(temporary, path)
def unique_crates(tree_output: str) -> set[str]:
return {line.strip() for line in tree_output.splitlines() if line.strip()}
def completed_crate( def completed_crate(
message: object, completed_package_ids: set[str] message: object, completed_package_ids: set[str]
) -> tuple[bool, str]: ) -> tuple[bool, str]:
@ -46,39 +47,111 @@ def completed_crate(
return True, package_id.rsplit("#", 1)[-1] return True, package_id.rsplit("#", 1)[-1]
def cargo_tree(cargo: str) -> list[str]: def managed_cargo(cargo_lane: Path, lane: str, *arguments: str) -> list[str]:
return [ return [
cargo, sys.executable,
"tree", str(cargo_lane),
"-p", "exec",
"editor", lane,
"--features", "--",
"dev", "cargo",
"--edges", *arguments,
"normal,build",
"--prefix",
"none",
"--format",
"{p}",
"--no-dedupe",
"--color",
"never",
] ]
def cargo_build(cargo: str) -> list[str]: def host_target() -> str:
return [ completed = subprocess.run(
cargo, ["rustc", "-vV"],
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
for line in completed.stdout.splitlines():
if line.startswith("host:"):
return line.partition(":")[2].strip()
return ""
def cargo_metadata(cargo_lane: Path, lane: str, target: str) -> list[str]:
arguments = ["metadata", "--format-version", "1"]
if target:
arguments.extend(["--filter-platform", target])
return managed_cargo(
cargo_lane,
lane,
*arguments,
)
def cargo_build(cargo_lane: Path, lane: str) -> list[str]:
return managed_cargo(
cargo_lane,
lane,
"build", "build",
"-p", "-p",
"editor", "editor",
"--bins", "--bins",
"--features",
"dev",
"--message-format=json-render-diagnostics", "--message-format=json-render-diagnostics",
"--color", "--color",
"never", "never",
] )
def editor_package_closure(metadata_output: str) -> set[str]:
document = json.loads(metadata_output)
packages = document.get("packages")
resolve = document.get("resolve")
if not isinstance(packages, list) or not isinstance(resolve, dict):
raise ValueError("Cargo metadata did not include packages and a resolve graph")
editor_ids = {
package.get("id")
for package in packages
if isinstance(package, dict)
and package.get("name") == "editor"
and isinstance(package.get("id"), str)
}
if len(editor_ids) != 1:
raise ValueError("Cargo metadata did not identify exactly one editor package")
root_id = next(iter(editor_ids))
nodes = resolve.get("nodes")
if not isinstance(nodes, list):
raise ValueError("Cargo metadata resolve graph did not include nodes")
nodes_by_id = {
node["id"]: node
for node in nodes
if isinstance(node, dict) and isinstance(node.get("id"), str)
}
closure: set[str] = set()
pending = [root_id]
while pending:
package_id = pending.pop()
if package_id in closure:
continue
closure.add(package_id)
node = nodes_by_id.get(package_id)
if not isinstance(node, dict):
continue
dependencies = node.get("deps")
if not isinstance(dependencies, list):
continue
for dependency in dependencies:
if not isinstance(dependency, dict):
continue
dependency_id = dependency.get("pkg")
kinds = dependency.get("dep_kinds")
if not isinstance(dependency_id, str) or not isinstance(kinds, list):
continue
if any(
isinstance(kind, dict) and kind.get("kind") in {None, "build"}
for kind in kinds
):
pending.append(dependency_id)
return closure
def rendered_lines(message: object, raw_line: str) -> Iterable[str]: def rendered_lines(message: object, raw_line: str) -> Iterable[str]:
@ -93,25 +166,33 @@ def rendered_lines(message: object, raw_line: str) -> Iterable[str]:
yield rendered.rstrip("\n") yield rendered.rstrip("\n")
def run(status_path: Path, log_path: Path, cargo: str) -> int: def run(status_path: Path, log_path: Path, cargo_lane: Path, lane: str) -> int:
write_status( write_status(
status_path, status_path,
"Preparing build", "Preparing build",
"Resolving editor crate graph", "Resolving editor crate graph",
8, 8,
) )
tree = subprocess.run( metadata = subprocess.run(
cargo_tree(cargo), cargo_metadata(cargo_lane, lane, host_target()),
cwd=ROOT,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
check=False, check=False,
) )
crates = unique_crates(tree.stdout) if tree.returncode == 0 else set() try:
crates = (
editor_package_closure(metadata.stdout)
if metadata.returncode == 0
else set()
)
except (json.JSONDecodeError, ValueError):
crates = set()
if not crates: if not crates:
with log_path.open("a", encoding="utf-8") as log: with log_path.open("a", encoding="utf-8") as log:
log.write(tree.stdout) log.write(metadata.stdout)
log.write(tree.stderr) log.write(metadata.stderr)
write_status( write_status(
status_path, status_path,
"Build graph failed", "Build graph failed",
@ -119,7 +200,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int:
8, 8,
"error", "error",
) )
return tree.returncode or 1 return metadata.returncode or 1
total = len(crates) total = len(crates)
write_status( write_status(
@ -131,7 +212,8 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int:
) )
process = subprocess.Popen( process = subprocess.Popen(
cargo_build(cargo), cargo_build(cargo_lane, lane),
cwd=ROOT,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
@ -154,7 +236,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int:
changed, crate_name = completed_crate(message, completed_package_ids) changed, crate_name = completed_crate(message, completed_package_ids)
if not changed: if not changed:
continue continue
completed = min(len(completed_package_ids), total) completed = len(completed_package_ids.intersection(crates))
progress = min(78, 12 + (66 * completed // total)) progress = min(78, 12 + (66 * completed // total))
write_status( write_status(
status_path, status_path,
@ -165,7 +247,7 @@ def run(status_path: Path, log_path: Path, cargo: str) -> int:
) )
return_code = process.wait() return_code = process.wait()
completed = min(len(completed_package_ids), total) completed = len(completed_package_ids.intersection(crates))
if return_code != 0: if return_code != 0:
write_status( write_status(
status_path, status_path,
@ -191,9 +273,15 @@ def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("status_path", type=Path) parser.add_argument("status_path", type=Path)
parser.add_argument("log_path", type=Path) parser.add_argument("log_path", type=Path)
parser.add_argument("--cargo", default="/usr/bin/cargo") parser.add_argument("--cargo-lane", type=Path, default=DEFAULT_CARGO_LANE)
parser.add_argument("--lane", default="dev")
arguments = parser.parse_args() arguments = parser.parse_args()
return run(arguments.status_path, arguments.log_path, arguments.cargo) return run(
arguments.status_path,
arguments.log_path,
arguments.cargo_lane,
arguments.lane,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -6,6 +6,7 @@ import unittest
MODULE_PATH = Path(__file__).with_name("build_progress.py") MODULE_PATH = Path(__file__).with_name("build_progress.py")
LAUNCHER_PATH = Path(__file__).with_name("blacksite-editor")
SPEC = importlib.util.spec_from_file_location("build_progress", MODULE_PATH) SPEC = importlib.util.spec_from_file_location("build_progress", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None assert SPEC is not None and SPEC.loader is not None
BUILD_PROGRESS = importlib.util.module_from_spec(SPEC) BUILD_PROGRESS = importlib.util.module_from_spec(SPEC)
@ -13,11 +14,94 @@ SPEC.loader.exec_module(BUILD_PROGRESS)
class BuildProgressTests(unittest.TestCase): class BuildProgressTests(unittest.TestCase):
def test_unique_crates_deduplicates_repeated_tree_entries(self) -> None: def test_desktop_wrapper_releases_the_build_lock_before_editor_exec(self) -> None:
output = "editor v0.1.0\nserde v1.0.0\nserde v1.0.0\n" wrapper = LAUNCHER_PATH.read_text(encoding="utf-8")
self.assertIn('exec 9>"$STATE_DIR/build.lock"', wrapper)
self.assertNotIn('exec 9>"$STATE_DIR/launch.lock"', wrapper)
unlock = wrapper.index("flock -u 9")
close = wrapper.index("exec 9>&-")
editor_exec = wrapper.index('exec "$binary"')
self.assertLess(unlock, close)
self.assertLess(close, editor_exec)
def test_desktop_wrapper_builds_before_focusing_an_existing_editor(self) -> None:
wrapper = LAUNCHER_PATH.read_text(encoding="utf-8")
capture_existing = wrapper.index('existing_pid="$(find_existing || true)"')
build = wrapper.index("if ! build_current_editor")
existing_handoff = wrapper.index(
'if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid"'
)
self.assertLess(capture_existing, build)
self.assertLess(build, existing_handoff)
self.assertIn("running_binary_is_current", wrapper)
self.assertIn("Blacksite update ready", wrapper)
self.assertNotIn("if ! $build_only && focus_existing", wrapper)
def test_build_commands_use_the_managed_default_feature_lane(self) -> None:
cargo_lane = Path("scripts/codex/cargo_lane.py")
metadata = BUILD_PROGRESS.cargo_metadata(
cargo_lane, "dev", "x86_64-unknown-linux-gnu"
)
build = BUILD_PROGRESS.cargo_build(cargo_lane, "dev")
self.assertEqual( self.assertEqual(
BUILD_PROGRESS.unique_crates(output), metadata[:7],
{"editor v0.1.0", "serde v1.0.0"}, [
BUILD_PROGRESS.sys.executable,
str(cargo_lane),
"exec",
"dev",
"--",
"cargo",
"metadata",
],
)
self.assertIn("--filter-platform", metadata)
self.assertIn("--bins", build)
self.assertNotIn("--features", metadata)
self.assertNotIn("--features", build)
def test_editor_package_closure_includes_normal_and_build_edges(self) -> None:
metadata = {
"packages": [
{"id": "editor-id", "name": "editor"},
{"id": "normal-id", "name": "normal"},
{"id": "build-id", "name": "build"},
{"id": "dev-id", "name": "dev"},
],
"resolve": {
"nodes": [
{
"id": "editor-id",
"deps": [
{
"pkg": "normal-id",
"dep_kinds": [{"kind": None}],
},
{
"pkg": "build-id",
"dep_kinds": [{"kind": "build"}],
},
{
"pkg": "dev-id",
"dep_kinds": [{"kind": "dev"}],
},
],
},
{"id": "normal-id", "deps": []},
{"id": "build-id", "deps": []},
{"id": "dev-id", "deps": []},
]
},
}
self.assertEqual(
BUILD_PROGRESS.editor_package_closure(
BUILD_PROGRESS.json.dumps(metadata)
),
{"editor-id", "normal-id", "build-id"},
) )
def test_completed_crate_counts_each_package_once(self) -> None: def test_completed_crate_counts_each_package_once(self) -> None: