From 53dc1e44d861c9ff85c6d63b328d95bd5042eea4 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 18 Jul 2026 12:05:26 -0400 Subject: [PATCH] feat: add production UI gallery and inspector system --- .../references/scenario-format.md | 5 + .../scenarios/penpot-material-inspector.yaml | 8 +- .../scenarios/ui-gallery-framework.yaml | 22 + ...ce_and_import_authoring_2026-07-13.plan.md | 8 +- Cargo.lock | 241 +++++- Cargo.toml | 9 +- README.md | 17 + .../Office/metal_office_desk.material.ron | 2 +- assets/levels/editor_scene.scn.ron | 104 +-- crates/editor/Cargo.toml | 1 + .../src/ui/design_system/color_picker.rs | 60 +- .../editor/src/ui/design_system/controls.rs | 68 +- .../src/ui/design_system/property_grid.rs | 50 +- crates/editor/src/ui/fonts.rs | 56 +- .../src/ui/inspector/component_chrome.rs | 244 +----- .../editor/src/ui/inspector/material_slots.rs | 42 +- .../editor/src/ui/inspector/mesh_renderers.rs | 254 ++---- .../src/ui/inspector/primitive_domains.rs | 9 +- .../editor/src/ui/inspector/renderer_panel.rs | 129 +-- .../src/ui/materials/advanced_preview.rs | 2 +- .../editor/src/ui/materials/asset_editor.rs | 124 +-- crates/editor/src/ui/materials/editor.rs | 5 +- crates/editor/src/ui/materials/inputs.rs | 23 +- .../src/ui/materials/instance_editor.rs | 16 +- .../src/ui/materials/instance_inputs.rs | 3 +- crates/editor/src/ui/materials/mod.rs | 125 ++- crates/editor/src/ui/materials/panel.rs | 163 ++-- .../src/ui/materials/panel/header_actions.rs | 216 +++++ crates/editor/src/ui/materials/pickers.rs | 4 +- .../editor/src/ui/materials/texture_inputs.rs | 2 +- .../editor/src/ui/materials/uv_transform.rs | 2 +- crates/editor/src/ui/mod.rs | 2 +- crates/editor_ui/Cargo.toml | 12 + .../assets/fonts/source-sans/LICENSE.md | 93 ++ .../fonts/source-sans/SourceSansPro-Bold.ttf | Bin 0 -> 253344 bytes .../source-sans/SourceSansPro-Regular.ttf | Bin 0 -> 254076 bytes .../src/design_system/color_picker.rs | 328 +++++++ .../src/design_system/color_picker/popup.rs | 765 ++++++++++++++++ .../color_picker/popup/sliders.rs | 167 ++++ .../editor_ui/src/design_system/controls.rs | 381 ++++++++ crates/editor_ui/src/design_system/mod.rs | 153 ++++ .../src/design_system/property_grid.rs | 294 +++++++ .../editor_ui/src/design_system/typography.rs | 94 ++ crates/editor_ui/src/fonts.rs | 54 ++ crates/editor_ui/src/inspector.rs | 262 ++++++ crates/editor_ui/src/lib.rs | 12 + .../src/materials/advanced_preview.rs | 286 ++++++ crates/editor_ui/src/materials/inputs.rs | 724 ++++++++++++++++ crates/editor_ui/src/materials/layout.rs | 344 ++++++++ crates/editor_ui/src/materials/mod.rs | 96 ++ crates/editor_ui/src/materials/panel.rs | 773 +++++++++++++++++ .../src/materials/panel/header_actions.rs | 238 +++++ crates/editor_ui/src/materials/pickers.rs | 420 +++++++++ crates/editor_ui/src/materials/surface.rs | 120 +++ .../editor_ui/src/materials/texture_inputs.rs | 413 +++++++++ .../editor_ui/src/materials/uv_transform.rs | 131 +++ crates/editor_ui/src/renderer.rs | 412 +++++++++ crates/editor_ui/src/theme.rs | 156 ++++ crates/material_schema/Cargo.toml | 12 + crates/material_schema/src/lib.rs | 546 ++++++++++++ crates/shared/Cargo.toml | 1 + crates/shared/src/components.rs | 75 +- crates/shared/src/lib.rs | 17 +- crates/shared/src/material_asset.rs | 567 +++--------- crates/shared/src/material_desc.rs | 84 +- crates/ui_gallery/Cargo.toml | 16 + crates/ui_gallery/fixtures/material.json | 10 + crates/ui_gallery/fixtures/theme.json | 16 + crates/ui_gallery/src/app.rs | 818 ++++++++++++++++++ crates/ui_gallery/src/fixtures.rs | 457 ++++++++++ crates/ui_gallery/src/main.rs | 19 + crates/ui_gallery/src/reference.rs | 318 +++++++ crates/ui_gallery/src/reload.rs | 243 ++++++ .../0049-penpot-led-editor-visual-system.md | 20 +- docs/editor/design-system.md | 61 +- .../content-workspace-m2/README.md | 4 +- .../content-workspace-m2/penpot/README.md | 4 + .../penpot/array-header-40.png | 3 + .../penpot/color-picker-adjusted.png | 3 + .../penpot/color-picker-presets.png | 3 + .../penpot/color-picker-sliders.png | 3 + .../penpot/color-picker-wheel.png | 3 + .../penpot/component-composition-rule.png | 3 + .../penpot/component-header-50.png | 3 + .../penpot/foundation-components-catalog.png | 3 + .../penpot/material-components-states.png | 3 + .../penpot/materials-narrow-420.png | 3 + .../penpot/materials-wide-620.png | 3 + .../static-mesh-renderer-narrow-480.png | 3 + .../penpot/static-mesh-renderer-wide-760.png | 3 + .../ui-gallery-delta-matrix.md | 58 ++ docs/editor/material-system.md | 11 + scripts/codex/native_qa.sh | 21 +- scripts/editor/test_ui_gallery.py | 128 +++ scripts/editor/ui_gallery.py | 366 ++++++++ 95 files changed, 11003 insertions(+), 1652 deletions(-) create mode 100644 .agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml create mode 100644 crates/editor/src/ui/materials/panel/header_actions.rs create mode 100644 crates/editor_ui/Cargo.toml create mode 100644 crates/editor_ui/assets/fonts/source-sans/LICENSE.md create mode 100644 crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Bold.ttf create mode 100644 crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Regular.ttf create mode 100644 crates/editor_ui/src/design_system/color_picker.rs create mode 100644 crates/editor_ui/src/design_system/color_picker/popup.rs create mode 100644 crates/editor_ui/src/design_system/color_picker/popup/sliders.rs create mode 100644 crates/editor_ui/src/design_system/controls.rs create mode 100644 crates/editor_ui/src/design_system/mod.rs create mode 100644 crates/editor_ui/src/design_system/property_grid.rs create mode 100644 crates/editor_ui/src/design_system/typography.rs create mode 100644 crates/editor_ui/src/fonts.rs create mode 100644 crates/editor_ui/src/inspector.rs create mode 100644 crates/editor_ui/src/lib.rs create mode 100644 crates/editor_ui/src/materials/advanced_preview.rs create mode 100644 crates/editor_ui/src/materials/inputs.rs create mode 100644 crates/editor_ui/src/materials/layout.rs create mode 100644 crates/editor_ui/src/materials/mod.rs create mode 100644 crates/editor_ui/src/materials/panel.rs create mode 100644 crates/editor_ui/src/materials/panel/header_actions.rs create mode 100644 crates/editor_ui/src/materials/pickers.rs create mode 100644 crates/editor_ui/src/materials/surface.rs create mode 100644 crates/editor_ui/src/materials/texture_inputs.rs create mode 100644 crates/editor_ui/src/materials/uv_transform.rs create mode 100644 crates/editor_ui/src/renderer.rs create mode 100644 crates/editor_ui/src/theme.rs create mode 100644 crates/material_schema/Cargo.toml create mode 100644 crates/material_schema/src/lib.rs create mode 100644 crates/ui_gallery/Cargo.toml create mode 100644 crates/ui_gallery/fixtures/material.json create mode 100644 crates/ui_gallery/fixtures/theme.json create mode 100644 crates/ui_gallery/src/app.rs create mode 100644 crates/ui_gallery/src/fixtures.rs create mode 100644 crates/ui_gallery/src/main.rs create mode 100644 crates/ui_gallery/src/reference.rs create mode 100644 crates/ui_gallery/src/reload.rs create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.png create mode 100644 docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.png create mode 100644 docs/editor/evaluations/content-workspace-m2/ui-gallery-delta-matrix.md create mode 100755 scripts/editor/test_ui_gallery.py create mode 100755 scripts/editor/ui_gallery.py diff --git a/.agents/skills/blacksite-native-qa/references/scenario-format.md b/.agents/skills/blacksite-native-qa/references/scenario-format.md index 078676b..e1ba75d 100644 --- a/.agents/skills/blacksite-native-qa/references/scenario-format.md +++ b/.agents/skills/blacksite-native-qa/references/scenario-format.md @@ -7,6 +7,8 @@ name: purpose: preconditions: build_lane: +package: +binary: fixture: steps: assertions: @@ -15,6 +17,9 @@ cleanup: invalidates: ``` +`package` and `binary` are optional and default to `editor`. Focused tools such as the UI gallery +can therefore use the same exact-window evidence and cleanup workflow without launching a project. + Keep steps deterministic and phrased as user actions. Assertions describe visible behavior, state transitions, persistence, and failure recovery. Evidence names the target window/crop and expected files. Cleanup must identify every spawned process and fixture mutation. The runner exposes the scenario instead of pretending manual interactions are automated: diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml index 71fbba8..cb36587 100644 --- a/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml +++ b/.agents/skills/blacksite-native-qa/references/scenarios/penpot-material-inspector.yaml @@ -1,10 +1,10 @@ name: penpot-material-inspector -purpose: Verify the final Penpot Materials list and material-slot panel at its 640 px and 420 px Inspector reference widths. +purpose: Verify the current Penpot Materials list and material-slot panel at its 620 px and 420 px Inspector reference widths with side-by-side design evidence. preconditions: editor_scene contains primitive, static-mesh, and skinned-mesh material users; no editor process is running. build_lane: dev fixture: assets/materials/pebble_bricks.ron steps: - - Launch the editor on the real desktop and size the Inspector to the 640 px reference width. + - Launch the editor on the real desktop and size the Inspector to the 620 px reference width. - Select a primitive, static mesh, and skinned mesh in turn and expand each material panel. - Compare Materials ownership, expanded slot header, PARAMETERS spine, Surface, six Inputs rows, UV, and Advanced geometry with the current Penpot reference. - Narrow the Inspector to the 420 px floor and verify every value, channel, texture, Locate, and Clear control reflows. @@ -14,7 +14,7 @@ steps: - Edit a scalar and color, release the controls, switch actors, then save and relaunch the editor. - Inspect imported-source, DefaultGrid, and broken-reference presentations through the available fixture states. assertions: - - The 640 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 592 px parameter sections, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift. + - The 620 px Inspector matches the current Penpot palette, Source Sans Pro typography, 32 px Materials heading, 82 px expanded slot header, 596 px slot, 569 px parameter body, six 30 px input rows, spacing, borders, and fixed property geometry without visible drift. - Primitive, static-mesh, and skinned-mesh slots use the same panel geometry and have no redundant second slot presentation. - At the 420 px Inspector floor, six 56 px input rows put paired texture controls on the second line with no horizontal scrolling, clipping, compressed labels, or missing actions. - Direct assignments have no Explicit badge; inherited, dirty, processing, failed, read-only, imported-source, and built-in states retain one header geometry. @@ -22,6 +22,6 @@ assertions: - Wheel, Sliders, and Presets preview live; Apply retains the dirty in-memory value and Cancel, Escape, or outside dismissal restores the exact pre-open value. - Material controls perform no pointer-release write or scalar/color processing job and keep the existing live GPU handle visible across actor selection. - Save and relaunch preserve the authored value, while broken explicit references retain failed identity and visible DefaultGrid fallback. -evidence: Target-window captures for 640 px primitive/static/skinned, narrow reflow, color modes, invalid drop, dirty state, and relaunch plus hashes. +evidence: Font-faithful Penpot PNGs paired beside fresh target-window captures for 620 px primitive/static/skinned, 420 px reflow, component headers, renderer composition, color modes, invalid drop, dirty state, and relaunch plus hashes and a written delta matrix. cleanup: Close the spawned editor and atomically restore pebble_bricks.ron from the runner snapshot. invalidates: Design tokens, fonts, material panel geometry, material picker, color picker, authored documents, or material resolver changes. diff --git a/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml b/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml new file mode 100644 index 0000000..d700eee --- /dev/null +++ b/.agents/skills/blacksite-native-qa/references/scenarios/ui-gallery-framework.yaml @@ -0,0 +1,22 @@ +name: ui-gallery-framework +purpose: Prove the lightweight production-component gallery launches independently and exposes the Penpot comparison workflow. +preconditions: The focused ui_gallery build and workflow tests pass in the managed dev lane. +build_lane: dev +package: ui_gallery +binary: ui_gallery +fixture: +steps: + - Launch the standalone Blacksite UI Gallery without a project, scene, renderer, or gameplay runtime. + - Inspect the default Materials 620 side-by-side reference and production component canvases. + - Switch through Materials 420, Renderer 760, and Renderer 480 presets. + - Exercise Live, Overlay, and Difference modes and inspect the reported difference statistics. + - Switch through direct, inherited, imported, dirty, processing, failed, read-only, and built-in fixtures. +assertions: + - The gallery opens as one responsive native window without launching the Blacksite editor or game runtime. + - All four exact reference presets render the direct Penpot PNG and shared production component without clipping the gallery chrome. + - Side-by-side, overlay, and difference modes remain interactive and Difference reports numeric delta statistics. + - Representative material ownership and health fixtures preserve the same shared panel geometry. + - Theme and material fixture JSON changes appear without rebuilding or restarting the gallery. +evidence: Exact ui_gallery window capture plus the hashed native-QA record. +cleanup: Close the runner-owned ui_gallery process; no project fixture is mutated. +invalidates: editor_ui, material_schema, ui_gallery, Penpot references, gallery fixtures, or gallery workflow scripts change. diff --git a/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md index 7961624..1c02454 100644 --- a/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md +++ b/.cursor/plans/content_workspace_and_import_authoring_2026-07-13.plan.md @@ -38,7 +38,7 @@ design-system reference. Penpot owns geometry, typography, and interaction-state Blacksite's editor-owned semantic palette owns color and is published into Penpot Assets / Colors. The current palette is the default, with preference selection deferred behind the same palette seam. Shared semantic tokens, Source Sans Pro typography, action-returning -material panels, responsive property grids, and custom asset/color pickers must match the 640 px +material panels, responsive property grids, and custom asset/color pickers must match the 620 px and 420 px references. The Inspector has a hard 420 px floor and one actor-body scroll region. Runtime material authority and explicit-save behavior remain owned by #67 and ADR 0047; wider editor-panel migration is not part of this slice. @@ -166,6 +166,12 @@ loader. Inspector. The new wide/narrow Material canvases are 596/396 px with 569/369 px parameter sections, 32/58 px rows, and 54/94 px UV sections. Candidate publication and issue closure remain blocked until refreshed engineering and Codex-owned native evidence pass on one exact tree. +- [Engineering-complete; acceptance in progress 2026-07-18] A lightweight `ui_gallery` now renders + the same `editor_ui` production components without project/scene/renderer/gameplay startup. It + provides exact Materials 620/420 and Renderer 760/480 presets, direct Penpot side-by-side, + overlay, and Difference modes, all material ownership/health fixtures, runtime token/fixture + reload, and a managed last-good rebuild/restart loop with bounded dependency progress. Focused + tests, strict lint, and architecture audit pass; paired full-editor evidence remains required. ## Target Contracts diff --git a/Cargo.lock b/Cargo.lock index 6347913..bcdf080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,6 +2564,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chiapos-chacha8" version = "0.1.0" @@ -3226,6 +3235,7 @@ dependencies = [ "blacksite_surface", "blake3", "content_pipeline", + "editor_ui", "egui_dock", "egui_phosphor_icons", "game", @@ -3249,6 +3259,51 @@ dependencies = [ "walkdir", ] +[[package]] +name = "editor_ui" +version = "0.1.0" +dependencies = [ + "bevy_math", + "egui", + "egui_phosphor_icons", + "material_schema", +] + +[[package]] +name = "eframe" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98fe83b2589105b69dd25ca1e0fa2135a6e864d502fd8e08978f937e128cfef" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + [[package]] name = "egui" version = "0.34.3" @@ -3268,6 +3323,47 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "egui-wgpu" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0c0559ac5598a1b887a6206dccbab7e3e6246c57cb00ae287262bd44776c9c" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "967c5b323625d46d46a59b5daba3fef742248d27693cc18972458619858c4239" +dependencies = [ + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + [[package]] name = "egui_dock" version = "0.19.1" @@ -3281,6 +3377,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "egui_glow" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b652957fa7e1ab01e8fecbfbf4e35f6e43a53fa98af8a562b50d5403cd44b9" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + [[package]] name = "egui_phosphor_icons" version = "0.3.1" @@ -4053,6 +4166,63 @@ dependencies = [ "serde_json", ] +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.0", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -5018,6 +5188,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "material_schema" +version = "0.1.0" +dependencies = [ + "bevy_color", + "bevy_math", + "bevy_reflect 0.19.0", + "serde", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5043,6 +5223,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5108,7 +5297,7 @@ dependencies = [ "num-traits", "once_cell", "pp-rs", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.18", "unicode-ident", @@ -5125,7 +5314,7 @@ dependencies = [ "indexmap", "naga", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "thiserror 2.0.18", "tracing", "unicode-ident", @@ -5424,6 +5613,7 @@ dependencies = [ "bitflags 2.13.0", "block2 0.6.2", "objc2 0.6.4", + "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -5709,6 +5899,18 @@ dependencies = [ "objc2-user-notifications", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -6756,6 +6958,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -6960,6 +7168,7 @@ dependencies = [ "avian3d", "bevy", "blake3", + "material_schema", "ron 0.8.1", "serde", "settings", @@ -7699,6 +7908,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7720,6 +7938,20 @@ dependencies = [ "cc", ] +[[package]] +name = "ui_gallery" +version = "0.1.0" +dependencies = [ + "editor_ui", + "eframe", + "egui", + "egui_phosphor_icons", + "image", + "material_schema", + "serde", + "serde_json", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -8152,6 +8384,7 @@ dependencies = [ "smallvec", "static_assertions", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu-core", "wgpu-hal", @@ -8180,7 +8413,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", @@ -8786,7 +9019,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", "orbclient", "percent-encoding", "pin-project", diff --git a/Cargo.toml b/Cargo.toml index 037d3db..8457eae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,13 @@ members = [ "crates/blacksite_surface", "crates/content_pipeline", + "crates/editor_ui", "crates/game", "crates/game_hot", + "crates/material_schema", "crates/shared", "crates/editor", + "crates/ui_gallery", "crates/protocol", "crates/sim", "crates/settings", @@ -44,9 +47,13 @@ blacksite_surface = { path = "crates/blacksite_surface" } content_pipeline = { path = "crates/content_pipeline" } bevy_ufbx = "0.18.1-rc.1" bevy_egui = "0.40" +egui = "0.34.3" +eframe = { version = "0.34.1", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } bevy-inspector-egui = "0.37" egui_dock = { version = "0.19.1", features = ["serde"] } -egui_phosphor_icons = { version = "0.3.1", default-features = false } +egui_phosphor_icons = { version = "0.3.1", default-features = false, features = ["bold"] } +editor_ui = { path = "crates/editor_ui" } +material_schema = { path = "crates/material_schema" } transform-gizmo-bevy = "0.9" serde = { version = "1", features = ["derive"] } nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] } diff --git a/README.md b/README.md index c54bdc7..6c4c3fd 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ workflow never installs it implicitly. > Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times. +### Focused editor UI iteration + +Use the lightweight gallery when changing shared Inspector/design-system components instead of +restarting the project editor for every visual edit: + +```bash +python scripts/editor/ui_gallery.py watch +``` + +The gallery renders the production `editor_ui` components without loading a project, scene, +renderer, or gameplay runtime. It includes exact Materials 620/420 and Renderer 760/480 presets, +direct Penpot side-by-side/overlay/difference views, representative material states, and live +theme/material fixture reload. Rust edits rebuild the focused dependency closure with a bounded +`resolved/rebuilt` counter; the last-good gallery stays open unless its replacement launches +successfully. See the [Editor Design System](docs/editor/design-system.md#focused-ui-gallery-workflow) +for shortcuts, fixtures, and acceptance rules. + ### Target Cache Cleanup Cargo/Bevy artifacts are managed as whole, sentinel-marked build lanes. Inspect and plan cleanup diff --git a/assets/Furniture/Office/metal_office_desk.material.ron b/assets/Furniture/Office/metal_office_desk.material.ron index 59852fe..fa8b2e7 100644 --- a/assets/Furniture/Office/metal_office_desk.material.ron +++ b/assets/Furniture/Office/metal_office_desk.material.ron @@ -26,7 +26,7 @@ r: 1.0, g: 1.0, b: 1.0, - a: 1.0, + a: 0.0, )), ), ( diff --git a/assets/levels/editor_scene.scn.ron b/assets/levels/editor_scene.scn.ron index 6ed996b..dc720e7 100644 --- a/assets/levels/editor_scene.scn.ron +++ b/assets/levels/editor_scene.scn.ron @@ -2,7 +2,7 @@ "shared::components::SceneComposition": (scene_id:"legacy:assets/levels/editor_scene.scn.ron",subscenes:[]), }, entities: { - 4294966529: (components: { + 4294966520: (components: { "bevy_ecs::name::Name": "Brush Part 2", "bevy_transform::components::transform::Transform": ( translation: (25.75, 0.0, 9.5), @@ -170,7 +170,7 @@ uv_tiling: (1.0, 1.0), ), }), - 4294966530: (components: { + 4294966521: (components: { "bevy_ecs::name::Name": "Brush Part 1", "bevy_transform::components::transform::Transform": ( translation: (24.5, 0.0, 7.75), @@ -338,7 +338,7 @@ uv_tiling: (1.0, 1.0), ), }), - 4294966531: (components: { + 4294966522: (components: { "bevy_ecs::name::Name": "Crate Copy", "bevy_transform::components::transform::Transform": ( translation: (15.624287, 0.75, 1.8058548), @@ -506,7 +506,7 @@ uv_tiling: (1.0, 1.0), ), }), - 4294966532: (components: { + 4294966523: (components: { "bevy_ecs::name::Name": "Post Process Volume", "bevy_transform::components::transform::Transform": ( translation: (28.0, 2.0, -1.0), @@ -661,7 +661,7 @@ label: None, ), }), - 4294966533: (components: { + 4294966524: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 3.5, -6.0), @@ -697,7 +697,7 @@ ), ), }), - 4294966534: (components: { + 4294966525: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (8.0, 3.5, -6.0), @@ -733,7 +733,7 @@ ), ), }), - 4294966535: (components: { + 4294966526: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 3.5, 6.0), @@ -769,7 +769,7 @@ ), ), }), - 4294966536: (components: { + 4294966527: (components: { "bevy_ecs::name::Name": "Pillar", "bevy_transform::components::transform::Transform": ( translation: (8.0, 3.5, 6.0), @@ -805,10 +805,10 @@ ), ), }), - 4294966537: (components: { + 4294966528: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( - translation: (-0.8005953, 0.3732614, 0.21393776), + translation: (-2.8005953, 0.3732614, 0.21393776), rotation: (0.0, 0.86742324, 0.0, -0.49757105), scale: (1.0, 1.0, 1.0), ), @@ -841,10 +841,10 @@ ), ), }), - 4294966538: (components: { + 4294966529: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( - translation: (0.59940434, 0.3732614, 0.013936996), + translation: (2.5994043, 0.3732614, 0.013936996), rotation: (0.0, -0.53118616, 0.0, 0.8472551), scale: (1.0, 1.0, 1.0), ), @@ -877,10 +877,10 @@ ), ), }), - 4294966539: (components: { + 4294966530: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( - translation: (-0.10059604, 1.3955705, 0.11393738), + translation: (2.899404, 1.3955705, 0.11393738), rotation: (0.0, 0.72083056, 0.0, -0.6931113), scale: (1.0, 1.0, 1.0), ), @@ -913,7 +913,7 @@ ), ), }), - 4294966540: (components: { + 4294966531: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (14.575503, 4.5, -15.5), @@ -1080,7 +1080,7 @@ uv_tiling: (1.0, 1.0), ), }), - 4294966541: (components: { + 4294966532: (components: { "bevy_ecs::name::Name": "Crate", "bevy_transform::components::transform::Transform": ( translation: (5.4, 0.5, 2.4), @@ -1116,7 +1116,7 @@ ), ), }), - 4294966542: (components: { + 4294966533: (components: { "bevy_ecs::name::Name": "Ramp", "bevy_transform::components::transform::Transform": ( translation: (-14.0, 1.6, 0.0), @@ -1152,7 +1152,7 @@ ), ), }), - 4294966543: (components: { + 4294966534: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.09, 14.0), @@ -1188,7 +1188,7 @@ ), ), }), - 4294966544: (components: { + 4294966535: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.18, 13.5), @@ -1224,7 +1224,7 @@ ), ), }), - 4294966545: (components: { + 4294966536: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.27, 13.0), @@ -1260,7 +1260,7 @@ ), ), }), - 4294966546: (components: { + 4294966537: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.36, 12.5), @@ -1296,7 +1296,7 @@ ), ), }), - 4294966547: (components: { + 4294966538: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.45000002, 12.0), @@ -1332,7 +1332,7 @@ ), ), }), - 4294966548: (components: { + 4294966539: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.54, 11.5), @@ -1368,7 +1368,7 @@ ), ), }), - 4294966549: (components: { + 4294966540: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.63, 11.0), @@ -1404,7 +1404,7 @@ ), ), }), - 4294966550: (components: { + 4294966541: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.72, 10.5), @@ -1440,7 +1440,7 @@ ), ), }), - 4294966551: (components: { + 4294966542: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.81000006, 10.0), @@ -1476,7 +1476,7 @@ ), ), }), - 4294966552: (components: { + 4294966543: (components: { "bevy_ecs::name::Name": "Stair", "bevy_transform::components::transform::Transform": ( translation: (12.0, 0.90000004, 9.5), @@ -1512,7 +1512,7 @@ ), ), }), - 4294966553: (components: { + 4294966544: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-8.0, 0.6, 4.0), @@ -1546,7 +1546,7 @@ ), ), }), - 4294966554: (components: { + 4294966545: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-5.8, 0.6, 4.0), @@ -1580,7 +1580,7 @@ ), ), }), - 4294966555: (components: { + 4294966546: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-3.6, 0.6, 4.0), @@ -1614,7 +1614,7 @@ ), ), }), - 4294966556: (components: { + 4294966547: (components: { "bevy_ecs::name::Name": "ShowcaseSphere", "bevy_transform::components::transform::Transform": ( translation: (-1.3999996, 0.6, 4.0), @@ -1648,7 +1648,7 @@ ), ), }), - 4294966557: (components: { + 4294966548: (components: { "bevy_ecs::name::Name": "Player Start", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.9900649, 12.46175), @@ -1663,7 +1663,7 @@ "shared::components::LevelObject": (), "shared::components::PlayerSpawn": (), }), - 4294966558: (components: { + 4294966549: (components: { "bevy_ecs::name::Name": "Ground", "bevy_transform::components::transform::Transform": ( translation: (0.0, -0.5, 0.0), @@ -1699,7 +1699,7 @@ ), ), }), - 4294966559: (components: { + 4294966550: (components: { "bevy_ecs::name::Name": "Scene Sun", "bevy_transform::components::transform::Transform": ( translation: (0.0, 5.316672, 0.0), @@ -1727,11 +1727,11 @@ outer_angle_deg: 35.0, ), }), - 68719475876: (components: { - "bevy_ecs::hierarchy::ChildOf": (107374181688), + 4294966551: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966558), "bevy_ecs::name::Name": "metal_office_desk / Primitive 0", "bevy_transform::components::transform::Transform": ( - translation: (0.0, 0.0, 0.0), + translation: (1.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0), ), @@ -1803,8 +1803,8 @@ ), ), }), - 77309410416: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966552: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_04 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -1870,8 +1870,8 @@ ), ), }), - 81604377613: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966553: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_tray_01 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -1937,8 +1937,8 @@ ), ), }), - 85899344880: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966554: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_03 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -2004,8 +2004,8 @@ ), ), }), - 85899345209: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966555: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_05 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -2071,8 +2071,8 @@ ), ), }), - 90194312192: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966556: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_06 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -2138,8 +2138,8 @@ ), ), }), - 98784246793: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966557: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_01 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), @@ -2205,7 +2205,7 @@ ), ), }), - 107374181688: (components: { + 4294966558: (components: { "bevy_ecs::name::Name": "metal_office_desk_2k", "bevy_transform::components::transform::Transform": ( translation: (2.0, 0.1, 9.0), @@ -2220,8 +2220,8 @@ "shared::components::HierarchySiblingIndex": (30), "shared::components::LevelObject": (), }), - 120259083352: (components: { - "bevy_ecs::hierarchy::ChildOf": (68719475876), + 4294966559: (components: { + "bevy_ecs::hierarchy::ChildOf": (4294966551), "bevy_ecs::name::Name": "metal_office_desk_drawer_02 / Primitive 0", "bevy_transform::components::transform::Transform": ( translation: (0.0, 0.0, 0.0), diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 7361fa1..5432f8f 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -31,6 +31,7 @@ bevy_egui.workspace = true bevy-inspector-egui.workspace = true egui_dock.workspace = true egui_phosphor_icons.workspace = true +editor_ui.workspace = true bevy_ufbx.workspace = true content_pipeline.workspace = true ufbx = "0.9" diff --git a/crates/editor/src/ui/design_system/color_picker.rs b/crates/editor/src/ui/design_system/color_picker.rs index 2000533..4420130 100644 --- a/crates/editor/src/ui/design_system/color_picker.rs +++ b/crates/editor/src/ui/design_system/color_picker.rs @@ -62,33 +62,53 @@ fn color_control_impl( ) -> ColorPickerResponse { let palette = super::palette(ui); let id = ui.make_persistent_id(("penpot_color_picker", salt)); - let (rect, response) = ui.allocate_exact_size(egui::vec2(130.0, 22.0), egui::Sense::click()); - let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(24.0, 22.0)); - let hex_rect = - egui::Rect::from_min_size(rect.min + egui::vec2(30.0, 0.0), egui::vec2(100.0, 22.0)); + let width = ui.available_width().max(1.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click()); + let swatch_width = 44.0_f32.min(width); + let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0)); + let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 }; + let hex_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(swatch_width + gap, 0.0), + egui::vec2((width - swatch_width - gap).max(0.0), 22.0), + ); popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated); - ui.painter() - .rect_filled(swatch_rect.shrink(1.0), 4.0, color32(*rgba)); + let inner_swatch = 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( + rgb_rect, + 3.0, + egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])), + ); + ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba)); ui.painter().rect_stroke( swatch_rect, 4.0, egui::Stroke::new(1.0_f32, palette.border_strong), egui::StrokeKind::Inside, ); - ui.painter().rect( - hex_rect, - 4.0, - palette.control, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - ui.painter().text( - hex_rect.left_center() + egui::vec2(9.0, 0.0), - egui::Align2::LEFT_CENTER, - hex(*rgba), - TypeRole::Body.font(), - palette.text_primary, - ); + if hex_rect.width() > 0.0 { + ui.painter().rect( + hex_rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + hex_rect.left_center() + egui::vec2(9.0, 0.0), + egui::Align2::LEFT_CENTER, + hex(*rgba), + TypeRole::Body.font(), + palette.text_primary, + ); + } if response.clicked() { let opened_at = ui.input(|input| input.time); diff --git a/crates/editor/src/ui/design_system/controls.rs b/crates/editor/src/ui/design_system/controls.rs index 88a2235..da9e335 100644 --- a/crates/editor/src/ui/design_system/controls.rs +++ b/crates/editor/src/ui/design_system/controls.rs @@ -7,12 +7,10 @@ use std::ops::RangeInclusive; use super::palette; use super::typography::TypeRole; -const SCALAR_CONTROL_WIDTH: f32 = 132.0; -const SCALAR_TRACK_X: f32 = 0.0; -const SCALAR_TRACK_WIDTH: f32 = 78.0; +const SCALAR_TRACK_X: f32 = 6.0; +const SCALAR_NUMBER_GAP: f32 = 6.0; const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0; -const SCALAR_NUMBER_X: f32 = 84.0; -const SCALAR_NUMBER_WIDTH: f32 = 48.0; +const SCALAR_NUMBER_WIDTH: f32 = 33.0; const SCALAR_THUMB_RADIUS: f32 = 4.0; pub(crate) fn section_frame(ui: &egui::Ui) -> egui::Frame { @@ -50,7 +48,7 @@ pub(crate) fn section_header_with_summary( } else { icons::CARET_RIGHT.as_str() }, - egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())), palette.text_secondary, ); ui.painter().text( @@ -90,10 +88,7 @@ pub(crate) fn icon_label( icon.as_str(), 0.0, egui::TextFormat { - font_id: egui::FontId::new( - icon_size, - egui::FontFamily::Name("phosphor-regular".into()), - ), + font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())), color, ..Default::default() }, @@ -123,7 +118,7 @@ pub(crate) fn icon_button( enabled, egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new( 14.0, - egui::FontFamily::Name("phosphor-regular".into()), + egui::FontFamily::Name("phosphor-bold".into()), ))) .min_size(egui::vec2(24.0, 24.0)) .fill(palette.control) @@ -133,12 +128,35 @@ pub(crate) fn icon_button( response.on_hover_text(tooltip) } -pub(crate) fn status(ui: &mut egui::Ui, color: egui::Color32, label: &str, tooltip: &str) { +pub(crate) fn status( + ui: &mut egui::Ui, + icon: Icon, + color: egui::Color32, + label: &str, + tooltip: &str, +) { let palette = palette(ui); - let (dot, response) = ui.allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover()); - ui.painter().circle_filled(dot.center(), 4.0, color); + let mut job = egui::text::LayoutJob::default(); + job.append( + icon.as_str(), + 0.0, + egui::TextFormat { + font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())), + color, + ..Default::default() + }, + ); + job.append( + label, + 5.0, + egui::TextFormat { + font_id: TypeRole::Body.font(), + color: palette.text_secondary, + ..Default::default() + }, + ); + let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover())); response.on_hover_text(tooltip); - ui.label(TypeRole::Body.text(label).color(palette.text_secondary)); } pub(crate) fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response { @@ -260,12 +278,15 @@ pub(crate) fn scalar_control( ) -> egui::Response { let palette = palette(ui); let before = *value; - let width = SCALAR_CONTROL_WIDTH.min(ui.available_width().max(1.0)); + // The final Penpot component deliberately stretches only the track at the compact + // breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference. + // The numeric field remains a stable 33 px in both modes. + let width = ui.available_width().max(1.0); let (rect, base_response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); let number_width = SCALAR_NUMBER_WIDTH.min(width); - let number_x = SCALAR_NUMBER_X.min((width - number_width).max(0.0)); - let track_width = SCALAR_TRACK_WIDTH.min((number_x - 6.0).max(1.0)); + let number_x = (width - number_width).max(0.0); + let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0); let slider_rect = egui::Rect::from_min_size( rect.min + egui::vec2(SCALAR_TRACK_X, 6.0), egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT), @@ -337,8 +358,15 @@ mod scalar_tests { #[test] fn penpot_scalar_geometry_matches_the_current_export() { - assert!((SCALAR_NUMBER_X - (SCALAR_TRACK_X + SCALAR_TRACK_WIDTH) - 6.0).abs() < 0.001); - assert_eq!(SCALAR_CONTROL_WIDTH, SCALAR_NUMBER_X + SCALAR_NUMBER_WIDTH); + let wide_width = 132.8; + let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH; + let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP; + assert!((wide_track - 93.8).abs() < 0.001); + + let compact_width = 199.3; + let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH; + let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP; + assert!((compact_track - 160.3).abs() < 0.001); assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0); assert_eq!(SCALAR_THUMB_RADIUS, 4.0); } diff --git a/crates/editor/src/ui/design_system/property_grid.rs b/crates/editor/src/ui/design_system/property_grid.rs index 5d8dbc4..2716cc9 100644 --- a/crates/editor/src/ui/design_system/property_grid.rs +++ b/crates/editor/src/ui/design_system/property_grid.rs @@ -3,7 +3,14 @@ use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT}; use bevy_egui::egui; -pub(crate) const WIDE_SECTION_WIDTH: f32 = 569.0; +/// Minimum live section width that can safely retain the exported wide-row geometry. +/// +/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome, +/// component-card borders, and the floating scrollbar consume up to ten pixels before the +/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px +/// Inspector renders the authoritative wide reference instead of incorrectly switching to the +/// 420 px two-line rows. +pub(crate) const WIDE_SECTION_WIDTH: f32 = 559.0; pub(crate) const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH; /// The actor component chrome can consume up to twelve pixels of the exported 372 px compact /// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset. @@ -20,6 +27,7 @@ pub(crate) struct PropertyRowRects { pub row: egui::Rect, pub label: egui::Rect, pub value: egui::Rect, + pub color_value: egui::Rect, pub channel: egui::Rect, pub texture_group: egui::Rect, pub texture_field: egui::Rect, @@ -60,8 +68,12 @@ impl PropertyGridGeometry { egui::vec2(82.0, 22.0), ), value: egui::Rect::from_min_size( - row.min + egui::vec2(96.0, 5.0), - egui::vec2(132.0, 22.0), + row.min + egui::vec2(103.35, 5.0), + egui::vec2(132.8, 22.0), + ), + color_value: egui::Rect::from_min_size( + row.min + egui::vec2(107.95, 5.0), + egui::vec2(182.55, 22.0), ), channel: egui::Rect::from_min_size( row.min + egui::vec2(234.0, 5.0), @@ -86,8 +98,10 @@ impl PropertyGridGeometry { let channel_width = 48.0_f32.min(inner_width); let channel_x = inset + inner_width - channel_width; - let value_x = (inset + 84.0).min(channel_x); + let value_x = (inset + 91.35).min(channel_x); let value_width = (channel_x - value_x - 6.35).max(1.0); + let color_value_x = (inset + 95.95).min(channel_x); + let color_value_width = (inset + inner_width - color_value_x).max(1.0); let texture_group = egui::Rect::from_min_size( row.min + egui::vec2(inset, 31.0), egui::vec2(inner_width, 22.0), @@ -109,6 +123,10 @@ impl PropertyGridGeometry { row.min + egui::vec2(value_x, 5.0), egui::vec2(value_width, 22.0), ), + color_value: egui::Rect::from_min_size( + row.min + egui::vec2(color_value_x, 5.0), + egui::vec2(color_value_width, 22.0), + ), channel: egui::Rect::from_min_size( row.min + egui::vec2(channel_x, 5.0), egui::vec2(channel_width, 22.0), @@ -151,8 +169,15 @@ mod tests { } #[test] - fn penpot_below_reference_width_reflows_without_compression() { - let geometry = PropertyGridGeometry::for_width(568.0); + fn native_wide_containment_threshold_keeps_reference_rows() { + let geometry = PropertyGridGeometry::for_width(559.0); + assert!(!geometry.stacked); + assert_eq!(geometry.row_height, 32.0); + } + + #[test] + fn below_native_wide_containment_threshold_reflows_without_compression() { + let geometry = PropertyGridGeometry::for_width(558.0); assert!(geometry.stacked); assert_eq!(geometry.row_height, 58.0); } @@ -162,9 +187,11 @@ mod tests { let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); let rects = PropertyGridGeometry::for_width(569.0).row_rects(row); assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); - assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); - assert!((rects.value.width() - 132.0).abs() < 0.001); + assert_eq!(rects.value.min, egui::pos2(103.35, 5.0)); + assert!((rects.value.width() - 132.8).abs() < 0.001); assert_eq!(rects.value.height(), 22.0); + assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0)); + assert!((rects.color_value.width() - 182.55).abs() < 0.001); assert_eq!( rects.channel, egui::Rect::from_min_size(egui::pos2(234.0, 5.0), egui::vec2(48.0, 22.0)) @@ -184,7 +211,10 @@ mod tests { let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0)); let rects = PropertyGridGeometry::for_width(369.0).row_rects(row); assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); - assert_eq!(rects.value.min, egui::pos2(96.0, 5.0)); + assert_eq!(rects.value.min, egui::pos2(103.35, 5.0)); + assert!((rects.value.width() - 199.3).abs() < 0.001); + assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0)); + assert!((rects.color_value.width() - 249.05).abs() < 0.001); assert_eq!( rects.channel, egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0)) @@ -207,6 +237,7 @@ mod tests { for rect in [ rects.label, rects.value, + rects.color_value, rects.channel, rects.texture_group, rects.texture_field, @@ -239,6 +270,7 @@ mod tests { let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row); assert_eq!(wide.label, reference.label); assert_eq!(wide.value, reference.value); + assert_eq!(wide.color_value, reference.color_value); assert_eq!(wide.channel, reference.channel); assert_eq!( wide.texture_field.width(), diff --git a/crates/editor/src/ui/fonts.rs b/crates/editor/src/ui/fonts.rs index 5e1925f..2dc6563 100644 --- a/crates/editor/src/ui/fonts.rs +++ b/crates/editor/src/ui/fonts.rs @@ -2,10 +2,6 @@ use bevy::prelude::*; use bevy_egui::{EguiContext, PrimaryEguiContext}; -use std::sync::Arc; - -pub(crate) const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular"; -pub(crate) const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold"; pub struct EditorFontsPlugin; @@ -23,55 +19,5 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added bevy_egui::egui::FontDefinitions { - let mut fonts = bevy_egui::egui::FontDefinitions::default(); - egui_phosphor_icons::add_fonts(&mut fonts); - - fonts.font_data.insert( - SOURCE_SANS_REGULAR.to_owned(), - Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!( - "../../assets/fonts/source-sans/SourceSansPro-Regular.ttf" - ))), - ); - fonts.font_data.insert( - SOURCE_SANS_BOLD.to_owned(), - Arc::new(bevy_egui::egui::FontData::from_static(include_bytes!( - "../../assets/fonts/source-sans/SourceSansPro-Bold.ttf" - ))), - ); - - let proportional = fonts - .families - .entry(bevy_egui::egui::FontFamily::Proportional) - .or_default(); - proportional.insert(0, SOURCE_SANS_REGULAR.to_owned()); - fonts.families.insert( - bevy_egui::egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()), - vec![SOURCE_SANS_REGULAR.to_owned()], - ); - fonts.families.insert( - bevy_egui::egui::FontFamily::Name(SOURCE_SANS_BOLD.into()), - vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()], - ); - - // Phosphor families contain icon glyphs only. Text fallbacks give egui a - // replacement glyph for malformed/missing icons without changing icon lookup. - let text_fallbacks = fonts - .families - .get(&bevy_egui::egui::FontFamily::Proportional) - .cloned() - .unwrap_or_default(); - for (family, font_names) in &mut fonts.families { - let bevy_egui::egui::FontFamily::Name(name) = family else { - continue; - }; - if !name.starts_with("phosphor-") { - continue; - } - for fallback in &text_fallbacks { - if !font_names.contains(fallback) { - font_names.push(fallback.clone()); - } - } - } - fonts + editor_ui::fonts::font_definitions() } diff --git a/crates/editor/src/ui/inspector/component_chrome.rs b/crates/editor/src/ui/inspector/component_chrome.rs index 7fac561..a03cb65 100644 --- a/crates/editor/src/ui/inspector/component_chrome.rs +++ b/crates/editor/src/ui/inspector/component_chrome.rs @@ -1,12 +1,5 @@ use super::*; -const COMPONENT_HEADER_HEIGHT: f32 = 50.0; -const COMPONENT_RAIL_X: f32 = 7.0; -const COMPONENT_CARET_CENTER_X: f32 = 26.0; -const COMPONENT_ICON_CENTER_X: f32 = 51.0; -const COMPONENT_IDENTITY_X: f32 = 69.0; -const COMPONENT_ACTIONS_WIDTH: f32 = 104.0; - pub(super) fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool { let mut changed = false; property_row(ui, "Shader", |ui| { @@ -137,201 +130,41 @@ pub(crate) fn component_card( context: &ComponentCardContext, add_contents: impl FnOnce(&mut egui::Ui), ) -> ComponentCardResponse { - let mut response = ComponentCardResponse { + let options = editor_ui::inspector::ComponentCardOptions { type_name: context.options.type_name, - ..Default::default() + title: context.options.title, + icon: context.options.icon, + active_toggle: context.options.active_toggle, + removable: context.options.removable, + reorderable: context.options.reorderable, + resettable: context.options.resettable, + copyable: context.options.copyable, + summary: context.options.summary, + body_margin: context.options.body_margin, }; - crate::ui::design_system::scope(ui, |ui| { - egui::Frame::new() - .fill(WIDGET_BG) - .stroke(egui::Stroke::new(1.0_f32, BORDER)) - .corner_radius(egui::CornerRadius::same(5)) - .show(ui, |ui| { - let width = ui.available_width().max(1.0); - let (header, header_response) = ui.allocate_exact_size( - egui::vec2(width, COMPONENT_HEADER_HEIGHT), - egui::Sense::click(), - ); - ui.painter().rect_filled( - egui::Rect::from_min_size( - header.min + egui::vec2(COMPONENT_RAIL_X, 7.0), - egui::vec2(3.0, 36.0), - ), - 2.0, - crate::ui::theme::SELECTION, - ); - let caret_rect = egui::Rect::from_center_size( - egui::pos2(header.left() + COMPONENT_CARET_CENTER_X, header.center().y), - egui::vec2(20.0, 28.0), - ); - if ui - .put( - caret_rect, - egui::Button::new(phosphor_icon( - if context.collapsed { - icons::CARET_RIGHT - } else { - icons::CARET_DOWN - }, - 12.0, - )) - .frame(false), - ) - .on_hover_text(if context.collapsed { - "Expand component" - } else { - "Collapse component" - }) - .clicked() - { - response.collapsed = Some(!context.collapsed); - } - ui.painter().text( - egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y), - egui::Align2::CENTER_CENTER, - context.options.icon.as_str(), - egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-regular".into())), - TEXT_DIM, - ); - ui.painter().text( - egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0), - egui::Align2::LEFT_CENTER, - context.options.title, - crate::ui::design_system::typography::TypeRole::Title.font(), - crate::ui::theme::TEXT, - ); - ui.painter().text( - egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0), - egui::Align2::LEFT_CENTER, - context.options.summary, - crate::ui::design_system::typography::TypeRole::Small.font(), - TEXT_MUTED, - ); - let actions_rect = egui::Rect::from_min_size( - egui::pos2( - header.right() - COMPONENT_ACTIONS_WIDTH - 8.0, - header.top() + 11.0, - ), - egui::vec2(COMPONENT_ACTIONS_WIDTH, 28.0), - ); - ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - component_actions_menu(ui, context, &mut response); - let label = if context.active { "Active" } else { "Off" }; - let color = if context.active { - crate::ui::theme::SUCCESS - } else { - TEXT_MUTED - }; - let active = ui - .horizontal(|ui| { - let dot = if context.options.active_toggle { - status_dot_button(ui, color, "Toggle component") - } else { - status_dot(ui, color, "Component active"); - ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) - }; - ui.label( - crate::ui::design_system::typography::TypeRole::Small - .text(label) - .color(TEXT_DIM), - ); - dot - }) - .inner; - if context.options.active_toggle && active.clicked() { - response.active = Some(!context.active); - } - }); - }); - if header_response.clicked() - && ui - .input(|input| input.pointer.interact_pos()) - .is_some_and(|pointer| { - !caret_rect.contains(pointer) && !actions_rect.contains(pointer) - }) - { - response.collapsed = Some(!context.collapsed); - } - if !context.collapsed { - egui::Frame::new() - .inner_margin(egui::Margin { - left: context.options.body_margin, - right: context.options.body_margin, - top: 10, - bottom: context.options.body_margin, - }) - .show(ui, |ui| { - ui.set_max_width(ui.available_width()); - add_contents(ui); - }); - } - }); - }); - ui.add_space(6.0); - response -} - -fn component_actions_menu( - ui: &mut egui::Ui, - context: &ComponentCardContext, - response: &mut ComponentCardResponse, -) { - let menu = ui.menu_button( - phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM), - |ui| { - ui.set_min_width(160.0); - if ui - .add_enabled(context.options.resettable, egui::Button::new("Reset")) - .clicked() - { - response.reset = true; - ui.close(); - } - if ui - .add_enabled(context.options.copyable, egui::Button::new("Copy Values")) - .clicked() - { - response.copy = true; - ui.close(); - } - if ui - .add_enabled(context.pasteable, egui::Button::new("Paste Values")) - .clicked() - { - response.paste = true; - ui.close(); - } - ui.separator(); - if ui - .add_enabled(context.can_move_up, egui::Button::new("Move Up")) - .clicked() - { - response.move_up = true; - ui.close(); - } - if ui - .add_enabled(context.can_move_down, egui::Button::new("Move Down")) - .clicked() - { - response.move_down = true; - ui.close(); - } - ui.separator(); - ui.add_enabled(false, egui::Button::new("Open Documentation")); - if ui - .add_enabled( - context.options.removable, - egui::Button::new(egui::RichText::new("Remove").color(crate::ui::theme::ERROR)), - ) - .clicked() - { - response.remove = true; - ui.close(); - } + let shared = editor_ui::inspector::component_card( + ui, + &editor_ui::inspector::ComponentCardViewModel { + options, + active: context.active, + collapsed: context.collapsed, + pasteable: context.pasteable, + can_move_up: context.can_move_up, + can_move_down: context.can_move_down, }, + add_contents, ); - menu.response.on_hover_text("Component actions"); + ComponentCardResponse { + type_name: shared.type_name, + collapsed: shared.collapsed, + active: shared.active, + reset: shared.reset, + copy: shared.copy, + paste: shared.paste, + move_up: shared.move_up, + move_down: shared.move_down, + remove: shared.remove, + } } pub(crate) fn property_row( @@ -501,18 +334,3 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) set_material_with_history(world, entity, material); } } - -#[cfg(test)] -mod component_header_geometry_tests { - use super::*; - - #[test] - fn current_penpot_inspector_header_geometry_is_exact() { - assert_eq!(COMPONENT_HEADER_HEIGHT, 50.0); - assert_eq!(COMPONENT_RAIL_X, 7.0); - assert_eq!(COMPONENT_CARET_CENTER_X, 26.0); - assert_eq!(COMPONENT_ICON_CENTER_X, 51.0); - assert_eq!(COMPONENT_IDENTITY_X, 69.0); - assert_eq!(COMPONENT_ACTIONS_WIDTH, 104.0); - } -} diff --git a/crates/editor/src/ui/inspector/material_slots.rs b/crates/editor/src/ui/inspector/material_slots.rs index 85d668c..444e037 100644 --- a/crates/editor/src/ui/inspector/material_slots.rs +++ b/crates/editor/src/ui/inspector/material_slots.rs @@ -10,27 +10,6 @@ pub(super) fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize) } } -pub(super) fn status_dot(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) { - let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::hover()); - ui.painter().circle_filled(rect.center(), 4.0, color); - response.on_hover_text(tooltip); -} - -pub(super) fn status_dot_button( - ui: &mut egui::Ui, - color: egui::Color32, - tooltip: &str, -) -> egui::Response { - let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::click()); - let color = if response.hovered() { - color.linear_multiply(1.2) - } else { - color - }; - ui.painter().circle_filled(rect.center(), 4.0, color); - response.on_hover_text(tooltip) -} - pub(super) fn material_drop_invalid_reason( world: &World, valid_material_candidate: bool, @@ -62,15 +41,26 @@ pub(super) struct MaterialSlotWidgetResponse { pub(super) accepted_drop: bool, } +pub(super) struct MaterialSlotWidgetContext<'a> { + pub(super) candidates: &'a [AssetRefCandidate], + pub(super) drop_candidate: Option<&'a AssetRefCandidate>, + pub(super) invalid_drop_reason: Option<&'static str>, + pub(super) expanded_header_when_parameters_closed: bool, +} + pub(super) fn material_slot_widget_ui( world: &mut World, ui: &mut egui::Ui, entity: Entity, slot: &mut shared::MaterialSlot, - candidates: &[AssetRefCandidate], - drop_candidate: Option<&AssetRefCandidate>, - invalid_drop_reason: Option<&'static str>, + context: MaterialSlotWidgetContext<'_>, ) -> MaterialSlotWidgetResponse { + let MaterialSlotWidgetContext { + candidates, + drop_candidate, + invalid_drop_reason, + expanded_header_when_parameters_closed, + } = context; let mut response = MaterialSlotWidgetResponse::default(); let actor_widget_id = stable_actor_widget_id(world, entity); let project_default = world @@ -165,6 +155,8 @@ pub(super) fn material_slot_widget_ui( crate::ui::materials::MaterialThumbnailPresentation::Failed } else if let Some(texture) = texture_id { crate::ui::materials::MaterialThumbnailPresentation::Ready(texture) + } else if effective.is_none() { + crate::ui::materials::MaterialThumbnailPresentation::Empty } else { crate::ui::materials::MaterialThumbnailPresentation::Pending }; @@ -227,6 +219,8 @@ pub(super) fn material_slot_widget_ui( can_clear: slot.material.is_some(), can_extract: is_imported_source, can_create_instance: is_project_material, + parameters_initially_open: true, + expanded_header_when_parameters_closed, }; let widget_slot_id = slot.id.0.clone(); diff --git a/crates/editor/src/ui/inspector/mesh_renderers.rs b/crates/editor/src/ui/inspector/mesh_renderers.rs index 01e77fc..3277013 100644 --- a/crates/editor/src/ui/inspector/mesh_renderers.rs +++ b/crates/editor/src/ui/inspector/mesh_renderers.rs @@ -81,9 +81,12 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent ui, entity, slot, - &material_candidates, - material_drop_candidate.as_ref(), - invalid_material_drop, + MaterialSlotWidgetContext { + candidates: &material_candidates, + drop_candidate: material_drop_candidate.as_ref(), + invalid_drop_reason: invalid_material_drop, + expanded_header_when_parameters_closed: true, + }, ); changed |= widget.changed; accepted_drop |= widget.accepted_drop; @@ -172,7 +175,7 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti let card = component_card_context(world, entity, options); let card_response = component_card(ui, &card, |ui| { let add_slot = renderer_array_header(ui, renderer.slots.len()); - ui.add_space(10.0); + ui.add_space(26.0); if renderer.slots.is_empty() { ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM)); } @@ -190,65 +193,97 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti renderer_panel(ui, |ui| { renderer_panel_header( ui, - index, - &entry.name, - thumbnail, + RendererPanelHeaderViewModel { + index, + label: &entry.name, + path: entry + .mesh + .source_path + .as_deref() + .unwrap_or(&entry.mesh.sub_asset_id), + slot_count: 1, + texture_id: thumbnail, + }, &mut renderer_open, |ui| { - status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active"); - if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() { + editor_ui::design_system::controls::icon_button( + ui, + icons::DOTS_THREE_VERTICAL, + "Renderer actions", + true, + ); + if editor_ui::design_system::controls::icon_button( + ui, + icons::TRASH, + "Remove slot", + true, + ) + .clicked() + { remove_entry = Some(index); } - ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM)); + editor_ui::design_system::controls::icon_button( + ui, + icons::CROSSHAIR, + "Locate mesh", + true, + ); + editor_ui::design_system::controls::icon_button( + ui, + icons::FOLDER_OPEN, + "Browse mesh", + true, + ); }, ); if !renderer_open { return; } - let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| { - renderer_properties_header(ui); - property_row(ui, "Name", |ui| { - changed |= ui - .add_sized( - [text_field_width(ui), 20.0], - egui::TextEdit::singleline(&mut entry.name), - ) - .changed(); - }); - let mesh_response = asset_selector_row( - ui, - "Mesh", - icons::CUBE, - Some(&entry.mesh), - None, - false, - &mesh_candidates, - None, - None, - AssetSelectorActions::Full, - ); - if let Some(selected) = mesh_response.selected { - entry.mesh = selected; - changed = true; - } - if mesh_response.locate { - locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates); - } - - ui.horizontal_wrapped(|ui| { - changed |= ui.checkbox(&mut entry.visible, "Visible").changed(); - changed |= ui - .checkbox(&mut entry.cast_shadows, "Cast shadows") - .changed(); - changed |= ui - .checkbox(&mut entry.receive_shadows, "Receive shadows") - .changed(); - }); - }; - egui::Frame::new() .inner_margin(egui::Margin::symmetric(12, 10)) - .show(ui, |ui| draw_fields(ui, entry)); + .show(ui, |ui| { + renderer_properties_header(ui); + ui.add_space(2.0); + property_row(ui, "Name", |ui| { + changed |= ui + .add_sized( + [text_field_width(ui), 20.0], + egui::TextEdit::singleline(&mut entry.name), + ) + .changed(); + }); + ui.add_space(4.0); + let mesh_response = asset_selector_row( + ui, + "Mesh", + icons::CUBE, + Some(&entry.mesh), + None, + false, + &mesh_candidates, + None, + None, + AssetSelectorActions::Full, + ); + if let Some(selected) = mesh_response.selected { + entry.mesh = selected; + changed = true; + } + if mesh_response.locate { + locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates); + } + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + changed |= ui.checkbox(&mut entry.visible, "Visible").changed(); + changed |= ui + .checkbox(&mut entry.cast_shadows, "Cast shadows") + .changed(); + changed |= ui + .checkbox(&mut entry.receive_shadows, "Receive shadows") + .changed(); + }); + ui.add_space(4.0); + }); renderer_material_slots_header(ui, 1); if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) { let widget = renderer_material_slot_row(ui, index, |ui| { @@ -257,9 +292,12 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti ui, entity, slot, - &material_candidates, - material_drop_candidate.as_ref(), - invalid_material_drop, + MaterialSlotWidgetContext { + candidates: &material_candidates, + drop_candidate: material_drop_candidate.as_ref(), + invalid_drop_reason: invalid_material_drop, + expanded_header_when_parameters_closed: true, + }, ) }); changed |= widget.changed; @@ -317,109 +355,3 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti set_static_mesh_renderer_with_history(world, entity, renderer); } } - -fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool { - let palette = crate::ui::design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), 40.0), - egui::Sense::hover(), - ); - ui.painter().rect_filled(rect, 4.0, palette.elevated); - ui.painter().text( - rect.left_center() + egui::vec2(12.0, 0.0), - egui::Align2::LEFT_CENTER, - icons::SQUARES_FOUR.as_str(), - egui::FontId::new(16.0, egui::FontFamily::Name("phosphor-regular".into())), - palette.text_secondary, - ); - ui.painter().text( - rect.left_center() + egui::vec2(36.0, 0.0), - egui::Align2::LEFT_CENTER, - "Mesh renderers", - crate::ui::design_system::typography::TypeRole::Section.font(), - palette.text_primary, - ); - ui.painter().text( - rect.left_center() + egui::vec2(132.0, 0.0), - egui::Align2::LEFT_CENTER, - format!("{count}"), - crate::ui::design_system::typography::TypeRole::Small.font(), - palette.text_muted, - ); - let add_rect = egui::Rect::from_min_size( - egui::pos2(rect.right() - 116.0, rect.top() + 7.0), - egui::vec2(104.0, 26.0), - ); - ui.put( - add_rect, - egui::Button::new(crate::ui::design_system::controls::icon_label( - icons::PLUS, - "Add renderer", - crate::ui::design_system::typography::TypeRole::Body, - 12.0, - palette.text_primary, - )), - ) - .clicked() -} - -fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) { - let palette = crate::ui::design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), 38.0), - egui::Sense::hover(), - ); - ui.painter().text( - rect.left_center() + egui::vec2(10.0, 0.0), - egui::Align2::LEFT_CENTER, - "Material slots", - crate::ui::design_system::typography::TypeRole::Section.font(), - palette.text_primary, - ); - ui.painter().text( - rect.right_center() - egui::vec2(10.0, 0.0), - egui::Align2::RIGHT_CENTER, - format!("{count}"), - crate::ui::design_system::typography::TypeRole::Small.font(), - palette.text_muted, - ); -} - -fn renderer_properties_header(ui: &mut egui::Ui) { - let palette = crate::ui::design_system::palette(ui); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), 18.0), - egui::Sense::hover(), - ); - ui.painter().text( - rect.left_center(), - egui::Align2::LEFT_CENTER, - "Renderer properties", - crate::ui::design_system::typography::TypeRole::Caption.font(), - palette.text_muted, - ); -} - -fn renderer_material_slot_row( - ui: &mut egui::Ui, - index: usize, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - let palette = crate::ui::design_system::palette(ui); - ui.horizontal_top(|ui| { - let (index_rect, _) = ui.allocate_exact_size(egui::vec2(31.0, 60.0), egui::Sense::hover()); - ui.painter().text( - index_rect.center_top() + egui::vec2(0.0, 18.0), - egui::Align2::CENTER_CENTER, - index.to_string(), - crate::ui::design_system::typography::TypeRole::Body.font(), - palette.text_muted, - ); - ui.vertical(|ui| { - ui.set_width(ui.available_width().max(1.0)); - add_contents(ui) - }) - .inner - }) - .inner -} diff --git a/crates/editor/src/ui/inspector/primitive_domains.rs b/crates/editor/src/ui/inspector/primitive_domains.rs index fdc5bce..103f7e0 100644 --- a/crates/editor/src/ui/inspector/primitive_domains.rs +++ b/crates/editor/src/ui/inspector/primitive_domains.rs @@ -65,9 +65,12 @@ pub(super) fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: ui, entity, &mut primitive.surface, - &material_candidates, - material_drop_candidate.as_ref(), - invalid_material_drop, + MaterialSlotWidgetContext { + candidates: &material_candidates, + drop_candidate: material_drop_candidate.as_ref(), + invalid_drop_reason: invalid_material_drop, + expanded_header_when_parameters_closed: false, + }, ) }); changed |= widget.changed; diff --git a/crates/editor/src/ui/inspector/renderer_panel.rs b/crates/editor/src/ui/inspector/renderer_panel.rs index 7bf6195..0c2ff2b 100644 --- a/crates/editor/src/ui/inspector/renderer_panel.rs +++ b/crates/editor/src/ui/inspector/renderer_panel.rs @@ -1,124 +1,5 @@ -//! Penpot renderer-panel chrome shared by static renderer entries. - -use super::*; - -const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0; -const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 58.0; - -pub(super) fn renderer_panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { - egui::Frame::new() - .fill(WIDGET_BG.linear_multiply(0.82)) - .stroke(egui::Stroke::new(1.0_f32, BORDER)) - .corner_radius(egui::CornerRadius::same(5)) - .show(ui, |ui| { - ui.set_max_width(ui.available_width()); - add_contents(ui); - }); - ui.add_space(10.0); -} - -pub(super) fn renderer_panel_header( - ui: &mut egui::Ui, - index: usize, - slot_name: &str, - texture_id: Option, - open: &mut bool, - add_actions: impl FnOnce(&mut egui::Ui), -) { - let palette = crate::ui::design_system::palette(ui); - let height = if *open { - EXPANDED_RENDERER_HEADER_HEIGHT - } else { - COLLAPSED_RENDERER_HEADER_HEIGHT - }; - let (rect, header_response) = ui.allocate_exact_size( - egui::vec2(ui.available_width().max(1.0), height), - egui::Sense::click(), - ); - ui.painter().rect_filled( - egui::Rect::from_min_size( - rect.min + egui::vec2(6.0, 8.0), - egui::vec2(3.0, height - 16.0), - ), - 2.0, - palette.accent, - ); - ui.painter().text( - rect.left_center() + egui::vec2(20.0, 0.0), - egui::Align2::CENTER_CENTER, - if *open { - icons::CARET_DOWN.as_str() - } else { - icons::CARET_RIGHT.as_str() - }, - egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), - palette.text_secondary, - ); - let preview_size = if *open { 58.0 } else { 40.0 }; - let preview = egui::Rect::from_center_size( - egui::pos2(rect.left() + 63.0, rect.center().y), - egui::vec2(preview_size, preview_size), - ); - ui.painter().rect( - preview, - 4.0, - PANEL_BG_DARK, - egui::Stroke::new(1.0_f32, BORDER), - egui::StrokeKind::Inside, - ); - if let Some(texture_id) = texture_id { - ui.painter().image( - texture_id, - preview.shrink(3.0), - egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - let label = if slot_name.trim().is_empty() { - "Mesh renderer" - } else { - slot_name.trim() - }; - ui.painter().text( - egui::pos2(rect.left() + 104.0, rect.center().y - 8.0), - egui::Align2::LEFT_CENTER, - label, - crate::ui::design_system::typography::TypeRole::Title.font(), - palette.text_primary, - ); - ui.painter().text( - egui::pos2(rect.left() + 104.0, rect.center().y + 10.0), - egui::Align2::LEFT_CENTER, - format!("Renderer {index}"), - crate::ui::design_system::typography::TypeRole::Small.font(), - palette.text_muted, - ); - let action_rect = egui::Rect::from_min_size( - egui::pos2(rect.right() - 150.0, rect.center().y - 14.0), - egui::vec2(140.0, 28.0), - ); - ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| { - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - add_actions, - ); - }); - if header_response.clicked() - && ui - .input(|input| input.pointer.interact_pos()) - .is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer)) - { - *open = !*open; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn renderer_headers_match_current_penpot_heights() { - assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0); - assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 58.0); - } -} +pub(super) use editor_ui::renderer::{ + renderer_array_header, renderer_material_slot_row, renderer_material_slots_header, + renderer_panel, renderer_panel_header, renderer_properties_header, + RendererPanelHeaderViewModel, +}; diff --git a/crates/editor/src/ui/materials/advanced_preview.rs b/crates/editor/src/ui/materials/advanced_preview.rs index 8905fc6..08a5889 100644 --- a/crates/editor/src/ui/materials/advanced_preview.rs +++ b/crates/editor/src/ui/materials/advanced_preview.rs @@ -55,7 +55,7 @@ pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) { } else { icons::CARET_RIGHT.as_str() }, - egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())), palette.text_secondary, ); ui.painter().text( diff --git a/crates/editor/src/ui/materials/asset_editor.rs b/crates/editor/src/ui/materials/asset_editor.rs index e75eaca..c355813 100644 --- a/crates/editor/src/ui/materials/asset_editor.rs +++ b/crates/editor/src/ui/materials/asset_editor.rs @@ -23,7 +23,7 @@ pub(crate) fn material_asset_editor( .and_then(|selection| { texture_assets .iter() - .find(|candidate| candidate.selection == *selection) + .find(|candidate| material_locate_matches_selection(&candidate.locate, selection)) }) .cloned(); let original = draft_asset.clone(); @@ -53,7 +53,7 @@ pub(crate) fn material_asset_editor( &mut draft_asset.render_state, Some(&mut draft_asset.inputs), ); - ui.add_space(8.0); + ui.add_space(4.0); let input_response = material_input_schema_editor( ui, &input_schema, @@ -62,8 +62,10 @@ pub(crate) fn material_asset_editor( texture_drop_candidate.as_ref(), ); accepted_texture_drop |= input_response.accepted_drop; - if let Some(selection) = input_response.locate { - world.resource_mut::().select(selection); + if let Some(target) = input_response.locate { + world + .resource_mut::() + .select(material_locate_selection(target)); crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); } if input_response.browse_library { @@ -112,117 +114,3 @@ fn material_source_diagnostics_ui(ui: &mut egui::Ui, provenance: &shared::Materi }); ui.small("Reimport comparison never overwrites an edited project material automatically."); } - -pub(super) fn material_surface_options_ui( - ui: &mut egui::Ui, - state: &mut shared::MaterialRenderState, - packed_inputs: Option<&mut shared::MaterialInputSet>, -) { - let palette = crate::ui::design_system::palette(ui); - let width = ui.available_width().max(1.0); - let height = match super::layout::ResponsiveMaterialLayout::for_width(width) { - super::layout::ResponsiveMaterialLayout::Reference => { - crate::ui::design_system::SURFACE_SECTION_HEIGHT - } - super::layout::ResponsiveMaterialLayout::Compact => { - crate::ui::design_system::COMPACT_SURFACE_SECTION_HEIGHT - } - super::layout::ResponsiveMaterialLayout::Transient => 100.0, - }; - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); - ui.painter().rect( - rect, - 5.0, - palette.section, - egui::Stroke::new(1.0_f32, palette.border), - egui::StrokeKind::Inside, - ); - let geometry = super::layout::SurfaceSectionGeometry::for_rect(rect); - ui.painter().text( - geometry.title.left_center(), - egui::Align2::LEFT_CENTER, - "Surface", - crate::ui::design_system::typography::TypeRole::Section.font(), - palette.text_primary, - ); - clipped_rect_ui(ui, geometry.blend, |ui| { - surface_blend_controls(ui, state); - }); - if let Some(inputs) = packed_inputs { - clipped_rect_ui(ui, geometry.packed, |ui| { - packed_mode_controls(ui, inputs); - }); - } - ui.painter().text( - geometry.double_sided.left_center() + egui::vec2(7.0, 0.0), - egui::Align2::LEFT_CENTER, - "Double Sided", - crate::ui::design_system::typography::TypeRole::Body.font(), - palette.text_secondary, - ); - let switch_rect = egui::Rect::from_min_size( - geometry.double_sided.min + egui::vec2(76.0, 3.0), - egui::vec2(31.0, 16.0), - ); - clipped_rect_ui(ui, switch_rect, |ui| { - crate::ui::design_system::controls::switch(ui, &mut state.double_sided) - .on_hover_text("Render both sides of the surface"); - }); -} - -fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut shared::MaterialInputSet) { - let arm = super::inputs::packed_map_mode(inputs) == super::inputs::PackedMapMode::Arm; - match crate::ui::design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) { - Some(false) => super::inputs::apply_separate_texture_preset(inputs), - Some(true) => super::inputs::apply_arm_texture_preset(inputs), - None => {} - } -} - -fn surface_blend_controls(ui: &mut egui::Ui, state: &mut shared::MaterialRenderState) { - let width = ui.available_width().max(1.0); - let show_cutoff = state.alpha_mode == MaterialAlphaMode::Cutout; - let combo_width = if show_cutoff { - (width - 58.0).max(1.0) - } else { - width - }; - let combo_rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(combo_width, 24.0)); - clipped_rect_ui(ui, combo_rect, |ui| { - egui::ComboBox::from_id_salt("material_alpha_mode") - .selected_text(match state.alpha_mode { - MaterialAlphaMode::Opaque => "Opaque", - MaterialAlphaMode::Cutout => "Cutout", - }) - .width(combo_width) - .show_ui(ui, |ui| { - ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Opaque, "Opaque"); - ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Cutout, "Cutout"); - }); - }); - if show_cutoff { - let cutoff_rect = egui::Rect::from_min_size( - ui.min_rect().min + egui::vec2(combo_width + 4.0, 1.0), - egui::vec2(54.0_f32.min(width - combo_width - 4.0), 22.0), - ); - ui.put( - cutoff_rect, - egui::DragValue::new(&mut state.alpha_cutoff) - .range(0.0..=1.0) - .speed(0.01), - ) - .on_hover_text("Alpha cutoff"); - } -} - -fn clipped_rect_ui( - ui: &mut egui::Ui, - rect: egui::Rect, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - ui.set_clip_rect(rect.intersect(ui.clip_rect())); - add_contents(ui) - }) - .inner -} diff --git a/crates/editor/src/ui/materials/editor.rs b/crates/editor/src/ui/materials/editor.rs index 8d8f547..58877ce 100644 --- a/crates/editor/src/ui/materials/editor.rs +++ b/crates/editor/src/ui/materials/editor.rs @@ -269,7 +269,7 @@ pub(super) fn material_texture_candidates(world: &mut World) -> Vec Vec().select(selection); + if let Some(target) = input_response.locate.clone() { + world + .resource_mut::() + .select(material_locate_selection(target)); crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser); } if input_response.browse_library { diff --git a/crates/editor/src/ui/materials/instance_inputs.rs b/crates/editor/src/ui/materials/instance_inputs.rs index a3a2826..8a74dc5 100644 --- a/crates/editor/src/ui/materials/instance_inputs.rs +++ b/crates/editor/src/ui/materials/instance_inputs.rs @@ -14,7 +14,7 @@ pub(super) fn material_instance_input_schema_editor( let mut effective = normalized_base.clone(); overlay_inputs(&mut effective, overrides); - let response = super::inputs::material_input_schema_editor_with_inheritance( + let response = material_input_schema_editor_with_inheritance( ui, schema, &mut effective, @@ -110,6 +110,7 @@ fn upsert_texture(textures: &mut Vec, texture: MaterialT #[cfg(test)] mod tests { use super::*; + use shared::MaterialParameterValue; #[test] fn instance_editor_retains_only_values_different_from_base() { diff --git a/crates/editor/src/ui/materials/mod.rs b/crates/editor/src/ui/materials/mod.rs index 0229726..935c1c2 100644 --- a/crates/editor/src/ui/materials/mod.rs +++ b/crates/editor/src/ui/materials/mod.rs @@ -7,14 +7,12 @@ use std::collections::HashSet; use std::path::Path; -use bevy::prelude::{Vec2, Vec3, World}; +use bevy::prelude::World; use bevy_egui::egui; -use egui_phosphor_icons::{icons, Icon}; use shared::{ - standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialAsset, - MaterialInputDesc, MaterialInputSchema, MaterialInputSet, MaterialInstanceAsset, - MaterialParameter, MaterialParameterValue, MaterialRef, MaterialShaderKind, - MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset, TextureChannel, + standard_lit_input_schema, EditorAssetRef, MaterialAsset, MaterialInputSchema, + MaterialInputSet, MaterialInstanceAsset, MaterialParameter, MaterialRef, MaterialShaderKind, + MaterialTextureBinding, ShaderPropertyType, ShaderSchemaAsset, }; use crate::asset_db::{ensure_asset_record, find_asset_by_path, save_registry}; @@ -29,17 +27,10 @@ use crate::ui::document_status::authored_document_status_ui; use super::theme::{ERROR, TEXT_DIM, TEXT_MUTED, WARNING}; -mod advanced_preview; mod asset_editor; mod editor; -mod inputs; mod instance_editor; mod instance_inputs; -mod layout; -mod panel; -mod pickers; -mod texture_inputs; -mod uv_transform; pub(crate) use asset_editor::material_asset_editor; pub(crate) use editor::{ @@ -47,67 +38,57 @@ pub(crate) use editor::{ material_shader_label, set_material_shader_kind, }; use editor::{material_shader_settings, material_texture_candidates, shader_schema_assets}; -pub(crate) use inputs::material_input_schema_editor; -use inputs::*; -pub(crate) use instance_editor::material_instance_asset_editor; -pub(crate) use panel::{ - material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge, - MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel, - MaterialsSectionViewModel, +pub(crate) use editor_ui::materials::{ + compact_text_edit, default_value_for_shader_property, ensure_material_texture_binding, + material_input_schema_editor, material_input_schema_editor_with_inheritance, + material_slot_panel, material_surface_options_ui, materials_section, optional_path_edit, + parameter_value_matches_property, shader_kind_picker, MaterialHealth, + MaterialInputEditorResponse, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate, + MaterialSlotAction, MaterialSlotPanelViewModel, MaterialTextureCandidate, + MaterialThumbnailPresentation, MaterialsSectionViewModel, }; -use texture_inputs::*; - -const PHOSPHOR: &str = "phosphor-regular"; - -#[derive(Clone, Debug)] -pub(crate) struct MaterialTextureCandidate { - label: String, - detail: String, - reference: EditorAssetRef, - selection: AssetSelection, - thumbnail: MaterialThumbnailPresentation, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) enum MaterialThumbnailPresentation { - Ready(egui::TextureId), - Pending, - Failed, -} - -#[derive(Clone, Copy)] -enum TextureSlotSelection<'a> { - Reference(Option<&'a EditorAssetRef>), -} - -#[derive(Default)] -struct TextureSlotResponse { - selected: Option, - locate: Option, - clear: bool, - accepted_drop: bool, - browse_library: bool, -} - -#[derive(Default)] -pub(crate) struct MaterialInputEditorResponse { - pub accepted_drop: bool, - pub locate: Option, - pub browse_library: bool, -} - -fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { - let available = ui.available_width().max(1.0); - available.min(max).max(min.min(available)) -} - -fn icon_text(icon: Icon, size: f32) -> egui::RichText { - egui::RichText::new(icon.as_str()).font(egui::FontId::new( - size, - egui::FontFamily::Name(PHOSPHOR.into()), - )) -} +pub(crate) use instance_editor::material_instance_asset_editor; fn natural_cmp(left: &str, right: &str) -> std::cmp::Ordering { left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase()) } + +fn material_locate_selection(target: MaterialLocateTarget) -> AssetSelection { + match target { + MaterialLocateTarget::File(path) => AssetSelection::File(path), + MaterialLocateTarget::SubAsset { + parent_path, + sub_asset_id, + label, + source_path, + } => AssetSelection::SubAsset { + parent_path, + sub_asset_id, + label, + kind: AssetSubAssetKind::Texture, + source_path, + }, + } +} + +fn material_locate_matches_selection( + target: &MaterialLocateTarget, + selection: &AssetSelection, +) -> bool { + match (target, selection) { + (MaterialLocateTarget::File(left), AssetSelection::File(right)) => left == right, + ( + MaterialLocateTarget::SubAsset { + parent_path: left_parent, + sub_asset_id: left_sub_asset, + .. + }, + AssetSelection::SubAsset { + parent_path: right_parent, + sub_asset_id: right_sub_asset, + .. + }, + ) => left_parent == right_parent && left_sub_asset == right_sub_asset, + _ => false, + } +} diff --git a/crates/editor/src/ui/materials/panel.rs b/crates/editor/src/ui/materials/panel.rs index 4e98c77..0b23167 100644 --- a/crates/editor/src/ui/materials/panel.rs +++ b/crates/editor/src/ui/materials/panel.rs @@ -6,14 +6,18 @@ use shared::{EditorAssetRef, MaterialShaderKind}; use super::layout::MaterialPanelGeometry; use super::MaterialThumbnailPresentation; -use crate::ui::design_system::controls::{icon_button, icon_label, status}; +use crate::ui::design_system::controls::status; use crate::ui::design_system::typography::TypeRole; use crate::ui::design_system::{self, DesignPalette, HEADER_HEIGHT}; -use crate::ui::widgets::phosphor_icon; + +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 { @@ -73,6 +77,16 @@ impl MaterialHealth { ), } } + + fn icon(self) -> egui_phosphor_icons::Icon { + match self { + Self::Healthy => icons::CHECK_CIRCLE, + Self::Dirty => icons::PENCIL_SIMPLE, + Self::Processing => icons::CIRCLE_NOTCH, + Self::Broken => icons::WARNING_CIRCLE, + Self::ReadOnly => icons::LOCK_SIMPLE, + } + } } #[derive(Debug, Clone)] @@ -275,16 +289,19 @@ fn slot_header( 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(phosphor_icon( - if *open { - icons::CARET_DOWN - } else { - icons::CARET_RIGHT - }, - 12.0, - )) + 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() { @@ -299,7 +316,7 @@ fn slot_header( egui::vec2(preview_size, preview_size), ); let preview = material_thumbnail_at(ui, view, preview_rect); - let action_width = 140.0; + 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) @@ -323,42 +340,20 @@ fn slot_header( }) .inner; let actions_rect = egui::Rect::from_min_size( - egui::pos2(actions_left, rect.top() + 7.0), - egui::vec2(action_width, height - 14.0), + 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 }), ); - ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { - if *open { - ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { - let (color, label, tooltip) = view.health.presentation(palette); - ui.horizontal(|ui| status(ui, color, label, tooltip)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - overflow_menu(ui, view, actions, diagnostics_ui); - if view.can_clear - && icon_button(ui, icons::X, "Clear actor assignment", true).clicked() - { - actions.push(MaterialSlotAction::Clear); - } - if view.can_locate - && icon_button(ui, icons::CROSSHAIR, "Locate in Content Browser", true) - .clicked() - { - actions.push(MaterialSlotAction::Locate); - } - if icon_button(ui, icons::FOLDER_OPEN, "Browse project materials", true) - .clicked() - { - actions.push(MaterialSlotAction::BrowseLibrary); - } - }); - }); - } else { + 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, color, label, tooltip)); + 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) @@ -482,7 +477,7 @@ fn material_identity( if let Some(mut kind) = view.shader_kind { egui::ComboBox::from_id_salt("material_identity_shader_kind") .selected_text(&view.shader) - .width(150.0_f32.min(ui.available_width().max(1.0))) + .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"); @@ -494,7 +489,10 @@ fn material_identity( } } else { ui.add_sized( - [150.0_f32.min(ui.available_width().max(1.0)), 22.0], + [ + SHADER_CONTROL_WIDTH.min(ui.available_width().max(1.0)), + 22.0, + ], egui::Label::new( TypeRole::Body .text(&view.shader) @@ -572,72 +570,6 @@ fn material_thumbnail_at( response.on_hover_text(format!("Open {} material picker", view.label)) } -fn overflow_menu( - ui: &mut egui::Ui, - view: &MaterialSlotPanelViewModel, - actions: &mut Vec, - diagnostics_ui: impl FnOnce(&mut egui::Ui), -) { - let palette = design_system::palette(ui); - ui.menu_button(phosphor_icon(icons::DOTS_THREE, 16.0), |ui| { - if view.can_locate && menu_action(ui, icons::CROSSHAIR, "Locate", palette.text_primary) { - actions.push(MaterialSlotAction::Locate); - ui.close(); - } - if menu_action( - ui, - icons::FOLDER_OPEN, - "Browse Asset Library", - palette.text_primary, - ) { - actions.push(MaterialSlotAction::BrowseLibrary); - ui.close(); - } - if view.can_clear && menu_action(ui, icons::X, "Clear material", palette.text_primary) { - actions.push(MaterialSlotAction::Clear); - ui.close(); - } - if view.can_create_instance - && menu_action( - ui, - icons::COPY, - "Create Instance and Assign", - palette.text_primary, - ) - { - actions.push(MaterialSlotAction::CreateInstance); - ui.close(); - } - if view.can_extract - && menu_action(ui, icons::EXPORT, "Extract Editable…", palette.text_primary) - { - actions.push(MaterialSlotAction::ExtractEditable); - ui.close(); - } - ui.separator(); - ui.label(TypeRole::Section.text("Source & Diagnostics")); - diagnostics_ui(ui); - }) - .response - .on_hover_text("Material actions and diagnostics"); -} - -fn menu_action( - ui: &mut egui::Ui, - icon: egui_phosphor_icons::Icon, - label: &str, - color: egui::Color32, -) -> bool { - ui.add(egui::Button::new(icon_label( - icon, - label, - TypeRole::Control, - 14.0, - color, - ))) - .clicked() -} - #[cfg(test)] mod tests { use super::*; @@ -671,6 +603,8 @@ mod tests { 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] @@ -740,4 +674,13 @@ mod tests { 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); + } } diff --git a/crates/editor/src/ui/materials/panel/header_actions.rs b/crates/editor/src/ui/materials/panel/header_actions.rs new file mode 100644 index 0000000..a130edd --- /dev/null +++ b/crates/editor/src/ui/materials/panel/header_actions.rs @@ -0,0 +1,216 @@ +//! 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, + 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, + 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( + 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)); + } + } +} diff --git a/crates/editor/src/ui/materials/pickers.rs b/crates/editor/src/ui/materials/pickers.rs index a2ec639..b587983 100644 --- a/crates/editor/src/ui/materials/pickers.rs +++ b/crates/editor/src/ui/materials/pickers.rs @@ -261,7 +261,7 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail rect.center(), egui::Align2::CENTER_CENTER, icons::CIRCLE_NOTCH.as_str(), - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), palette.text_muted, ); } @@ -270,7 +270,7 @@ fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnail rect.center(), egui::Align2::CENTER_CENTER, icons::WARNING.as_str(), - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), palette.error, ); } diff --git a/crates/editor/src/ui/materials/texture_inputs.rs b/crates/editor/src/ui/materials/texture_inputs.rs index 22c7c7b..45a7c47 100644 --- a/crates/editor/src/ui/materials/texture_inputs.rs +++ b/crates/editor/src/ui/materials/texture_inputs.rs @@ -372,7 +372,7 @@ fn paint_field_icon( rect.center(), egui::Align2::CENTER_CENTER, icon.as_str(), - egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())), color, ); } diff --git a/crates/editor/src/ui/materials/uv_transform.rs b/crates/editor/src/ui/materials/uv_transform.rs index f67829e..1ac30ab 100644 --- a/crates/editor/src/ui/materials/uv_transform.rs +++ b/crates/editor/src/ui/materials/uv_transform.rs @@ -92,7 +92,7 @@ fn vec2_group( reset_rect, egui::Button::new( egui::RichText::new(icons::ARROW_COUNTER_CLOCKWISE.as_str()).font( - egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-regular".into())), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), ), ), ); diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 46975df..7a3e680 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -5,7 +5,7 @@ mod asset_card; mod audio_inspector; mod build; pub mod component_registry; -pub(crate) mod design_system; +pub(crate) use editor_ui::design_system; mod diagnostics; mod dock_tabs; pub(crate) mod document_status; diff --git a/crates/editor_ui/Cargo.toml b/crates/editor_ui/Cargo.toml new file mode 100644 index 0000000..ad38dc4 --- /dev/null +++ b/crates/editor_ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "editor_ui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Lightweight production egui components and design system for Blacksite editor surfaces" + +[dependencies] +bevy_math = "0.19" +egui.workspace = true +egui_phosphor_icons.workspace = true +material_schema.workspace = true diff --git a/crates/editor_ui/assets/fonts/source-sans/LICENSE.md b/crates/editor_ui/assets/fonts/source-sans/LICENSE.md new file mode 100644 index 0000000..69fa3e4 --- /dev/null +++ b/crates/editor_ui/assets/fonts/source-sans/LICENSE.md @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Bold.ttf b/crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4d80fce21b2ada8b93dff62d02ae1356c943267f GIT binary patch literal 253344 zcmce<2YeO9`u{((yX=OL-s_%|UVxAu2%)4vLP$tRD53Y>d#DM$w@{_`-aB?t0Toe1 z0a57!f&wA}g5>-^&&~;=->diD-}m+X<9(mco;kZSv-3PN^UUlyxr{T$99b!o*}$~a z!Uo;+c5gAMx*t*-W)wEcgmNZ-w-joVnbDx(lYr-C_`EA)@~q7Kyh5J&`~{PuA28l} zTxMbOw89h}eaLKelizGIhuod&Kx%^xvl-osepu^xR#c8^}L zw?1RtZ!kWjXU}e(x_XRYTXF91AdwPmOYDc?`gpt#?%8i>@%uH;euMWH7}GEG9nhuI zeoNucjI|t!o9N%KQ}ICUG<$;2i*UZVf2V%kTpzS}!T7}-R6layfFVO$MSkYM_%+lg zdp+)qF{#RA+ot>abZ|`hm2vqtQrLxdYbF`m-nmM7Quzlq{p6pi6_`Y+_=oG{3+4Bb z=Tdy0WYf>cNqlC!YKJHVuk)B4>%z3Ze6AobS&Ee2WR*~X84}gDu~6Qg#qtQMmL|)J zEJ;d7CgF~7h4o)*Mp_;-Gqc4a-Lt4v6Ygwgd^LT{A4m%!X5_w)<+GE>r;g>B)Q;s! z4Ox^l8X64jz&N~L$8vdf@Pau>-I1n(XW%uk1D`juYBy*#76iQUStF^MabLP7RPm181f3{&6$_v!2+e)NOxmN+{5xI%1`AwOO;!(R4D~i zHO^0^Yxo>|_6Bq3CCpi>$x@^$%ucpJ9umr`%B$SAl( z_z>tA+v&Y-c?xypg_a*_9IU_?4{1#N8{`6(X^fXNX8uQ@{r~1XIq^TpgT`|jljkrG ztI~12!19YRR?~R>FOZ6H9mes04cf6{jHhw_{}7r#UWMk7e}d+izamA8xsB{%%wdr% z3BHsp)kR+9-$h9O7QX$HrTkBRTo-=r1pAhX`A|BKb564;oEspa{ds50!n@{9Vg4{x0nPF;jpu|CIT$r^Xl?EzO55HiF%l)AvAsWL|t0?rj$4DxrQ* z2An~SSJLPlBFL)o#vD^IX8d1CV$ch~(fM*ZbFF;1ZED*Cw_?u+6bH}nxV%&Cnm z_Yo&hT7+Rw*zsjtRvBY*DcY7|1{%L<4#ymc`B==qh_znMt+iPKeI_lzXEeWspzUdX zqB-Fyi#Fo12P|GX!Kz99F>WunJd(ZOFB<^nez~&ck@POW`En5IP>Ax1SbfPIv1Bmn zM*jH)i;!YjBJz$#9a5!0lo`(Kr2-sRf!}-ue~7?!qnQirT?cI$%zwl@*&q;Iy|HmHaLPgtHvCy>6}a>uyu-Z-xp&hH5A0L_4A80~R~+C;vE_cx)J zpx;7EEw>5&x#bC9dJoGTYG3&R-rs

RgnkmB0t0X-|+qc^ggs7>PB?2Jd+P1 z_TFfDq_nj>QQANYpiObkr}&&W$#RuGNb6&HRLQ~eq>?>Ufyy{{ljSxc|HP^(qj3(} zr&0huGeJF}9yn(q&H;vRkbnFa82&>(^fwrOMZWbPfVL(7i$FVnV)d1iI6eVL&j5;P ziL2l-I0Zhna@p`J^3%VAe3z*3z5iE*&ywH%NBkFUX|(%)&`y79%m1_<73WdkM*HwP zU@!W&7pu&xVeF~K>R2g&CSd-!!bZS{tFqSM64?X#rj>)x72pbJ1`4g(4m!$85ww++ zK5T>K30rGboE@$=;vN$WKlBdanl{m!ZzW8_MlKb)n=bCB069A9MKa&st^)sT6}hoHSM z7t@$byoKwje34fW?jP%BV=ZMz>m_+9lQFO5N~dui)rIOr<a zdAha!%klmve7+Y-c~`{t?Xa$%jJbBWSf3mBi*b(DrAB=yo~JS>UxLaaD6WqIh&d=O zkg|{u#nQhq6)}gOl!-JV5Y%BR_6kx_u2_R(?n0WeemBN*%Y40F3w7p^9<7JL0QU$;=#I#^EW5pHaRM>r-qf=EARdRrnWT3)zmTGQ}ITKA^f# zolu^X1}#F~6tB1=zJO0+EOAG%gUi1q7PoI~*v5sW(4!~91vJl!kBMraEe zYmt|AqB1ICVJeFVWMT3N_{4J9z=?%`hCl}%z!i9c2v7}By9NR}FGR{<349sa#{sb% zogZzad!j!kqg|?D&Wp!!ES+cYpXKi(1+eNyzA7^t`35jws3+26jC_NTXTZyP{M-6k z`*B5|j*`E?en2zU)ZjCWub*MrjSH!YFG-wH;fXUE8X3yQ5VJKSeYK?KTndL=KB=RxFs(8#-@3`3=RurEN| zZI-q%1-Jk&=`+NiQ(25$7wrnW8b102`$~c6S2pNV8R&bzfN$}>KHm2eeUYCt;^|!Y zWv;XnvK%jg5byH4@Tm^05xAED(;K!?NxA}bgwUI%Y2OcrhEVQG_;=M_>bpSv>~N#@ogi zZ+`+b?_a}v2GT?t{H;3602O;!k1A}0{!ZhtVJouNUr@w*>|v1|D^a1Jze+TuCzaYUU zF_z{+os~`5@*I?M3Ca!Epk{CTgGGEWc46{Aa`@)vfjK ziqF^K^D$7Wr`1kU5bBtRn4~_HgU{%GsVy+h823-Mq4uTwq`XlUfpw}kVuu7i4`cZ? zj4k&uS5`rr-@_QJVJ`Wc1#k=IX^uJNk9jE$^HMzKl0VH$=&wm2)0ltOVa_>%YyNI- zDMnevnA3`xuazLH218>(D4;YtPhiLDVIB%%pR)*HvZnw4lgIyS*?+yR2lMCSaX-Vb z-!KvTL`!h5Q<%=TLkEG*U^m`>z+%}aI4(j=whGz~afJkZ8+?k-mm2EJ*FcA3|F4+# zcF;dY!R8^%hEHG`?}~Y>FZ1N0%t&4fWm4I#nHRf_{mowZtdvEvH_@hVTQ2h?2e`nQc*4Cr}gq-V$*FM?4AGS<7rA9c58@ zz}?7a8q-l;q+!G7@Q-%zlQ?KUV=vAPb3N_JMFTr4?$8uKabF^!&t!q+CupkmeJGU9 zrE?kp1yGDe$gHEKc?H;q^HHv%PergOxe16xtkD44l=;8Zrp#9!!-A~ueGL`o_~JZY z#MwHfDHDzBd{73(o&?n|P;zFwUrDR@tU_b0pZP7d>T%2u z=a?algj;Ab>Iz5*5evbg3^hK_jHc89LBN_^LCdXIEkGj;+KKtE6h7J>J{CU6k<6U>+; z6%>Lg;21anJ_e0I3y=nqL0{186>W**W}qEt0(OG!01>OfCMYom%mF1}B&f*O4ab!M z2Wz2mA%4a$F5R?Kclgc6Je&}9w@E)Mg zi3WhKskmRNM>;scSb35FJ}U0HLg{llu6SRey^z)e*W7~2!215BR^0Pn72gZ6bOA#_ zH;@VH07|cG1;==5eH(s#n}q^5+02|lOZX{c^eI}__%{Qucv4UQ;FDg`5R5B~o|)6r zaCjJF3S&-*jN^#0YDmQq(q(3Aq^p=28LP~mSkFM%Xq@@)Ubr$^|CM)&LS|jRmHtlXHl#Mi)Jwx$KzN$OHei|+mtOD-C;7T!3p>oGDNB{tl)1`0WwtU$eWIE9N9>9Et@^RDKv}3A z&<-lQl~u}orA)no@t`A1Vx3ugHi1oKli7H-iEYLmZe(Axi|hh#t3_zhTC5hQC2H|n zl9r&=)FPSU-~v;>j;7Gq>u9A~tUhavoJO&6d>k+3bNK?kmha*3^Kbceev9Abzw+Ps z3&~eXlG3HNQWt5cG+G)bO_HWbWzr()HEE@^T3RPZc1@+^6tyjI>S?~&h=Ps?ZIujKRc z*YY>=ck)$5Qyi5VN=v1K(nT4DHdqdS+pg?a4l8e<-OngrE7z4Dl>5pr{vrP9{*C-| z{qy|`{EPhC_^zeMUd+Gsth+b8%tvAvO^)C7VeX>4RU!t$le>1t8{7nr^ zxu&M3mZnywHl}u_v8Ds24@_rG-VZ`QvjRH?b_>`1PdY5UA-xUT9hW|rE=X7KMI1-j zS@x2H}NK^b~Vhg zuw5;43T&5Y9%vp4+m%{vw+Xh}4%_WDA21&=Z0GUPcKNW~yqC6fhV8sx+ODf%J3p)K z_Jte@c{Ai)*zP0P&dF-Kn^A#L$%gHiu$|(E@%}yV1`W5CwKOLf|2kUrrC3&%JKzkA z_Le!8*_K(B>6RJv4)5DoN-Xs(edLGqj(?%_kRK^si;$}F~tKJN}835J4f5O=HP1o07ZT0Ttj8%u9&y)p0ViW`$|lw2Knwe!`ESKD4~eH9VM)qYp) zt{l8l$ z^N|v%R8wM=no1hHARDuA3s}Fc(oyNH^hIwp{7o5*9B?Gw8F>z*&y;aWsWL^GhPi5m zvQIgnoK`NNuin6!KUP{*>13rZRQPYjKh;kyQs2cK^;lzCum)RTYKhnCYZ+P=j*pz?n(=BJC5m+MYFv#R{`sFM`>*F{XfgczcX7*~Vw6|ojhC?l zSEkX-uYRbWRCXwPv>4@>I!;-n$%uSD&|(o6l&fiKx)v@iRQ4*Xv?MiEEman)SJi9k zb!7(T_$18PwTuX)31X1;h(bDI{vI#pZ>-$bVD6?ld<)yEK4yp51!E@vmi@{ljO!|* z6&pkzU$*1bSr;D1x?;W2jVG}Fye{j+W7trh!G`e$Y&dVo zM(|8FhBs!VJdaJn-t-iFhiEo$!)9V%dn#|sD`5{}4%U@(d3&rt%dnze$h)xxSg|hR zUD<0`^{&Kfb1hcetN8#vj4OC%zKxG&2l!;RosXdih`qFl-|TZ>(|8NEgO6ob`95}y?`PNf0d@ns=a+g&hicX0>8??<`*T780iwf%&+hp{Cmu|wf|OO1%7ok|5tv66L!}lKi7mQ~pV*CErt$<@-u)`GJxm|E$!JAIiTd zb(MOUed^1Sob%nZ0U9E0ZH>=y!UFvSE ze*;+|4`Qu(Bx{3bU~RD$9>i1GV4lWC@z~=LgY%%Z7mS9h1DeuXa z@!o7X@55GLpJg2%%+_NMWdk3|4)Q7NT|S?^&lj-|`C@h)`#dMGXY(oca!%vBz-Rb6 z_9frQzT%tM1AdJC%-?6f;5%qf`Nzz{&v4A8JeP0AewQPwkG+>v?u`9(7d%&U#a_A_ zz7y)s8gdWpwRz&%jTfF{c(ZKo!@BcC)`KUpp4gA+h5er1Jel?3wOL=D!us(#Y&!O6 zW?)ZgCm+XlVIOBVFJ*hMFS8f>Mf>%>~ja-LAuM|WIyt^*iYDxdW2^{zwuAlGk%Ia=by^h+mzj8ciBVsMBL_$hS3 zK9)W~B>$-#ZA9(~a-y6h*OY5Xr=`!NFQl{5m(o|#IqAGyUrv=X5Z5=9Go_2tH;DW% zOIM_;(rvl5+(vFMcaS?G;_obXk?u-AO82D)($CUE=@;pd?1I&S$ygtR%dv7C;>&b7 zN6wYo%I)Otau2z;+(+&!_mlg}1Ej~&6X~gh@BB#5rQf9&@?d$0JX{_j7t13NkB*kG z9GAH)$@J~;O0tU8MTtB?o+wX}C(Bdhsj`h+S+?aSD zUPtu14=b2o<)!ix`87nfo8+PLAgohr$YT%}zk|5=gj6oClviWDk}TK8N~W%yBG*H# z{52xxGx92Vjl527F1L_d%0+UP+(>RFw~~I4?npPJ@1<+<26>~rTrQQz%H!p(@-*pN z={xBs>7J~~E97OEMV4T`rS;!R%(__rVVy@S!7{|myOq`2KJ|*R@>#2_!<>9bIjGiG z*Q;NsAFCJD^Xdi6^6#rZsz0j_)f?*f>P_{QdRzTLy`$b$e^T$M_tgiQjryzlRMRvU z%`An`YElZtKEEAJkD;^Z712rpUd&cF6fqT{Ooavt6goFZpnL+Q-3Fi@hlU8$%g|7P zf|v^Ha|2~2lwyPk^cf%*tqO`uGKRu?Espiu&40k@)$c~WexNTa1OvW?7RT6AAV?K zS!eb>Lj4?1M!8RI@Cpg zUq&&+8-h5fn}BtNx?72ddI(rIsHc?#sF#5Chk9G73-uAuID~b8AO`9uU_+t)Rx+UQ z55q@>K}}W~K+OUM`|A*04ghh!(J^P`ZDjEr72ZRDx2w6XyZ7 zHDGg~)V>7SE>XbdLX)htht{-0?OaR1%Am1|VRlr_@rdjC)O&740&_>bBas)I_@LVgSp-ludt{ZkG+fD}g0-8&BQ!C^f1p*r54SSQ#r-5byJR@URa|){KPN_GV_u4C0JSPvyT0h$L48{kscj?8mxBum##vK;u4E&4L}!-U1r; zvGx({g7y{AJb)FpU^leCfaU~Fwj}m|fdX~~O13BVg24h~tRI47;vFzlK;t@|O$(^a zss96GtRI16g4(uNKx022DWEZ)kFs(aO8x<8jK|tvKx{Ob_Baa2SKddw5eWTM!0JM&u7m@idH^;J zN_8d32hRv79>VVg1au!~1?(#HODp@J)Mo&@20dqGKa~6cu}@xgFof6C8q4zJTRJDKEkdZVKqxA-`oM3wlRDu@S#(h1&R$fDM8EY9$@|RKQk2 zf3wmbN;U>84@&kUOlVUt0mVmn?rnhG6ejry7}`nl74QwvBmuRJgz^a16pH&HsBI*A z5AeMvCKZ4}9HX72W>z?~xq#Y6f}NxyoO21<3be)X4QPAN1;_9QsjC$Uiash)pS%a{ zDc}#G=#K=ojfDP4;CntyLVuK~4IV=WfT1{k4jl$Y0~x?4q;bF&l!8gX4#1xboT2bh zsSLP+gU`Ofbl~MfxahD4?>Rv_<|Mz z{T!S@|ApU5WLKcYLCKatON7GT2`wJ_jX)u zP-;7%?u1fX02TKw!_^2a8OjA(9jGMG>O*k?p`}3;ftCTSB+wc{Re^^7C2ImL2Wlgr z-Eg_GK!Z=q@CiaIfL0M`&7klPLWBRxjsmR+3SS|#)==1w(Aq-bH-y$6>IKX=?g$M6 zVK|2G%HaYHek#KTawO6RL8}S0AHdK>4w?=c;J6fu z{!eJ|FS(OIn+T=6hJpEDl$FI$x}KN=CRtekoor9o$@2u#E-2MwEz)~J*MqG%ru*6^p#CXu7f4j*4zL^NY=rI+NO&*r z1@9q!I`o);`keee_z3CCpdSk)lp%j2kSM=X0P~GRWu6w`yOB)(9GpS=I_Osd>f18r z2LmeWYb&^a8U4awCKP?afZF4#74%1i0S)hwukozO5yuyC>;!7y826z>gO)gc0fnC_ z9Y84PERe53yI6^W4i?DQq3|06Q2E8DVpFrVb z2I`i0C(D4WX`p(s_+=yb78tP+@2PJb{Wn>z^-BH$w{q>Q-nAfeL&3w**DV z2R8O^BT!*me<}m0E1~cuLM4Tb2=xHN7bOMwMjyVUD4;d5t_oNoR1@$ZsI7qZ8TBdx zTI1?=0*3N+djYL?bq4|MLF$eITKnoA0yYThDWG+st_#>;s7b(KU)?NVBcTBTj<(Z- z1+)jLqs<9g+v!v%K>Lw8)dSGlPLCC^c~JC8g4Rho`XRyQL(%sL-VvH8V2hzi0$Ky< zH3e)56n%-H^^lJKL$IaL+5%b|>F|4kErY`E30fQJ^#yD>l11y}`;mG(0jIWVFQ9!%o!TAXSr^kg z3fQO6P6ECP+F3w*lzJBd{e2vr+78g(q)zPx@SKe4)JA}P3GFAqvoogm7qG9O0|a=U z#`IDFdjK6T;K!g71hhw}PZZEvOP?fQzd$DoINkdk0qqy+a|L)l$MiA*?E~ry1hn?j z7YaD4s#7}xT8HV>c7XOIbZRevXM0Siwg9w8tFIBza{zseMM?d|E?1S|u(T|oPM`VIkW0Np8|JwJVy zfHj2f7SR5mjIVdj{P&=M_6YSu0+tOuETH{D{fK~d zhaMHszL);Gfc1dBA)q}l{Y?Su34KezYeL@^uwKx21hhA%zbjzyKm9!cPlg^7F!D$8 z6+nAt`Ue6=zDd3UXzxrvE@0%RCj_*Yrk@nB>Cle^v{$5)KLIua`iX${jPz3idY++w zDxiG`o%#%*XB#^C4Zw3;rhhJ=XB|5EBH*P^@;^Y&JoK{yJ|0Sa3D7eT{VM^V06iz5 z=OOxe0iOuHAfRU?`qu(HD`xsd0X_H7zY%b{mv04(?DL&~&xBqQ(DM-evVhNmUJ=l< z5&f!w&xT$TFtXuw0iOfCA)sdn`u74nqh>n22lPBax5AI$__l!k0JXxYZg&Lqyg|P! z!1HaU|0tm65Bg66PIbE{pl1>KeF2_>GyQ>p(YZeh@NAsv4+ZQI^cMl1mouH-1A6|T z|0=*Ubf!NRuxHRG0#3g0R6x%vbh-x6{*Vb-m^fk;C#VE89J@hnKxM@3u)E0?IO6yK zl+JU;IsQ-=fIck;K)rxB&cQvJd;mU|-i4Y0K9@tFfglXW5zue|#c#5hDH7DcF?`$< z4PtN(X)H*>aXhppsD*RjyQXAN5653X>w{FBLz)H};5Y-C1sdU;i_mP4i(~kXDIYY& zIh0laTH?3~w3UF?&!#p434UN|Cy?L^rVaw>Hnbz?glk(vJA)oLZV&AVdZ8@X-P9Wl zz%lG>8VCmA9BQ+{U>~{rr}@&j$@(4U?h&|o<@PuIL?8N0b_C87Fr6% zmK(y7ccums0c&Qh=%=UAX?z*?k_gRTRcaZLGb0lRT* z1Kk7m;@AhePaxC1qCXRIHz@fBka7Q}Lju_kdRQP+8yo>gQSR$d;td>Aes2mS__FCO zflPILTOgy}rgsGLYtVNEwB9wnCy+Nmj|pgvZhBuJquov9XMoo2CiEXdt^qwEkf~iy zf{#!iD)VE2{zQ8`CiEXdDu+^^0rE;H`67_fj;7B9v<^3Y4$dGw8Tth{i{m=ba{@U9 zdR`#cgI)k%BY)D1;2RuM9^VP%RnSWUnQV1gAd{`G0Qjuj9C`y_ER(aKHwAJt=q-WV z3VIv-fc$~!4uGFZblr~v=^FGWa1Z%xfZi9#%b^biaw+r|fjk!aNFc)(P2_Vx?h1V( zpuG*#Q}7$g`WE^OJjXGW^SeOSpf3dS3MjQTke4xL#%F}K6IvNm!SODrlYrLKW@mwM z?1E#UARn_UaKrIZsJlQ}3H1=ry4LIkym8KKsE>fw^kzTckMy}vT|n!5vl#>+o$3%M zP~ZdRP=PW93g03$%0F75!dB*35QlVJZ%zqF5W2o-(DJQR$>G38exP`5x~WAhxOH-#<~ zsBc24Y@mJ$-5^j;LpOoVNdFAFRiK`MZWpLuKz9gK^er>B9ZU`0rgeC zf*uj57okVN>qtkxF~22H(N6+g1)41sHYJp7sHZ@|7!Y6IKBaG1KQ&lJ{&Mzp#B1#2NuA-cFj2>&EB1zJ_0Q91AhLZk9K z3UE3L>fcBG6#lAmmSI+wfJXH3H2E3LA#( zN4gsn{uctD)nJbh_)Q3GrXk;ucfor&M%_b>3pCt!$VUPV_ZY{;A;duf7)Is z;ArRPOd#Q!=NzE@H2CIowAFL8u@s6&einhYpYfl21K3kJfeRU{hX3;Li>BRphI$L% zY>edfcw^p+Pvm9%1OC0_EX7Lw@#~c@rSI`gKa<=F-|bs3AI0}k1Mus!aro}_`AT&w zb*$94QgNkum3CCRq%yUd+E$&b?pE(;j`*ckjy6%-tzFU{YUMVzHXb&CHc>XoHVthG zY}(uOwi#|S!Df!lGMh~{his17oVK}WbJON=<+#cNE03wXq4J)}Z&rS0tJ=EQ>b8-# ziMDCBO>A4+cDEgDJI;2BZJF&V+ikXoY>(NVw!LV3)Apflc@^6#9#sOXL{&+yl3itN zmEBd|sB)>wohpy*Bs&K?AG=VySi8D**>+`itL(Pf9kM%S_r2ZEb}#HJ+q>Hb*jKl& zW#7QQseL>93HEdBm)UQ!-)H}}{m1s_?0>TV&7qQmvqOMGb%#ucLWd3xeH=zOOmvv* zu-sv@!+wW%96oV4?{MAWzQc1z&C%7-;>^#nShVx?QbbAgbwc96dKe?OS z^WBHKm%7h#U+TWWeUJN_?kC;9bid;Mqx(}2#ly+N&m-KUrbmWHzDHY+o*u;>pLqIv zMtIKfTfF-rmE#CwP~6U-Aj_iSrraQ{pqzXNk{xpWVKZzKOnRzD;~v`*!yo>^shP zi|+y7cYRO!Uhuu)`@r{iKN~+czs7zo{kr%K@Eh$n)o+2{YQMvN@B7{Md*sjj?fm`x z!|}VOHvT>QhxnKH&-7p7zuteh{~P`%{LlJd_P^`@1iw*p)P40ZJx;HuH`ZGkzfT*j zPsQ)k*6ZKm*J*=HdrWVdPMW?nT`~P=jy9*5v&_xR9nF2s#pX%oh2}Np9p)qE56qvN zzct@BKMF_*=oK(5V0^&rfY$;x2J8*E6mTctF@AUEfM1-22F3=~4a^Q~5!gAff8eOV zDS>5ys{+pkUJkq)_yoUXatvx5G$Uwn(7K>qL9YiL5BehLQqY~C$H7vtL$FV9XmD(B z-Qeut7Qvl^w+DX{;uumrq*lnFkewkvhE@w55V|t-YFJ=c^RR_sE5aUyhldXj-xq!_ zqH08b#QKO^k)DxFBZo$AjJzHBAoBOB&Q+^dtyQ%_)d5v!SADPQXVs)?3Drhc+fnUG zb+vkI^-?~Z>X{zUxQ_{;Hk z6S^cUNjRDCWx|z&9}}J?Dv3^seu?3U@rm^la}tXZyCx1y9FsUJ zacSa)#65{`CZ0<2OzM!-CuvAhNz%-uB}wa(p4ZfBy4Ez+tXeavW=_qbnq6xStXW!f zR?Q7H_tZRJ^T%4IT4A+n)T&#nWvvdidej z%&fDg&ds_ub&Kjws(YmF?RviT>em}oZ+pFy^`-g^>W`{_GPQDQqtxc9ol^Uyj!d1L zIzM$~>ekeQsqdwJntCDidg{H@$7xcULz+)oXj)8KN?Jx*PFhh~*R%m?Bhn_O%}raL zwmEHo+B<0AiEG(9%GPI`m%rs?g{d!-LcAD=!ueNp<#^sVU! z(%(rxk$yJ)a{Ar$CmC{vV}@@=P)3c6+8LP{g&7?(wr4zU5Z9n~gEAA zvyNwdk##BSPS)c_QX_{(K8->f#Wt$jD7#UMMx7h=Z#1gWltyKZRyEqz=uo3$jZQbZ z*yv`XhmFd!ZL>YH1GA&Dld~IU7i723?wvh6dqVb{>}A=TviD`bo&9n4x$JA%_p+Zg zRvWuC)*DAQPHddkxJl#Ijk`A<+<08$8I2b=Ue|b6Z~R5$OO5X|ew-uaIOO=` zgyzKN)XmAxX_3=8r+?0ri+UCZ-H@AJO z?bmIew+m=jzumxgYug=fce>rV_Ac%9_D$NiXy3kl_x5G&m$g6FzPy9l!KH)VA+kei zhn^hj)ywl=p=Ot>r~WfT&F3W$~rCUw5ijsPOo=5*6DPo zi=D1@y4UGh=Zwy~x;S>J-KC(*xGryWx!l#MYhKr$T@Q7A+%2YCk8Vr4ZR>Wn+c({= zce~r|m+p4m-MagAZ`FN4_jkIV>ml{1*Q0Zf1wEGcIN0N@9?yHqJw1BX>zUazuV>4i z9eR%JIlkxgo(FrL>iMKsOt0p>ruF)$*Y(~py$AK4+Iv&)eZAlAeY*F(K8}5|`b_9k z)@N0pxBGn4=T@Iz`uyJ4rmuJ3$i9hv)A}~)+q!S}zJvRg^qtvvLEja9kM#YZ@27ol z_mleh^{d{mZojep-t6~rzw`aR@Aq4O&;HT9}oC?!0mzQ0~ZXuIH=N~R)ba#x-huG;N62S4ZbUqchIbs^ zcX;veNyFz2Uom{k@B_o&9e!%~h2b}bKN$Y|2%8aJBZ5ank4PDjHKJ(5!Vzaid{->u;am`}!>A9H=o{V~tSYGYl;n#NWgn>03kY~I*5V|$Dp zGPY#w%&|+xt{=O9?89+>5~mWslJJuFlKLh2 zC2dQ3mJBT^Etyrav}8lco{~39o|Y=5PNjaO;id7V^-FU~yOj_36ZMHX6Z=nGH*wpZG_yO(wON)PB;4NhOo!PkL?A?n#Fyy))^TNzW&nCO4bhc5>IrvnOwve0}oW zDGpOSrsz}hrnH>0aLVUX9!@Er8alP<)K*hFP3<*x(A1Gr$4{L;b=TBuQ}0ZZrUgt( znpQZic-q=&?@#-Adf@b|>G{)(rq7(dWcrEepG`kM{ql?|GhAmhpHVtv{fyl+-k5P> z#-*7yGi%N4Hgm+x*)u4FXx2Bg?Ptf%&YRtGc9+=$W{;RXarVsF zOJ=X1y?yrK+3(LW&ncMGZO)uIC+FOq>o>Rc+#z${ookt=&#N|;QwJ+;YHn41D*@Uv0WedwzmTfBAU3R4Gy|S;$u9p2+_G?-B0&RiQf`kQG3wkeD zzu=t(cNdx$)?7Gt;r@l6F1)qy#Uh_YL5r#_N?25PQRbrjMXeTfUessN&_!bxO<6Q| z(b7e07HwIyXVDjnrNz}3H(uOv@sP!97av;u{^F~PpDn4pBz8&Rl2J=mF8OlF)1~oC z8!qjG6 z@xsO*Hon+oyD4Z>-ln2WT{ex}G-uQHO(!;8-SlL$=jJ+_vo;rP-n{wyE#6y_w=~<* zW6P#3_qS?WO)%}cW67Dc6jdy z+;MZqqn*-Dr=9xFYCDs6=I-pcbIQ)`JCE%AaObBxzuNg=m+h{QUG;V~-_>W=yj=%( zo!Rx%ZkOFjyK{DT+C6ml)ZHs~@7;ZB_pLqh9@C!Md(!t5?&-2;(4N_QHt#vS=fs|G z_T1X@V9#%RrM-T8Ywc~Zclh2VdpGPou=lOK$M=4=_rl(*`zr0z_eJfizpu}}$@^yS zTeNTGzK{3a-mmU=+@G?)*Z#%(-{1e>KHU|R_rW|Z>aNNO-2R}bp ze#m?%?NHZ4Qw|+GbmOqk;rzoR4zD`=-r*ZZe2&yP((%aRBa4rEA5A#g_2~4YEB^A| zmZMmcvr6<0QK>V&XDGAE_zNs93_B)q&H>ILabaOWT2fq6Qjn*d6vmyke95JJd=AbwwD&Z=-Vc@bLYn$YS%7bb@cV}*?h?B$kOr*FX_@OSWJ;YvX_kM(dQ}S@75m1i>CGbe)X1Pvb9qrwRcupm7KOjz8;x&R`l3k!Lqikd z6BALArxqF-6sW51ZXPHm$y4R+3T6~GpIuO|Yd{n4q@Zezsxo( zb>YIR(yZstUkv3=<@kR_qC3N1N1g>slWlPZnI7p*_?yT3k&b;U&%n^IBo8t`0?u$# z)oa<^1G~CJdxu9XsMgM|cJ1r{b4)7VT%KF6XI;8G@|nK47W;^lXPj%C>>4C%?zsz; zW9vTrVeM)dr2JQ2rTq8uV(!{?xAhv1210)@u0h)_;1cTcU`$1hJMk&ni}LgE@Qh1L zOmf9tk)tF9X<86^i!Qml=dSIAy&=Wc*|x`;DOzP&Nod!!UA&^$XejfNUtL|V>&F9M z483p8%Qr3m?YHHo{5;>FZo)G*Oa0?C(oi=xSm8&570ujx&iqyxKx*LwF^ zl$W=-ckkBaBHTk9d|Ybx9oCcT*mGFWPu%VssB21}%J$ZpqvJCNhCS_< zHP>hWqPmNLBlm8D!5Q@u7H%BdG$*-Eon#mxq^L<&JJ0gxckl9!G11YeG-p9*U0d7( zZU}v*!Xx8D!@{DZKMxmZVNXvnJaM=BC9%zdT7*YckE_}|ASJYRuZ)@lBO9CRhlN(J z71^wI!{q*U(NW#>&_I8aze}(~jfOQ6i{h(Aw)6Hi`RTfIpjWd7iEV45eC%9HuDBO$ zL6hGFxCc3(+RvZumz>6odGUa1*a$Vu!x&JslMXFNfU4AHeTEPw1YQ*gb(q z8~xM)gR~jf1^nf@Kl%uMY%WHAO`bY(Np;mhRcu{t8;)&=S4Fees?Q#Jw7!jfB}KKj zse}AKi|Y{|-y@F4lz$e}Ga;czERTVchll0{2j_<3uhnoG1*K{z6D<4@UySI?8Do9!{U$zp`i^z7MHiZjXDLx z0Kel(Jd21+-~liX4{#6W=JMzK_3|q`s1-jvrd4?(j3yF`#Mt&FekI|}f{f7(-jig^ zGg_E2>$p3^KUFmeGfsjr_q?5vKWTv!6k)0#oKT}ra)<84wu&j!#yiBdNs7s?WxXav zjsam_?w$OD`wuR^5$6{)*weM8ZB>6SPn4DlukeuGVs6&a9&<0|N_WjD>W`rzD3GR9 z-XI{|U$GmqK=IEAOew07(xE6cu|>5Aw{W`vbAt5NjvOEVx`T3>kE~bRFlS2jPh9Z- z7{`KfKX2iFeEvF{VCsB1bE@3C!GP4P;Tchxehq@m3H9nldque>hqSP(GpwL+XdT_N zgMUtH{akm)j%G~45(`7FzoXu+%v{k*#*`dJgW}8fG1?7%V0 z$t%powW?cCVhy{QBbyGZ>*tlT^+kG&Pf)R&H)@@p*{A`^pqgJr&At9I6B;8?01ZVn z63KChF9KC=${d`Q-nVvUyBdmAe%`iWOhRHzX!~XR!>R=)+SMIe&~#|MdVMlnLn$J4s{B%>ildrvxov+@i6(8HLl5awb zcssRkrAh&z`jGO86b}Vjs!JMbTm!#+%{JNvu}V^cF@{lR;t?^M{m|+79+B6Hu zuZ+E}*6|O68J+SlxqZz{S6{Q2PegM2glYi?^R&wGMM?f9m%xbpww*G@Wc!DO`Gbhq z`e7k)-T`*LbH^KLact8@3;fM~;a+gR zR^CJo% zhe~w~$d1WxjQb6Vrk5K0L48y8s^Nh#6}KH-zL)MEQqg`WL(I$8GG1*zWNXfS*?{~( zJ-vEGMn?OIK45s%zmFO`>J7-q7*MZX|BQ_O^{PijRgbD(-P(KW3@t1eR%b+R{nQ-l z#AMb~OP1t;y16nuTl9R{0_253a5+zR!~evvL$~kWD6DnQln#mJ6rW0Y7<8&ex`iK+ zwxG*SAJV+Ip09V_d>&je=pg?vOBVjF4&{&Wp;AJUk!?lSic5-fhObkP5Q7l!rFy3Y zib0|-jc_+!4iHC{nDvtF2vdt%wFK!n}&yJi@%Z z9jXM?ib`)sKJJWC`%6P9>Y+#gp?p#j!f-4aDi$&-XKnK`a-2tv7!l-e=WXlhnq=3z z#Yg?rNt1@0hzwDcJ}Np0XDP_(DStp7_9yR0Tx|@(=g>KIvtOvkg5oN&Dci13J04$t zIU&NwpJ$ajH3+GOJSZaz@({7bE7SG<%?s;O6j0gKHg)zo{@_kXZdh1u$enU0T!qn6 zdVs5_|GYXCzjFN_v;D#`Wznjwig==OQc2Cqj+&xX)@n=}v7mtE$<0*eW(IHi1j$ zy|yaa&eKk*>{dC9OZW2KtGeTV`Iesx+^gZp1CP>i@0Pmw+fNTDBL8C@la%!84ap8t zu}6TTkLF?%5^k%#voNy?RuDGMl~ZQ#@T{44OjQQ*O2Pg<{Q6gc4MT!5gT5-SQnxw6 zP)^Y_KY-kgsKb?lLOISo^z);9;OFP_c+`+403u|)>zyBh= z?w``rU!I@(r}Sno)6@Typ7k<4?@#G9UZywrQ+oc(^z1*S|54AvKc)XsZp(^vi%xqC zFZ1bDkq+NUmxsShZ~CY7l9%c8{wBTbPw9(Zp1<%<>F>WxUtEz+^^q&uEle}o4ekBs zdVTlux}_D@8TI`mec7MVPrp2W#h=nYd6~YdBHbvzqFuu@qg^Y?r#%@)YM&FD_6>P#LyudCDU$@3-hB`nMNfx)5%sC>`B2|`Jb6l=qT!Q^)!;;rz{g~q42e-4wTE8@6g95=VRpKwKDh|#dsP#rom~RqJ5VJC%OebtHDW5 z3VcF?lbjTIp9ZJ%Ebw6sPW%%1n1Y{kke^V+=Naq{m41?kqWub`J$C+EoWBC^N#K%O zM{-i&x63%B2HGd^6G`ozs6nW-KbgSMp5&zH=ahmIom@^5PI3}_=5h-3La&YC%<6GA zp!RA!R{+iLWvT4?^KhSbcZmW6)m#FWt~-IW~04MA55#vR9k7T`N9rt zNiwABqbpMlyLI|Pd!8eEqop9(pDp+-t>XI?mKONg#Sk^&tLq>?H|8bqc@?*7@ck;@ ztHBQvo+6Rei5i{c`{jO0@)2;aISG62Ik!MPY#tnh|GdBd3b%D>y?IGJ!-r-i8z*oq4AHL`C zCg-xi=LyFyslwSZfNRj-bT@%L0ml|X775w#@%o-nVb!AFRg|5PXDZKYg*>R~wUBv| zQ(u%grPPz)&__7u6b}6f!?fz?T!@i}Wr`4;kSv^Y@I~_daFM(pEEIV_d)#?|@1nP} z@;*t%=T*E{)Bd1>W87cy70Z@s+dg2N&b7KLcPi>*oOpXe0$nA%>kOawl!w1>r&+|NN7=dm14KO$hDzm=*C zVO~q|mebDI^2?)fxf{)=v+W=4D=kNmZ2M66D7^Ie-G+gDlkpc7p`P(OddAa! z;;M|!RA1VMFj;u2b%TFL7vg;N%u^DyGK*_Z{2TkX)KjmD!`uXXs zgEYeIRGja6;wS7`ip=5*riw)yQKN=@%E?+Q*-6i~*7tY{$M$!M@aO(beh+NhTwI4+ z{qp3Y(_Ed_w3$0TBqE?UJzQ!I+hyg(rqoC-mmZ*lOO-Fl^B23Hq-8zV)QBD8RE)7U zRhq5j?GcscQnV=vr}+zfmL=mP{RBRy;Eaf$HX!-|zmoC!WSnap>IWK!`r)Tl z`MF-r&rw}^l8!t^%jt#FfzyiVJyahiv~XI0!nv0fG5L+|E!KU#d3iX%46 z`6v1rQ~Pmf`kANpuv{i_3O;qYvb zXeS#pk6U%f@BwdT8E92;S~G!;ouFY6S4<#m`BUVF(OMI%eODvP(U&7%PwOX>kSVdKK$_J_@6)%k9_lWxAD2P z%68OJJf^{EKZy2S8l3inz4Nlrp;C&jLw28onH8{y1fv?oyq+JERN`oip znbqK*R@>jK!T(3auh-zeR`FXkc%uCcD(;|60=2zjuL^#WJQQ@Up!QfZ!e7ICYvji; zWauEiqsGbbn;7yV6xl9K9KT9VMCi}=7DAS1jUTtiH|JM+z_P9^7FeB{>BjG*e1;PhcUJ|EnYxC9e7 z2w1(Sl27v|2}1VZUHmjI#bX+rcDQKYrNL<@3w%Q0BJ+Xw51$zCAD$W9Dc`{7n%#c#ptE{spHK zkrvK3{0e?z-Pqr`)D!qDRXtX5qDSCk3XZ!D@;Fp{o+_YFd)#r7@q>gzv*W+yLBy>D zmJ*j&iVs(l<3|_y?W3bhGyRSnVYS@`R-noEdtdOb>6;?7G z7uO9e^daqP-PGmnty9v?AM&l67~2v!bmrp5O7zi)Q+WVd#!eAf?njH1C+C?c_G<*0 z4s7bWkLuVHrpbZ80N(jrGCFh3(9rQUEu-o2-x>$ZPd-#oxO%0hV4FKIdTQ^UyT>jc zSur(?^D%}oc3}*WNhoVF?x}VeDZJfi<$8>UVjr`IvddEQGxJ^fv50f9aPd~}nqcR) z)@|mr^sDuT@-@?!%vJg0Ez-^X{;IAMS3dPPad-^5V_D#YPL6V0aMxEfCLKhhacuFh zvofcc-elTOmXFcx{tKx_cyoM}(p6`#Z(0|j735hrx+qo_CN6eJRMtaF@%Cg~dP8mB zsliE>ihjB^_%~F1T!WLW7VYU9A}T%7f2#OkGOp9B_*62E?_VhW%&547vWZlDUiM{+ zdC?b!#kiAd`?sY^U8RcOCh&8AL!|ddN%$Q}?Q?M(Q2JR+;Ar2k;&-Yz=u!FRGjYCw z-&(r%t3P*_`g1gX0>4LtlWr9AxL1SssrbiLoW@u9>N9b^D&u26Rhk}eH>xY3_ zN1!}4T;lfkR!(S=wK5y*`MIh3?(AIFtR`*AR8o!@rxH7!d$+_dTKaetUZa8%RK8Oa zbLB|`=O>R@%FJ20Trq)2r%I%|Ip@4&vh)&*r6&J;8u^LBH?ZkMF|jiI5u&ecSw%R+04>hd#mY$)b@>#>STSI|rxUz1%X(&PN@ z3SZ~$-u&{kP%uAdW09lURYl2iPPdh(%YCJ`xq;3mp8=m4Bq#PxJG+;kS&qZs9=K zTL-ruv7@;!|HAovjNF>bC-t#b@^&tp+Q&`{52(l?Bx<0ah&xo@4zu!cEl=DL_&lvA z$4Sd4@PmAvc`l;VsjRQOpJn2aB~QM3E0i4x5!$_K($OE<*9YaS#5?a??Cm|2rZ4PK z^N!4|I|l~lTTAUDLj%LZU52x-))YFCo_Mb%t0fX`SGfN=UzIXWf0UWkRY}I%lX1RU zqJ5_Zr_~bpj0Pv2CGc?#PC85Ay&9ZktH1{}IPqEFqbly8{7My{S8?QBBKoehpG?Lj z(ygMO?gTECbKNBH+XQ~@kJz=ZC*gM_wNK|dOSE51;Al@eOW=2^IOtUQMmh_8<~oCZ zr99C9%_F5IrBTrtBHv8*%n7%ZoG_XkoK#Xrcht0b^3pO57sw#3sk0R3!lUEJi~fM8 zl0sW}T5=?|G^5l}<3?bS>k27_QcDwbRi@OdbSd6K4Nclhib9pGCRS0fYVXpV)|QyR zTu}~(6_Q-7d6sK;Xi{7&veE(tvS#2Ead`nwamm@iImmY=qWV13R<{SY4)UVmebywYb(eotcqzKE8ao0Y3mxkvIwaAQWpC6X%w@73UR?gid0 z<6U(4bPK@YufuulLf!_gS{H7O$QxGidFJPNfyd@w~W6AyaQk7Q}A&a|1)rkg(-Nirhlx3+|P`_ zNn)hDNitZ*CuKZ>Xe-aBrn@uAK*GVM%5#B~cThLbo_Muze!x}7O;p@fao=04iP@ay z%0+)!ktNNN=E!SBQggir|3bo*23+avOgtyM4xVZ*({vftE>~5R3niGa!Wd0(OWMY% zP`LFvX4y#Ay-f8Wr;g|8;;W8lU}?~88HwZ6IaUK%;H-yp_s6`SPRau3JIEp-3miMh z%kf023rmLDEqujTO98^GKP|q%^lqO(PW%U^)Pvq5x$N+ zEsvnT2HzK>=xz{}AM)7Mk#|vTV=-e5J}>Zd@4!a#>Nd*lDe6m<(tZ#>mkp0dB&olH zgyT&8H+1qv7$2DoWaWv#uayG7YRb;9Mq6VIdl!f5uSm+CKkQl8#=>t^t?T@zl0;9` z!FVj?@|jL7S>i7UstM}|=!`BTN`6jSm@d~$nkA&T~sNq9B4_ym5N!1)c#l!V`r)IOhEf};Im5^my_ zpuq1`anPgkhb%$5zmWF=zhWI=$KdZt|BIg+_3EKS4+x4|gI{hg-OYs*!pEnwRyee8Y+l04SzNYGE`D_zjc=(4$WBIPE=i$O43h@0_ zSjC={hLmQFMfWb(`{byuM}An9(J|lAxw9iYS>l+g?OsjS`q#y^zR|m(rgqxtoCsA0 z0+s&ST4oa0dcmDQWo1BfpAS@4*Ql>hUZmSRjvuXA7+=@;pfP88b%FthH_e1NMSsxc zKkwyBeyYF9;z+N{ZY*gV%64svY2LxeJ0{Ha{*AKaL4@l9c$8{zt2pUp(S9-+=k|cW zyHoxow|CI&)b{h#p3geK-}U@o#GD=Q8x`Qysd((OdBp3$bs&#!C}r}qA87Eh*1WXt z-9y+O*rQtymu6Sn6faS``VNTC2XyfH-zLvroFx7}h)c$5j;oRpO5`+`Xo!`Qq^3H} z=*I^Ag_k%uc>>>+@?nlc!m_8iWD)p`+@A2;c>e+)mGKbtT5>=0az7j=`-%7OGxbt? zI{D)7ao6nC{M*1QvGYpL*Qf^3w%b!E%Z`I#doLt zOm45mcd0mD7V-Isen!=P9GZUSsXbXLI0bXkKgu^)c+inQ8x93@;Si6{L3AX-&M@ET z7VFT#j5D%FY6dDA8v;e86`@dtwbWYf3&K z3p`cg_RrGyO56kjhxzL#Zn@O)6&X!=BIRa|Tj*63I4tnpGRG{~S;_b=6?bUxc@^(v zN0RzEDDZQC#*FsxSd-{~oW=#`mDgAzM#ATrG}rTAW%Y`8S(=ww`Rnzo_~WeU&AIaP zEX;_%&f*2)tW^llB+*xCW=QBJf@fPTEo6gUL9L zX$X8OflF!JS`hej0%z~=GcE9$WE|fgSNi{m2ESCruhZc3Dt?OwPqhEAiaY4Fo7!H{ z`hw5ouM+f3QhTf{aA_lIyW`eMwyqKvMy4Y_QxvA6e5NH&2#*crp+)3aN%CwpQ4;o8P0U`NnTh9sjq2;b2A`}#T6r*}EN#G3 zqkSuwxCzSR@^^=zLd0kyE*IhhnnBMGeX#864p$zNm^v;PTB=-r-#R2btUAT)~SZo#E##$nhwmDc;VKaNfUY->Jc=e}Ru{aPm?Kd{o7~QZC>30-wh>KU39lY2OQc zQf-e{jr_JC@NOB$U7CHK(;pUmlWi1^^Ybk584XVK2z*q*p`-DxQN`!!`!Jx#3mu${ zA0!;Qgkqg>)MC#f$sQ588KIDmN6PTHO>s2ZVgHhOOy@F@O!;(sWmTBj8j7l$H^=5X zqjRCsW@}OFt=|3lwS}GT{_@+rm$#0#?D$Pd+ZIQjK6Y4bL^Kgus;XhNjUm3Q-qyQ`1ENrO+Q@p1l;o=PO}XW?aIFQ*!J zQJ|c;#EW|6iTL-L)*?L0_$&I?aQ<_+gj2`iarX*mWp)wX@0QvZlREmJcpm*MRx1ME z%O9mz;fdg$q!bTDXu+E(R_otEJFkC1r#nF{PyUo2_UL#{DEZ%*b;l)_XXr~O8afBA zJ*lgyD{HF=)o#CfqOHVMSZy^gEHGPpxqqy6a;wGZE6m?q;$hZ|no_&1xDex+A*JmY zuaxdd7>`Hi$g}4X1veB{fm8f;BA3GGclJ8)P&_kTXE5ZN%WSj7y3*0AwHLK+TJ39^ zrRV8phqHmLx93W`^{L$@UcORlT@0o2#y6FFhZt5)>*Ib!6aNF*Sn{~MtQG6)Z^gL& zwZl{nqPW20(@Utvv36b*;}DU-&JcS$x~Ub_A=0aZJ%x_z_QDXqyld!))&2q^gYph+ z;#DVLZrLq7gIK5|Q96sFt@0y$6rqxHN|1VJvvmb8nrOMGtD&xaV)t7AtjoBsrm3a1 zC}XzLy|$?Bv~<(J-Y(x{EV{y?PhUN}d7;2Lh0PhR4>oPyUR8Z_{HNvl7{B7&bG*t) zDxxMS9+yejo5XQ7p*it<$4=2SFv9IZ;Fk^51|8ng^50Ho6lRu`i<%*wxgJYPuW4ju zOYer2gU#ud&xCD7ma=!s9g%|awavVO$m;B@$zG>ztiQEuZD~Htes(U7Dgkd{{}#eL zTDDe<^w3A|t4%`~J~c0`_U^kM`FJ$Vf>+~~^yq069`9HvYOveuix=Ym5WoAL;>N}_S7s>QKl#U-)eyH z<*GjaYPmE4>X$rww$|g)>vkDaS$(2J0jGnkkZ(fL;3kzESru!JH&Vj5C&kT+%5b>| zcbpnuWv-Eqg0jq_^z@QK$c77*PnoKptTPyP>D->Pf6$gu;cNXi`xQzks%&h+?Cm?89`fwt|j&V9Fk&KRhusWUr@)|59krFrst zcXo8_Uoo{m#-ep~P!A#s8|3F0R)IQCJJotn*-X0{)5Lj73d6473(-2*fhuLB- zv(`qk^o0XDmDeJa+};p>m^&Z!sjjjjf1*|lq#4~Oe-E8x)Scr}46Co(2|s5rC6+{u z&5l*KmRn7{m;?5+nyVy22|m10;&w|Um>}?t)$`43gWd)duVK5cZtE&+`%r7=n(}UY zgVP(Vbm!YF_1C_Hbt&x7`^r#yy{xWU&|7LBef&G)UrBFSQ9HCf9CWVBFK-UkG#E?+ z--sV?D6F`=_FI;pZg35jQeT;iAmui#nr$Dqr(lcxI@nu(sIg?(3cQdBT2ss5n|XWp?)l zgV79qW~(C@bJT9k$|||*1f!hdr#$}t zX~Rr?v^Z?540(Jjd-}@DiUWmBxv9kHOJ z6&0_d!Cxn;w~|Y!^VEQnk&>VU%fo7iE*~7eybq~!G*-v97FTt-JK$({ z6*aWR%q`ZP#DU4=Vdf6*mu+*}opukl%>JkhVso zE?G00Ou`IHDm#^Idl`}{P$k)1D|q1d7tF#VzhC7ekWG#egf$@VfFzszf`eYjJU@T< z#MxlEV!-oA`K4@i(>#r&r}XLk&IHSyjgy~H+77)%IZ)5wS4PsA&4R1AN%H!f^vDAD zl+fTHa}N$Ku=It6_@5WVyifrPEwhE5Q(12}F0FvYW@PM}%?#S#*EHV2=S$rJU@%Mjm{(=I7z58ErFC9x$lrMf8UYi}D?# z=E*qc?9nw?sdt5!y%OKeifH0MzARW?y2ExCXiztpmGzf9bnlXO$! z5}FVM7`nEk0wZUc3i6aovnWRA92DcZJnVn9Rn?xrP7{2^C8%pI^_&-GL*sc#>mQk9n)2H6} z;e#2)8G1u1JM#luo2_<(=f-%B@;lIM$H{yeG~@gwaYnXWxs;?!n(=x6Y()#4BXyl^ zfkIbqf9c9l*Y57_z2@SwP1#lo{$%ECv^(1NPa_ycVwc{_SE64x`DbLULDrUB@KUTa zR5xAr6AFa3G%1kMwL8OMTdln$w<clmTP!fOTod`1a?(Uq*{m0xDGuhwDHn;9yHM7v#x-he9e=ECp^XB-gJ9lE9 zWN*EQc^1eo`&9j_Je`FUq033cLQ|#5*_gZY!0_;a&e8@CEBVe-PyHnRGgrt)SM-v` zva&{V^TO)c{ZW1D&e_dd=S_zFMwV+VjfI*ziZGrZ<9!X|<&$RvD<&I3;wCL$giu5n z$-OOU7P-C_H^H{j#cP}`|xZ0 z7dpH$A)Udk$=u|9sS3ZSwHS_qf}GO!d#@u=CTzglOgY-G-O4(jpYfQTMI^#|?Y zzt8rlJ+!gU;G8LzRT$nn{i^oDF_B5*J-kUXVzd>VwI;jQu&O65)@N#=wDOS6~ba$qUsa5&O~7UbSw} z)0}Iw7TPSHLRQ~A7fCf9GU}~$l_=MZ9Y@xM0bIzU7l4pAay51o$x|vSyWL&iZkdbJ zx(_|HHstqt$NcP!V=&g(oBx%E9%6cLXzN^=@|N!3X~#zqc_q=!C?wf@4%)u}S7wSk@}2tiD`@MOrj*g@E9F zw!Obp-#t_AabCFG;#PA71W+NooJColmm_PNZkJrS?^1nHZ>)!pX5Ns7Jx94`Bd`Y% zJ4lkITAo|!0aL<}>Y5rT@!&R+mFg}@*nE-{S&Mbxj@q+lWzQ_#u=b&M2(+Z$`3+06 zs#YtHFlw(`$>S`!dA*t#>!;!lMiI<5iiDDmA?Zy;Y(+iE#C|+KNc-}R8Fq}vEaS)2 zijoxfZIh0J?pj(i^rqMW@_ACjz_KipcRuG6ztK?*OST>k?IM&(blCNUy)~1A6fr$P zlY+9AnRlebYduk-7zM$S@||;EA!;m%`Eg#du)XA;p9;ezAoSEhu1nz~p_wWdRtPuZ zBDb}C!r2sdGlZFNb3KK-E05zvc_GYGsDatD&UicixA`2RcR#0Mn6qSMxgf2r=$)jd zxVb7S5c>eVUvMY>6lga$xfC`NZ!E^2D!09fRu`DO6J&fwZX7Y(pQu=#7mW0o0$4L==*1w3GP2jtAc;xllN9r zK~U5cue|56Q*7-{XQZqw;@la36jjv`uR^!@N$`TNc(^>QROu9(2oi{WCE8nhE`=BE z{EjgCvfM8>PFCCG3Z6$4;Vq%Rrz8vdODBJh2gXt`$Q8TJ$Gz%4xp&xr;z|qIf+&aE z$nMK=WE(S0&e9tvbw+(~YaNE${{3u6cF|y@zQs4)ZOky5o-VH_$|;V=v$IRHB%NL! zp6OWkcymMbP<1(GWPl`i4iso*n(EucB~ljNuMTW=cNeyMsw1Hqa$w&5Eq3LdE7x>f z^m2XqI-66u0WFNbY}_#p=Oy}1Sc$SkBR3EC@L)M9j!sgWO1_EP&aT2xc2#bPy%sj$ z&b4cH!A7hqEXk|PZY=Vw2{x`all^DQtJ~%u->_lazpXCMMwVekbv0ZUjB28?&tS)J zyIBo2fsx7)F|NA_$>JCDlw`TgsR`_7}ZZ8LpYL!mU$`-RBmojnWA38_x$mKyc7FmZ1?a&qCg_J0)`M`B!&G={WF#h-Idc%tKhM)Xo^NP){ zLi(b5N6KsXSp(4+q0}FpiQBYd`A2PrQwCeTw-NW7#>W3-!G|A?fA^1JpFbS-`@*p5 z>tQAS9em4x6j1XO#LvPZMgCS{<-qEWB=yT|+m9aIPX89vl$6vIi2oLOy+!!f{Oohj zefG1@J@@Rvnw=Zg&)3$@uivn<<~dJsQAI^ju?I6IIrlV1MYyS)*J>jl=TZ%P9sn-} zJN5Wtcei(CPg#?t*j$uf=z4r<3HbPNDg$qCoDOD~b{mZ4ZhMioBs}607+<%CqOA12 zU1Wn+NOy3*PXRm!4}mTP_YqF?LO-pa)$94#x}RfgSdkxqSIZFe9{jrif$z@F^9U9`vekHdegPj@#n8}UcJlDy z_%2?z47W(+@1s701j!7aR%^;Dd=<#cwvdF!WSq{)KB`A0x9^g1TB4gNhfKzMWSmaz zA*#J6;}bHD`!t)QN;xv#C*w5kYN|*h<6|-|#!d7;t7(6k+J1$CBNGE0Q1N*ghYiE# zsWP72|1jZL;Seh65y#57RZ4p+0&lbYy%N|q$_89!&gmI{d3T z__$8_s9ZRpSpKR`;yXLMi~x^8azB5rd`Af2OEwjc+V9r?H8|=N$aH;N#c6zn zuT-ZXX?$oyA2RPOu;V>)6;ytf$nGY2%=HW3b;$=x8}WJqB?`(i&DeJp>-Qqq#RK3C zUbQxX8vB+r@*0cW{3{5wD-+3hEVd-qAi(#(n=&C`(}EH;okKkZCJ|_AB3n&MQyEnx zShG{12B)IjLCNL0R4u`oOp&`OS9jAve7S|lskfKAtCZ~W1VQ|?fF6D-Ci!V;XV7aL z5A)q-WN#+^Ozr5;kQ?o+EP>N5knv6pj@6OzK@E;*ij0pE4%-7!O04D|Ar)#+^?*2| zIEEs8#T3_^$X3vDMf;8uuP@|C<-5iMH>*!K&kI$a9~orNE`6aX=%rVIf*&)Q`9TWE zc&`Shyiw7&fhJ< z@ApS&uyV=<=k4ho+fCTJ^1DX-{hyFiSen$|LFy0p12XHkVrCgcC$C;gkJv2h+Uj>L z_Sav=Z0>bYyjSzD>xe&0JC5wYR@gT33u9>=qo@}X? zFON?ZnT@<6vrg3SMD~CVnV9gZ=E1+BOJoi3Xa)~f6KCLsdHdxp7x*+D%Q&>E&nm*VVH0+4#B|)h%N6 z;_GVh@5^(c+O|e`Ta~#{tym*@A}}LLx4~xx@jH{?pQs(~!6iJ*ei*-$4aJYJFC9M2 zat|GfA3ls8dG%P-H8j#b600K(qX!f1abb~8K(UK?^*=pzWu`of1Y;i`Umcw9x}1E_{*lD>AN4e{#e@2sVC?h z>0azI+`w?xl_Mgm9|3pia`g&NLs)C}SZhj$uUoU`x?xAPZBP79!N#%P-mzftpx)NE zwrSms?G+tYN1uujN9bPy}uQR+Do{E+nD~ za$OF^f#l{%g8a=S*5u8#yJK5hss;-5Id^0_ti{%xoZQSBM<|x13x>-hlF1o$wbYt# zykhD=M<6`ga{MxHVZL#fDcw^$b$Lh4%xur_Amssdo}0jHyl-N6Bd~sftXs0)5Ph)c ziTp1f0p*DfMiVSKo2?VMw~-HZR9Ci4w63rB&v6jzj?>}X$ug%pDr=Q3uuIj8$H9TXjsCCs{bH_)T%gT2i zAGrIo9eej48VPL2C6_7!2!D%CR;4d6)g=zcFXQuTd~DcU0l{R z(bT`A^*@?gn%k>mMVY;H0x9o_BB8Y^C5g;Fas_koPX0uzoaILmQU3V|WDs_QYCPVy z&CNAyk@?l;uIkUJuvlyIeHEc#L2GGC{c5wTw=vjk)|=ZY!>_ntLpd`1bZlOdD9bO$ zHs`W~hRSn&>?^Psppy*nBhpOe*l3nyf}C_`;XR!Vo^bdw*>wxnLSIW@I^4dq-#Rks zWc!Q_t9yK*o{`Ry7rdpuynJhBdi|!U)}vR2nWWS0-|v|H&_-{2Q&x6Mv~VrWx#`>l z`?FY8gYt$?BvRl5R^oeeRtwvV9aL0aR9&#BHyuab|8Quc`7S`;Miq-8+eC|(@kDqa9Eikze9JS)b6)h+7Zn4@v zewwUZ`qUE!z239xLs7H0x3aK%u~|K#0mWv;a!+T@#lX4UL{_e0WCtgnyT-I4Qlv8hgpA#nGB@i?vkj zbDKG%YAjYg6gKNE1GPoL9Huku-(OehYStMS4EmOU%afm$Ue@5FT`wfO0dsus?-k~E z?CI&*(;;#<>Z+^j0@c;aeXo$E_*Ii4l+Etbb6CMIwPg8?2@E~ zob<)q++D*`>Kx>!-Db+muw>Pl=f<*!*H^Co)|RxbuT1SR?yuf5VlK?rv0cVA(BF>z zK==Aw@iItwiYcomSu+rKp$*z&yLxsVYTXcW_7v+3(xfLmQyI3`KEv+YaCysh2k}|N z0%yyv2rKO$y?(gkqJkWmKMj~iKKbAjsoV$y&Fd33m6Gwx7m#Il?(FQ_8)L=(nAhWv zZtwEf)>Qff0dwmmQ!|%Dt}qyw-a6AfaB=g&?)KK{j=|0W+9gFe#XrO-Ho+~KMaj+x z78!3pG0gUy(n(fJR#8q~K?^wZ%iYF>&MRsP^Y!{YCYZ}Cr5Zg^J;nsJBsl3z=gV#c z`ZL1%BsKNJbJcpi=~jF#xYFsb%FeyjVm)AP+7xpSRX~mv#@x;wL44D1w0?Jwv+b(b z)ptOqRe$x7O1haz85BZ^s(7j)mXY#o} zara9v-Larwea{oG+;K?%l9|o0NZF77DgMRy^JTvye$;^4uYy|G**v!gH7_$beuaCB7<=&c`|KV$%c|_{l*S%BlCBL~T&)MvjGSB`|8l4Ml zYIjC1X}$R_=@A-}@`%0)YN5xKJ1S%;lohWu>uXfj6|^WzLAw>ssNb%c&v)By^7x$2 zE|cM2gUR72ahZz?KkTYfvWv_&9IITn+8t`!6AN|Cxtgl0n_QvF!CrrNkFTNWim{_N zQ=$>5s5sZpv*hUnD*6-8(|JS=LS0B4@s&(Ax8GV)=ydf)%6l*A4h$Ffm%AI9L-ln{ zcE|JH((1edN4nY7JQJJT*GgX&9&BrGvgA}WRu)0V>0qV3E!E;Jd?D;p+@cIF=xKQ% z+GZv>lS#c9n)36>kkJ`Uik@X1_3MVJ>pwIKW33?^=<|2&>aT36Z_2Byc88{z)ZJI; zFRiXJm$Yr^`(^*0F7HG~aLleZ21n}#Hq>5S7x1@cW*N=KP^_5Ec-%R@`r<0@Fv+)| ztbuuq7hid>;Y$Iq4e^Xo-3g>Znj%IuZU>UTSUfzaFRd$UNYT;9JaCoSH86I zik_m-+U6^d?~_<*Q>kfaMLEN#yI-B3atG}hVdM&yty`1f?ink?XUCs^^7&`E1flby zhYbMho#p2vn2e#pex114kj^4QM&=ksM`+g0KFh)#uF}HNYX+|zj@Dl3@ixbTtC}|N z8@arX8JN9#ZLM)&bTAe=d+u>v?7(!_EumF`wTj8+d&+^O~zq+qdm7YI8Ko-^h33xU<%l@r#Z?!M~EO9Q`(aLT!%lm@;`%C=>+EKlX#^n^!6CXY5rPVaNxJVlkdQf ztF3{81ILtJbzx5VF4oT}PjDb4<9$mwP%Y!2Q^SEO83(<<1qX=gcR1C1cs;CSsf}3mf@+|k`Wlt$d--U4&ZGLHGQO1RJ7jz*)wj!dGSyf&T?p&ullG+ilFvq6xh1$* zJIsG0Wr1EO7?NyRdKREG3VOFPWuOBI8SDDHY53l3B7Z z^I7g&I?E-vnB|`^OZxhk5x#3W?+kyY@0=M^t{3zo!fuAw$0uEuax3SWyhEW~{uj7c zy6iCR44DT&-vCuW`PqXIWC`2;P=aTImhJ_ z3j979FXOn_pLBX&oG` zThK@N%`$#4iHyF9#=jD)PyhC8aZDCPH}F5_Yn?H6LunEf#i7$ zy>CxbviW3uep#o}rVLAQMYUya#M|*6zNe?6Mw>0SKsPM4)KrFwi7uMA{e#iP)w!Zu zKe;Sf-}ZKRN9HWmo)XJ@k))$Jgz9oRSc|>nndhUQ@h+;7%1`_!{wLEIZq@9BpTxH)rKL zJ(khIil)!KH<`~yQ}c6ki}l00mU?$XsSZ?wN?OJ5eo!hk>&S`RxEzsjRuZWg9JP3y z*32XCgGgg(gS);(H>@wt&CO4Z5Ro!3zy3j~)bcWd&=#9nj?A+!CwO^z>5=yLSn?&j z%z7>NM=~#8{-9K9c}YaBT8_wgMuL~!?}JD!FTF%0s3f`61-ayb6*BaGB??@hu#RQ- zkm6Y6o&)vf`om?(e#P!8&PvaeI!%=o1?9z=8D<@Rdnhd{2XCIHy87Oo*-~>(PDV~z zV?~a|oRb!EKT;>9nbOQstFGMX@h+Xw;Q!lU)SJhZ!mLuz9HkkQQ8|aIQ~r; zN5n_)!60$`pJe=JYJ0}qrZ5cnZ1piI~$6c}p^ZIYD2O;I(S0sp> zZ>oEgyXcpVmPs3?VkaeouE1){HNjQ-?pw>eZj>1UTh^A#o@99!T*)o9H)X1E(E^TL zqrZsb&|9>dbR8Tg?m9VF=r%++><`i2;NbWf8UM4|-pX;@&v^UasqOPQ{s$SyIOvDC zdxCTKk1`HZQG-Jx@qRci-;~|3if$y_v=KMubXcn525nLCxx@_`UzeiXGSNP|4BoE6 z6E|@LcZr_!Zs-p#gFn0s{(uIjEJ8ugqs!nACF4A2O0*x;;FP;2@Lmm0IdB5+PT(3( z!bWCL<^`Pib_?g{oCYVp2|SvFzs&h5@b)A;*;DbzGWdhb;14f@KOo~vJsukwmr^1k z#Oc3Hrau`c`UM`P-sot2}zFWpA zTIJEv{udsVjqs?@{-?;Khx0+;bF$~>TO6nP3S6zt4xHC!mvN=q_&0ca;)7`ah}_RV zF2Ns^acWACmFc(Vka7m+9fv&mkYE@}!4q zm&&+O`~BO%c@23PS8B*F^+eq$_rDaEJydux{Wc5h2Gss<676M=8S(!vS^gBL^2fmW zFYq~5pqwv=YvRAaqs!p!8l3nq+CQ=k{@^nB!^_|g$T*M1@cna-On)_}U*IGM1U|>A zIsL*DNb~)e+}_073!K^ud`|WR3Y_LE@aQsly9THEiuR8zgFmT#CbI+&1Z(IhKJ)g2il=howf0Nwa4{ym=`Fedq z=KlfCe}NPK1wO~P$5q5N_EUP z{U`LM7EkC+;G93Ay`ne$*e8hhsr&F6w!q~Z$2oss)pL7NIbUec_m996=ZkRKKLSsj zFD;%pUxX8XMEk_~(&CBprNtBHON%GY7vaPo(SPE6Y4ODQ0?zp(+Vk@TKW`Y{k)aF| zAMPv{$}mAb1^g#Sd|y7%#8zLE^Q32>xTK?e*@P4KV0(Usf40(lQMa6UB1hjSzafM2 zRXuW=7{%M<=v$MVSBSJNJIj~T8f^9B0g>vkc>wS8w@&tUjgEHpPR8H-OjghK9#N$I zLq~13GqrGHLr(Ou?<0C&mK144S{Nd&jaDz}?DFs)ujat6TCf)s1VS6z`wtF1S~2a4 z^hSHa%{B*eH~cx4qKvH8i$*C=KHB=PNVFYBou)8oq24%ey}{>`Jetwh_jb0hdl_KTFkE8@OL5JSQ>&r_yfeGywO1^@qiTUwef zba6}D-;Fuxu`7Gq{x)#Q#!r7X<5^2^ZLP~$5H35kudvQu((XC5ciZT$%i^DSwP7o) z!N9o~OJV56E*R*K*oisfj1jM+JL{|ec4)46W~xrPwV%eR@IeIg}S zMCY}a;~!|Rz)LT=bX$8-YGwyHMH`8t3nI^=%|4w4qt3}~K8AUW@}FvxktH)jKhshz zdy=<#L~gSZ$P*lSP)4ZDa~yeCM&^Kgfg=yd$dytf`>bvfqd$5c@{o+ImfG=M&tIdB z@@ZgC5)Oa1)TK|W z_*BZL<)72IC9h66G}ctF+ZdER)*ZcW+=JQ~h((Y>1sbv;G$^lDq*8iB}ttXqcmJ3;b(P zMvY67EA5ov)EH5ce)vkGgo6%cQ!Q?t^kO8(ZPVwZ7G^jyo2SbLqBpk;l&^|pm8KV@ zW*clSOY=+j-Pm(;&y8&6h{wis*BSJ|-cu~)T*|55px$tuj@dj%;-6yH_?z@EXu+#< zyimBCa$Vqhgsve6K4&j#BFl*{(Dot+R;kU&@;VQP#yykO!Om#gY)@-_W2LvVY+L zK9WZ-W$~iUZPJaM$TH|VpZM4vS$g~;-TWWXqoLba-`V~D89m%|Yy1UyWoLCGuy(jF z*)+mt@TyAmybm+NoqJhkjx7$Z@=ipu9O(sV;F~kQ|FwHRJt*A_KJ_$mJ{7pHj6WuL zME(V-5409?4J7lZ35$fifn~v_$&$K@TsbJCnQAF6&dzsP*x{20%>`y_c-X8rUdwd( z-TmvnxPhLP^d0TmeFc5Rk2tXOeT({b2Y;`5ts=L_YZdI=gItTz&#Ny};FHw-?CT0<#>Xk^&gAz9y?<=XyDoV5F1uSX*^0Co~zOnD*Hys`U>~;TdT1TJ-fj@c8|{TZvtL;>6WgA3bs6X#9T%J30p0twS9h zL*~_~R~hRjI4ls@?$uD>3x+_V()IzcCEZJYYj<-mQ$uC)SJ|8^+-j z|F@c?l=#@~AWf#qPA#l#T6e zy`#Ck)R%pdrRsHo`Pq+uyr)U8W7%i-lMMo1BQ7Rh{U{^i*Rk6lzU86YmC-OFRhR&M zI+7?k^8QoWlMoR<0FjaOGh9kQCeV64i=UHgOSzF{th}Ld3z_G3|MbgjWq<#H!QO>| zk?v^wp_r6&`1e(-2gWy49;gY@$`GZg=*5iriW6{tFN{cE>>6I^?%O{U3rD*Sh1s2p z(#^lGTskBJJrVkDEloV`m zHICf1f8pc+QHS`-4d^?AZ29Ed0_|{;nRp(UC}|>H+&wVR-8C@Kb=k?ReT$2iT23si zJ~l9LY+?VFEqnHC*|PuYQ`g7ONH_oOsv9Rx?bvb86qbZuu_4b!UYGK)+vFB6R&iUf z=vB*H*rG0Pya%3dh{^pyE**1)hulC?o+otEd^_#^BW^bVyf-z-- za)R+^dUdpgY_pU?aOxCB!dwi*DK3CX*OQc?q;qRxF6^h3#rDiBTh-pea?8p4CFy&1 zzpH&mcf*W1eV@S>*s9mX{~f#I@KL?KdgJ)enkq#Ty+QRDbf3qFc$X@^1j=O%WCyL0 znlqiKA%x3|QZbCpP2aR)<;~NbTg%4_Bks0US^xIy^p!pSj)L(i^T5f4{dbKtx;EHL zdxj3J8m=f@zllaA9h1%_6ZVkc1MUg*5-pwnd=9a=#Oo{UVQ?I8RtK@FcDfbu+)r5p zHS#y4LL2F@jfacf?&4yX>ni+PT;g(lc2RoK?{N5&|2^AF5Be}JD(tY&@o^zju(HT- zz}Ho8M$UUxl1zkHC&`{THj0S%i1lz!6uNVYjhSgV#U&le#mSyTboAM z3stS1hpLhVzkDPh{f~7*`X8gS%B>vTQH-BYbz?{iB0{Jc)}jnKw)m5i$|yf!JF9SH ztFTS~Cily7;%E5z#~#AONc!OJB-fn~o5pHYKj(Uy@0M!Yu4A_cTk%~1d6!&#+4Ijg z1!>#JYb5Q{U5}meg1l2^`8QEW>&$ZeiyUtytqb0A?LrkjbT4y!gyH@!q#=J}362|3 zMny<#w~1wZY4InEPiLsIdjE^GB1HWNV#fUS4bjgzf{njK>cIND{ouiqKlzD&V4<)3 zl7ZTamS{_?jQ#q+rcDPnx+c2E*ZVH@2dh2UO|l-viwWD3{Y}Zv$P_l3P^q6D>f;O6 zY-aYl(b4N>#WUFGXa;j-jI_3orpJGlK4u=jXV2a{M@H}5v*(_1sd{91cqBePGc`4X z+5Z)kZ^jA23>tNfdGJoq(-W;Br~4A#yJi(+>&g2PXbO*DSwLg`TE2#CiEwrN`fqZhkWUQ@m0|t0IifuyK53l#nfXs-`ohF-oY z^IHFVTB1LLfvmAbd$}!IjnjzNHnOgG4`bA1`f0;@|MJG@rm>6{Kc8CZS=Jt%++x92 z=6YMYRmi{#*^LPnoNqkduru>`<$3nwzsR#g-?XpKpl_@bKb03Y@xT2!HZVBtomB04 zZkIkafCG9V!&IjEVXV8bHAsGl%L=K!vBlRe; zx9)?u@5%on*VKQYr`>BUpGVJ&qfHghz z@yGXFY(Co7WwvKgt-5wL!P*bmLQZGMb}0Vhx3Nyw6*W2>jYZdCj5dsMImW=6UC>?> zw?VDZIAK1z)NbTv9Y4QP3wo2SCs~0fgz?y-us&~Zj+OXwq?>;S>+|B(r%(5WA&Gx{ z0c{B>nWEdmCGB&vr2Q<{qWlic@n>WlT*7Mn0qqO9)*Dx}FRTud)L)R>m2tcgTKWKK zX$DW7(9)3f5wyecfUVFcOL{I(sTPy&%N(BoPVLqEiVpf*)d%YH(?n)*VIs1 z9q<)4+2=1i8d$l|SKDYW$?Gf+4b~PIibnjQ*-+a&%UiW_DApY*E;nVy|M^pQ@4J1v zD$SUgel*)+FlCiCH#nS)kev%1U>>jLfp4GSwo^nN7@RS=?yLNw%&p^=0hAK{MqHk| zUTt0*_SMuyn&(<3qn(@}kn7AWx$nOD!12I5 zO07HdBc^=~;~7qGY5 zei+ZW$-6#aYq$Oo{KWd6{;~Waxa*1bo zj;x}`9=J5b&OEoS(@$K=Eo+Vco%6^D9-S5RCU`_AL2*VHja?QLF+nlW{8B@Db{gBW zco94k<|(t$09S;|KfE{`&tXR*dm}5?(5e%4E77Ca+jhlv5gdR!hFBn~h$1Lr?fPUa z-IA(L%T0?uc8wub(x(?^L?>I*OEUC26Bzj^N2I7I;`mfN=j!5#$&&Knvit8Z8+DdU zP846Qj7I#1MM&C;bQbmys{Y@`ne{Y$I9c^4Xwyo9O36wT>?K z;vzmBK$F1H86sQIE6lLyy0~t9Gj0-b(s1!d(dR<1hZXqa@jb4|v(FD4h=L1)CL`w4CGt?OTWn z7Srd|7tUVXJX`Om$<^Oxu!W18SJXzbYwYes+iqXGm>4BomF!B>Jf7; zuSu0{9z1z+G&47L$rv8J77t#{Dr|3Mq|&F2@bop!{FXVDW(%J_dz;gacsOwk@?roS zTk0ucFT}53Jh3>Q_2iRa9*QiII6(V){LsInJ#;eS-lq>$-G^0iPO22j+sSb@3-&70dQN+%{tS?eM@ljsMW!aPJxr+<_C z5<7ZHPhE?VcA}*{(l9=b87H~`7wCJ)^vwb6&!p3QA@|4&)oWZ$xs62)9cfQ~Dm_|@ zck&%Y({*^`+DV@tn90hkZ`s^fot-sZdMM$ynk+(J)ASLxHy`7XjRAd_V(>Nm2X-f+_B+BcINZzwuw)(V}xuhJk6i`t+;>93a!*>bf)A~B%=|&b%Pw(q^ zXJe#%1l+OCqMon~XHny;CVk7@$!Ob~XoDamVMWQF~EI|mN*1qMqx^85ugZ5fY0k=EHz+>jgg z%$TG5XJ!|o4*S~doZ5!Xk)SQ{%b|WGmBEI-%;xKmy+*9{Cf85w8JqDD!Hfp zl8=jg6s$Njkn{#tyo@pgl6EcWpu|}eZWE&sJ`6ffq>~B_nWn6=)XX$J+v*qR^M#fA zjI8V_wzYg>qFg8_M@{eIqNE#1HL>X)GmPMui zTUZ%&O^QYC^QD(a3SeGmX)>sNhA)J2{&FqN*BSbO)>+QKA>7Duq1B_L)v+F2S8#0) z8OF6ebefQCk_0;OCp)~5pcMaioBq{s_u{>acUn}bo1@CJ5MI2?v%0X)gE)Bav15y$ z{A5t@Em%D>U0Fw(-_-`a1s1Vtg-&xTl)Qp;p^V5uv3D~=O{Rwo<(=ir8lsa?%aacq ziVBvqMI%GmpHlWt7r3B!1htl8V%F!Gi*x(3jz zwC06u&?5^=jZpO11KVO6Yb&&OlIH=Zum`5xXy(-`g*8W4jh9}cxmBE-=JFDABfci& zrLdKR*8#TXtA>${;vrzIDuTdr~hS+4kYnr{SQuP3`HD&8I;{7PwLfD!6|FWr$Q$+N7+U;-AR6 z`?mXPsCIs8ZmR3<+b8#6f7YArW<9rZ7T1M!>az zdnYbrqY3YX)=-;I7U zCV+8Q)gJKNS+h)%nM`Ki_a&1_GLxCfz9-Gn$<{1Q((S*C!(C6Q7SydE!HPBBDO@Js+KXzjOcpnM?|(-}?&L&fN9fv)yygJy)Gt z=$%|JI0tJQnk?8C6|1f&(@5pYl9E!r!yMf;-nXmuR}D=q?bXeevVKHT;Y3yTdFgeq zZJH!~boY+*DrMl%A{PvH-(VN;3*U)jW1;nej)d2(5E-^?dkDkLo!esyl&ffqrlPde zsJ$;d>bJ^EN}JlW6(yyH%KJj20js>Uq)~cNbT}3pjz(9=zdDac^ zGnQg61xE^eIL2Hq4T>qjd~%i|f8c;#I6#3k@~zNsFryF&$8j$N!~J_{iMP&RG<&qG zHrKDJ)9I_nQbW_Bc8{&cXAY>ZtxN`-O=fRsDiYp)QwoNxzt65Svo|Y4Rr;9QXbYbI z6|yq*PT~DhXKki-tT)n=G`iX>u8~;FzJx|EKT_l|IlW)^$1KiXpEdwW@qYckt1yXT#2pf{5waeG^7dp6-GfiA(>0T zQs412+C#9lDtoV(>unZ4d#dw;05(KggK-5Cma(vG3%mgX(T(uwq{ zuHEW*XoJlc^x49O#oB<|@35>3RL`xc??>E9roClNHE|&11^aKzznJ!#^wy!^Wzf+J zA%q97{@!bmc}Z<4k7s}Po6imovZ5W%afc;k&F(w5ja`<#`M^1vfe|x!1?@Nme#t3n zz8?tc;6PIm%W$sv%gCNUx4k!1-`p6r`R(16Z1MF{cOX62*tJXD;NM{J$Ls6j<&_cE zb>c`xWTv-&p#dYMyEJcb3V_YWu#wLN$jFwtPPC)=G6C=?0Y3}5F*m7f0A?5pgH=^npEzUz~BtvRu-=kTScH-P6zHvfjxN{~Dl zIq3$61duEqqUM%TMkFlgUL9X>VmR8ZsX7*#J8JxO4eMKGcE#PX&cCYpy#a@#;WH5%D88e3(Mn@2k0V=;DFO`X5S=MP4+{}K!V)mmNB9;pQ` z@d6NLL)?TNoZ!&~XJY`62BMLbN{fC6VX%DVK3V58Cv+ugkE^b&V}oNvld}1md{Uq> zH5G53Z)zt5vnS#aj?b(*<`B^6^hIWRy5|#JHy${A=OA!X$j#7Xe~X{f1-!n@+tzA} zrP?ZdcLsw_7hcH7^LS`}V$J$MVEvkj`OvKqk2ezWdLk=&)nmuBqE{K{$;jmh9X|@l zFAybqU)jfH=uq!3dw?-ZG~bsrVzm9v;hWwZ%yw8DZO zyh~(xR_$)NayP!U>|SLhsb#f>4Ti9K^n;7{Je0Y8aq)wrNBkSsOs?~I)=jS2K(BoO zYpfphSR~kyTxxyfNMg{vm%YXX)-}YHZhZSUi~?4tAg{2*%YsCdRyAWsd#5~2rm?!M zy&Ly8&c-3D_H$V^4^fqf?^~afRM6z2q(Tg{uv3E&MMS}+5~X~sk~F<22k`kg*Zj?( zwDTB&Qa+)j5fLB#^j&=PL8ZI5CHn{VaH`w8f{)N*16ueaZ(*{8dZ4^if98Kr!N@@N z;XI~oi+BIH(%S|f9>Y^>-rCFpPJgSV`^$Ut&5-syZ?qBOc7aS zlLdNkl^CUq|GgcLzA7m=EC8+keT%0Y)kFTx`w!>Ls!fZjfIGzX;Knue8R)_Bj+SY-bf5>9o`4?g7W5z<3i`Wx@aq>t z4O*M6*^T#YV>f0`9k>rzG%h^|E!h2k)`A{C*MjR({y9q^-B6z@*HyEjOHY+YXZ!Fr z6{ug-g5a+SSkRQE?+HlqZl2%o{q)kv?kDb_ymVd9i7W5eNE{?;CbU7`D)@0R>fZwE zYT_SJ8^ok5w1J=uM8(B*!M(M9d)lCMN6ntPriP(#(p2Z{%qXlG*V^sPo0~HiwYME- zhg#!KZ)>+Db^aZ*ZJSm%v)|=Au)SwXa_shnogZC?vB279e?s3%`2s{)POf#omt1>< z%e7~y6r?x%5o&R{ma}9hNW%BCd9Ro&vFgwNwt&~qaZg`9dw2n^MP&O=-ce}sHmy$r z+m}xs{M0Lm}_r3d?=acbh3Dnv+BJ$y--i(jHCaFd7 z)mx>@nZ1K;1}nFE+s9z_u5N(UyK$^`7%y;6_K?+koiSncM}7WqUG|@Yp;*0H*WxO$ zdWoO@KU%$B3sBnVb=9?ZY;cZffy+i8Z621b-b3u^bj_Aq@yw&RgD*Lu1drn_+ z^e(&}jEuYgCHcMN5%f>m4XX|#1;Xk>;!ASs{1b90%kPSzY$eJDQ>h?cPb{di+R@m| zq;jb@l?ucg);~K^)#a}a1!6;86Gut1@Sbq9EGWMc3)u@PhM$1vZlEp0uj264@OgX? zy#?e~!fl67*&H;3N`9sEL9QXjL5nino_U`a@s8^iXBGN_7xAvSrtn2PsbqX<0?#+T zBh)wIdV$V}U0WUl^9udEUNI!%;WO8;c=r3(us_gqdW4f4H#{JH2ogVlH~9kX$j2)1 zTkgoLTxL`iG~2LSma%Sc$~l57w$^dR2!TwOVv;~?pxjB zZ>pOn&y4TuHanvH4D8D^&|b``S+v zMWJW*L-ZVw-^@pJGmQvvw8&mXivjs99DYjIUK&SBIkl=xGwL>f2B^n3An`r1Z( z96h~=w$TQA2qgmYGra9HlBfCTP!n%x6Z|ZPpCve2td@L-xmi8rn1s$RtN;9+#YF-B zecWY>3UIf+n%(&QjT?FS?@Qk5SmN3}Ewzs2?F;a)J_*U*Dx z$t)dS`YG9%*yD)$jwp=zH=L_W)?CrkdqqvM?%c1tn|o^G@!Fo|ZqyoB%CM017u+%x zT#Q7M@H~F6qpz=HeL5abKe^-QS6}_}9f8d+?cVj$7SyRj9gSEAQV%>%y@7;pT8|*1 z#`UQAWMIonyLP{{Il$|p&dkyxi%6fN^DpGJ=jZ4%KianKoNWEb`OXnqy%_BC zNoZr+xBuwei1e!??{JOo6x#qZ2$Gq1Em%8TOrc#KMMAcv5Nk$4Aff+lce_I2udnwj z6z$y|I;TPI3j};}gHv~|ZQPekI4!koS{k#s;wkTV+lW0J%EX!y#q#1rQ*3oOY|oFc z3FD*Q5ne@oLo|x-Mv$DNpew$hgg!mTBK_%&>0ElbAOGZfm#(95DCf{scy+$3ObA`I zjeApZmn9~hX0;Y)BIz4%BkYtd&9fBFs)-JC30c{9=D%~VZ2LQR(Pz_3O372wH~D8c zIh~&WtL!)LAmokW!{%APSPN0suYGs^yJ9VAK`pF_4)tWb?)*G!WKU&>&VQeMGuwJ6`EjnEM$Rovvi_ZVGum;XTjcKaEzwv5D zs%zwR^YwWfsI9*2GPTf-_#0^>|I)+QF+BnK(#|OcRLa*aCZ~nt)Q%N1^7sRNm4jZ9 zfV&dkWmR|=f)*GKzPHjlJyZ3qPJ5uWr!`RRFVk$CY@T!W7#l6AV0&MCusT?(-tvem z(Q5Z3M}ywUaP@G)q^q)8oYh^mfnI|qZ0Vf}o7Pv^f|XXQtERK2y4$1)7<=Z}117h{ zQlSsHg03nfjX1dU7{5CsBWzPd4OEvPD-ca|!E-xp4<}7 z(%C$%Cf;{{6S5M=qOb`bl6;X%BG?3oB}V2t`E?wA9fPfc@~~TF9njPP`3)R?0|O}m zPUWBDaH0GSbT2_DFTJOr{B=|wBc?kdPXW%oI%#M_z3SaNJt*Tst^aUk>0zb8mJE86 zw(;=ZruNN^o_NBO$Z7qD_Vf(88@$1&%{!Egg@zjHdlS_)_GPsX%9;VOM5IrW4(1MU zEu@L`$&vGSu7>Nafnw|HEXW{_AYP4aAGzBHF; zZY`CLZP>u-u0v@-vQcS_iy63lB%6ZEN6ZJH&x~a6r5WK@l8yXRaYg{mk7VhLlgn0| z^%c(P9Q#c6UU5#4tKz4*Ih`9}^Qa1mQM2?H=~t2WLEoG^OJ`zjy_Zf*9P8;hHZgH&?{M90Ys+-4*VLxfwnr1K zx{6-J{nF{*@~G=zU;N#12XisVO0QQBInCwu-yCqqZAg+)J5n2Tt`7R0e|wlSY_GYg zuD;Vb_lZus1>2;~px^#Lrm6}a=jE!}4jE`5&dSl*jtHZ9Zz&|}pH_WxP%ryw#>v7X@4Sh9!!{za7 zGp(%)+S1YrP@BEY{J;Yj&l5LPg1XO0AC>rV0*m>P3Bd6h6ham9 zl9gK%!pShMkG`wVn|jjq+KAp>rL8vmyV9<-p$6|MY07k!5vQl;$c2O1Sl_DA>(%Na zow^!l$3|^iskW@Rq_n6~9SMXw)bAdr3FCYM;N%g(XmO)2)sM;JTla|lL^gWKWCw+8F=kd)=<~EH}XR2^Is*MlFHr_nH^V7$>=kDFvx1-M!^4b04eX*JTh|wB(c{)0t9=LRM z!}1E0jg1`_otfNv`&i4ybU0Zg)%CgRyXq$!On$Xgvo2N{H`Y4qcdc#O zJspp%T3hSvwRpy6EnYn6qcM4LHaB`=dcz&Nn?|mmojlZ9?REH9x3ui&#H5}b^AD!l zwzt78WPf>Cq{WiNYI&f!{wTc>dlleR;vI zI(#r~7@8^{ozqWNE#hl*u55CXamuj6=-E-e_@#~eHa>uV`0=GLeTkX(kq%EoW9Q=` zB~r>F!Lm{fJZ(7iNvjfITtb?RUY!hjRX-@%QCgxal^5$?dFatk-(|bUwiFHrW`m)% z*?*(k;(PXhqDe6_v_W@0+*}t65-#TsTyfWfqVh7|YMF1Uu4`&(-P+cd3L`Kh)iOKV zBGnk0lAazsn%0_3^}1M_(`+$hzuDGA+7C9yAEh?Yzna@SUC66S);Mr%FmrM+xz;hJ z58E1s@!U&&<(O|?-Fj?p-N~-7eXXu?d~o%csd73P0JdrD28_J~{76=WD4Ul#FZ00E zRjY?DAG2B8`}^CiQcdC@?n|Z)u9_I@!MISLJ%jqN1`uISGL?U2*p44R_0-R$nuF_i zZ#yWK`#i^ULAeau_0m86lT>qb*S@a`bHv8qR&Wk^p*%`Ma3`>>VErSP4`)tgT4o)i z`l!1((>LgP!4_#4ecX<4P1!hk9y)2S>XRBz$sRY?<~oRMgfHSJl`|t+~SDZU1uVb>O^9o z!Bfvhv!6_^ON#y0qaJy%Vbc%|gRoTk%o+LVkAC7C_nc^?&VQQy_K!NT@fYy<1j@sX zq5cyn_(Q2CyLeP=YaX9LgW;3B42MbuH$Zy@@x+{7pZU-lm)t$}!K+opGI@NsZn$19 zFH%W0FU`)rbpEZg*1-YmiO+rRgmqxh3YUU_8QkY3PqWY}QqGX2CdLY7&YogChdq8b+K4Vq;r5>m^a*cV;cb749}xEyBDeA=4el&R zh(r6$!co`EA`SR(_rjXWjsWf@Sv0AIwWEWh_J^Coq1>HWn--Vm- z>?czD%KPRpoPy$0iKMc`?Hhu=$YfP2vmFEFTv00bTs!}Gy}C#)FDZ&iD_#q)35O>` zue}zUx0GR>9bs%BaN>tz47+pX^`e^wnl_wSEqB|8KPXGqenelDHuH^xjW?I6`rr zrN)xTxleqK&FyxkU9PlqclLqLVN5>M{0eFoQ%xN$3Ia}jzy1L`_3QtfVO>XOvR@(J zx}e)+8pJX_53Q$CwMfn{-Tof%_CmPyU-IReXp^YzZ<>hW7@Cx{bJp3a$IPX7)*Px2y zHP=7cI7@EaktceAy}fMXKmyWsEKPO}y|T@{b%HghT()~&tn0A`H33*YAA|)It~Y4Z zdX-z(lCy)(zf)i1OazMe>>*7lZ*=H%#fqVdDrCw)zudN}gryIfCFua4ld)jCEPI#1 z#Y*dXQlV7HqAAf)H&gF2zR8vwJuC z;BE0fmN<8{vu|rFFn89I2!WL!{Q|^ z%A~JjJm2Tz%rFm)lX4D`)tZMNVb$Er6e+=c7iYNJe&>wq-&j*5@q$5-3`da~SFNAaHD3tG70jGHi@R(!o?|oue7kA!%R*=_g4tJqGbWWplpNi|BL&Z?B`kRsrtKH#crirH*UPB zd-u4&b?oz;>wd`F&amV72l*|`ko)Fr^XSRtIKLVypgfD=^+@Jt@Ar9D(0 zzK3<^xEFAuQlb1zzWjdyPShyC`&Pi0%l8l*t3|ecoAlm-TM&GY*3baaE?A<1L$7dW zdf~3ENTcfOwEHS7>JptvQ~aJgIJkP{()j8k#SXcqs@z;OQ?6Ow&k6X&evYW>#~hDc zES;AM*yrWpI~jKTe65YN|I5P<<>9?6;714!`yF<#^aQ9E@IDa^N|3z4X;Oe^ z#CC*!X{`nLj94CrZj!$sGh7ZnF2Z}49FqU!xD?9ol-$8_904cuc`2wSGKEoj&L;~wy}w>)?zWmZcE$Z*=<{!B>MS{<0$BYo%&u8V93k{R zB#w%9=)dOJ`w8#8i?xXqp_O6>j`A;Y_#TGQaf)!PfbY!1dso1Z{793NAQJ8g^l}yaud?n(e9mn=;wWR3cYGknnokEf;%(9Xwdpg z%W(OXlHW+)6h^tC{7$jF98&cOzQ)u}P}L`)(U4RX+gs{gQSS)BvFmsp-{5iKJFX;B zMPsx$$b^+K*%aS>=8E_eKRoN6Xk``u+&ex$&m(l8Po(pF-v27ho6QJ#B{-)h0iMah zmmGl83nb=NazMVpN3oC$gsu#-7ybru52Vc@k;m1I~Lis(cVYy#E zP63|Dmv^ivzcW`J_CsMiM?^UHa`5s08y|lQtLNjt5Z+!0=kzF)?^*#TZWG`=E8t`= z2=LJraFWmhysr>0E6u~F3gH}YLiw3I+(BNNeEFS-9wCYp#!p->lpoKRhgXN&1p@r^ z`@nBs0VlgaD1UYZoa_Pt{-Hda#zPXE;A9s7pPYg*E@2%uafahjiSA9BVwZq*s+wag>NHgGl0Nu=KapHz~!&7C`eUN!{$ z@R0G7E4F6p>ea(njI&FgfArCpvR77Bb@ul+9Gc&BD9L@F2U)a#Y@#=Z>21<4YJV5#GaWB857nyk8N{DV^qseIfbn zTogYk?#^@f;H`@Q1q3;2r5d4X<*IDN=PdOgLxjn0DG3}k-1>s(PL z+H35+JdZmBtPoD)72q>@xRyMUdH6m_mP-;Zc8-PcJ))g0v`_dG+R5bGajYo6bGf|iIe0w9 z^73D>H!)r&8H4RP33`Pz0JRE-twfVB$c5ZFNarD(FB?^YAC%qSJZ9>z3YP1wO|2!f zWf6C6-PwkH(`ye^&iGq>!4Y*pKcP{ZjTWD3U}L4JwK4l$Hh^pibF0>>ed%!ZKsE3Q zTTt9Ny~6P}BYB(SO@Pz91b8L~2OhAa$;0z!|&JQpageA#A9ywHy%E`(L{dR!-v1VPI<}EqnFHo9V6ntTTT)G z&S%!bYWd7Agtr&Mxy%;IcddXEr3mnz6>u7<03TfeCkhteeJkK(+X?WYLU_SA*A&15 zTn7r}rwZVLFF}CM6v7enlWYH`74WO_@M~AVFUiAiSpi=ze|;XF^GOK(?qs!mg#~<) zjuqhJR30lv@VlkY(n%+yFu9ffbAD}?$Q&O@4TmlgUFp450dt9RZ>cWBeg-#sp<5lk zD~HxmQmcvE+PcK`y_@i+xQnlE;W`$=3)lT_u33cge4M#V3u~+E@f+7~7LE!=)*$4V{D06V&?A9%UFoq)3(pP?9Mu=s z7VHUtNA$<%dGCsO@{p@b*GDZ(k_iwE|A<3-HkuaFQ?rJd=aN`p4Np9==oZ8qt~9FVUG$emqwm^z}1t z6AJJi0WO>*@bRNQ?bwj^pL~PD2?O!70H0X_r|}5zOb(9m;6x%1-zj;A)4dP7kV5zo zg2VE#;2V5#ILU!^#XX@E|HHNS^2xL4`doSbylK;KyOcjKID_7^3hmJzg-!~^Jm}b| z=UQ8ibjqCRY2D7x3fpFCB5hL5m~*(NYuvy7o0)(eZBQ=D8fX_2aU=8#cUHS8KB@2u z5rUS|$ZXAGLl<>Q8C$5><83u`d$)HE9_;G6csSHl@?%Aly6gB14$%(VbekQ%t`jpe zC%O(cq?0Ld2C|P!>(Glmr^CPyqFqHPyA%4H8N)8Bsx5P9H5#)f7BQx(&fL;9k)CN- zuP!d$t2Fg?PxL$d*?Xm$WLN6w*6VL2tY(&Gq~*YZOz>JIv=^RKXU?2(hBelzN{8{R zJ40LRPZcTlDgzPrcD5XWR(yOjwELkbi~=2-I}FAEaqKJ{5aXDU?YH^io6)F@RZ@OB zd|mFa*xCOnm3)^}X158a#LLIVf32N}gHrzrilj3fC7sED;%EiQ8?EGCYT+FG3mAuj z_S7)Dk->QPPo8neA-y_z@rb`*;=AQoJeJ9Gp*vQku}xTYa*|TtBlZe@L>;o25kHM@Uqv0>Bko9GL5+yx2|W05cH>X3 zxc;{@pMPTPqFX-7`xC7g)P03!kUJ;E-z2+ah`fJOdRFSed9%Q&LYe|{&k1`Ly$qq} zNOPlrVTe5D%)R&OtM^_>-+t2=i#4(l`d-;xv~OYCo}!M9qCMLd_7!zATSG$ek|N&F z5XX-M=76!%$uIi-6`upV3DU!u1H4#n>JuGON8s2L{~h7>ta8VZnQ^U|vh>?!&b;R1UtX$BI&Pn4TTf|&%Hl}kVJ#CY>}7kLnUbgjl` z>b@j1a!I$zt6ls1aIh`e&=w3`EY~#j1PAwbbnG7t_B3eZ7w-s8Hgsh&T@91L9fY}@ ze^9AN6=3pbI03;x=zI<%fzOG@KIscj26NF*^isz1NnkDlihb@(?(i=c1jQcAu0vx) z(=x~v;0_*k@`U9wby7=0tPT4aLTZF?n_MkmL^i`Q68;BqLe0G&Uf4rt{x)i;+Y2R- zFn0F87`v!L_*OrsMR162{@;>gC?cp9OuvgVm@BH9pr@&!BH&Tiw4$Iu-pHq#9I0AoT5mz{jzd< zWk^?CR$k0Dj+S4Z{TF7b9d|K@IpEK_o?R%Kn62(!Rh!+l@a)4P?=4F@(3=$9z60zM zZt-%EpwGxv|E~1B1=)F}AFZwms>+lmy)%)qG>319G+9OxE}cnPrt<5f_ujoHenWf@ ztC|fmj zpR!Gvvst*{&12PoCWU^HemNVx9C| zJ*ZmPvURJfuTQmg>y`y15i`_B70=0Q>g#Ld&nXB)7fSaG_;Ly}P{Zyid?AQ^Cwujo zGiOGT_ZE))U8LX^p2dt?v3P$%c^zp3A;^|11e!Ub=h(c~YgssWTPS92qv<2aDu}1Aw*g3zsv_N!+*Jc*oTC&h+b|&S=@NcmE%A8+d#Ymgu-$i zK@NcHu}0E4)Dt7n$Q6*ciu3QGJ#`POOLT5)MnGkC`^Z!!`@S^JLI;`_9hInyq* z)zUz1AvCVAA*=x9clik@1#)zmbNC}c7ntndOwBn*jWK(yr{syp^y{5T-LP*yx#|#7 zgsQ6%4_j5!wtKTlyUyopJ371lL?ew4H-my``yb1ID-G8-HrC@SvF)mg$unoBw5Jw^ z4|a4M+`DG=P$o09dd;zm7hizZTiDfkY-a6c-RJ>yhWuRO>bzG5T#AxmUhM0QUR?ia5JOG1Em7g2~3%n-j?>D zua!2va4VUgW|FSU-^*b4XdP;UqFAoARtI(F(le*nGY?g|42eyx>q}+weN1LCyYQ~u zOB4dQStj?aZXaJQ_WBi>6cY1a(JSpGE4jpktl^Q%ASH2EMOL(UK1{*f%W;>DgU1F| zBY1fh!OPY5>aMa!pD5|97Xz4^k8NCkqJsx8*VXS%CwK%i`W3v3cGzj~E(+r)w5wNI zL7-;Byc?PP{%`M}<_3baR9p z2f2d?zERv*^~;jxXiZD;uZmk&>=oG;b|sV9FSMr9q-gj{K_$4vw$pFa?N%54hmqTni zGV*5rqw3OP?6e=uzCwF#A^-i0z;7A)Ca*GIZ^9V86SVuMCr>`RPVxKWmuw=fD21+a zVVpQU`@6PgF1x9ajrZ>uoU^;jFH8IloAWLhzj>>3l3Sg`brhis|0y{g|F+qwQhiYo z7HU1M&iQ3vdil(v=i^)6Qgw? zeg0vz4Bwf+b%_hwrVA^2Qs{O4&LM7nB6W7o`h;44m-#7-c0bluur2s*jqg54GSGGn z+4QF$AtAu63S5xDIAX}iOE*zS?=DBs7smTgjHgnidQFkp{_d+N=1w1Y(r{|;1-DUb z-l7NJwcB5gl18i;r@lh+$L}D+dk=4v-d+$decSxuQ|pziCLc5Xqb;Xs-1HI(;sWV* z$=`biXk|F}#P0ITo8Kp*`&KN^a!fbg0h*L{qYvy3-peW!678-q4e`=Sp>-%)Z^9l% zj+QEEwFR$Wfq2cFam5x6o(|O-Y^74o7gg&ny8ZV0uw43@AU_NBA7mcw|9$|u`LZB4 z+3}^D^ZQ?d3-L0;lK`2Nl9O>@X|iK4Q^d3M=DtxpI)hPokV4 zxBEo7El5YIPkt1YYLq3ZpPbZ`DwRUSiXh*qz98TGNWP15|842{Jba4FeIZk97wE7U zv1kyIhz}q~Dr`T*HFMuB|4z0B-vQ?G#HzUIf%H8>hF+W;;`e{9vIm1YM_K1oq%Ewi z3J%nFk9krKW7<~bD7o6%9*??xLjmvXp@>`K)Hu@Ehp`*29*x%=v}ea_Rs~btMtj;E z><;y}xD(+@L!{E&;#ogtC_1QgR9PG!w>pbe#<;l*?rtgN0R#uxp)cOsvq-K*+NYBV zXu!L<+nSoTb#^T@H!pO7-gEz}doG)qIo{cIe0t`xo`ViNj@oCn_Thea9?e&YY#g$S8US33`Z%*61;9G$P}I>cG-01NP~ibm#%+zd%rWGABk>h zn%^=q0oPkH-Ym6>Q{<8aN~q)#S0MlF(6{tQ~g8SS!F&gaAP!xpxm+wuQ< z#~xR=r6=r(8oSzun>y_c)`9R`!-fN!;+?JA)FJPj!D%yB*((;@wz$!_*6kY^91h9K zI^*$v98^fTcadyqo-L~|Bc3;x8TK>#^e*%H!Z!*o<=KJlNBT9LtdE|_SN16Z2$;=<1JfDt6g(d zo`A<4DAR3FbK{P#-o5RuGq&N1gr%laVeECqR@bD~y{JCn{D2~rl#@g@aj zp53wI*@B!6sEu`y0js<=d8LehBfr#+XP@1{w&v3=@iQuRx3ms?3rE*Kl3Pkqs?=jMv&iL6a<>Tgn`CJDinOMt#+UGhl0pxnpjJ%dk9PDRP>=!UU@WJqk4N zw{uG+#_MdMkO{e^s{8?~%WRFAJH4~jVs0r{z7wHqok_1>;|#E?@{@$bUyXbluSj=8 z1ARb3uR7sxoLSJ{H}SvIh!gn_=_3BVng6{CufhEpzImnmmfYuvn^;6Hv0HPWBg4~S z{C%4LT`l<;bF*%gzXNMXxs-mvD9Td?oPsZ^+3H`2nT~!Ty}1A`-6FsTmM%xWpi{(4 zMu90{?~t#4IbYDJ=`Ziw|MYZV{PEe@$H&n}9qMSrIzfKoCm!rC=LW?~R=Q!3fE$7)mg)1l`%h7v&4uJ|e9fIgbMiCUJ^w2zyW* zHBh{aaKL~+Al)I}F!W^yEdBr#*@8+aSy0IZxq`Mg4hQO7rZAgfVWYb?IMg^|^xBe+ zhWa9TQC)*0Y4;lQBWuFQsNW>FzYC-X9i5RAn7Spr35kO6$Gh?bPO@w$+<^Zp?y`anlhagIix9me-lf2fV2#KTCT3%+S2nnE!&@yeNXguD0t=%HT z!p^cy(%)c&a}Y3;JJll91F{p4^*IP$!Pta&UzKu$2R5kvGihwRVwzSPV%Nq&klz}QkhVGQ}?Dka|o1Q_EGDD@K*Cek6jB!XDwbt@7z!--FP)2AO7~>Fcz7{aH z)DNn*EN#M=pX7Uf>`8Nb_|u0EuRU_)zEhKzUp~ogehZ}6CS-R6XH(`d3+r=DP7;2X zt_S=kX#n_LD^cS6Cjsrqz9D&?1qF->Jp=MQhXA7-5?*STd}8S<0!BH+nSD<3IEMhE z7#(G){V%qO-Hy@0LjsvejK*ob@YML}{`KqoSFK;a%Ix);@ukLF;(PXN*|KkhEvFJXd3lD%rIwthfhe!!h$TGj}ViwAte*Krs|-1pVO*FCnnDU}g!O!o^lIJnyjT zyxO+b*{;!!@Zsk6O@6z}Y-k+o>!_WHt)n(D=5e$kNO{O6Vb6SFQhzQe1bzC&#Dt~G zTW54{@KsxkI%j#Ct2^dv4>+ujaHLNi4kYT#hD5v33?o*X^13Yb9%r>iUF-FwU}8xr za+4jv+!VC?gw=*S?7~Yb+#Pf6m{J}JJbClA&CU0OFUh<)llq_6`w>VkjC2C^Y=qnV z?jH6sqS+zNRNQicEcw!|RF^JjhOg4(w8pe=+@UNp7c~q|sYerLqt|4&8(q)NrDU?+ zmS#vSDSw%35*R2ZKWW;NGtOX}3F{~BaQW*1XkPg>hfU$r>uizHnN2g1Ms1|rRaG5P z_-Y%weI1Y4B0jS%`SgJe8xC12dP*xA>peYZx5cEYOXQ{+ z&u3U5T3ab^+dP+8*W9>$Pd8a&0dZ~2h|8(XQIwc;O^t~wD-7cG>F&uItJyu) z*gWbr+QXCeW>clc!Tk4X@JZjZfQk zMnk#Lqo)`d^0nr1q^nBAg}_U?OE0)t(n^Y96ArY2q^lBMlfq}v*{s#+Jc8;PyOH5< zYy@+Xu0)+MA`CtuImTs4Zmlu&Me&uh zBA?9~2v}`=<(x~GNghX8P%O`NDoj>bgQOu4VsbOedi-5irqw>1({Ay^Yn#TnUXuJ3 z^81`1WtozsEK@=+-6Tj^4zU6>YM^b$b=`Ibp95yYFXMS6)K9g>G7uSF8QR_BD z+Ztn*kft%s_Q$sj3~W!iO`R2*f%eW-dTkpxUCKv&Ge%uRdka2h40bspE`D;`u_K$U zmC3HIl$J)fZU05P*R-r2p~y0t@d)acQcWJc08c?eA7_TooIU$lDm^_my%F!u^O8dx zzXc`Z%=FlYKE#V{ntni>;ff?I{Ei!Z8zRe|anZG;K<(JBfsfnkDhMvxj-Qk+HZ@urn zt@dfx9b$h(2{^$MTEs@9AS+9J&z@?Tyl69iNaZSO<%N0gbnw>f*bg`Q)&$OgmWV1j-#Hj-qGLR(LO9v zCh-%U0n>!lbh}RfyU*nUqevxIWa0ZJ##Bn$ zUsn)AM;;fxl@j$Cc}B>K{Htgp5yi-!S+&L3Ked0|?1AC-@}$*fs8om9H-@`td|kuO zb;c_W&C#f*F47q)iiVZNIc_2<;}JdiG3Rs4@m)y#zS>MI?A>x`yrDi54DfX~wIpKg z+DGoZ^FP}h7OaH_fIL6i<0eIc?boN1_%Q}qhG|G^?z#8K)ONgU~P#LlZ|5ufw+`29WJ=Q!6Du_IDD z>bWkchiShn_sZ<}ZkwV=AybvAYSzXOO$3US+qbU1DqKpxDgxB{H$1&wZ=d@c-*ENw zpV_fz5_QIe`5BrWQTB24MtqfC`8J-H&UTQ#L8Ve1KX}Ni)E3K%$`!5y7achiEYcK9 zi!?=O{}(T2Dm(^*ry}!{pG|8$RaG7>_?6Fo7uu~ryBFkM!TkMr`BjR;R{tK%|4wGy zXs)$bYRwz7uiuF=88N15)CGy@^4V96U;Z?UKJyiJq=Q+f+q1u#Ci)Zg9!3v~Qs6+r zUJ?U};mV~M;O1HyAtd^$D`V-|EmEVYDrWM#2ZNof)|ALAW6BC+S$&Z0Zw)1zi}glr zS#OnTX!y&XO4De$I<2s4A)t{19CWZl)(?3h$ZN{!L6KxRYdFvJ@Vfp#2AG&dV~cWK zjj^>q*wJS6SgjVTneEpbyzGUG;#&Rs;ij?3M65mO0ZGyHMRJ zztq2>c6Ci4SyoqKEO&;2PQA4xqG`g*WD}{8u%WEGxFjA9CCZC?%IG*tumlQ5oog4- za7aMKaRWP7a8Z1`V<_C|wuCAai&I5XZB%A8m|@5}+5U}Fc-2FvN$$?pyR^m$`_=mD zNFqk<)2MqfYU2f34BHqLk2@e46HM?>h_BUVYN7*?c)LUKoHFJLc)Zrm?T1VbqlfK} zWKzjYb{fHW3{^8_ium!kG1ZAJbY>V`_|5dDXfK zov*o>9r^2Oc`0^DO2v?(pWZxSlzr+3R?;tX?kzTw=ldKlZd(p^;X@P@7FKxbH zaSE>0gdiykZ{=~%tCyF66u7T>ELNuVS6YLeKBwVWqIe{QgpmrR!J6#oTo~1vOgem7 zU7`Rw&0)H@&|*) z#AM|al{#P{0*+XRRjdLQmT3-gtYU~sWKpF}x*O{j%PkcapbR9|R_PpS`{!pIPHn}% zVV&S@k=!Yp0WOFmq32}<`YN*6U~wZ!u-lEQL)oX;VF&VD(HCsRUm&yi-KeRd{U9(W z?n6X7piu2u4c5|1MOjf**`gt76kB{tAf`?0mjyabEi|FTqOCQtmb* zw2nUVtMidaAs2&i?w4E#D@7RH4YLJz_Ui9HmYCq~v3ORM-2A-@@4B5JB|g+v=2 z>jQ5C(3;!i5l!Z03pw+?7#WJih9cF2F<8`go6T;wT48}@Qt3=2l1U>Y=tMjeP9(x1 zY=a@UB!6T(FtSQIPo~_c!tAM4Tz2MQB zxxn&vZ+syyv*?z|A8DU~wjmSK$+Qrr$61t;d}`?~M^3uDrKpG+I)=kt-rAtFc5 zH;0-Gu}Z`ah0Xeqbrsuh)^)ip<=T?6XgJnl(X^{IrizLZmC5gjwNqbeQ08A{2J%P| zQ;@KNw}j5mb9)u#Bkif0xOyg&F;*5;s4C0-Z2w^MWf}R@)c6UTiOGlLMbr{h5t~7_ zeX>lKEe;BLFK3^MRKjRr5?Byy4)sZYB9?{c z-<2KSwhg8#E*JZqL|bGjkuAYHCWfYViWWETx7y@YhCQ3N@3Xt) zW)nN|>5n$V;|-twXriHkc6>#Wci1fI!;oE;Gd#+5+4NB=*HjowD~n1LMw?P`YO$uM zL?u_KmHw><%$@JZWEmMVR+h7l8_d-fQ_OTj_VXQWgcCDH929W^M;MGZcMxvA!CJppA?tlYbqt>g zpDf)hc@}3T&k+sCh_9B3h&&?6i~p4|o2guTaf5m^FDS)72_@e@R8Us~96F zg3enf{VMh*pnk6LQWP+}j7n}sbB8I}0s5-X*0<<&i#sApwMwQeQ--%L==9BXC1plg zN$KxWQFGMA)Ys}gMtG{O&HmY4V~(aeD%7Tk!BK9-Jh>$x`v`usa%&7Ze4t|rq}aRc zfk#jF@A1B`O(1<^U+?AFvG+C%{!jgVHMyt%#NX&Iad~f6|33X;MZuNbkR=9^qoRCH zCrEI@4 z9(A}aeyo=0u@R&+as9FoeJ?W&LewuW9-CCjVW!#iIWuid5;-Q_+0VmBTVvx!S`FHE zfD-2LK7xa?qAhb)^4;>9KcPz|5jO}Qwf5n#sZQth$SOkyG7dd)udHZdT}Qb|t+tgJ zJipUb_Ndiiv(jCmUE3cYf<2o3a{th8%QU-7i-VyIaUN_J$?eeCqJJSm{)HE^_2k)t z#QX&EOa_Is6A3>$&2lY~o z+iJyypZr|-1g{;iTi5{J$@#l__4A>wV6ZC`rte{g-|w&o0_+xlS0vKq7ryt0-L7!h zzt>GcY(WOFK(xOl^0o5$Tc*jJZoi1*?5 zN=@5fzp=rRs@>*`$9?!(Gt?gI>WH-u^_$K8L+v%4?zFLeXq8%R8cEhfQ>kc8l4cxS zx*bt?uhVTOBUVf>%aRSkvlBoX7+p}6xko4TFGV+dI|F^b@jz>DYha>(bKQV_$QpOy znGynwX7(*8g08JLN1w&-@+5RTaHX?XsVYuyZf{)U8Pf(#p<0#v>?y|duy^OG z#*(Ts)wH52tm{}Gj|9i``dD41uX0XRR^)52%1@wgSc^zqbt&;7MXd3!6_SmVE`2?7 z=bd*RKXXSyZ7!3Snabj4&m6z&uDj0kr~8@z8h5t~|6h~+H~e{~pjAlj_ipi-JFma5 zpskPHaMxQytSjHnSB5Basvfs1RKQEUaJv&Y2_SJ-12oRcSV9c1s!Cr~C16K1VcGb` z8;tEK#!~IZTNY}jT@$*nrMgM|lV7l>9xiW+*rU2}-+J}M2m0hHwF=w)Ud4gqp#XLo z?M*|9QiZBQHJom?Syo4}JW|BUy@Y&Cw?d!7?p26kB`_ld(g@f5=ta@KXi>=Rdr{C-Ig*TO5wRhEGcgf}ra=USzNtS&}u2XL?+ZCJG`kJna zziK*(A{|TjvN!Qcoj-SOWW?DE&HTbRN=#l=Es=evmUvAypL_53G((Ryy*J10_ zQ|58z@u*}5qYlx)a0Ebz!Uj{t>Fp~ce&H+;lnK*Tc~K&$qPvVbC7VPF+xiM)TwZdn zqJQ$T9qPDRrhvagrH+?d>UHB~<#iGFgm*fQ4@#7x#XBw+eXZIFIR)=ckTesrw^e*;Dbim3+;=ojnd+?LEhvNu?;T)9P zq34)aBoQJMfrWz2s)ZqgOeLRamdOg)h<)T1nL>K?DWxBbr0uTmQ>abqK4p3NGACh{ z!vGG|M!-o5lA}^JI7tQ%z8Bs%aI?;s2+GKKfL@4~=nI-ipH2kfF2_3#_&Qf+ z_2Dgu{{HL7T6gu~AAWvKUS_?ow%q?gzr0pje%WQ`|M*6OvaUh-Tl{OTYkuR6H}+oM zTvu0zKB;#Vde^~!gA)Xpx0C}8eovY!-VHzsI6Wi^^3ju8?P_m-xN&1=aZ7V?*QT`l zNO5hzY!1{F9bqS?6c4yowaqmR`F|{R4g@@JhP)DqoU*-1J$!ana6k~$fs+oI487o$ zBC^TwodR5C^j|OmQspMA*T|H`hT<=HdRjeK;%4Z6i(hlZnQn8Qwc5>X@>01~`V%=a z0Ls}Lx7?DgV!ouDIkK<5{Wh~_Uv195jNHpF?>G~QoWU37*o9Fjq#uA)6cF`qZiRzf zcoBMWF`aC%U_8HDQbE1_a&T?^>X5ryFF$?y*x=wou}qhiLM?f$QQsMM>!Gc4jbqii zs-B9W`ubjFcArmg-t60NsSecE{L9b~Fp$Tv1GA5kOcHP`;+e}H5<+xG1d7V5*EKX? zi4&=YAP`S@=V%Awhc7YqG&0uMV>mM8KEW=%l$}^KrOoEFc_&-fpoldnPBr&8LvqWD z70tcfKV}aWl__f!#V38fH=!PJKN;HU_uy0b=@i0^+`J$Xj12=iBYbE<;x<*tIc6|lv~9=Uvg1dyKTcP`=StF- zy$$J`;?fri88f9@pTZB(Taq)^gjwUeTQsjp8Zz&$z4DeJU8%L~^w4K2D&0<7y`|qX z-8Q$YsQW;=^y-KI)k|9s$P}{iv zm*1f_(rzYSK)w^KxR0;hLOTuW(1pZUlzyZR&9 z7uXBzvwh0p>}qK}*hETP!hV3BO{6DD_jB1-cmhMh4}bH+e-cFP?Vsw{+1|C-=J@*8 z&9P)MX1=vo5${#7P++2~Yr!>?HPvBbaT+i<>T8#@ZxsD95jb}*q0MAyiK68s#Wd=D zI2T%%3o$12vP&#My$Be)a4x$FW-FAqa2&Z34zyw5Rt~ zaqM263o?ul?Y+$Clb|w3Nn6?1cw1X!I18tEgZzlMfR}_d7O=mKMhx!uNH)SYOwrjn z7xR#O#8f|g?6EzMJ+|=+i(kP1yc`}Jdy`#8+(J7%5BnZ72=8vzNe-}Aq+Z^F@IoMe z9`M-UEw>EbdMp2aVCdG)$wA!dSVB)9!>Io#4JhB19743)+mf63 zoEOo59r@w>{-j^8Qt6FG_GY$$eebdBu6u0p@$HWvYp#{oHm@uTKWaJ|3?>zN4;5qI zTNFwidmMzzGqZ@T;$*R)UCd5s`{56K;9=_Hu3L`ZaxCVNdt%w&0fiUTT<(QLqlYT7 z@9}CvonyBg&;FD!pQ}e}hqHsi)x$k*aRsx#V&D77^^2eURi-4HB zjz^V{4={fB;k%E;YUDLBe0o#%&=uERcLkq&2s7G4zAoA>&}&3)SnHq-Xt{?$!R) ze*UZ8t6A+Ie#_`EHn%j^;)lbApG3)a$q8sWlQf`Ww37HPtR$!xRH;A@NjGk<;{ot{ z+4n%V*;i;6;+I^6C{-&?NccgT4WE*P43)fqj%ZxDSmGPsPaVyR-?mx@-w8bN(V9l0Rn1nTfnwoPNg;E33Wgs426 zoDHh0iO-7Y9|INSi3x*huQ%CogpHqc%w<>BP3ovH=_)>c()7!&oEcbMfpP}mP0N|g zCpZ+x;d#Z0Vi$pxA6EDzdCSy{yBn=&&RGP%ZrPi)PXA zrTwr9z^F}0HMaHsW8`^ZbZAKdnPQoe2u8A686*~owXNlGGWk!KC^K*o!g z8lhb(JHD3YUK%vk(Qgm;qg{v37wsQ~6~Nn&97c*?r6eZHTXU#1S|8#e(ct3McQq&3 zeRAG^sLI$opRid6hohyTD$Hsbh+)oVxi+?VveK=zs@AK5W} z;_4BR(Y}Qn0)Y+UNyZI=`@_RocDgG^FdGFO*;z=|o(K9x8Zx%h17yJmn`HL6nQ{(MCETZhZ_@6h= z5Q$=HPsl?8|1)Ilr#%{5f;ZpE>jVIpdd?LHXMF=CZQpY2(_qESSHgZ5-_R z3t-PZ_M)XrFRHB-@YuQKEmf5*<>f7vRmYWqJDvKNs(qmq#m>o&&mKLKJ~rj>I=cGs zMP>BaDTgP{tfZ?&dyPT;$D@{*3sVcx7s+GD8JwJ6Mp@1;Z&ShVeJLWGG&$K!B|CgE z`P0Wb?NaisY|kgrYOhOt@jypLOH5-~|K1Zgu3rlZofHC}OjDebqmnHqKlDaAX}5mz zv7+!EQ22(x2y{b9Asa7IK^4<4Z?hatqmu?WRVp(((Mj0R>;A+^y|lNV?As^N>ykU!0A(Lx}KXm%)b2NKCPE=F6Ts zjXXP`bm08N+(e1Yw{XZg11rWX9^y{ogJ!yW7Yp2Z{>&K(!&49MoHmvmtb6W+xMMa| zmlv-tX?(B_wYGy_;Pvl&3K2|be%zk3V!7FcP5AbD=t{Ib*W!G4- z1JWlIWhS~KtkS~PfeE>p!^(!)Cgsub$?>kmWg}}dl2S(w$Uio|_lkz)Xcu4b5PorS zJ=ziTf2vTfCFMEt;4yXowr%USZ5z*vMvpEMAPN_(>wCBNZa-oD(xvNR1kcow&Bl*7 zCBT%Bn3!tYb%~dzjv<@LNOIDhVqJiE@*(1LZPW$7vCTHVllaiuRAdu-=%*vJn!EkXvO)ld)2U%%NrY)yr6<8LM9$ zc@rV8%aC#%vV^+vi>*(PW@0F$0wDtPY!QMw^~bN^PKV3|^N~1ROt#iQYW2bkDAS^~ zc)#E-q^&kEWg?^mB^06r%PwC7e{eS~Gj>YfEhW$4l*zVhbjYbF;1z25+05}yxf+!w6wUyv{YAIdU{-9dRm0` zkB!U7h>OoiOVsvk9iF7^*(y9WN!#OaDK|boJvAaBJv|{lJ#Do1FHOa9gD`FQHDXFi zQgUirS`u!}rX;2HzLpBVSPPJoQn7SONl8rYT@ebqFD*%AK)RKouoF{(0Ekk1U;i~; z#EIdJt|7q+aME~i61WhW9GmwDp3Yjx?i zGA(CtdQAU#wvbL7E8T-EIB9ueMqXn19NEK8eOcU#q2+6 zsC!s^Y!X|-C;5iCa^j>jKz9xrlrq4cUM;O@eqd^)wZ>(jU-Sf)x0m53mbHr(i85N~ z%f=PBHLPQD=wN4(jlMp3Mt+Q+Gkwb=zgjGlP2C8BQ4;?x9V*E2VAlh!KOc^70-SJbd`z ztl`6H>4?FZBSvJ3U}Z~84;lBf$^RNt$KbrY!7z_%n?7{3W+l*n3oS7kFd}fDj_GeP zT2@>R8x@n7F)TlFS#90~S~8aRk4s2q^J!%vlnBUt_Zdssntz%ve$n*Y3G}|PykEcA zc!sexT;!v{jnrps#(Xc^AiQ5&)*IsdT>3~GC#2ON2Z)Gxp`6Ll;*{B*N_{17s3d>m_jQso8W7#W<4*& z)i`V&Vd=aB(v190~@cmsHIK8u%kJ*5&?G1|pP zYE;g|yxL`vqjEA5X$ecsAL1!vbu2j{uD{sP1)s(pk_wg~%gZZrelX|tfUAdy!mQ-M zgHtM9G~kp~H&VPF;*15%ZMAZ4yS+DPGzOm)y_2Yq>XKVqYkGr~lPaG>IoBb6j*4em zp4nK_+S(g@4hax{I~{K{($~a3MCMOUZhe4`pD}3*;tr8Jjh&FmhanIo0Y zyN@MzjL& zAws4FbFpH(A0e1<6bEkojWmc#A}Mm_K0f0D)mh;y9@5A2BI0WlXB`U>6Dg5o_~tSeuKl zH7UPDM_aFFpx@Ck%eBIG@Uv<9n<%Yru|1gnH! zzC+2G#vR57WQJI^6dxs%6{EO2r`(&Lf<+H2_2ia&MyCuE&N~v*6TJ!5RapJ_V2RfG z19)$d;&;0+MR0*#SS0#$g5Y)V{NZB7=tZVrds*j^sPi6GXPh#C6HlJfK4m{@e1VuF zPYy;R{t-2Yi&n|Qksn%RcYFH}_upTHb8`}`4n`Xf1$T-Y?{;pV{MNRmj5KtuJNZG< zK45qxj~tOvT1ciPB__uQ;_)ov=Qm_vVKJjNYRurlX(=gbg9ne1Jq?XS&5-|yaTIof z;q~B`2Kql8+FIy56=*ok=;m;T~-M8EtSn#23Wp?O+U zO-%Xr5E2EPV>LvrQ)zvtHVo6rmPrR_iJ8y zJ^29Z<0YpxCgBdHd>IQpcI3WBOcCLY9lk)W${LlO&@a)PXnJz9M`kAsjGV~5`*k}# z>a^E$DJcobc1cM}N}gR@dXdv$jveZe(%Zao&B(3N1bm*-8HlDGV7hknNv^KT0+ zBFSt7%24y5+(V0TyK>B;?nPsk^BpBKYG{#IB3Ib?n;o_LfEEX=UF^%{z>!4 zE#z0{H@UI@ZfeS#Og*#9kf--!oHk!^bU6^wh03|RrDmp6&RX9lQ4YiI_^PAJ5qX?) zwlupMoN|sCH%HgU=w%+zhTT4TMb{^M#bP;CJ1;6WZa{8qT}75pm)y_8WBSF$vGHu= z(5&1JO2y^HY$@N zu$doYPqynyCofqjr3OPGF2=s4b}S_jwRi579Byyx{CAvuXHTvTDas0IyRM_ z-ABiI)CFyQbbEwz#StyDJ~B38P+siJsz5khCweLN<8iE(`9`@Vf2fvv@!w-VqRozr ziA@{`J_h3nd5dnjHqCwI%;CN!$a$`1eqhi373~fWhH*>HBy+NvGkkcCIhjvRQmi=5 z@{SO^m=XyWEvp_KlbD^I7&E&10>O-9i;|ObY{@r`*h^o>p8HSBntF^i$;ulsA`jLv zvA@NhkBs-kq+q8Q;~9T3xKs0JiQv(0NHNZ1l}CdZ#JhD<06V8 zvU75?V2z``?0NID%NLIw9-la1Kw|vxvB1`hS?3y1e#D4B65HaEIVa62S;*;>>7X}v z_=p@>)6@u>yr|3_la!g66ywHuUT_i}ZETei9c(Yy5&LHG!-R>0N`}?c;fOCewrt4S zNpymlnm;tZnAXvR{&BIa>_956e$ik#uw4F6uwWEU?Za|vU~Tr%Sf;<8dO%=lqF(gB zklCUCaVcAE{>8d6^|xk=p)P-AUB8@x@%?adQp`h6=Svs$nz=cdL)bVL9cf3>Zqfgx z=zjrG)_72SV~FA-=1WT~eJ_oPP0q@->uQD-zg#zD4V^HS#m4ndK*fvE0I8{Q2ZVe| zVA=AYVR-GtXI%4DdRyf! z6>JP4@)I&RWDxz6h_uR;gM{MSGjxc4Tn^5mIY9gterdYlxajEPHY_-H>im&cj$AP1 z*aa#8KSrIh03q{hhvpXKj2Tc`FeP)q7)lCq3kGCP$r+Y80IuG5uDo)`=o;68MYk+j zJZZ_qmI<~!p=IKdNsE`cTQebN-mAhS)7wPcz90E@WH7$ zho8m6btzeS-M4_#aKHFF_Bh>wF@#lxNpR>-vhikAb~0`2eXRESOW{`&78)o$0J)6wrFTc!jmNy6bhMs}! zSjdv{2rfX}GJ}+o(ixwT`eVt^-gl0D7kee4hlF|{xy}`Qhqf3-X35hr+~cI;H^5GK zo(RXkB;yd;fN-2X8&AS0H-56f_zjk| zPrI3sV!f%|9Bz%1m_~vv?!ki|Vi}osigCLkZo;^58{P%i=#Z}jt_q{c=rEQU&2Y~& z)*D^81J-P`!Ok+KK+4mI*iK`$gbgyT2{FTsm2h_>d@Rlx$KZTX{1q4tA#4RQXB*;1 z;2wS!Vq1`YC0upD-v!eN8BjCQXQ7QdP*OKsHNen@(5$R9P}5=bAXTH$oRtpTVSc2X ziInY<%37r9Fe+hMfz5-IE@aVA`fLsaSQ zD0d7>>y%}xJi;wFss}j*Z7s;#h1&i%Tv&_JdeG-t=;Z>y$r|f_&Y9id_tl`F9`#Xe z*2jsWMVEudwX)qrZB>d^*x*ReFL+pRuc(V?VNo}yWoBaROoxAl#4k9b5A*aRFo>~n zG#3d@5hg2)vLnlNsA&LaH2_~L;4(l#XWvK@9Ds8H=m!?u(2cR0b=0`+LR~~JtVXQp z2f@j$utl$}K<~{!{^>_i(pQsz)<#XhBIXf+c^TTr;Se=O#JCmXy8@go=9rln-C{%u zKYVhwm;+ZmdUOVOtRAs*5L<`83d|7oh!Z1Jv~C?>73>=DfXFdLx>Z~NaU%UZ*wYXr z(ume>2339BD8}7tP%8LP@U7z47L1qGXbHieO|t$o;S#O;fA8Te@R7pmTSVK5R#a^y z`a<-8;svpmk^h1(Vov^zfA%f}GU!Fj!5SkEU&Imkt=lN{+-UI282PpJ;LfA?5*6Wh znkAUa$70qmGs?k-evGS0;O{9|zfQvlI|kf2!>Gkbtpla?;QHC%`MKcT`Njfcp|Qxg z%Xr3k%DCHDMkrxMBjLn^{zaQakVq0mqDc(tM`B5T5=Y{3(>&3*h$P{TUkXVjX=wYy zB;5#-3^ITWB!fsMbYVY-c6gd(8P7s%H7@v)VI+s-k~}h;jKB@BQ6!&?CIw^+aS=CG zJwIX<=fgs=h!m3&tir~Uaiolt8-FsMhi=UA#*1VEG(Aov6{M0>k!s>60Wyh9CR4~% zGL1|p$3U0t3{s2pkz+|6nThLYv&d{?IW#`cCG)V7dewNzcp1MkSV0z$g=7&~3|+TN z@r#3Hq>(g{X0qJ4*tmzRAS+1=IS#sxTS*&fhjxP12k(@*}k(0?O^ELBUWbmOq{GQxH?j<|OePk!OpX?&L$sV$oJU|{K4;fpbd-)Oa zD0vLOE9o$L$>VtN?g_Gw{E<9Ko+89J9^=20JZ*H5XUMbUIs7Q(1@a<(#`O|;nY=wA)k`Z z$miq>;|=m9?)LwU{GEJFzQOMZ@C!n6i2Oi)H2z9{B0rOVkYC7Q(rc_EL26J!aXHP{ zfH~v@<3wX4etT?E+=iqP&^{1Fqw#Cnet03czj32+FJAkLrwKHXCedWPi;{|86{OP) z=phcb+{ibUgMjMmvrXRd2~MBv0F$N(ZzHLT}m71GTKO+Xfs`oUBgOf z$vzIc;ah1NZKoY{H9a18bh>Ca?ZMsmwR9a`6Wl;gpd0Cl^d!28o=i`nr_$5tW_mh3 zgPw`|qi54|=(*61dOp2?UPv#Z7t>4VrFdcXa=L|H0WGRm(W~*s`L*;qdOf{?ZlyQU zZS*F3GyNUCh2BbUqqoyL=$&*sy$km!e^2kB_tG7B1!X6_pYEc&=^nb5K0qI&57CG5 zhTfy}G3fAqoIXMK(Ld5B@xJ8K*f%{(pQF#y7wC)hPk5*0W%>$zmA-}-SN7wQ@0;|` zc**T;dH^?Z-=%+{@6q?^2lTJ>ApH<$b+nHS6ye0TK{epf;zoLJmf2Uv5Z*Zsn zJNi96M1P<^(w~eQ=+E>Ycm?J#?S&ZGV1!Y|7{_nOEoQR_7RjPmG>c*VAd`+|{aG9& zKJhGpC9))z%u-k?OJnIQgAHH<*&vq524gRj&4%EI$-`I<%Vl|NIBx!qWTRL<8_f#X z80KPb=3!puV}-1U6|)jn%Em%0P{zt3O`gCevIx zn*kZ%vA8rblhw0XY&M(2=CXNgK3l*RvPEn$Tf&yI2DXefvL@Edma`RXC2L{Fu~n>< zwXt^A!B(^5Stsja-K>YLVQbksww`TZC$Np|M0OI}#7<_Xuv6J-Y%@EZox#pzXR)){ zIqY0^9y_01z%FDLv5VOy>{50ayPR!dSFkJDRqSeZ4ZD_I$F65Lu&wMywvFAyZf3t@ zx3F8;ZR~b-2fLGPXLqr?+3(ps>|VBm-N$ya``IqGo9$tH*#qoB_7HoRJ;EMkkFh_n z$JrBXANwPFl0C(qX3wx^*>mi9_5yp6{fWKAUS_YbSJ`Xqb+(_q!QNzlW^b{#*#Y(r zdzbx%y~o~XAF#i&gX}~05&M{Z!ail6vCr8T>`V3)`y2Z^`t1N)Kv z#C~S~V85`#td|A3fd^42XPk4BTik|X#z-E8UysM|ems`<=W#rqC-6j`#FKdnPvvPm zooDa?d>|jhGx=bi#k2VkK9mpRIXsu=@!@;~AIV4Yd_I~N@G;!Q-Q2^y+{X)f5ijN? zyp)gS<9Hb_=i~VVK9N`ON?ygQxt|C4BtDr>fr6=Nd^$gd*YFv-c)UfuF!P@)P+l`8E7nejUG_-@v!>8~HYV6Tg}Nj^Dy><+t(M`5pXDzMbF2 z@8-Yf_wal94t^is$?xa8_-?+3@8u8h2l+$%Vg3kzlt0G*z#o^Amn58|q~I2C8qPp6 zAVnX@pWyrWANiB~DgHEnhCj=n25c{3ZS}e}%uwU*oUy{rnC7CjT>ki@(he z@OSvT{4e}H{yzVJ|CJx)AM%g*$NUriDgTUr&cEPa@~?1W(I3Ac%QWu99h95IuL2=e zGL2ovZeyo$zp>Bwqw$FGC?pVf;70up<39d3{&)U0|HgR2IMb-)-}3Juy^1l$Lz1`H zSc)4ZOQ0e86G%@_g*K=&jI;Rn{1E?v|HyyhKl6X^U-)6(%Y&w25|f(DbF4YeEHlf^ z@#X}a$yS(^W|diO`ptkj3DW#2(84&)oNgXt)|fNQTJu=5&YWr1o3qT><{WdbInSJL zE-)9Gi_FF55_759U@kKo%_g(iTyCx~SDG#6apo$s)oe4{%?@+5dA!+acA4E~kGaNN zYpyfbn;Xm%%#G%W=1Jxz^JMcB^HlRRbF+E6d4_qWd6s#$d5(Fmd7gQ`d4YMMd69Xs zd5L+cd6{{+xy8J~ywbeNyxP3Ryw<$VyxzRQ+-lxvZZmH(Z#I8t-eTTr-e%rz-eKNp zZa42T?>2vL-ecZt?lA8&cbfN`yUg9@9&@kxfcc>LkomCri211bnE40uaq|gtpZQ1g zN%JZ5Y4aKLS@SvbdGiJHMe|SQOXkbwE9R@_Yv$|be!PPIruk>{E%R;jfccL3uK5@9 zJ@b9@1M{!uLGwfNBlBbP6Z2E^GxKxv3-e3!EAwyW-_5VhZ_IDa@67MbL*@_WkLFM2 z&*neOU(Ca1uNkxqi{N@2v$$nimStNJR-_e$ca3A9&nwpIZ^c>hR)UpiC0WT7s%j&jz@MDIx);epwwZS^U+Gw3#ZBCt=5g!HtQzq zX6tv>E!M5pZPxA99oC)JcIz(dZtM5fJ=VR}4(mQ^r**%z%i3-2vG!UISPvT4Sq~Z4 zTMt{0SdUtdS%0t|x1O-}S%0*iw4Sn_ww|$`wVtz{w_dPbwEkqhWW8*?V!dj;X1#9h zx8AVcwEk?pWxZ`3u->uWwf}-38Jrp+<{|1eqd+cG@bq+SNjmwP7jZ3l5yb!z1TaBBION=XVOXqjSRmRnJ zj-6}g*~9G-_DFk_oo|n}3+yqr%XZry+iUymLc7Q=woB|%d#th79%q-?<@R`cf;|y( zjPs2*jrZ*eyV9<*t8v@>&&CDDTgKbQJH}s(1ID|?dv?H{gah0u_EdYCJ>5RWuCZs} z3iz>hojud8w`bY2?K$>bd!9YtUSKb@7uk#LCH7Lg!Cq!J+D&$|z1&`5ue4k29(#?w)?R0?w>Q`)*clzf$L~)cGrm?23*R9qrAlBDG!66fH}3`CML?TjX}R0}`9d?U%V-9-miX zb!lv_qUfr|md?hWw&ktO>!PZfI=UMg8=Kp^t?I@GR7t1Sw7E2GEI~uwzze?rT z4d9mz5akb{(RI*utkifaHJ(ac$0}XNDos_Du47e^?bmhGwo^xUiBm_n->qBHEwQ1_ zzUWC|b((YpF)p{qV@(RHo5v+v&E+cwM%g+pcd0jeavug&2al%7QxPfqwX~?9kxyRM z&}mKSZfR|5wx_5z5UJ7o0hOyt*SxC2o}y}^`pDyV=m$Yn(Nm8s&E@v$I(c=AczxFN zhQ^-mW@~zgcBHE0(@{HBR49us(yJTWs~g#;8`pFx;{;a zuf(3AD%fwv%AWQW4V^u0tqnc0Y?r%QvYXpqr8~AtmszdLtk%`2)>x}G)@n(s%k9(Y z3q9tHl^va$?W%PRsx{VXoyxDVYNo~*Q;mwTrWxPuFD$a^RD;!pYKKr=UZF0(P?u1s zODObPGi9#mnMe0Xp)B0xaqH$S(nJ*Ls+Ib!`mmCUBxaXeMI)OosYsVp@)O2vBFecs_jbEmww&q{vvZ$U+emH zMf{pFzoyKuTRGseXN9wDu}8G7HD^UUj6v$?CK%bF+CM9=Gs zRD_f`s5Ly)df{(zO0Zf z1xC@_T_IZve%Vs6bxTz^leQkdl{$Z=&R?nXR~Fk1dScSHGcmae9UjC)B(Y)g)%{eW zSw>AWZojKIy0Nc`q8dYWx0=E@$)#y?Y1&)?t64=i>zX&KS+_ZqU$u6nu3V)?P^l4A zI&+gVXm#bPbmgjw?Pgs$Z9A27mpYZhtgD;Qt$QR;7`-B_GAoWC29uoES`k($k6X>U zMRL}SUfGvZ(eBZ-lxhy~RPvQ#u5D2xq(#+Sqy^jSX06iot*W$J!Wq{SaHs|u)zQZt zSr%qdT@$Zv2XA4_sui8h&F!rX?M*F>R;!vvS|$0s6%|wEdi8(_<=5qVb$fetJNtAy z`@B}WM5VF^O0D)#i-4nbO+yo~rpi|u-QKXeqpQ2KV>Q&Qb*P&6>-df7K#vt?<>7pT zoXNRb*H29sZoir?U`x6|fu^_6Yj*Ta6VXQY(Gnr~W_7|Hy zeQoO3u z3BSx=QK`b=mn;ukb%RHLojrxJS6z6XO^y^#g=%b%Vi=@XlfOqX4*asQVe3z{!zJny z?eZutfvxFs*rG_)$5W)~DAIJ4YJ5eiQ$6}y?kUprl*;Y`KHU!Ln-0GduIVV%+^X2k z^P(tm%PrB{7kCik*6DyGYy6y{F2jptJUO= z{?+AazVfKAwaZhX=yI#=02b4lzS0u5e93YXW=V^Lt?+kCS2vqd&=4_2vb=CeMlNXR zH>HpJMX(&?1r5{Wi{!urJ>a(P3p!jP*t z=(8;K!sN53dQuT z6I9t^r6A(vZ0?Zb0XY)6ORX1xycLPmhgmpuYD;{a9XwvqJLnTEze4r*hU)J{{Ucht zS{u4n7BukXKo_YO4F&6D-+PKm99L;|!8)^|b@j>yUfJB;U`}diYiqFmtGilSJKFht z1TjB?>sP|Xr(g$dPHR}bx&b@3wq;EX^q3x6(?jRBU<1`6#00dqh1IR>FlV-`Xlr2g z4L$ZOg^Ja#Y++R}wOuVyvb^q&_KvO?U6S-fpd{%4<7rona5-!fqYF_vHDc-MuRTu6 zs2Od|D-o0%9%~gt=$cFv##O>L@*KAEvwifs<=v4u~k*Ey@#%A!P-RD zk9DGQU9xiBs9cY(+-g*;5e8nfLliA1HLo7zUbTvId8^$fQWi8sNt#qOG#%0t0Xn1u z{pB=(a5>eA(Nw6Mx^U^~uRTu6C`pBkiVw#iLz0e4ECU6_?tn{9kFe#m=niP9MnJE# z11>pDB3@3Du+>^1pcn7~m)e{JTwtD1mY z<9Ewx*d6d_d>&07WDk;Vtc#`X(SDEP*ZDn8eodc8)92Oryc(b0mIw68FrWnu0ln@G z=yh?xtMO?8L_n{H18S{=-I2xz(SoGQr~Lu#4><9fUbXCict_Lg)A;qqHK5m%0llFO z=+#v~uP+07eHl>819w1gfCG9p7EqsicR+pS-GP8EH=x@|Edvnm_%$7RRT(JK?WR|k z0WIYUsL!c8pgyCpHD2`@gsnB>TG(Wi1ek-6?`2n@8aR=102K=r*^VoIi z@ghI-2$#zk*lMQ}aH)l7Kub6Sdb1sXWK;5kTbJXO%Lm}sc-3;r9dK*ETbJY3<+yb@ z9-ZIgF{VeA&r!Dc{N_IgI9;EWtlsmWp)9*pA2|) zxlp65@;i7mUcG+}=zUs13&#RlXco}>wt(KR1+*Y9p!bylwG2f2IO9dvOUrr#T9g;i zJIjC;2L=3^9<^+D2h_3~cr-t#WhBBiKDBIzU)M)1=i%4&QOkIYEA7`}x`5s(29&rC z@w&bJy8c>{7SNlyK(XdOwS2{R)8(sWJN&wQwS0$Pm#3ER;B)QQLYsgVy9Cs-7xT5o zrzPnDwG76%({!n2F#NiGN;JL_hhH6j(CO7O7voOzgO*|kv=lp_Wmo~djS8q`FVbuL zTE-DjyHSYjHU5CcuTKyHT9_3GX!--X9n^B!9neCqfZ8s>uklxC`nAw2pf-H?(#mpR zOTK|E=MmU)+`GK0-(22ee`G^@M|X2;b4x?NkgK4vVYT3A#7jD07n<@b87ZNN@8^7K zLtjs%(%hwG{{bzP3TUZRAfRU(y%!EBAsSjm&qi7f5zr#$fEEe|w6r6jwp$(2CAgw30zPuC=@aaaE!mt?GN<117T^Z!rawiZY;t>c&fwPRbhClrCYX^Pt9sB zpPJQR%ie@7dmFZDO`n>@;FrAzTlJPttq$N;}W*6f%=ZZuWPKnYc8LfZ(!@1sP&@D zr#KY0ZV9~t_NhY>mru)pS>$yQ=ja{JLG$`VM~GuIl^b@~Q6=Y~4QUdj!AkVf8(N zUzhLG9H!QT2-oFnS-($pWKi=CiZS@Dnhez|;F z4CO1-999_OFfEYsX~C0E3!Z#h@Z{5iC!bnb0+a3uEr5a+VO5eA@ZgP8<<|lcpZX$z zqjb%+z{{srbl@c2gKB1nU-y7oiGibZy!z6?ukomz2slf(yqX)qQ4XhRI`kL7r@sI` zEz9+(FEcnx_kfyr}cfoTIlZXLHMnmE9^~dy7V>%UT_w%3Q4r z?RE;44pFFZ=t6~46{=jKP?=5?D$}V#rBf9u6UsuROBUAQlp!6W4B^ma2&XDTxkMQ< zohU=5Q)Nh}DnlleWeAsQ%SKbQT%#$lH`=Pr8ZFsiumsXZY_QiMPN8eG6|P20B5IU9 z<5%O>!ZBE1}X z9D(Td)q@3oQH8Q9?S^iA zWxJYLOF=iQDClC9FjX+sFn*W-%p{n}FjHWr!c2pi4s#4l4a^LfT9{*D>R@KV)Wghz znGG`sW-iP;nE3@=5fw^i2Zxf`b@i(Vli49y$?UqKE1YFcL06={3L>&A5m%R8A!T-5 zTA~Pwl~k(26>@n(E?>x16mpe>T&TIui@IwUYOh?CA$*k~e3c=5l>(o0QKTy>+997lW+|<(A+JJgjIjt3Tu|xzb8C_RQ z-yIW#s#_)@q@fDBLKSp@AS4sFP2li5ed2dY@gLDA>OM+ASF}H@PxS2+kzMymOy3O^ zr|im5GgpSFs0>k28EWRLP%~GBnxHDw%vGY9ox3VsQ6ZOZW_4}FDbgM4V|QPx169AU z>nk97l0$UgO%{a#vQm%`p>DG{NIjrW$x79Y7AI*bx~^Z?t(H*w5bd5&y>Yjt%My26 zfL+Vf;c!|!T&9j-XB70w)Vo45^{#N4y5v)FHO46#GIhoUTj8BdH7ab*To6PIS3qQek`$nk=li0Mi=tSR({7{C`| z0H0`mb(h8=CjcA~HM+!z8r?RkXkVdIrZ3dyz7Y97k#mN_-ZPxWo^b?w&u|)hMi_hR zdpQMN{g1er}B{;Nj`dr=HQE73y7|K-`qNosbM5y~b4$4A7khNAQS9DZ31peH@HMp&Ar{s+bkcoo%=su&lMqNl+@#s~bR0 zP?4rW+yV+=3{h4TqO2%X6s`~*)uo|y7&)qS{UQGFgGyW8DGJ4fT1@n>P9LhNKUC9z zXit5=$l(KZ!w7_{J4V9c)I02+5vm)yX@n%|u90v!X~V7^A?>`drojy0_Lub05UNKY z#5r;RtIJ5hIp6tc_1#NyDpeX{&QhQ;{oO0k6!qeaU!@Rtl^hq20cBHQ`a!NJIT4Al z23wx;U`7(>O2{uymULRLz#FO0evm|*Gs%KPLV4DNz$rNSfLES3fnIgugwOafJpeTV z%xwV{eJBKPExaa9pO8tOKf#9+C?uJsj%nbp#qmp*iE}97?h@Gr!QKjRLaZpSuR-H= zroNC^Ss`(n($%vJbXw& zWJ@7l=~9Q^K39lTSBO+ss3~0`Qk}J$khFvlhDddVNDWPbkhnPMibG_ThAQHWEFpPu z(v^m2DGgEJETDx1#z|Mv7aO9$Sr`jRjFV0-hXq^voYkI?$2hT7p{8^`kJvkhD0M!H z2n$ga`m8}Z<6!ak#fE4JfP(0Tw$;s@UHE)p0LwjPm)!5+cQn}V;jX*fBX_C&9xSon z>yrDuF1_FD()+zGwcqQCXkOQdlUR&534h&pwg(g{*G5iN32|r%V{VmqPbn{GKHf}IigoL zbmA(VLMh!kPrqfj8@8&sTSdicpN@BMF z@3wTHb45{&9XL*xPKWYk4&`tt%9r&~zAQw!a4E`>PEn3@4&`tIXh=EAAkT)710GM%7Yrc;zlr=naY zl#~lsSepuOc$*5p)270$DHBdb8RRCNk{hH$P$nFjGT~H|DVLy3rW2IObc!c(&Fn-v((3iIvNvh3XzHRQG7nr$-$Zxo}M@ zx>>KTfjzoeFCO8Jk@*Yp$rHuOCTOr^{TeJ;hX!;rF8;{)XlLS+Nc7w%-Fo^HIM!ok ztg7Mup3V+QoV(Q1Poijpn6JB`b3Jcu>6C{W9#=(?wYs?rp-S*pQOcV-+E*x#68Cys z>OcXulAgKL{YvFAQB zxs`YviIq&yt&Vr$S294i5{kpGN^=*wB7sVdpp`N58>ZY{(PAFc?ie}Pv`3dCh0?$y zh1J+{saohezK{ueBgJQ|tpVRCl>-Mf4V|4GYkTAYBocZerC)s9WQsy{G=qzwiuOWv zbp|h5d+hGcmWCBNXQ2|D<07d}2LZZ_Z_;>lixsM)8sJm37O6u#Tr5@oVs&_ji=?{c z)sYM2r1Ag&wi38|R1dj4>hKe`I_C2;qsD?9Kjfxkyx0YUldaLwy-sWmxP ze~lrR$btpKvU<@ZG#l z!(v=vG2qoBd@I7zse>hj!55YaJQ$X$Dl7&)uVXO0Ty7mB-ABYY6o?p55SFJjEDyLZ zEXEragK?o_io(i~?jvHHazspDIl||ZBi%>jamo=fedP#Bi^3?t4-FLsI2J)o!`$kp zi(VP7ThJ|kAfVF-x06P=HBYL8Qk>eTnOyUx$E%L;J#Kwkqtkm_>Y^5G{e4jf?eJGd zb*;uSy+C|N1Y3JtiX{X7fOzc*I;imSB6Kgr8nmUUxudhAt=-_&f$8-|M&tU0nheE40h`Si35HZuBnTkL+)iLnb;EzC4 zRtMl}{84BG5-(MrfIniKi$C#h@OcQ?W$c0b5&RKoes~liZ{UwWtHURNU*V77_tbwg zC}D&fMChaf97F~oB$MD39B7~#0rz<5lp@e6H4*MfWD?+1g176Xz9%!fzxu0^UY$L&#pT7w}Q?7~mhEbBRKS(tfz#q{9qCbD)ig z3Qa_SBk4$l6wxBU5?Ts416qU#Gzj5+NN5k5gER~10=S#$3c!_grNN;qs1>lCqV=IK zXf@y(iud}UD`*|y3G@WOO>`6BW{Ow)pe5)`z_Tf6hL)i70WYMW9GZeI2E2k^0eBU? z3h-KbE#OwV74UX?J92HO+X3%kXBiawWzI1OG|b@5W@wbT!r;&;a~0qX>;}MXY#ZRc z>|Vs~WIF+OvE6`s8CnCHV;%&2lsyW#kL?3|iaibZJbNDSMfMWl>+E&FHyC;lx?v6i ze#y{-&;av2;1BEvz@ORAfQQ*(=o|$r0zwN6pvg@@{1hKBnnwf1@i@Rlo&=c2(*QF# zdLMdUvH|fhIAAHq>)_De5&(RGe*_4vAAms~G>FjH0a$M0-4kfbK>I^aMjPPSCR!4j zEv^RKYND-#z6wBSs{nl1L>+~u3P5P80DQ`P3h){88NlaF)EC+*zz@()@n=BjrvQA{ zd>8ON^F6?WCh88|6rTcqZGH{-t%(+Z#tGC8Iwv9lp=Sax!^!|0XrW%voq(2y&V)S% z$1ncDOZHg%RDalRbKb#fdxiAI)M zZiO8KEic2Mb%o5VtIUF1XvO8g(hs^{aSz`MZEj1N;07XW)QUBDW3%$ zE}77Tr+n&Fg;ClU;kp-;lG z5>AvbAmMZg>m-~j;bIA!+8R1nk>e!XDB%SXZk2F{g!?4iFX1N={wyGkkuXz2SKF$# zRkTvV$r9E`STEsx371N^T*6ifJ0)Bv;Ykv1?rPc4OwW<<0tqjXaEpXjOL)D6+a$b2 z!aF3qTf!X@3LS(*egXb>XiHq+NpOiNzZC!f2*I1avS$Aqyb?GrgTWZUSI#ibg4qmn zGR#Jpbuis9t6^GUBEjneF)s6=0kRAlAE!gBqZoYzdXA(WF;Lq@+TN$^e)2_LgY+*rZcW@tac9I`0NswxKN7bNeqzLsN_?>md>ssi=bxng z&)iP@(YBNR=m}XK25(py@7wrNNzvzhTf@=Yozp z1Evf*=_W(}Ts?HnErnjWR@RAf7PEC?g#f*AoAC|52&L|jcwd5MxNCv)JAAui@OK+D zzZv*;KL7}=_t5e77<9Wm54^8Hf7`n#?;z~YP`(BIY;Qs@+b{U~M!>{E&sql0LfD%; z7w?V#On-(ZH4n6=jpx;DBs8PdLJQh#NoNt#Eab~D^jAXDSvPWT;FMMJli4AD2Ad2G zW|u%)+4cNpR?qJc^?)w2UC@R18FY^=IrBx;-=Q`!(1^9h+=$j_HBW^(%e;WEG%thS0A2H$fi=KZXtLT4tyO!Vp=uwrQN4t3 z`vGW~`UD!KzC+x8^JnOdvY;a>0s5daVPfz%3^{fn^)1lg51KlCU%Q4(&G*i?TV79qwbGTbBG_e%JU3}F%u zks(h>c%g**CFU>v!W)`sbi)qOrR zRhK}QVl}i%*FuBzLTK7vi9LHacIpBbRuM$JnMj}$TEHX;g-n2u7y+SeOu$bi{6&TX zAmkhs+YFXL*Rd>?i*gT=1JGvcp{>weJ05y!r$Q&~ zY&?nnJX?nR2k9{AoNY(yZs?Ug89HRoz|Q^>&{hUrvDee3&2bxxgL#wKf zkENM>B2om%ci44LhlbR-(1zMXi=g{-HNxR1+u1$Pc$$LP)iT#unPVgQ4t2bipNf7r zf}eql21z(h!o?DnN|-00af5WXNmwgGu9WUr38zaqM8ZuH&XaJqfX0o|JxRhe36msD zl<+DE6C{j~FhfEj;V=m;2{R>(5zx3d;>qbRLY$)Bq&2Z3Qj=G6lF%z*rG&)-8dpg8 zfea~^aE^p-3Hu9Zd@tQ&r2CL`uaNGKB>Y9fA7yxvgxLaGd(3sn`>UDS_z@Im3kWTn z;=AXhNWo4t1-rr_kew7@UpN+V2KhTuAEX#VG#jru5oog)A-^X-1MVfi0Dep}0Iv|? z)-vljiBZk{m6&-eq2Kqn=6o^pZ@~Wa4D46i4WXm*dbn?fCf<7>qkRbab)9d*=W-U` zBA{`FbkCM>x`cSv38jmDCV`Gk;ZBk8S_zp9|A&P4O8Arve_Fbqly0$iCeWHM;CvZ= zv2?p7#6!Udm){Vw%X|{0=p8JXjEEW-VgEC?6MwYrq(3_Rzx6vbgxd}-C(Q9X_CE`E z;{R9M!QW>`K=w8as{vtL_!D!5TG1ME1!m9>WXOF2vSR6eNxG8&v6|oum7U$B?E1@; zeIEEw#6LA(*@p|HO}xVps-&2Plw#&eDds5j=x`AO8HSK0*^p61VjUxNJ@{MI`PO5DReTu$->Ixxx83FSr=z1Xp1luoV)7^C0El zDYe4yg|lZkiYMT++FB| zeizdAgOIX+tu#JEx*jRDH)mJ_h4dUU@?{bVHx>G8VfP1*2`vkHQBAmb-caSh=z{ajd&;RR7m5+OqvISw^zZBh2Ttarsc&c)*j5! z;_Rvrob-uNBzVavmS~CBHmRxjVI+f#8dJF0y}uLqWEqRyAjYveN)720gdnP z%QmJ$Cop0LpiL}{SrI=FZ7b&#)cZ~H9yuu94WSG#eT)QeE(f(QLwme~A;O+0^cPEQ z#nYjqxYi&8l+^PLtk|C=--WHqeR#X0>0ip+{*A;fOxh+TZP?O3Z+#YVFp@^qN8J*& zJ!(hPo~TEn_QlloJJ|2D*ofHN*pk?(u?u56W7oxQiM=*g&WOv6n+{p)y0}y0&WhU-cXQm1xX0pNj5`qbS=^8DR(w+Y*!ZRK zXT)!he_C-ITQmlF3U8A+K* zB}ucBx|6mg?MZqz>Aj?{l9Q4>$+gLy$>$|Mko;QmXDKBqXQZ5$a!JaSDUYPQm-11{ z7b)MQtxVgHc3#>gX}6_4koJ7q2Wda1^`@t#XQmHJFG`=DUYEWy{iO8G>9?fsOW&V< zApM&Ro>7odov}D$WkzSl#*Di&{yrdfz;uI?I-@_qds+mqaFJOUYf+@uNFv9|n0&-k zBc>WL`FPJKk<3B(0wbR`V@%peS%&;&NL7neWk^+qRAop}hLz0C;HV?veHh1-pJv4I z)4_GWF8lwEF{})liB%W*PvCD*7KZ0PkL3>rv10qz&=Pef{}B{46d1|DgGOrbBO@*N zqmco3Venn!CD<>+yaMwo%xf^O!|aE71Lp5Y`!&orFyF#_hp_Kq4#E5Y^CQepFh9fm zBY2R!7<`xf3Fal3mtkHBzDqM;2EYsqzRS-J9^e-QKjIg`ToF9PuY|b@=31ERVD1XO z#&;skE|}fH19lGR-2uwK0Oemvss{!SVZM%lHuVV1ED^>K_(#IdhjC-x@xZ)<^e@A_ z0`n@&YcQ|Fi2chOF#oO=R`3w9VIp86VWME7VPatV!NkJ!hlzuUhe?1*gu(l$BpD_J zCKV+)i8dT0L&zq$uLu3roya2 znzbPhvk&HvFb5F!F3ewG-iP@s%x5s4!+c>x;7zFr%3;j^$KH9sX;GYi ze73!J90we5^p2FfqjyxI*gJM3Hn2AY6gANpV@zVy7=y+n#u#I7(NuenLI1>FkPrk! zKtMe4P`D65_WzyV?!M*jy+%>||FiFBo}G7gW_HRm&&` z^6Zjlmpr@V*(J{|d3JZl?19-6vlnJ>%s!ZXG5cZm#~gq;5OWY_0_I@MA(%rkhhYxK z9Dz9!a}?&An4>WhF~^ufxOx#>y$G&e1XnMDs~7pVgsc5ob}Wlrn}}SSh+LbDTssT7 zwjXkBGIH$@&aY|?ZrAh3gLr)i?O<5=xfzZb5w6f~n`;R(8}kp$ zOPH52uV7xqyoPDGl~>cst7+xcwDM}&#{ydU0$TY3TKNK6`2t$`0$TY3TKNJe6|SRw ztfPIbqkXKSeXOH>tfPIbqkXKSeXOH>tfPIbqkXKSeXOH>tfPIbqkXKSeXOH>tfPIb zqkXKSeXOH>tfPIbqkXKSeXOH>tfPIbb9N6mG{~#Bg{z(0F?V3@#N36s8*>llUd;31 z=g!|TvoJ4UUgI0%bqwDx&YKu!w%yj@YPSuhQ~0_4W_G(^x?=i<8{B@F{+KdMIp&mb z15&ISDOTba77uB4tT zspm@SxsrOWq@F9O=Su3il6tPBo-3*6O6s|idak6NE2-y7>ba77u5^w^=DbD^@;U~7 zrT17y@3G98i~EO|O<}c*%yp5u^eD^dQI^r8Eb}i9XZb(CT!Fa`b3Nu)VHIt#iZ)n9 z8?2%YR{8gZm9)Vw{tMBVpVEtZlqHQQxh0g`5=w3fCAWlQqL%;3NZ#%bOeusIUvDe=* z>_|Y8`_yPPHCj!LR#T(Z)Mzy|x``UyM2&88=}D>8P1NcpqJ$#q?A7=H=oC8bV;Mjdk6ZL zQ+z){7Q2@Ek2AK)d`&w1Uurz$&qid=M!0rk&40v~sfXNJ*%tpAm)rmPO1RI~T_tr_ zN!?Yd?zYaqI{BdSNKI_9d!5P<# zEr}sJo+yOn+nRhFakJz1wYvoTJ8AEQ{!dJSe+}lR$i-{9|BU-gQ&7+s(~sFaCc^Cu zNKB(xhleZtJs2yxeq!_N^Yk1{^d8@zNKbwY-#^Fl#XpI$s)I|FDe{lTomE<%t>sy@ ze_S}vXAJ0{!2e0%eE-C-GWBvepVUM#RO4&4d`#kgVpz@7)h5N$O5(6jtK+AYJYB7D z3*sk9xMlVU;i~j>d7Q4~=>ncESFFuR-7;cci;qfTt|WE05c?`pw}jNK^(S+WQaBrV zF%jSP{p1hVWs1Y*JW1JV)gKvWi_@44`t-{6rt2E90sXU)X?2*5< z{(1bL!5c3K@Ato@TrVW`(9NHq8mUjHi{vzMM2KCw2D{ewlwhsU-dWh2i2jl?!s>v3amQmf5#Z zVo@BnB$6jfOk4Ld)64z7>B5R;NB0NZf5iO?Q|eyH{jWTGCj7ViEbcFI{~zveaDS8g zg77nUA$~r<@5k6z(?f6F*qq>fxF(x6qrWd>j4*^T!Vty?Ll`3rVT>?@F~Sh%c+5S_ zL=}1uhx5Fr==a~HbR24_5Ys(*E2ENI{Z}d3 z*WtjoF>}Kk{dc)9#JtA{cRAmdD|o&V^C4yx<|9lc=3~ri%qN&C%o@z6n9ne4G3zkZ zn15r|WB!Br9J2wl5%UFRQ@A)_4?&Q^uuCCepG43M!|sTnFQy-+KZczUK{gdDgJUqqVvfVmssi?V1d}k6F(+b9!kmm@=RcJV9 zGcjjj&c;l^uqPy#ikXI)j=3mY8C;C{4(1ZfcQKb@zK6LC^M`Ov@JGy_Fn`AUC0vsl z6V5e$P!-M()`#;`E~XWdr?!iok)5aUK^4B&;=3B()%aeU$F~i?8b7P?vl>5^?;j6q zhs^I9y(?uUw0}3|Ud-c|CoxYWwWH?|t)0?3;!L$cVh1(Tm9q0lb%m&4?x`@+N zzK;9Yl*qkrxIPZCUeBy=>vIGDRug-*DN5~t&BoD}yN#!R_)o%ogfG3p`l1$>E5?uG zl-f6~Q}`5NGp^;luY#}xZSSQRD+rnCCo{HUWO&ktR3ZPXkmK(o;TOgd(#pVvNVbJY zwtJCmD_DPs`ZanEzBi7=97hbtb3Y;cl+cy*9BYZ88cDjASe76;KPB`kB7*Bkq~}`G%AaW35O;Ut?oQm5#NC~^*T+)5+MjOP`{(k$^GvBfgZueByO93Y z#_h9)(b4U}Dhp^Iw-@u~?4e;DuLCRQJJ7?IgQ*!|&U1Em4sZ^14ss?q2Rny4hdGBk zM>t11M>)qjXE|p(Q=D_0sm?TKx^u2`9(zvCcP?2}hf}y8VbIkbgY2!22cn-O`*8bx@2lL#veDl{MZpq8ajI8}KQ%&9f_xh4bAbf>6 zH(jit*-QVzSLkV@|8o4Yi5=(E{m$@DbwArSPmmTH!nHE*ZJRgb#v0o3++s#B^TM|o z;k}*v6!)iUx@_zJ;aoB~*be@ozNS^{6q2<6LV&M)n4UlSg% zb>5Jiw{o3P#G0CA$BIFXOR;HAA?gcQ+g1M>J{8^@d9}T*T-gUyBQSrh8~uQ#^P=bB z2i68%#;=k&7~4~@cOBaT#?@=-LFb2y^>jhFxOR+RzDrYjL2|%+%GOLHrPpA5;XRb^ z!tnQ7^SyP)Ma>TiZ{>}*hU_rn;$QeucrPaUj}tr78gYg9Fpq86*QSequ`T_7mUSU# z3mI9Fzb53+*EiZ6(aPMJQNC?A=i-aIDV$LA6|A(YH9+f{scqCZePh!QuB0tREiz!X zpiyzxOmpu4a3yI^&oAaaYs^1d`{yPg_jaby=O4B>g>i8EhK8@krXenYB4X?aX0Lrt z%+|HZ*Or$6uk5H96!iO10?#EpktAg+m%t+NfX!=rc1E>H-lF^FmB6Ac44IbbP4PTArftoRWmEY|()AH5)|8EI|&we!}|7E_FQ9~8R>Y>=8tC2BSU6PqA z_aE-e4K1^eb6a{QKw_)WPtQx-qkHm{oK-PCwE43mqXK5UdRW@no-sGpE&lg4`7WrL z&-^2_XsoHRN{vxx^lcE8F?)hQQq_#H@&9f7^<7(1lMTgP-+MF9^5xFvI=NIr+p+{2 zD4`@B`R-e%1oFjPkK1+G<_j(e@j3+g2Vk;!Y) z587UAbKe|(W<%zaQ*xh|#2da5+3NBs-w!6fx5b?jp!Zh^q$Oxl7qw@c8lM6iN8LRI zIMy0hy(RD!@<4sLsF8&{f0L4|{V1o6IHufL=zNAQ$Y@`x-IIEwcY}8i8kO%ui{kIS z2fPQ-t^BdKMmt0I^C$Ti_*eQ@qviNM|M%?WY>yt`f#?7pmKu>7nHp0tpkN4CfHGoT zXqr2pTm5Xr-QM(L-)jfcpS`agO&NP%yPFogtA}an-ss+F+VjpEOi$i>54gU2y?afH z_uprBAPx6}<@>$&d((!rJOIkzLGMA+nl$|Z%-zS{$DsPwcx%|xyraLP>4i3?olI|k zKYu^=0Z;NLnQo-_0@IZ=UulX-_tmBYX}`s^BmMW89{%sa%v$Y9ZP-uUKGhx-)fK4< z)15pGG;PV(P}7mT4KrQH-w4x*JdQM-Q)5zNOe^v_!1OH`LXOez<)Af*ALm=>SBQ=d zAKYbg^BuIbpr=J&+ExcssW}HdOzq5Wntj-v9myTc9?V5_G<$LuK_@hHb>_DhSfnoK z>gvjGf3$sdLuZ23-Q=-Isd{P9ccJjfc_58S%mfu&v_sH9Uj_Mm+*8cdOSSmL+3-nu0oH8$A0jSiGQ`T znixJoN8zQ;8fOhY*E(yVXhy@uww42&4QMkwh+X4fU_b1tob66?rbARjp7W*ykEoN`DdfkToPV`)#>)z$wWv09Lqdoat_d)kT z=%3ujiT?@rNmBKpyNb80bJt<7cE2zec_m&~Gu<2S?PJbG!}%%DQ$5b$@xF^r{d3X% z_7kW*0)oO>5qoFCFVr5%q_*|GR}$_>#guskdl?&N@82(t-@!eSIN^)yia&zm4_A# z?=$Z+{H*oXlK%g2lFa#b2S2gd^JLERHhG&&ColAk>BFvm2YH85Ju?Hnb)FgGbAkIs zgB?!`{X(QCN)#-)wl#!N!%U0eK=aw1J< zzn$M6dk4P*K0ErIu%i|WN!rElg1svz)s*|){O*M9>G#CNo?0`*-vLbLxBPy7KeMCX z-|tTv%KS2(R`>&;1O0(!XMd1C2>W1vFxX0a%FPUL2}8{ounEI(AK{N6FC+btA%d7M9v)b8T%La6cnZst6HcYk;M?CI}`|GhXl zXNJFzzc25--2Z`T?f(!w^r8M0{uS7-@~;AW@)MAv?fjpD6>aNZ3tF_){~37Ew*E{I zqow|J{`L6$x&L$XUH=CE1~UvK#4oV_(*Gs)U!fi4VE<-xqa5r%)eWz3smZo$Jpv$DkYTUF-|}h0yo>_dp>o_7~G{z3;yd)_;k=#I*F6 z`b)vaEc2I{WBm{O55VDYmVi0VU*T^u&4M66gJjSr7zo`p*wq{p92HE*eo=6lX&zi2 zTxE)btApE2i{SR)F4GD<{|}my;19uH%yDSSd)%DNX#mfdiNSNhbLPU}@4?@3v8MoC z7`z<3Y`!T?c}EAY1+ST-g13UV%+bNy!Q1$;=K>7}_wg^&H<%yHH{H-w_%8MZoDnoM zSQsocgRI^{T;31fH@!I{U@7)x!7?*4_#jx0eMPVW`^sRY>7WyYMg^;aRb~VTl8?;c zL1lmrnBe2!W7D4V2|mFNx(uIdf={u37JNqhYlF4e*Ky)dMNl358~gfTJ%0WZ{0IBz z!RN%kA=qFJ3pNH{;1UL*Ig(QhjOm@C6Eb~MZpt+sI3&Wy9;5;@${uil-JW06F;$o< zG$VD2(GjVlR1xl+h+u}MTBKT-L8+FhmbkP^wIYU+R0(#J-{HSBRca1TwM(JFlMzM- z+&iW^5~@?GlNp}slIns>w^TQ1_f!x3_e}NTy*HREGHL0_=Tr3L*!FSjjtsNiA72JWDl^ulKF?!(@o7oW~Zayctrb}e-= zw#{4Tsy&e0&Rn8B`{8VoRm5Ja62|opS8!UC4T?J^Agh%TEG`2_HaEVf{l`>f)9L32RmjpUj=Bc z;=8FB!wFPE0IWub&jWhz?crlt@7V@cFsR0UlP2H54ZVUK#50lht}jV@XQx8+J!ik5 zYk%w27b`LHHNLGbyD}9n#x!azGo6akv`rS9!Z?48Oe-9p{l?8&-OEiweg9aEf7Ct? zUrc<#S!=^t(Kn~|gDgy*Y@NH>dsbA)w`g@c%Wcc8tQo%%-W)!Q41LJPX1O)GXi;nP z&sN~~FKJ(^X>Ydf>>33#anKd4Ot5RFp(}hWk=KM!?Zc;58rXd?D=<~8md zZ0-_i9p%q@Oy;3;C0&#*8^S)(z>!bXyM&hl) zC6;#aJ0;0#WuXZU;bP+az#8@2zlv-1rX-4llhXg2#D__!{CDcF4rz&|s|I&S=8rRS zMKfZ2p<%<=wx#yfCcKjI6jyjj@?8Hk0;RuvI%#F}l222gD*ec3P2S56U)!Gej%(>W zt;W6*$F$PA#x3(TY#)Wt#$Tk%_7KET|CQl5{MW2CzDOFmZy@hWGV#@JV@QA;UKd|& zR!fXtt?+3+LC(~hb97wgT)An*{z}gU(sJ81o3Jv$1CJeuI%T*+Y19c92M>*%H*7 z?b~O?cBRSILjAQK$5x_y4}34mS96gsBwh9>9KZM{w@u{~IG-9*N*@E|^cY{Z8^RNC zv9jxD+4;-1*X*$}81QUgb=Yfsf$*Lh{hw+RITPWVn*QAty%)VY>^+a|4^Upe;6Da> zplD@y4EBVB4d12obm4vBZQ(z_qRt6l4R6H0gz>-aSr!w+f5JDT_!>dUWq4cUFMJcO zDBTXwK|?4*X( zcw(`;=W--fZlEjLr4GNp<4BvKYV^y@n$jtMR$3|7d4tY2NXXFtpVW%YA!#zn{^9d-8Td1L0}iQIa~d3k&OS<@xQHS?!q(k3G(VzM$lsn?dV zy_lyq7Q?KlzSv~1kBk$#dmqAQv@4X>*fVdxrQ#5?u9O3%t7A2vQru^GsWQEr=3#xm}l*(H#}nNm6UmQxLWqH?4Qu3(Iq^gYga~tQBDWaRt6GtEWsmJ zI~s{D+GS;8*FBCc$F1H!$(SYmhI)N}?H>B!Vml_KMwZo1JGEq=d@d?`Z9k25iQ-Cp zC#9PF-_pBAJn%sFZr=Lhge&cM!nR9B6PfWDc8#*K`u_T&$$F-}kHlqT%+9OjKl78>~5jALyT2x#&!UCuz`Bqm$gMqh+2g#S*cGkrA*s*oy%OrOU!+0wm z(`54~Wor9~PU2pBzG-PR+7>5JyF6`@oXgZu?&wSPv>-kDjM|ELr14oq3N2^l5B(O5 z(f*S^TsF^87U}Vs<;^7D#G)B)TrAh7N5uJKw62T-)Lhp-R7o=-mk|bU&e>shQ&QBxC2;k`<4{1NV$%$Z~9?HF{shGc(3HJwfJ4#+E6Ej9dLSz6C0o zKbcJ%)0|0C(>4@OtfZ`UQQp>Xp-Mg~?-XuvEx>Ja@`b7y4fXeQcv; zC=+MK#w=|ma%>UC%KjTM8!@-WZ?$XvIqUsNPm#pXr1|S4P<%(eu%elLDIc5^U{~#; zD?9gzTfTet{IV^d&*$11 z^ZZvLn+a=co0#gn*k70PtWfg2EX%EPWKZOL){}azInral{9dzqyxdgO;H3PaGp<=< zq&~P>!-Z?L@EO*&K62Yyv~lrSU$M`+=D!+95Irh08A*GkOl#(^X8n~g-dxU{O`m9| zAtk_GZu=6Y#mbvTjLs7ON*p5vP$$t<65r!nR*Q@3FZ0~)V9dU9|JgQ&3%QH-x5jft z*#Z79*^!vh{5U1gkeY)y$j z3P|m(L?+Fq9v`wUxw*(c%fYwo^Et1L|HM*mb+T_RpP!5yWv4f0ToPO6>o{Xeo;C6A znr=d#7*1n&)HvSbU`ql}CG)_#&G+6jeZ2R*_e}+ukQE?FJ_P$Q6zoTp8R>lr(ql9j zk4;leVJlKW{wIlz#!N1sl zeFJZTMn*QD^-YdAg>b;8nHScv|v)Gyn?@jM5-fIyyDNeDN zLwYRErbIZKX5K$3#|oisx`Vb^Kv;{m8R0GR7Lm61DBp^7`7R|+i@E9Qec&x89*ex` zMTxJ1e&l^bN-XlGM97==Lf#Ce4n8FYi@)h5{Eh3a^QuYDzrBAG+j?(3Pc0&+k5D+C zlq$6r3TLoTI30w-=|RnB!s!7DrwF^n-}Dy#rp#~Qx4>>uI4y<386f;kZ{crB zg})gL{-zzF+JnCtEc{Jh@He0-{Z628`hvpgLJSsvQzraP2fv5kgHus@`aQv^SQJi6 zzc+kS0N<41zuYeer&8fpke&f>Qkn2KWy0U|75=8T@Hd^o-wY=Pi@)gspN)oE%*|k7 zZdwX$Q!2F0VE-HbH;B_>ZhH8;fwt-3@8R!3sx0QFOqiSA;!6j<{FiAXq{t8k#@p}3;`pu8lRtl z5g8(kNIx(lYjF7#q)0!IB5R4wVnoV?5$Pz5NFaq zQzd=Mkv@OJZV`Otk~^Ldd0Lh@KLjMgA z`mbD)$Uzc)fFCP~CI|y~5c23lTr3jMlT?~2sZ=hs^C6N={Un>Zf_(lIpH?*N zT1}L+nj~p;f~3_%Nh?>F!3xQ&iIQ1;C9@_8GuTC#!AX)?t}ug>gcTm0X)7xi(Q)!v2zOeI?zxNUrTEEMaG12`5Ur9V_WJNz!ei z5QP;&6t)$ju#FIfZT%nnKc=s?xWYiV!d-VsR#?Ryg;M;6P>LrDr8rn9#lb=;o+6auV4)O;3Z;0e zP>LgjQXDIM;y2WD6bfTFSv^OQdXCBJIgS*@a35g|Ckta(ER5k~^&e%z7*1BtQKp`w zL>R-p)qgZs|ItGI$7Ep)n+an$S^Y+2-ldS3*+>7bJ|9dwYUgO1X4aCmCR)Q;x3)Xu4$&FR7$ri3@#MR>zO#vN+)8<=4uM|>N7 z0+%s<%ghH{nok7JEm zO(8t=m9`(=zh3k9c6al#JgF(lnO83YNf6I`*C-{sr!JK)9c)RyqB!H7#W8o;_JWV( z-=~><5e=2%m;YRCe|hp=rJKIOmKJV>l#ZP>Y{ZpWBVdkRyzu zr{RTK+?c)68r50cqT}`*C52j^6P1Kw$SM=$ob54Y>#lM{C-w)-@TVn>&;qlzdX~Os z5vr6d>ivtaJmJv{_cl$2R(sgpL@%m7H0NbSj4bSEYzgaB)h5Ll<7=i3AtyJr{*dPf z!WUu*qNmbfU|UJj8^kq$l<=e7p>~H2>wi`+0-6w-K7U;+5nr)G?R8|^Cq(^1!%3qo za->hgA)4Z&X>m8U|IbZ9ENAQJ|1;wZ>RV%u+|Xb5f4TiXEy3=MRE>(J*VZ$APh1K- zNqQ(GL_8kb+NA+^#XI2|iaAM3L;mtV&*}k%xPt#z@n%K^$(1!`NJi7u(nGS^jwWpz z;v2^9xznlBV4y5YrdAK0#MbzGCS?tW*}SzUTc6Qea`qbPb@6WOXzxW1_iv57HJl^M zV~u-LZ7J56*oj~Gr%caBtx5Lt-sSLoafCCZRx2ZIsWw*aHUpQHd4nB0X7(PaM62ZU zbBkg4x?SSpMxIS+$4caVv-Xy6ALUYW)w*`7;DoePOrCCUeL;r%k`y-X&Q5W=KLGia zT>2ieZGdmk#0?+MWZ4($#HMALFQ)qH-+ohU$@S%zH1m#jKJvZBNS>(Y6XFnQPpMgH zYE9M|PFvDD+Ww@mrPp|$*)e9yH)G3rZs!MbWWRm7dGE@Ocl)&?->rTlJ42Yvuw;z{ z$a{8cN%!%#L>K|YwFM_cVfcPfkJ6~qtiFmescVE$yUxG-)JgwUqxpYprH{5hkJjI) z_s0ESqA|ZJLELPvJkHI7)zeT{{nYUJ3O+GSxit1EwW~38UChkW^F>!~I&$Yw6F>Fc ziHnhP)2{FscB3|GzPfIW7L9o$^_`vrqa2qoEA|0;7#1h{WV7R7G=RPyd@UAT75*ap z3ne)h{Ss(RVIS|Sw%_32Qs^vpG4@IPa(|o9D{Z(&UNv?wHZoL`yv5Qi>rJ-5LS}I$ zbHfouePphzu>W#h8jhz9A3ifQjT&1v;atD0SJ^tW`Nzbq!14jLY0nE>nB9hxRj91M z`u@eQ&FwpwWj3UJE*Gt}B)&JhG|(53#L@6Q^NrrH-&`NO#r9K5efAUdqDV`bI69|R zYx~U(m(R|qiWr)DMI)+)Lp4?QMKMx)Z)jCvcJc;V@IuV7>mcV z)!<)0$b11>Tc$)*{}EqQ+t$AY7p*c$!4||)Z&2xoU7fvin*3Yte>;<-x+94EXbw5e zA94QGe@4CN=G44>=U3MMzqft#|8Q)8pk`0^OeMzeXonl zbKUc`!hCK?%$f8gQX_e?J>8?;uI?0Na$)6LxVUbgO?lF|k+qF6S5He*-qAR}jB(OB zEt3$S3-a_+8I2>-d!ui=sQ&38?b*UYA}4Vq_go%IT*=q`#`V?7NnDwbU#^YviN9<} zbJl$4=A>eC!iTr;-bRgpGBdcFmA2?xy-`8w`*VD=Qt*^U1a=)D(yNQ4#MRQdprVRx zw9zO#;r!ef>V9o_Q{B&MKiSefa<%!MFCa#>HeaHPE#2HJBR%3Vfnu&dw(ydq_8Zbc z+N7_l`d^wg;!5K@3)xfSN5L{JH_pdGP6nLKS05`!%o)YJ5ozwrq&k;B8D-kmRM9{} zw)rd@^}8|t{)>~sh~H|Q`9xb=x@47GG%~hrD(0zNpZWeJe3kFn)^xM&C^N3fj9uV* zKgvPO7ocAZ<}WBCGPi4k^3X}N2bKR(*3Dv?f%UDnd#tO=l;TU z)ERNz++TA>Tz5`~yT$b6T)5j!Z_b6g-}L1yxIdVIoCWuHGuWN)E-|~iA9?$MuR6xN z+T8E`)cdJ<*ZYYeWr7<)&>6vUNY6etHB#)J=m0aW}}cOn}jb3 zEyBYw7Te)C7Q^AV7H`3+hpw5dbN7neGu<;e3-c`ZEYn$M@U?ZPPxk?hUy7f?DaRS}CVier?Kie&1l7 z+*huX`&v>%x8i;q=l2bAZ>P4J>lD9oo#I#M-tFGaTkdi1p^oo$?;}oou3x#%^(%M( zK#g{xMxP|*Pf@S!sn_?-0Cx$sTjqY`t|k5JyjGloXV3U6^tyR{I1{w5H^g-ChI+f= zJ{}GzhXYQ5+Ef1uy>q=wcxuo7EBCJQt^)CUHT+R7{un6!=pz0I#2$gc+@9@Crzi|so*IPtxv-x?ZhP&aLMzWgJ#cAYbIW) zfLH!u`Ufw;F+<>(S3#oMlhj%TuLrMl9@!i4P%rV2FCOy6Lyk~Fj_^S#;edQ$b~xwD z=`S9dCcZgIXE&ZAzByhTGfihVULw9ZUS~IcM`t%4p|cxL5Dy(A9-1f~I#xXNZSl~h zI>+$@@y)5?o72QM2Z(P@7vG%WUh7_KW{7*vbZ5FVIdAeh_c~6Cv^;dCc<5yD(6_`x z2fDv>e`(GWCr#10uam`3CyJk@=sd`S+?(B-%{k(&DdMgD-QT&t!^NHrIR)OjgSXfd zBBzMAP8DyRA>KL@-ny5!-v@_9Cr3_kfA2m(SbKit6!F<~_mA!$aj|DfUaGSsr|B%o z6Lgm3G@T`RsaN0?aCW2Rwo}DzXNcR*gxiLhv%KNnaL$z+;f=sP68?)$ot)zB3m2Z~ z?e9$lfoXYhig@u<@#2}{#VO*&Gj$r}6z>}E8uKl24TZahUni8(HLWtJp!m^GNigjnrLI~BUC5#guwYiX}BZNfVT}afFkf;?xpY|8lbcEW>SYb^aVNJ_~ zH61CeDd$phGG&plrY+T?TIgiTk;0n#!kUg0*0e}i(~-iO4inb2NLbU+lF(y>H7yd> zG!WKwq_C#t!kUg0)^xP6rXz(l^@KGYDXeL+u%<=Anhq4!bfmDRMZ%g663VntC{tf3 z(~&}%4iw6Cq1d%$M+#+H zB$VlJp-cmzOv{BbZ7GzgBa~^8P^N8#G94+DX_0!QF+!QP63TQ>p-fu|Wja+T(_Mu! z-A^df@j{vICY0%Tp-guZ%CwbGrX@m|wie2?L@3kNLYejv%CwJArhE7nWx9({rn?Ab zx{FYzr9zob63TR6p-iU=WxA_Src;G7-Bl>lU4$~-MJUr@Nl{nu{f8 zj|*laXD^bR{jScfJY45i9x0i7sATRTlDUUT=3W@Q8N7*qd!FTyI?wXpU`{Xxmv@49 zVoe(DggYH2+-V2lPDcrMx`QNjZ{bcy33u9AxYJR>opu-Qbd+$X-Gw{tA(`G&xYJI; zopurKbX3;amZO9_?IGOhDB(_f3wJt7xYJ(3osJUjw1;q~ql7!{F5GEH;Z8@T^%fXT ztF~ubjuP&)hj6E()T8ti?sSy;m7Rn;?JnGDSK&@a33s}K`kUUuosJUjw1;q~ql7!{ zF5GEf;ZD0`op3oyxYHekJ8dV#=_nyiM+tG-UAE1$|_7m3h zd%~LTCamdr<5t{jzpV!zH)J$=3C?GPUd>zuyL0%CEoH`bS@=Aw{A*dYuMA&d7G$1% zf@VXj+mQ2b_-$mr!m=$L<9_+!_5Cg#sF`;+ecweU?AO=!Rev{qr6rew`+ZPg-dCE6 zCcHd*R-Lw|Rri=$EXqb&aJ(v${?_KZJ9E)u*YG8d=0jSwJ@JqbU+*j0c^>6~+}RmO zo#Ya`+=|qSN@)p_Y!x%rMagc(?IGW2=vqMvA0pjjS?yXC{)_ZKgpAd`NS=IPQ%}n0 zBwB!3Mfn(ivrIE2Fi-6nJLLNsW|`+bkWOZu_#>vYAhVW+bz3J(DfDFV6k{W&LAZx{OiJTKdFM@a{Il%48uF<+im_VPU0$)Nr6DfOW(qEyB?PxgesPA%U92gzn5x7R|M{OiN+hB(-zbY zYWdlQ4px@}qk(#6O`2X8)aRpW8LJa4Mv{LuOwZd@!#Jj)Yx z$+hT9y>U@xe2L@iOv-|Ilw1|Qo_QMC7#n0E8W|se=dyf`ZIRveij9>YXindjAbM-Y z)|mfw<3F+(OI|0ww(K7MrB?U?!`xUsSDk4fg?HOLtg;@`qY`TI#uSkidV zqC=Z<*<4u3k~!C!|MNIOIDL`p`SuU8>I2K=$Q~VKL)%>M<~+WltZa+OzNH!kRA`(j0+YZC%Tl5>F#K`V^r(WH$$ zH;$ymMXq)<96z_=bEH`|k6<618_!qq52+Hjvixt4zE+S=NuKh1`x?30?@ybXtX(c` zlzcRMvmYQC@s;LeDLuo@*G~HM;~XFHpKVo6O&TZUOITCgSO@qzUG;PE*C{7WOsn0u z6EF%$_U`1!n_TJB#F)Pl?~NN-*xF8NoY8?D2XWQJ<00F=Hd5>V=zKy{F*Rv14iVo5 zI%Buy^BTQo?Z?}{OU``Q)=RF-*PE+WonltlWA3aKEx#+jBGcXU=3AhT>5Fcb(dKB@ zJ5De^<@Bwa%$?>`^C-XR=5>DOI)T&Eoagj$%FMM+g)`9n+!^fbZ+@v$w*KlI?;LNQ zb*^--G|xG=I=7m?Idh$P=6Pp^O{@iwlJ@|t=v}TO}CBP#=OPZT>Z@3oXs`XyyG6?9%`1k$GXRv)k8dADa!rhkPMq z$R=SwLNFdT16%>o;kfP{?j4S25gLwfQ5jCko#p<^DR7s%tDJ5YN8$AKe&YSa8O2FN zuREjtH~l%zPX53A`OYr>ilCV@K4=vTbxsZr2#$1q6r2{E=FAMfAAH}rE_gI}%(Rx(MT%w$zu$t{%(z zosifmYRrBM_o4<1O@B`EZN_)K-IX^;^;)5NZH=$)oVeD5UuVAGd*W)(S?fV<_olA< z@EgdzFQduP<{;iO!5l(}L(Sp5>j?Ou6MO*9%bdrrmrh>mZ!R(4<6W2WD>L7RO9qKc zD#Rt-#U*{qlRAi#I*XG^#YtVnNnPNi zzNUxU4}Kb`v)%@{W8ta_an(R^RcCQkXSnJVuy3cjr{eP*_Z%>RQ{gaA9Ol7c=VQMB zK5HR9^Mqka3Byz*Zu7)#MdG%W;qBnzM8k;W5m9 z4L8S{5&VwVsf3e|kE6^~wVZR+T8>f+IZ9^}o}#k}k215(YoyNcGA-vP7B%1oS{~6gj&VvY87WY$2rHD8EPBToD-cBv7hCf553TtiT~@I z8?pb|`JMTu&N$r7xzG8VIm>z8dBGg3^A0CDuQ_j-sm?pjJNTUI%r(<=0^-?fS7$l@ za{h(8mC$E6i;&P~IPW{}L#>>i;VgBQV*k+jkWj0T(`V@1#7WL-XEkqG#MrDqFuI83O+ zk!rE+ggPu1>TsCwhQ-1gwoqGbrMB8ec*ADG8;%g(ut<2rF~S=T72dFo@P>}?hQoz7 z41_oIgg5ln!XvcmD7Etv;SCFfHykCrVK3ngM+Cjqu!{)*px{?&Fg*R+2ykUv(hRuaHbc8qbg*O}}ykUXxhAAOhn@h5^7T&PA@P;W# znbyJ^4i(;TnDB;egf|=}yx~yc4aW#?=m>A<3vW0|c*6qW4O7A!76@;c0`IzyX)e5B zZ^^9I!W%Xh-p~==aFpmUSKM}N9(l0gLGQqgy8nzF0g5L2M?MgLqVoELao>Nayu z>h{zf*zZi;iT$qBz1Z(h-H-i&)C1W6nEE61PpLnf@u|P2{)+n}sYlK6smD`~dY2e z?WcAIf>g6}quyBtd}h{P;x(fG*ga>|VeYPpFE3Z^lUHfwiL)S;8&0r_+$sI#tPL`+ zqIs5A>^ur7euJ8sO{zDrYi>h&_QkGB*%{SE7`vXnh&bQa^4%&)3LAH?F#~Vc)S^7q zJGWhL+yj#w*6V84*4i~m+i?Y9p%vK*GSyCm+)k6=D*dLnv{IT)!H(;RBO1~(cv=rsEkhL$bJ4b z`*-fk+aL+mMUYGHEI=BUqkdYV{zZMd}#y8rEnVerTdCT^lwSK&(3QSU3 zS*WiqqCFukGwTZ9c=zZ{O&C9E6jym0At=1Q@9bs@${#KtCsn+UxI!{qt^0J*1s}qORYV6WbQsKh)8}u{3As=-; zPxm#k2RJ^i6YIb~+kb%zn zzm=SN_Z<0nD1Oe^0zF477(qWrI-iR)BH3r0!&+)OqJJfQwjO-4%8v ztV>QMypxV~D_hhDM_(-IvnZoiB=e*BUzpp*bN$f&rsYrWlg;^uJVA!nZdr|utA2Ua z*jP5-AM%E|DZ)q6jQz)aztsMAWc@DxJLnhEa?qAS6t>n_p^g%_{%XH1|C{P^MgGmp zekPvD+ASRMDDu zTU#Asd3O2d@MUR@G_VXNFIxiq7i!9OHyZPhdM0bgIQV{@Dsf`ldeei z(X+Bnl)0=g(>!}zL&$XBpr;wnIkuV@tGZfNPM|vfa8l!ap> z|HNbQT7jD6z}RNK#!IE}PBFjsj4(Imiz2Rz3Xd(|?hws#p$X5Q&Bzwt3pD zO;62QOj?w^*OEVJskJ#dLGROFh%?p$f-PNI`D2U9Kl3c&Jo1)b{$$Jaj+s!hZ5{b@ zGppz~v!Yf6pKO4G+R&fPA|L;V-X@Kh^KI+p%WDD8B8`{KQZQ=jXew}9kfh7z4L!C( zVQRF2F5)@RoTyoXDfBmuMVn+-(8cs1s*_`5A-yo|OJ9N%N+qd+2dxe_JC2S`@hI7vNF9JzY?$5Q;$? zyz8_CXRyd=?JNdq(8gKDuhjX7Upue`Yn=8LPvCTM*7NJ?Y;YZ?8|VSg>Ei}&fz#J* z<~DcAEPB8h0B)eQGtlA&oIxN4Ko+_E-EwCr$bf^L5$<8`G0y((@$T`?;dU>%bA*3~ ze}{9Ff0uuk^G*LA{~qUP|1tkDXQKav|Acdl|CIlfbFBY{|AuoMd$i{{$FoO!xidK^ z3R*f-*kQegb8fI#u)lL5`>aoOE(uN#E_1G7pY>mzyMu>=N1S=7-=u!y%un5ty2W{y zy~%ev3sQHd?seWv{XX@3X9@d}|LiQaJCU8`b|P~276n6?bWWk2_w@>7@7XU$W&109YM5MjLcAB zWJU-hGg|fCQuSR#efQ&?{rMFL7gGc(W&pF?77^1|h?r6#V!8+s(?f`uzCy&52@x|u zh?v1b#0(Q6W~8{ML?{@?{LEYjhh5LFnfW=tl(_+}Y9_8KfvcV-XU~{t$;)&6+L*tY z{~-?+HPcO~nO>k~R*<@t=3{mMtu~)vw+Na+LePv5f@ZYCWl9{^aZPu}gGWomqb%rHQ3SZMj z_?jNzYaRo!_PFzm>FqoV)~2tpHUosU878dFNO*k?VJ+gO$Z|Y(INr2{;}>JM_?o_! z@3F)8*gvM`hY3^D*ZIV$B9uke^cAwEz_tX!T4YUMkTsil8aknAYk`5BJ4d!nOWQ)A z0Vtc6Zh>2Xdo#Bgeu~`YoN#8bHeGCM0r_T8HZ6s(DFa{A*^H#kQ2TC~Th3Ej4q<6I zP+E=&gu)nWTaFoHTMm?#1Eu9aX*p0@4wRN-TKWI<|7kpboP#gpzsbugm)M&yu&El0zsXP4|X?Rut%_mDYWen zN;|~;^k537ot+a*$9`^bu4x^d7knFp+eN`;rmJueJ;6cz8f@E5!S6t_-5T76-C`mJ z3KKD0n21rqUxU9A11;IKvMt$kvMt$kpe5gq{SL4O9fURLNUOdZ`#m5II@z{uItpvh zL0E&%!WwiI)?l!(2Ayf)PvifY)HC?BxPuP1wbSBU_}Nxtf7*yo`?F}yGPQh9ZM{q_ zy-Y223BGF79Bp z=Ly2a6$=+Pfi`@;nIK$T3*q7>sNIfNyKOC8TwAr(L29c*)K=T8l}->Yu2{IZgN2K0 zD_q;HJ=9qV4)stW<%)%rYoS&>M6J55+Vcdp z=Lya_XPwzsc)1B`)oq2Bn;^VgvG8&egqLeUOKxWN6KZaPP;(R1lE(`*S1i8CON9>*4PMlR}5p?Z^pc^X$-Ht-gjS+%wFCpk!2thZ-wK&&<-P7IE zLD_u=#PlQ~-zEtaHc98e9ip?}j`SY&9yQ16?6(ti(%VFx*><$fY&$`xwN3O^cq{Pp znfDoVgSXL4^c~+ZhwC)8<8{K>aXQcJn>x?zc%5W+oX#yfM(8hBa;JmPU#`$!hY9`F zMd+{LLVpbt`m3wZU&Dm{>MHb?EA&?y0FBfxQ)Y--Co4>n*(RLBfwYNZ9>wx7go< zgeB{ZJpDFyD@lDx(m;r^l%!{&q-Qfp&!NFX!9)0bBzO!uKbUWh&{<+f3DY%Nn66!f z>FO&?S3hC8`UulCN~dalLo&CO@LerazfJuPDQr<*y9(viQj&L9p}bnA?n~XrQ;YT5 zRr0r$5MM1Ng`s zzs4B1=m?~;DZ0F9JtLOu8B3S4*L^no-|YCh%zioPtGtXkl~-6n?Sspz@Nax&-^`bl zT|HgEj$ErRdUkkA*0U$VnYdNg@c%&+3-j5Fn4_!=@fH3coEBad9!*$u`|?|WF88U2>X`oq0&U{3xY0(o>FxadI{%{z}mdMEco7nVckU#E_09 zDura-I^#F7F}KECBqe4MMpj2G>jbfjb+sre=2CF0#HYoJS^MjoI}4J$b$h)>8vVA{ z)YYA?WVIs0^L7RznOCCLn74{*64!)}G;a|j=f$q{fSEeMz9HuRmGoD( z9i;0^&%|HZZgK93g-f*NDpc%)ThuP#)jO~+i`>P9w#`>rWJ~OJN8&Sa;rV;mhwuZg zopH4`wRIcAdV1CukOf_!7TGn+rjy@FGZ6Z;o}#M}ZvP?wKBn(p9sV#kE@D}%GAgm* zK&#G5p6BeDE0t@Bb+bI4^UOXf$>Wwp)Tle9!QS_zjFKR@&Q9Coz45mE?3J%)%j@=L z_(wQ+VED%;^zVatek?fTY6ep%}YHr@yYNEyuzZ%yJzR=$$|_HNx& zM)9gx&1c#F8*7ip2GtgKSyRSPyJ=|`{w(p?*q9T4tjk#5PsfzFNB77b4*3p#EDxmD zZ1~oXv|AXyY<_H9ebe}S)BKv5mYe@ z`)O9g6 zc?UakE;lnZ?@+FJhnbppDA&BhOwBu7sdhX-GY&&F<6yP!j4;P(#$mK(9In%h!;duM z@B_^PUYrbHn<_l(OreLP#2xe-IpiFZFGc`w0uGxW^njM&_ z*?}uHJ5Z+CfdQHqI94+P$7)7ksAdGN(~Q6*%?M1^jKCF|3nwHI=b-pXjI+sg>&dJi9bBeU)^pMt^ zY0{c=nY8BgkWQ^$(wEai`f?6)Z*^~DM)OYhPUv0kUCe;q?cQy^D~&ikqzk8q^xu42 z`fo0BpK_liowMBkfxhOx37zjQB+X0QCD@m`ACU47(Z|)-{m5NS%GbH;%tg|a^KCcu zj5$O4a*p&0y=LZAY0c>(tvTP7UYs7%i*uPY;`ES4oG#LZ(?eQtdU${K{%kJP3`>D# zSPu0b^Byx3G|y7({oVV!X{Xti4&Dpi3+M!U(R&^CN8{d1`dzq)Nd9UIB4et$e zp!c@-HZF6$xn?iT&g`eznPP9Dw-EQm-eS|iTjDJ-oxNq=GSf=)HHUgDy_LkU%3Fn> zkG+r0Io>DUC%mP~Lz{+Xa}M>^dTUANI&Yo1+WWWnZ}St)?{x6K@V+n?c%g61&otK) zpwQ1{-q-UzbFk0F>_*@h@U+k`G|l~Hei8QOesla3`^DH>`Yo}y@>?PGO8gSj-f!)* z+TpkH+n9a)wtidul=`K}tag5T>>d0L`0VI+!rs~MZ1(oM_+7Af^}Ct_{BC}C!uIfc zFc00+?}>XazZWjO{odwVntLkI+*5PE%rE0^RcJP(oy(O%Skol)=UboxVdI=!P4>D%Z;`u$UVQhm&Bss5?{<{PQFr81n=)5WBh||wI^=$LnloQXN>J-JJ@R0u7o)FcMHG!d= z1hKD~WX?9A{{k=kUXJ?};uu6NdEs4(kh@ z=+}ptZp1_mP z!FRE_g88{y%>ArYKhN6Xe6!qqYBo7VtS0wnOi{4g5&QNur|ovkUOmk%dmMH|PcwJI zZb$WWT246ppq|d&haYfwPv_Dj4m+Ty^GKXR)({!TlriEM&-pXoG$)&B<~!!ctYH4m z{2pDGe@D~hyR1xpW^H%c!>z%V0 zpDbWCbFE`o4e#jmiNmE>>FLg>Xf$OzffdwK(YyIwbCvmpxy?Li9yc$TH_bxxp;^aL zXA7s3(>D%RzzT2=#!+L;9<1yfV@^X;=cVRq^GkEP`Ga}FylCDsi_9uh%`#z2r?b;9 z4rlcc^}?KX zcfpzF0%;HZHETnEW)=7!<{k4sYrOw)0(J&;bIRjz%^7L#z<3bZiB{4R(8hS7xt#Id zP3CU%7gmm6GIPxm)`dT3WY-2=qZQ786HlCSet|DqELtksRkV-jK+%z+<3#rqJy7%r z(c?}YKI7zqQ$(kVep~c%(Q8D1DSE5u{h|+vJ|j9?^sSRmoN;Qw0@0PCYb`A_qQ#;e zMf->j6&)wKujmn?lTSYRoau#AL@yPcDSEr;L!vK=&J$fFy3x{REk%2Z4nJk;v~!y6 zDZ0PtA)-f#9w&N|=oz9@Mb8(#SoAW{D^5G_#FLv{Bl=6xTSe~|eMs~f(b=MJi7pUb zE?On}`IIwHKe5OcEfOseZ77u|cxw3DY49VmL3=rN*`MNbo* zDte*lrJ`4eUMu>`sTZ7cUePV0cZ%LG`X|vxM4u9UUUatTYoc>R=Zh|$#;s_%Xr<_< zqU%LBS=!teEfOseZ7~ZK8zUu)C)^(F1>`?EMWcYd zJDgt0uAcu3E)L^S%RyU8J;uXr8EaZT`7&(|BX^trEvK!B$K{)YIeX%3DOx1ABIZZ! zGm9k3y(jK1V)x{3{W+Aw)}dV#&z946if7AdXT`Inw3u(o?tDR2@O3hXFNPs}oetwm zaRjmd_gT}+#o-Ix^4pfufbYd#$o#&1w_ANp+vUHCqmeVVj0RLpNZa;I+jdIZhNf-( z(zdQ?Tbs1ao{gmV$~UELYtyz>iLIg-IRL*__|d(5V|-s5-z($$viNTI;^V)3PJDkQ zzR!y9PsR6#;`^uEX$$46;`@^LK0m&{72jWq@6X5gC*u2G;`{ya{r32NQ+$7foT#0Z zKajTFp0?eXw%OgBk-s0NZ5O9))6=%o(zfH$w!_l4eG^;Rjc{iFvg<{!5xqk6GSQ1g z&ljC4dWPspqQ{9IWohp+X!G8^MZ1c&7cCKG-ioKbrM)(Zt{44Ov{H1rrRBRoyO)m@ z9WFXhw4Z2C(axf!qAf)WMO{nFHd?w>T3pm-Ije)YlMb4#+aBx=vtm}BY#ANPJk`a_ z-t2g^xhr2tuySw|7Vp84zf6F@) z@T`jK|KB@vXYRYOM3xkjum}MHvZM%zfdFEJu!_iTLRb|M0xBXRL_|cO3b@o2k*rdR z7Of(pve^DwwMda7AS$&M6%o-|q-v4=KXcv})Pk*j`s>r@|NQbk`JQ{{&di-Tv(Gtm z?w!;;XltaLj{4g@3pLdrtG>DVj_UiXAC-Gu{qq{tYBZ8vN54k11g)8spl9TWADg}* zeQWx@^iMKWhC7YUd^Dp@Mzf4g8Oe8L3`o2)V`P#JPf}N7d3VO(fAV}<^7-OKnlct< zJdm*_>Goujy1MJ)I+&pKT}|h{zN`0+W+!R#o%LrY?@N-jB1y}VbY+sd)mFv;p_x|Xh|8|X~kiZ4`j(RsR`9;^%W zcs*H9*R%9oy@0Pz+^O%=D|Cfkt=H;x$lG2(s4fyAXV5Pg6buhW1>=KB!L(pTFdMndLgX%Y2M+`*(JEdOJi$4MjpzlxjNb1q z^m^Y34g`mTBf*y(!-|+1rjAK7jZ8Ds%Cs|`Ojna<`j~;pOA5?bGtm^A>1LLhYZjQ> z%-v?WsW5BMv)y1`;JXXk_;SKt^R{`<95SCGl{jt#8$+s457|McZE3S@N882ru=#d? z9c)M1(e^Ss*Fe7XnW`%9S_F+!gJ?t6w3kQY6!%^Y*a8fueoDt3r=Y zA8rm`47Z1S!neZ%;V0qI@ED)=ibQHe>O|5ajUvq=ts?Crog!T$d67Pmfsvt+g2>p& z#7J>udSq5)Ze&4ZN#vf$ibz%DvB;B==OQmeUW{yu?2PP zQ=+NSjA-L%R-EV?TC zSoF#0hUn(#ucBL{JED7{`=a}!2cw@vk4BI2iJC|(B~~xiD3%q=j&+Q6jpfDq#0JKO z#tLF%V-sV=vFWi{vAMAYu_dv)V-Lhu#;Rg#Vo$`@$2P`(8GAXlJ+>?MX6&8Vf!N{L zk=U1UFK*-2;E{Lc7& z@fGok`0DuD_`3LW@fYGR#<#_H#`nhGrbVc*vtU=j^93&uyh!ju!9IeQ2o4jxLhxex zJ{6uHc1_aGNqWNlg5>=bLi;5B_2Hc^T%R!EuTPlp*C%wb_QjX-1|-qFUx)n#uLFay zSkUq0dv7&)T21&j&C3HowenL>=*4M{-a{Le!CZSx53+M?%+; zH`JAL#B$uGE#>r%D=ACFWF#IaN}cntbZ&XsXnn)X=rWRN+%9@$((O z@FG%|ppHlG{)D@0->LGpRLTDVdBXvD+kvEut91v&&tZ95PoCCGUb|LSPg0R4?rGwl zCTUDdy1N#dCNZQ*oN1Ewbn%%kJV+NFq>G<);bKOZL#T`}SFpQa55WO~d4ld6lNq5K zy)(k0g07S^!hz&7gRvtwN!?1!-JfvhWS`u-(#r_*lm7B~XW~BLFF#E9%XjJW(_LEp z21#`9uP>>tFX?R%PQgWxSaLq2#imglZ(YY~J-Cf;oEGcO$X=qGb%4;H}+C0(e5lK#n^zf024Olp5KiNBe|-(2cQb4f{adD@((+5@G|7rA$Vj&q9~S3t+JMb59kR8n%bq_eeLx0aN*mb|o1 zx;sv^h7+!qv=&aZ5x;FD2W`Y}8>yFVBy<}I*G5v)R-U#Mm$u@kt@vr1eCoK;R&t*$ zyvdfBvnA$i;Z1h(se602)Shf&{?oYV8_MI(n%a;5<Ebx@jBsR=yuY2KrCsvcwX$}S3TBF2w-@*JlE(H)ch~yb%UjyJIJtH;xr6xZ zAUx=NDr=7u+c-NMCSkMKueUU)k=R9s3}L)Ks)-hBs_FQ0~GJjoI45|(nE zE7&b8=emdB--oD_5DvS96%xW>x3CKAA;_Atc?P;UUC=|B$=ZG;Yx+0Q%n1C(etXvM z1+3iXvu0o8uk$yvV&92A>;cy6$CTwOkeWQEz_tF&Aald1LZ?zI`V#-tlTW85pE~NE zGQZ@h&#G{iOUFrnwiju=d>zZC89qweY9Z$9Olwvs-fJaykO zO{4&w6}WvO1%CIWKS!NE=D_%4hU!)Gncamw{9fRclm160{X6RXj}ZUl%pb?q*YPe( z`g7Fzb9GBN;Jb4C+TWI>KS!NEzSx@Z2WKg7;n}p4SGS$Ka{dxeKR$6qUXyO*>crK^ zlUK`5UTryf^{%JvdiM@B47Vw)=)16s)y~iJ7ohk1DXa0WdSS5GOfqZi?9kF8Muz)Y z=^c%Biq4AejJ2WlbVXNa02*GS&}u41@23PUuO(^v|N2Dv_gF@v{D@tTIIsCrj_te2@jR< zPzeu}@K6a4mGICi^_i#iy^3AeFh~8|aYa1~=BW)}zIq%iRBOQ^=QEtAo(4 zXkd**Z*J1bJ-9pBg)8$`qfxcN+lB=YJBaSyQSTU?Y=WPE6I!GLp{1I> z6+V|~E3{l|p%vN}TB(iDDiP7Uh^H-=Nk z#;e`DqfBkbUk=*e?j5g#d1?=s&)36MI6(atEL7XTBJ~m0ehbW1Z-9krFIc4h0G6uXgJtS%utMzvE2Z_k!&M=2<&!-(lCVy+j6AiUCpn6C zMr;ScJoN#X&-cw;I{ye3s`tTC^&VKJ4uIwVMh%EcNfv4(B9wTMt}e7x$Ay;ZnnKGp z-^Jm1h2|Uc&`MoHXcao~QUhFgN>su_B|KEZLnS;^!b2rIv`U9k13m=rSAPN@Rv(e0 z9OZiFTy+R6bbhD@pMs_86R=Dj0n3%^$tu;K!7Ain?HQr+DEGnWFOElJbvhcF^S#^9 zAia;irGj>_)?0@z;|p@4WT&@RPn0z0Xy$jMC0Adb%nMQu?u)b=jpl_Vn%*2*s;?AU zrWtX#FV~ZWR_IAWD|NBZDqV{d<>=aAu6E-{o=yYv^%-EHP63N_23Vpefb;YZz*1co zEYoL#z0(bG&(V#*T-^XH)UH1-(wShXZVZ;`CSZj=3#>%e;H+SG zM7Hm>9$~rz6zYDTY;sz zC0M50faUsZutK}BxKg(UtB|*6o|rY!`{gsH45t4Z&8RY&9&je(%6#1ppE>#*Fjsd5 z^K=fFuYLj!&_4tVbv9U}yMQISD|m~}1#i{ez{UDpuvB*d%k+6*x$X#7==NZx?gXB$ zYL9tW1|(O`uh1y<^@;Au*WSpl&zryw?F z6vV)Mf*6=h5Cd}wVqhjg49p`=8w2B@i{Wpe8v|V$6zIl3mj(^GvCpN!fTu|V{iw@P z1ay6ME<@7(5E>rT>-lO%*A;b==w6pr7586$6U-) zLDz@5m?wd*|N2KUPv@!Y4P4CEfUYNSG0z5FFW_Rn4s<<$i+LvKTEB~V7Wmz1p375L zUtG*Lg08N(m=}Vsp17EA23;L-G0z8G{ctfa0IQg7vsz5spZMpxW6}k=Ra!!Jb;4yU zF8n9`_-UKDf6RqG>B5RIt?XMat8uyKTP`a+KRxnIm-I&XaW*AS#*>cBE4|-0TzHnA z!@rX`yA-N)PB3t4@D0ue9#M6a#Tt>H-ioLGne&5HT(|M*W$+cSHtQA^(kmpN<3{TS zYuIV_Dx^Qx*y;9KJHuXw9O!yG+s?7GETxdnw@~a@JI;=`6Oa>KZYSC+>?C`oEw)pU z8Zm!Q_mo=?L-}|5PcCieI+wKLxFqzV5-ut|CEenF?4&Q1&fXmMU6y0lWrcm%uEerS z85XrFu;)_c>}X-pWi^&u*4RhwV_0$Fo89&aEVw+0&6a2FbHugBQr>CU5wqRx`AD@c zuzB`E+tc4y(b{5Pu)pAr5x`z-2iZ%IgAK7m?Jzss zju4617(2=q*-LGKrMF01!|B`MoVywsTpAPvg~;`ag3-a4pqTSl(}JsltAlHT>72rv z!3ivtwglhfg9*W9!R5ik;0h#wR|b=VDc|9nR>k>>U4$$4>gZ)*=di2W31{EC7m}5} zVSo03FAgsWhlInz5#gm_F>eudT?3|1G4dm)i0ARSg)guF8uuNzy=rz4TRZQ1ciQSZe$Tt_ z!GGho$$b<4l;0NOJ^k-h_oa7tQtMm4mq_2%@BHm>=e69)t<(H|=T32*=C{**KmIho z*W7upQ-4xz)tTM>r#b(&jni-2gI9wcoPXOHyoP*sH+Jh@5B72f?oCd>?c@C0+njrQ zhx2axIp_8s=i3eh9|Q-34}(L&N62wM4n7I~6nq-|8Hw&^!O`F^!RNsj!I!~bgJZ#0 z!Ex*_VPMcGXMu@Ran^*$e52T4ikoVtI?~^oCdHg#YMI)|f$N%7^8-`QoQXs@-DH^h zrh#dQZ1^nG*filZT~nmQ%}thRVOpBAkr%f%ZA@E}ZGMR4_#D&TbTA#wxyX>uGdZTS z$u(V&CU?W4)A^Q)EV)F-W4vnek?-nPw)K%doaL(Okj0{Yo<#n|sArh`Jgn^)=>NGs9eG zW^&4Jmbt;qHgimgxzXIjdB2;@d~*vj>|4!3vxt*_KSG*)yIEqEnq}q=bEmlr+4hgk zJ?36>pSd3!eGek(e#opa51W;ylrw_m$h|8$CHRP0Wqx8-o1Y>Df0XlrKQn91;c9sf>?IDK2#@~t-DTEwZX;QQ72Q)~LEb$plEZT6Vg(G7Tm z6OM10edhOQ3H-slWB$ll$9K^ec%O5QA8@|$Lo^3IGKbB_oNW9PIs|_b3@F8dEsYSj*PtEMr}pYJY$R z!kISBrrQi#-<@NWQ;dGyGJm(H>K^gx*v7WCZG&B~Y^;K{!zNgJ+W}pLb8RQAf#qNe ztc%UHU2QjfzvCVH|Gb^LR+>#a{dOz;t~T0}R{Ec6qt{9+JzX1}`){_<@83#Y8-0qt zE7!0ds>2$b^SJ-r+FFnLdP;3IyBQ~#UDbYq*|T4J4I18_&pf^MYA>!XRRvf^9i@uY zXf;NSRpaa?H6CB9(EJCFXW*B4eC-PC4V;JWS!dNn<*Kf#o9eF4M-%M=m8ULLJykE2 zuX?LKs;}y&`l|uzA~jH5Y;UyB6U%(&?4Hm1p!QrZhBekp)L=D44OPR`a5X}Wv>Wj= z8@+mtGUI0^_FP-4vsEkATD4JaRkr#eT5sp5_Nt>gmsy*t%{~P_Iw-WkYL6#Rr{4op zs-?agIbciuh~O$gR=_9Se=66k8gY44@G-%k39cnQ%=>CPIZOiC>ycgkRMmp3v%I#d zDf6JSR2JCQQ?-t$Xa9vU^?zeT{XXOA|D{p$>3=#$`A74U6WmP9MgDmo_rE>w_`ZGG ze`?m@`mLI)H_t!y_w70Oe{}Yt zm;Kj!^wZ8iNY#IHPIQ_Xk=bnf19&^?yvAG(6e5P z)b=?vtzY!FW7+6Ubgd6y+xRHb+W>9r6l@GNP??M}*^D|}R1ZeC0gPHB86_`Mld&H( z6U#vJvGcP`-J_PPGPO!Qrk=#|&Sv#1wN>pGca|NeV5eozGI-4{QtU_vyX<0c zxBU^g$KD3MZf^(o+9lu{b}9I#T?W2o?*RANJHg-EyTG?CBXRHt`(yAOdk^?WdoQ@( z-Uq&G?+4$r5Ae2E?1Sn&?zY+G;BW0i;C8zLd=(qIE`2NU^*dXt*rg?fW!!Bgjpf|! zvK8QNTZ!8qTcs}L?sd!PPZ}BhNh6~_X!T0QwjP?Bj_Cvj?rlF@yJ9RdAj%o$ASFOPg3ND+DiWz}9mpO_uofJFn z<~-F7%u(lnofSJ=CRec=X1b_z!LDv*qdJ*&yybnxZhde-bp}6BUBH7X7yMAMPahmo ztR;evRCn;OIv@O4^#DIn7l40KdElq&Lh#S3CwN5l0zXsv;8E2Z{0rv?RPed#3x1*c zfnTcr;9nJ|jQot?n7W9&uhc;BxMHu|)U+GG6#FdtGTkKKnd%Z;nySHIGc^Qku7-kH zY8co;4F_AQ5#ZTsB$&;9kun#k0x(apKV^C?4k65AG6n$}S6L;nIwK#R=@m4QA);^56zsnzPS% z8GCd?*h!lh%*G{)eaFk$vm46J+7-bZTw1UXIgvfQVYJ0b0Xi;dtzXB!V=p_LowqB4 z8*w?CeacDf@r__7ZgMaemsadsUdf){NOtC?1jr8PGsgvEY235ft-Q#N=4uYRmIE!k zVO6;W>||d6&to^xu)>_hj%I&5itEny20Mj)LpaLbA+?|VL-=j`x&1))QmIeur&5Q) z-sp1rK`Jv_ch)CR6L6b~h3H~01HG1ASWmx9U9MjbnwsO^Z*MQ82Z>GgYKD)WcHfmf zSGV^{t7*;-a39Y@Yok9Wfi6NOmx{!?fEB_hZwlw}rlI+AjyIdy(iMv;3y^RvsoScr`OeQzmNWj^vA_C97qw*uXXG;baD zSL%De@f-S$ysds?zm>O*Q#iTa>wb5CfcF9R_lINOWu$*8wrC3d(Z2S_`s1(=#Ft;N z)`Jy(EY!?k^r*)voh5vOW10Uj;YSm`Jcc_=5nNXnQ&nS5{{Yh;)n2J zHZoqviw^K&k=GesEb(%Y@7_hc4`AJ>2X=;5@`f^g`CcW`%-(2}Jb^`D?>bd{DT#9BwxDR`^Z*mY(BD2fmSWuHVIb6wdbae$FV|*V_Pphj|+vf4yIc z6~Y%Cf4!IB?3I+t6n~oc3a6m1_Fjd{bG#k?jsDGiU*{Hof%khje!KSvIDV)1u79`x zWA6Z#3Lo?iGKR14KB5fDyu*~iI`0$8VLj)pHu%qD!DtKGSlZvp$X=Z?FyZKMjOTH> zV=}e;Dm3uo;q~EcuNKm_dGOswg4zvhJcF?tRDcxqI&Tg#)ElXFOR@ZP2ldX?y5(M} zSB^COQEJ^Uv0wCStVwO9?(M*`&`zxV?Bc8!_I`M~hlQagex`r6--_8tFTbDPpV`Q8 zzSUAh?V8|UM!lNsU*%sz%_^Zr-R0lItYo?W5cR5@xk;s8McsOuI`td>RlYUkqurZB z>&>O@qVb6yTu=0idUI-J0Qz);!oldo4M*Ro07=Ox=0sz|ap4qnq-KV5!jf<-JO12 z+>Vo$Gx&F>$LsS;BQ*`Vb6*R|pjT;vTQgGDfcChJbZ%#-;Tck~(EC`e)iwPT>=o|u zr*TH=n15gJin-B$mR+Q$RWYlV9jb!)_E%~ndxvMCed42!pF?SUT^4;$DGSGc^at_z zJtZUXu|6qwCz4${+p3x_U*XMQ2zM4^OY#ml^N>OUN2$PD;p!&m%MZXn(o_(R;u=}F z>)+vyvlX*ru_HdEGrxH)HeE!;0u`dE(m_ zg`UMTH^RB`ydR@?U2iISHRpOueC_qd@|p2AaptwPx0#+Nmp0eU@8Rv^`&IqCcjyr> z_4W&gJ{5bkN8wNvBk(Hvy>{r+zUKFK+@jBfPyT3ED*hP9Eq^R!Gu0obu2PHqE1iv1 ze?F_QJN#Q{AItq)owZbdA(l{g`-{|G+V8FQs^ZQv6D#VAnHKwTH`H)ksc0 z_Ey)pGF5Y!6OK_O%oeApTdAG%)g7*s)t!7PYL!|ZycE2oR=ToMrH;F*%yCzhJ3guk z_Z1~oXKDACU zrwsb?YfrCR%$+O6j_4`}-UU)0@}#8}&=Ys{MoIY>(Wd%)ms78<@Fp_iJVIUF zlKOcyr9aKPMl9J*r{8(VyH;w$41JtlWoFPV=;mE7Eo>I^>*3yf>0fS<_He88GK;0X z-zM#S39UBATS~8)>n)>q%4b~lsjDYP7dQ5pufAW~rdM>e)zwjt{>zOJt_S1q{{aOY B!CwFX literal 0 HcmV?d00001 diff --git a/crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Regular.ttf b/crates/editor_ui/assets/fonts/source-sans/SourceSansPro-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a8eae164ea11967a4c12fabab6ef9169a797f1bc GIT binary patch literal 254076 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

$mi!mFVL0u}A-QzTH) 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 literal 0 HcmV?d00001 diff --git a/crates/editor_ui/src/design_system/color_picker.rs b/crates/editor_ui/src/design_system/color_picker.rs new file mode 100644 index 0000000..f90cbae --- /dev/null +++ b/crates/editor_ui/src/design_system/color_picker.rs @@ -0,0 +1,328 @@ +//! Penpot 420×350 material color popup with live preview and reversible dismissal. + +mod popup; + +use egui; + +use super::typography::TypeRole; + +pub(super) const POPUP_SIZE: egui::Vec2 = egui::vec2(420.0, 350.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PickerMode { + Wheel, + Sliders, + Presets, +} + +#[derive(Debug, Clone)] +pub(super) struct PickerSession { + original: [f32; 4], + current: [f32; 4], + mode: PickerMode, + hex: String, + opened_at: f64, + owner_rect: egui::Rect, + original_intensity: Option, + current_intensity: Option, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ColorPickerResponse { + pub changed: bool, + pub applied: bool, + pub cancelled: bool, +} + +pub fn color_control( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], +) -> ColorPickerResponse { + color_control_impl(ui, salt, label, rgba, None) +} + +pub fn color_control_with_intensity( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], + intensity: &mut f32, +) -> ColorPickerResponse { + color_control_impl(ui, salt, label, rgba, Some(intensity)) +} + +fn color_control_impl( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + label: &str, + rgba: &mut [f32; 4], + intensity: Option<&mut f32>, +) -> ColorPickerResponse { + let palette = super::palette(ui); + let id = ui.make_persistent_id(("penpot_color_picker", salt)); + let width = ui.available_width().max(1.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::click()); + let swatch_width = 44.0_f32.min(width); + let swatch_rect = egui::Rect::from_min_size(rect.min, egui::vec2(swatch_width, 22.0)); + let gap = if width > swatch_width + 6.0 { 6.0 } else { 0.0 }; + let hex_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(swatch_width + gap, 0.0), + egui::vec2((width - swatch_width - gap).max(0.0), 22.0), + ); + popup::checker(ui.painter(), swatch_rect, palette.control, palette.elevated); + let inner_swatch = 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( + rgb_rect, + 3.0, + egui::Color32::from_rgb(byte(rgba[0]), byte(rgba[1]), byte(rgba[2])), + ); + ui.painter().rect_filled(alpha_rect, 3.0, color32(*rgba)); + ui.painter().rect_stroke( + swatch_rect, + 4.0, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + if hex_rect.width() > 0.0 { + ui.painter().rect( + hex_rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + hex_rect.left_center() + egui::vec2(9.0, 0.0), + egui::Align2::LEFT_CENTER, + hex(*rgba), + TypeRole::Body.font(), + palette.text_primary, + ); + } + + if response.clicked() { + let opened_at = ui.input(|input| input.time); + ui.ctx().data_mut(|data| { + data.insert_temp( + id, + PickerSession { + original: *rgba, + current: *rgba, + mode: PickerMode::Wheel, + hex: hex(*rgba), + opened_at, + owner_rect: ui.clip_rect(), + original_intensity: intensity.as_deref().copied(), + current_intensity: intensity.as_deref().copied(), + }, + ); + }); + } + + let before = *rgba; + let mut result = ColorPickerResponse::default(); + let Some(mut session) = ui.ctx().data_mut(|data| data.get_temp::(id)) else { + return result; + }; + let screen_rect = ui.ctx().content_rect(); + let popup_rect = centered_popup_rect(session.owner_rect, screen_rect); + + let scrim = egui::Area::new(id.with("modal_scrim")) + .order(egui::Order::Foreground) + .fixed_pos(session.owner_rect.min) + .show(ui.ctx(), |ui| { + let (rect, response) = + ui.allocate_exact_size(session.owner_rect.size(), egui::Sense::click()); + ui.painter() + .rect_filled(rect, 0.0, egui::Color32::from_black_alpha(132)); + response + }) + .inner; + + let popup_response = egui::Area::new(id.with("popup")) + .order(egui::Order::Foreground) + .fixed_pos(popup_rect.min) + .show(ui.ctx(), |ui| { + ui.set_min_size(POPUP_SIZE); + ui.set_max_size(POPUP_SIZE); + popup::show(ui, label, &mut session, &mut result) + }) + .response; + + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + let outside_click = scrim.clicked() + || ui.input(|input| { + input.pointer.any_pressed() + && input.time > session.opened_at + && input + .pointer + .interact_pos() + .is_some_and(|position| !popup_response.rect.contains(position)) + }); + if escape || outside_click { + result.cancelled = true; + } + + if result.cancelled { + *rgba = session.original; + if let (Some(target), Some(original)) = (intensity, session.original_intensity) { + *target = original; + } + ui.ctx().data_mut(|data| data.remove::(id)); + } else { + *rgba = session.current; + if let (Some(target), Some(current)) = (intensity, session.current_intensity) { + *target = current; + } + if result.applied { + ui.ctx().data_mut(|data| data.remove::(id)); + } else { + ui.ctx().data_mut(|data| data.insert_temp(id, session)); + } + } + result.changed = *rgba != before; + result +} + +fn centered_popup_rect(owner: egui::Rect, screen: egui::Rect) -> egui::Rect { + const SCREEN_MARGIN: f32 = 12.0; + let desired = owner.center() - POPUP_SIZE * 0.5; + let minimum = screen.min + egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN); + let maximum = screen.max - POPUP_SIZE - egui::vec2(SCREEN_MARGIN, SCREEN_MARGIN); + let x = if maximum.x >= minimum.x { + desired.x.clamp(minimum.x, maximum.x) + } else { + screen.center().x - POPUP_SIZE.x * 0.5 + }; + let y = if maximum.y >= minimum.y { + desired.y.clamp(minimum.y, maximum.y) + } else { + screen.center().y - POPUP_SIZE.y * 0.5 + }; + egui::Rect::from_min_size(egui::pos2(x, y), POPUP_SIZE) +} + +pub(super) fn set_current(session: &mut PickerSession, value: [f32; 4]) { + session.current = value.map(|component| component.clamp(0.0, 1.0)); + session.hex = hex(session.current); +} + +pub(super) fn color32(value: [f32; 4]) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + byte(value[0]), + byte(value[1]), + byte(value[2]), + byte(value[3]), + ) +} + +pub(super) fn rgba(value: egui::Color32) -> [f32; 4] { + [ + value.r() as f32 / 255.0, + value.g() as f32 / 255.0, + value.b() as f32 / 255.0, + value.a() as f32 / 255.0, + ] +} + +pub(super) fn byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +pub(super) fn hex(value: [f32; 4]) -> String { + if byte(value[3]) == 255 { + format!( + "#{:02X}{:02X}{:02X}", + byte(value[0]), + byte(value[1]), + byte(value[2]) + ) + } else { + format!( + "#{:02X}{:02X}{:02X}{:02X}", + byte(value[0]), + byte(value[1]), + byte(value[2]), + byte(value[3]) + ) + } +} + +pub(super) fn parse_hex(value: &str) -> Option<[f32; 4]> { + let value = value.trim().trim_start_matches('#'); + if !matches!(value.len(), 6 | 8) { + return None; + } + let rgb = u32::from_str_radix(&value[..6], 16).ok()?; + let alpha = if value.len() == 8 { + u8::from_str_radix(&value[6..], 16).ok()? + } else { + 255 + }; + Some([ + ((rgb >> 16) & 0xFF) as f32 / 255.0, + ((rgb >> 8) & 0xFF) as f32 / 255.0, + (rgb & 0xFF) as f32 / 255.0, + alpha as f32 / 255.0, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_centers_on_inspector_owner_and_stays_on_screen() { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + let inspector = + egui::Rect::from_min_size(egui::pos2(800.0, 40.0), egui::vec2(640.0, 820.0)); + let popup = centered_popup_rect(inspector, screen); + assert_eq!(popup.size(), POPUP_SIZE); + assert_eq!(popup.center(), inspector.center()); + + let edge = centered_popup_rect( + egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 200.0)), + screen, + ); + assert!(edge.left() >= 12.0); + assert!(edge.top() >= 12.0); + } + + #[test] + fn penpot_hex_round_trip_preserves_rgba() { + let value = [0.2, 0.4, 0.6, 0.8]; + let parsed = parse_hex(&hex(value)).expect("valid generated hex"); + for (left, right) in value.into_iter().zip(parsed) { + assert!((left - right).abs() <= 1.0 / 255.0); + } + } + + #[test] + fn cancel_restores_opening_value_and_apply_keeps_live_preview() { + let original = [0.1, 0.2, 0.3, 1.0]; + let mut session = PickerSession { + original, + current: [0.8, 0.7, 0.6, 0.5], + mode: PickerMode::Wheel, + hex: String::new(), + opened_at: 0.0, + owner_rect: egui::Rect::ZERO, + original_intensity: None, + current_intensity: None, + }; + let applied = session.current; + set_current(&mut session, applied); + assert_eq!(session.original, original); + assert_eq!(session.current, applied); + } +} diff --git a/crates/editor_ui/src/design_system/color_picker/popup.rs b/crates/editor_ui/src/design_system/color_picker/popup.rs new file mode 100644 index 0000000..ac797f7 --- /dev/null +++ b/crates/editor_ui/src/design_system/color_picker/popup.rs @@ -0,0 +1,765 @@ +//! Fixed-coordinate renderer for the exported Penpot color overlay. + +mod sliders; + +use egui::{self, ecolor::Hsva}; +use egui_phosphor_icons::{icons, Icon}; + +use super::{ + byte, color32, parse_hex, rgba, set_current, ColorPickerResponse, PickerMode, PickerSession, + POPUP_SIZE, +}; +use crate::design_system::typography::TypeRole; + +const RECENTS: [egui::Color32; 8] = colors([ + 0xE7D4B4, 0x53D3E6, 0xCA6CE0, 0xF06B75, 0x55C58A, 0xF3C85B, 0x6F88EF, 0xF3F5F7, +]); +const PRESETS: [egui::Color32; 24] = colors([ + 0xE7D4B4, 0xF2EEE8, 0xD5B59C, 0xA8795B, 0x734C3C, 0x3B2D29, 0xE45D68, 0xF09A55, 0xE9D05B, + 0x79C267, 0x45B7A6, 0x4FA9D8, 0x5A78DE, 0x8E68D8, 0xC26BC7, 0xE68DB5, 0xF5F7FA, 0xC4CCD5, + 0x8995A3, 0x56616E, 0x303842, 0x1D242C, 0x101419, 0x07090C, +]); +const PLANNED_EYEDROPPER: &str = "Planned — screen sampling pending"; + +const fn colors(values: [u32; N]) -> [egui::Color32; N] { + let mut result = [egui::Color32::BLACK; N]; + let mut index = 0; + while index < N { + let value = values[index]; + result[index] = egui::Color32::from_rgb( + ((value >> 16) & 0xFF) as u8, + ((value >> 8) & 0xFF) as u8, + (value & 0xFF) as u8, + ); + index += 1; + } + result +} + +struct Geometry; + +impl Geometry { + const HEADER_HEIGHT: f32 = 38.0; + const CONTENT: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(408.0, 276.0)); + const WHEEL_EDITOR: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 74.0), egui::pos2(190.0, 276.0)); + const VALUES: egui::Rect = + egui::Rect::from_min_max(egui::pos2(198.0, 82.0), egui::pos2(396.0, 268.0)); + const RECENTS: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 282.0), egui::pos2(408.0, 310.0)); + const FOOTER: egui::Rect = + egui::Rect::from_min_max(egui::pos2(12.0, 316.0), egui::pos2(408.0, 342.0)); + + fn rect(root: egui::Rect, local: egui::Rect) -> egui::Rect { + local.translate(root.min.to_vec2()) + } + + fn at(root: egui::Rect, x: f32, y: f32, width: f32, height: f32) -> egui::Rect { + egui::Rect::from_min_size(root.min + egui::vec2(x, y), egui::vec2(width, height)) + } +} + +pub(super) fn show( + ui: &mut egui::Ui, + label: &str, + session: &mut PickerSession, + result: &mut ColorPickerResponse, +) -> egui::Response { + let palette = crate::design_system::palette(ui); + let (root, response) = ui.allocate_exact_size(POPUP_SIZE, egui::Sense::hover()); + ui.painter().rect( + root, + 7.0, + palette.panel, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + ui.painter().line_segment( + [ + root.min + egui::vec2(0.0, Geometry::HEADER_HEIGHT), + root.min + egui::vec2(root.width(), Geometry::HEADER_HEIGHT), + ], + egui::Stroke::new(1.0_f32, palette.border), + ); + + header(ui, root, label, session, result); + mode_tabs(ui, root, session); + intensity_control(ui, root, session); + match session.mode { + PickerMode::Wheel => wheel_mode(ui, root, session), + PickerMode::Sliders => sliders::show(ui, root, session), + PickerMode::Presets => presets_mode(ui, root, session), + } + recent_row(ui, root, session); + footer(ui, root, result); + response +} + +fn intensity_control(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let Some(intensity) = session.current_intensity.as_mut() else { + return; + }; + let palette = crate::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(272.0, 55.0), + egui::Align2::LEFT_CENTER, + "Intensity", + TypeRole::Small.font(), + palette.text_muted, + ); + ui.put( + Geometry::at(root, 326.0, 43.0, 82.0, 24.0), + egui::DragValue::new(intensity) + .range(0.0..=50_000.0) + .speed(10.0), + ) + .on_hover_text("Emissive luminance multiplier in nits"); +} + +fn header( + ui: &mut egui::Ui, + root: egui::Rect, + label: &str, + session: &PickerSession, + result: &mut ColorPickerResponse, +) { + let palette = crate::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(12.0, 19.0), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Section.font(), + palette.text_primary, + ); + text( + ui, + root.min + egui::vec2(84.0, 19.0), + egui::Align2::LEFT_CENTER, + "sRGB · RGBA", + TypeRole::Small.font(), + palette.text_muted, + ); + paint_swatch( + ui, + Geometry::at(root, 348.0, 7.0, 26.0, 24.0), + color32(session.current), + egui::Sense::hover(), + "Current color", + ); + if icon_button( + ui, + Geometry::at(root, 384.0, 7.0, 24.0, 24.0), + icons::X, + "Cancel color changes", + true, + ) + .clicked() + { + result.cancelled = true; + } +} + +fn mode_tabs(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + for (index, (mode, icon, label, width)) in [ + (PickerMode::Wheel, icons::CIRCLE, "Wheel", 76.0), + (PickerMode::Sliders, icons::SLIDERS, "Sliders", 76.0), + (PickerMode::Presets, icons::SQUARES_FOUR, "Presets", 78.0), + ] + .into_iter() + .enumerate() + { + let x = [12.0, 92.0, 172.0][index]; + let rect = Geometry::at(root, x, 42.0, width, 26.0); + let selected = session.mode == mode; + let response = ui.put( + rect, + egui::Button::new(crate::design_system::controls::icon_label( + icon, + label, + TypeRole::Body, + 12.0, + if selected { + palette.text_primary + } else { + palette.text_secondary + }, + )) + .fill(if selected { + palette.accent_dark + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if selected { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(4.0), + ); + if selected { + ui.painter().rect_filled( + egui::Rect::from_center_size( + egui::pos2(rect.center().x, rect.bottom() - 1.0), + egui::vec2(60.0, 2.0), + ), + 1.0, + palette.accent, + ); + } + if response.clicked() { + session.mode = mode; + } + } +} + +fn wheel_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + let ring = Geometry::at(root, 29.0, 88.0, 144.0, 144.0); + let sv = Geometry::at(root, 65.0, 124.0, 72.0, 72.0); + let editor = Geometry::rect(root, Geometry::WHEEL_EDITOR); + let response = ui.interact( + editor, + ui.make_persistent_id("penpot_color_wheel"), + egui::Sense::click_and_drag(), + ); + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + paint_hue_wheel(ui.painter(), ring, hsva.h, palette.text_primary); + paint_sv( + ui.painter(), + sv, + hsva.h, + hsva.s, + hsva.v, + palette.text_primary, + ); + if response.clicked() || response.dragged() { + if let Some(position) = response.interact_pointer_pos() { + let delta = position - ring.center(); + if sv.contains(position) { + hsva.s = ((position.x - sv.left()) / sv.width()).clamp(0.0, 1.0); + hsva.v = (1.0 - (position.y - sv.top()) / sv.height()).clamp(0.0, 1.0); + } else if (52.0..=76.0).contains(&delta.length()) { + hsva.h = (delta.angle() / std::f32::consts::TAU + 0.25).rem_euclid(1.0); + } + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } + } + text( + ui, + root.min + egui::vec2(101.0, 252.0), + egui::Align2::CENTER_CENTER, + "Drag ring or field", + TypeRole::Small.font(), + palette.text_muted, + ); + values(ui, root, session); +} + +fn values(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + let _values_bounds = Geometry::rect(root, Geometry::VALUES); + caption(ui, root, 198.0, 82.0, "CURRENT"); + caption(ui, root, 302.0, 82.0, "PREVIOUS"); + paint_swatch( + ui, + Geometry::at(root, 198.0, 96.0, 94.0, 30.0), + color32(session.current), + egui::Sense::hover(), + "Current live value", + ); + paint_swatch( + ui, + Geometry::at(root, 302.0, 96.0, 94.0, 30.0), + color32(session.original), + egui::Sense::hover(), + "Value when the picker opened", + ); + + caption(ui, root, 198.0, 132.0, "HEX"); + let hex_field = Geometry::at(root, 198.0, 144.0, 166.0, 24.0); + field_background(ui, hex_field); + let edit_rect = egui::Rect::from_min_max( + hex_field.min, + egui::pos2(hex_field.right() - 24.0, hex_field.bottom()), + ); + let edit = ui.put( + edit_rect.shrink2(egui::vec2(5.0, 2.0)), + egui::TextEdit::singleline(&mut session.hex) + .font(TypeRole::Body.font()) + .frame(egui::Frame::NONE), + ); + if edit.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + if let Some(value) = parse_hex(&session.hex) { + set_current(session, value); + } + } + if bare_icon( + ui, + egui::Rect::from_min_size( + egui::pos2(hex_field.right() - 24.0, hex_field.top()), + egui::vec2(24.0, 24.0), + ), + icons::COPY, + "Copy HEX", + palette.text_secondary, + ) + .clicked() + { + ui.ctx().copy_text(session.hex.clone()); + } + let eyedropper = Geometry::at(root, 370.0, 144.0, 26.0, 24.0); + field_background(ui, eyedropper); + bare_icon( + ui, + eyedropper, + icons::EYEDROPPER, + PLANNED_EYEDROPPER, + palette.text_muted, + ); + + caption(ui, root, 198.0, 174.0, "RGBA"); + let mut bytes = session.current.map(byte); + let before_bytes = bytes; + for (index, prefix) in ["R ", "G ", "B ", "A "].into_iter().enumerate() { + let rect = Geometry::at(root, 198.0 + index as f32 * 49.0, 186.0, 45.0, 24.0); + ui.put( + rect, + egui::DragValue::new(&mut bytes[index]) + .range(0..=255) + .prefix(prefix), + ); + } + if bytes != before_bytes { + set_current(session, bytes.map(|component| component as f32 / 255.0)); + } + + caption(ui, root, 198.0, 216.0, "HSV"); + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + let mut hsv = [hsva.h * 360.0, hsva.s * 100.0, hsva.v * 100.0]; + let before_hsv = hsv; + for (index, suffix) in ["°", "%", "%"].into_iter().enumerate() { + let rect = Geometry::at(root, 198.0 + index as f32 * 66.0, 228.0, 61.0, 24.0); + ui.put( + rect, + egui::DragValue::new(&mut hsv[index]) + .range(if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 }) + .suffix(suffix) + .max_decimals(0), + ); + } + if hsv != before_hsv { + hsva.h = hsv[0] / 360.0; + hsva.s = hsv[1] / 100.0; + hsva.v = hsv[2] / 100.0; + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } + + caption(ui, root, 198.0, 258.0, "ALPHA"); + alpha_control(ui, Geometry::at(root, 240.0, 256.0, 156.0, 14.0), session); +} + +fn presets_mode(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + let content = Geometry::rect(root, Geometry::CONTENT); + ui.painter().rect_filled(content, 4.0, palette.recessed); + caption(ui, root, 24.0, 86.0, "MATERIAL PALETTE"); + for (index, color) in PRESETS.into_iter().enumerate() { + let column = index % 8; + let row = index / 8; + let rect = Geometry::at( + root, + 24.0 + column as f32 * 47.0, + 104.0 + row as f32 * 52.0, + 34.0, + 34.0, + ); + if paint_swatch(ui, rect, color, egui::Sense::click(), "Use preset color").clicked() { + set_current(session, rgba(color)); + } + } +} + +fn recent_row(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + let row = Geometry::rect(root, Geometry::RECENTS); + ui.painter().rect_filled(row, 4.0, palette.section); + text( + ui, + root.min + egui::vec2(20.0, 296.0), + egui::Align2::LEFT_CENTER, + "RECENT", + TypeRole::Caption.font(), + palette.text_muted, + ); + for (index, color) in RECENTS.into_iter().enumerate() { + let rect = Geometry::at(root, 74.0 + index as f32 * 36.0, 286.0, 28.0, 20.0); + if paint_swatch(ui, rect, color, egui::Sense::click(), "Use recent color").clicked() { + set_current(session, rgba(color)); + } + } +} + +fn footer(ui: &mut egui::Ui, root: egui::Rect, result: &mut ColorPickerResponse) { + let palette = crate::design_system::palette(ui); + let footer = Geometry::rect(root, Geometry::FOOTER); + text( + ui, + footer.left_center(), + egui::Align2::LEFT_CENTER, + "Enter apply · Esc cancel", + TypeRole::Small.font(), + palette.text_muted, + ); + if labeled_button( + ui, + Geometry::at(root, 254.0, 316.0, 72.0, 26.0), + icons::X, + "Cancel", + false, + ) + .clicked() + { + result.cancelled = true; + } + if labeled_button( + ui, + Geometry::at(root, 334.0, 316.0, 74.0, 26.0), + icons::CHECK, + "Apply", + true, + ) + .clicked() + { + result.applied = true; + } +} + +fn alpha_control(ui: &mut egui::Ui, rect: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + checker(ui.painter(), rect, palette.control, palette.elevated); + let response = ui.interact( + rect.expand2(egui::vec2(0.0, 4.0)), + ui.make_persistent_id("penpot_color_alpha"), + egui::Sense::click_and_drag(), + ); + if response.clicked() || response.dragged() { + if let Some(position) = response.interact_pointer_pos() { + let mut value = session.current; + value[3] = ((position.x - rect.left()) / rect.width()).clamp(0.0, 1.0); + set_current(session, value); + } + } + let x = egui::lerp(rect.left()..=rect.right(), session.current[3]); + ui.painter().rect_filled( + egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.bottom())), + 2.0, + palette.accent.gamma_multiply(0.65), + ); + ui.painter().rect_filled( + egui::Rect::from_center_size(egui::pos2(x, rect.center().y), egui::vec2(10.0, 18.0)), + 3.0, + palette.text_primary, + ); +} + +fn paint_hue_wheel( + painter: &egui::Painter, + rect: egui::Rect, + selected_hue: f32, + marker_color: egui::Color32, +) { + let center = rect.center(); + let outer = 72.0; + let inner = 54.0; + let mut mesh = egui::Mesh::default(); + const SEGMENTS: usize = 72; + for segment in 0..SEGMENTS { + let h0 = segment as f32 / SEGMENTS as f32; + let h1 = (segment + 1) as f32 / SEGMENTS as f32; + let a0 = (h0 - 0.25) * std::f32::consts::TAU; + let a1 = (h1 - 0.25) * std::f32::consts::TAU; + let start = mesh.vertices.len() as u32; + let c0 = egui::Color32::from(Hsva::new(h0, 1.0, 1.0, 1.0)); + let c1 = egui::Color32::from(Hsva::new(h1, 1.0, 1.0, 1.0)); + for (radius, angle, color) in [ + (inner, a0, c0), + (outer, a0, c0), + (outer, a1, c1), + (inner, a1, c1), + ] { + mesh.colored_vertex( + center + egui::vec2(angle.cos(), angle.sin()) * radius, + color, + ); + } + mesh.add_triangle(start, start + 1, start + 2); + mesh.add_triangle(start, start + 2, start + 3); + } + painter.add(mesh); + let angle = (selected_hue - 0.25) * std::f32::consts::TAU; + let marker = center + egui::vec2(angle.cos(), angle.sin()) * 63.0; + painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color)); +} + +fn paint_sv( + painter: &egui::Painter, + rect: egui::Rect, + hue: f32, + saturation: f32, + value: f32, + marker_color: egui::Color32, +) { + let mut mesh = egui::Mesh::default(); + for (position, color) in [ + (rect.left_top(), egui::Color32::WHITE), + ( + rect.right_top(), + egui::Color32::from(Hsva::new(hue, 1.0, 1.0, 1.0)), + ), + (rect.right_bottom(), egui::Color32::BLACK), + (rect.left_bottom(), egui::Color32::BLACK), + ] { + mesh.colored_vertex(position, color); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(mesh); + let marker = egui::pos2( + egui::lerp(rect.x_range(), saturation), + egui::lerp(rect.y_range(), 1.0 - value), + ); + painter.circle_stroke(marker, 5.0, egui::Stroke::new(2.0_f32, marker_color)); +} + +pub(super) fn checker( + painter: &egui::Painter, + rect: egui::Rect, + dark: egui::Color32, + light: egui::Color32, +) { + let size = 6.0; + let columns = (rect.width() / size).ceil() as usize; + let rows = (rect.height() / size).ceil() as usize; + for row in 0..rows { + for column in 0..columns { + let cell = egui::Rect::from_min_size( + rect.min + egui::vec2(column as f32 * size, row as f32 * size), + egui::vec2(size, size), + ) + .intersect(rect); + painter.rect_filled( + cell, + 0.0, + if (row + column) % 2 == 0 { dark } else { light }, + ); + } + } +} + +fn paint_swatch( + ui: &mut egui::Ui, + rect: egui::Rect, + color: egui::Color32, + sense: egui::Sense, + tooltip: &str, +) -> egui::Response { + let palette = crate::design_system::palette(ui); + checker(ui.painter(), rect, palette.control, palette.elevated); + ui.painter().rect_filled(rect.shrink(1.0), 3.0, color); + ui.painter().rect_stroke( + rect, + 3.0, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.interact( + rect, + ui.make_persistent_id(("color_swatch", rect.min.x.to_bits(), rect.min.y.to_bits())), + sense, + ) + .on_hover_text(tooltip) +} + +fn icon_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + enabled: bool, +) -> egui::Response { + let palette = crate::design_system::palette(ui); + ui.put( + rect, + egui::Button::new(egui::RichText::new(icon.as_str()).font(egui::FontId::new( + 13.0, + egui::FontFamily::Name("phosphor-regular".into()), + ))) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(4.0) + .sense(if enabled { + egui::Sense::click() + } else { + egui::Sense::hover() + }), + ) + .on_hover_text(tooltip) +} + +fn bare_icon( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + tooltip: &str, + color: egui::Color32, +) -> egui::Response { + let response = ui.interact( + rect, + ui.make_persistent_id(("color_icon", tooltip)), + if tooltip == PLANNED_EYEDROPPER { + egui::Sense::hover() + } else { + egui::Sense::click() + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + color, + ); + response.on_hover_text(tooltip) +} + +fn labeled_button( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: Icon, + label: &str, + accent: bool, +) -> egui::Response { + let palette = crate::design_system::palette(ui); + ui.push_id(("color_picker_footer_action", label), |ui| { + ui.put( + rect, + egui::Button::new(crate::design_system::controls::icon_label( + icon, + label, + TypeRole::Control, + 12.0, + palette.text_primary, + )) + .fill(if accent { + palette.accent + } else { + palette.control + }) + .stroke(egui::Stroke::new( + 1.0_f32, + if accent { + palette.accent + } else { + palette.border + }, + )) + .corner_radius(4.0), + ) + }) + .inner +} + +fn field_background(ui: &egui::Ui, rect: egui::Rect) { + let palette = crate::design_system::palette(ui); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); +} + +fn caption(ui: &egui::Ui, root: egui::Rect, x: f32, y: f32, value: &str) { + let palette = crate::design_system::palette(ui); + text( + ui, + root.min + egui::vec2(x, y), + egui::Align2::LEFT_TOP, + value, + TypeRole::Caption.font(), + palette.text_muted, + ); +} + +fn text( + ui: &egui::Ui, + position: egui::Pos2, + align: egui::Align2, + value: &str, + font: egui::FontId, + color: egui::Color32, +) { + ui.painter().text(position, align, value, font, color); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exported_popup_regions_match_penpot_geometry() { + assert_eq!(POPUP_SIZE, egui::vec2(420.0, 350.0)); + assert_eq!(Geometry::HEADER_HEIGHT, 38.0); + assert_eq!(Geometry::CONTENT.size(), egui::vec2(396.0, 202.0)); + assert_eq!(Geometry::WHEEL_EDITOR.size(), egui::vec2(178.0, 202.0)); + assert_eq!(Geometry::VALUES.size(), egui::vec2(198.0, 186.0)); + assert_eq!(Geometry::RECENTS.size(), egui::vec2(396.0, 28.0)); + assert_eq!(Geometry::FOOTER.size(), egui::vec2(396.0, 26.0)); + } + + #[test] + fn exported_value_and_action_fields_retain_exact_sizes() { + let root = egui::Rect::from_min_size(egui::Pos2::ZERO, POPUP_SIZE); + assert_eq!( + Geometry::at(root, 198.0, 144.0, 166.0, 24.0).size(), + egui::vec2(166.0, 24.0) + ); + assert_eq!( + Geometry::at(root, 370.0, 144.0, 26.0, 24.0).size(), + egui::vec2(26.0, 24.0) + ); + assert_eq!( + Geometry::at(root, 240.0, 256.0, 156.0, 14.0).size(), + egui::vec2(156.0, 14.0) + ); + assert_eq!( + Geometry::at(root, 334.0, 316.0, 74.0, 26.0).size(), + egui::vec2(74.0, 26.0) + ); + } + + #[test] + fn disabled_eyedropper_has_no_state_or_action_contract() { + assert_eq!(PLANNED_EYEDROPPER, "Planned — screen sampling pending"); + } +} diff --git a/crates/editor_ui/src/design_system/color_picker/popup/sliders.rs b/crates/editor_ui/src/design_system/color_picker/popup/sliders.rs new file mode 100644 index 0000000..9b41b17 --- /dev/null +++ b/crates/editor_ui/src/design_system/color_picker/popup/sliders.rs @@ -0,0 +1,167 @@ +//! Fixed Penpot geometry for the color picker's RGBA and HSV channel modes. + +use egui::{self, ecolor::Hsva}; + +use super::super::{byte, set_current, PickerSession}; +use super::{caption, text, Geometry}; +use crate::design_system::typography::TypeRole; + +/// Each 186 px channel group owns a 178 px row: a single-letter channel label, +/// a 104 px track, and a 44 px value field. +struct SliderGeometry; + +impl SliderGeometry { + const RGBA_X: f32 = 12.0; + const HSV_X: f32 = 206.0; + const TITLE_Y: f32 = 74.0; + const FIRST_ROW_Y: f32 = 90.0; + const ROW_STRIDE: f32 = 30.0; + const LABEL_WIDTH: f32 = 18.0; + const TRACK_X: f32 = 24.0; + const TRACK_WIDTH: f32 = 104.0; + const VALUE_X: f32 = 134.0; + const VALUE_WIDTH: f32 = 44.0; + const ROW_HEIGHT: f32 = 24.0; +} + +pub(super) fn show(ui: &mut egui::Ui, root: egui::Rect, session: &mut PickerSession) { + let palette = crate::design_system::palette(ui); + let content = Geometry::rect(root, Geometry::CONTENT); + ui.painter().rect_filled(content, 4.0, palette.recessed); + caption( + ui, + root, + SliderGeometry::RGBA_X, + SliderGeometry::TITLE_Y, + "RGBA CHANNELS", + ); + caption( + ui, + root, + SliderGeometry::HSV_X, + SliderGeometry::TITLE_Y, + "HSV CHANNELS", + ); + + let mut bytes = session.current.map(|component| f32::from(byte(component))); + let before_bytes = bytes; + for (index, label) in ["R", "G", "B", "A"].into_iter().enumerate() { + slider_channel( + ui, + root, + SliderGeometry::RGBA_X, + index, + label, + &mut bytes[index], + 0.0..=255.0, + "", + ); + } + if bytes != before_bytes { + set_current(session, bytes.map(|component| component / 255.0)); + } + + let mut hsva = Hsva::from_rgba_unmultiplied( + session.current[0], + session.current[1], + session.current[2], + session.current[3], + ); + let mut values = [ + hsva.h * 360.0, + hsva.s * 100.0, + hsva.v * 100.0, + hsva.a * 100.0, + ]; + let before = values; + for (index, label) in ["H", "S", "V", "A"].into_iter().enumerate() { + let range = if index == 0 { 0.0..=360.0 } else { 0.0..=100.0 }; + slider_channel( + ui, + root, + SliderGeometry::HSV_X, + index, + label, + &mut values[index], + range, + if index == 0 { "°" } else { "%" }, + ); + } + if values != before { + hsva.h = values[0] / 360.0; + hsva.s = values[1] / 100.0; + hsva.v = values[2] / 100.0; + hsva.a = values[3] / 100.0; + let [r, g, b] = hsva.to_rgb(); + set_current(session, [r, g, b, hsva.a]); + } +} + +#[allow(clippy::too_many_arguments)] +fn slider_channel( + ui: &mut egui::Ui, + root: egui::Rect, + group_x: f32, + index: usize, + label: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, +) { + let palette = crate::design_system::palette(ui); + let y = SliderGeometry::FIRST_ROW_Y + index as f32 * SliderGeometry::ROW_STRIDE; + let label_rect = Geometry::at( + root, + group_x, + y, + SliderGeometry::LABEL_WIDTH, + SliderGeometry::ROW_HEIGHT, + ); + text( + ui, + label_rect.left_center(), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_secondary, + ); + + ui.put( + Geometry::at( + root, + group_x + SliderGeometry::TRACK_X, + y, + SliderGeometry::TRACK_WIDTH, + SliderGeometry::ROW_HEIGHT, + ), + egui::Slider::new(value, range.clone()).show_value(false), + ); + ui.put( + Geometry::at( + root, + group_x + SliderGeometry::VALUE_X, + y, + SliderGeometry::VALUE_WIDTH, + SliderGeometry::ROW_HEIGHT, + ), + egui::DragValue::new(value) + .range(range) + .suffix(suffix) + .max_decimals(0), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sliders_mode_matches_the_two_exported_channel_groups() { + assert_eq!(SliderGeometry::HSV_X - SliderGeometry::RGBA_X, 194.0); + assert_eq!(SliderGeometry::TRACK_WIDTH, 104.0); + assert_eq!(SliderGeometry::VALUE_WIDTH, 44.0); + assert_eq!(SliderGeometry::VALUE_X + SliderGeometry::VALUE_WIDTH, 178.0); + assert_eq!(SliderGeometry::ROW_STRIDE, 30.0); + assert_eq!(SliderGeometry::ROW_HEIGHT, 24.0); + } +} diff --git a/crates/editor_ui/src/design_system/controls.rs b/crates/editor_ui/src/design_system/controls.rs new file mode 100644 index 0000000..e9f52c9 --- /dev/null +++ b/crates/editor_ui/src/design_system/controls.rs @@ -0,0 +1,381 @@ +//! Reusable Penpot-styled editor controls with no world or asset-store access. + +use egui; +use egui_phosphor_icons::{icons, Icon}; +use std::ops::RangeInclusive; + +use super::palette; +use super::typography::TypeRole; + +// Penpot's scalar component keeps a six-pixel leading inset inside its value-group frame. The +// exported child geometry is authoritative here; the group's transparent parent frame is six +// pixels narrower than its visible children and must not be mistaken for the control width. +const SCALAR_TRACK_X: f32 = 6.0; +const SCALAR_NUMBER_GAP: f32 = 6.0; +const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0; +const SCALAR_NUMBER_WIDTH: f32 = 33.0; +const SCALAR_THUMB_RADIUS: f32 = 4.0; + +pub fn section_frame(ui: &egui::Ui) -> egui::Frame { + let palette = palette(ui); + egui::Frame::new() + .fill(palette.section) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(egui::CornerRadius::same(5)) +} + +pub fn section_header(ui: &mut egui::Ui, title: &str, open: &mut bool) -> egui::Response { + section_header_with_summary(ui, title, None, open) +} + +pub fn section_header_with_summary( + ui: &mut egui::Ui, + title: &str, + summary: Option<&str>, + open: &mut bool, +) -> egui::Response { + let palette = palette(ui); + let (rect, response) = ui.allocate_exact_size( + egui::vec2( + ui.available_width().max(1.0), + super::INPUT_SECTION_HEADER_HEIGHT, + ), + egui::Sense::click(), + ); + ui.painter().rect_filled(rect, 4.0, palette.elevated); + ui.painter().text( + rect.left_center() + egui::vec2(12.0, 0.0), + egui::Align2::CENTER_CENTER, + if *open { + icons::CARET_DOWN.as_str() + } else { + icons::CARET_RIGHT.as_str() + }, + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.text_secondary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(26.0, 0.0), + egui::Align2::LEFT_CENTER, + title, + TypeRole::Section.font(), + palette.text_primary, + ); + if let Some(summary) = summary { + ui.painter().text( + rect.right_center() - egui::vec2(12.0, 0.0), + egui::Align2::RIGHT_CENTER, + summary, + TypeRole::Small.font(), + palette.text_muted, + ); + } + if response.clicked() { + *open = !*open; + } + response +} + +/// Compose a Phosphor glyph and Source Sans label without asking either font to render the +/// other's codepoints. This is the canonical path for icon-plus-text controls in a design-system +/// scope. +pub fn icon_label( + icon: Icon, + label: &str, + role: TypeRole, + icon_size: f32, + color: egui::Color32, +) -> egui::WidgetText { + let mut job = egui::text::LayoutJob::default(); + job.append( + icon.as_str(), + 0.0, + egui::TextFormat { + font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())), + color, + ..Default::default() + }, + ); + if !label.is_empty() { + job.append( + label, + 6.0, + egui::TextFormat { + font_id: role.font(), + color, + ..Default::default() + }, + ); + } + job.into() +} + +pub fn icon_button(ui: &mut egui::Ui, icon: Icon, tooltip: &str, enabled: bool) -> egui::Response { + let palette = palette(ui); + let response = ui.add_enabled( + enabled, + egui::Button::new( + egui::RichText::new(icon.as_str()) + .font(egui::FontId::new( + 14.0, + egui::FontFamily::Name("phosphor-bold".into()), + )) + .color(palette.text_secondary), + ) + .min_size(egui::vec2(24.0, 24.0)) + .fill(palette.control) + .stroke(egui::Stroke::new(1.0_f32, palette.border)) + .corner_radius(egui::CornerRadius::same(4)), + ); + response.on_hover_text(tooltip) +} + +pub fn status(ui: &mut egui::Ui, icon: Icon, color: egui::Color32, label: &str, tooltip: &str) { + let palette = palette(ui); + let mut job = egui::text::LayoutJob::default(); + job.append( + icon.as_str(), + 0.0, + egui::TextFormat { + font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())), + color, + ..Default::default() + }, + ); + job.append( + label, + 5.0, + egui::TextFormat { + font_id: TypeRole::Small.font(), + color: palette.text_secondary, + ..Default::default() + }, + ); + let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover())); + response.on_hover_text(tooltip); +} + +pub fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response { + let palette = palette(ui); + let (rect, mut response) = ui.allocate_exact_size(egui::vec2(31.0, 16.0), egui::Sense::click()); + if response.clicked() { + *value = !*value; + response.mark_changed(); + } + let fill = if *value { + palette.accent_dark + } else { + palette.control + }; + let stroke = if *value { + palette.accent + } else { + palette.border_strong + }; + ui.painter().rect( + rect, + 8.0, + fill, + egui::Stroke::new(1.0_f32, stroke), + egui::StrokeKind::Inside, + ); + let x = if *value { + rect.right() - 8.0 + } else { + rect.left() + 8.0 + }; + ui.painter() + .circle_filled(egui::pos2(x, rect.center().y), 5.0, palette.text_primary); + response +} + +/// Fixed 128×24 two-option control used by the Penpot material Surface section. +/// +/// Returning the selected side keeps the control independent from material state and prevents +/// stock `selectable_label` padding from changing its geometry. +pub fn two_option_segmented( + ui: &mut egui::Ui, + left_label: &str, + right_label: &str, + right_selected: bool, +) -> Option { + const WIDTH: f32 = 128.0; + const HEIGHT: f32 = 24.0; + const OPTION_WIDTH: f32 = WIDTH * 0.5; + + let palette = palette(ui); + let (rect, _) = ui.allocate_exact_size(egui::vec2(WIDTH, HEIGHT), egui::Sense::hover()); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let left = egui::Rect::from_min_size(rect.min, egui::vec2(OPTION_WIDTH, HEIGHT)); + let right = egui::Rect::from_min_size( + rect.min + egui::vec2(OPTION_WIDTH, 0.0), + egui::vec2(OPTION_WIDTH, HEIGHT), + ); + let mut selection = None; + for (option_rect, label, selected, value) in [ + (left, left_label, !right_selected, false), + (right, right_label, right_selected, true), + ] { + let response = ui.interact( + option_rect, + ui.make_persistent_id(("two_option_segment", label)), + egui::Sense::click(), + ); + let fill = if selected { + palette.accent_dark + } else if response.hovered() { + palette.elevated + } else { + palette.control + }; + ui.painter().rect_filled(option_rect.shrink(1.0), 3.0, fill); + if selected { + ui.painter().rect_stroke( + option_rect, + 3.0, + egui::Stroke::new(1.0_f32, palette.accent), + egui::StrokeKind::Inside, + ); + } + ui.painter().text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + label, + TypeRole::Control.font(), + if selected { + palette.text_primary + } else { + palette.text_secondary + }, + ); + if response.clicked() && right_selected != value { + selection = Some(value); + } + } + selection +} + +/// Exact Penpot scalar editor used by material inputs. +/// +/// The track and numeric field are painted as independent controls so egui's stock slider +/// padding cannot move the thumb into the value field at the reference width. +pub fn scalar_control( + ui: &mut egui::Ui, + salt: impl std::hash::Hash, + value: &mut f32, + range: RangeInclusive, + max_decimals: usize, +) -> egui::Response { + let palette = palette(ui); + let before = *value; + // The final Penpot component deliberately stretches only the track at the compact + // breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference. + // The numeric field remains a stable 33 px in both modes, after a six-pixel leading inset + // and a six-pixel track-to-number gap. + let width = ui.available_width().max(1.0); + let (rect, base_response) = + ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + let number_width = SCALAR_NUMBER_WIDTH.min(width); + let number_x = (width - number_width).max(0.0); + let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0); + let slider_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(SCALAR_TRACK_X, 6.0), + egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT), + ); + let track_rect = + egui::Rect::from_center_size(slider_rect.center(), egui::vec2(track_width, 3.0)); + let slider_response = ui.interact( + slider_rect, + ui.make_persistent_id(("penpot_scalar", salt)), + egui::Sense::click_and_drag(), + ); + if slider_response.clicked() || slider_response.dragged() { + if let Some(pointer) = slider_response.interact_pointer_pos() { + let start = *range.start(); + let end = *range.end(); + let normalized = ((pointer.x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0); + *value = egui::lerp(start..=end, normalized); + } + } + + let start = *range.start(); + let end = *range.end(); + let normalized = if end > start { + ((*value - start) / (end - start)).clamp(0.0, 1.0) + } else { + 0.0 + }; + ui.painter().rect_filled(track_rect, 2.0, palette.border); + let fill = egui::Rect::from_min_max( + track_rect.left_top(), + egui::pos2( + egui::lerp(track_rect.x_range(), normalized), + track_rect.bottom(), + ), + ); + ui.painter().rect_filled(fill, 2.0, palette.accent); + let thumb_center = egui::pos2( + egui::lerp(track_rect.x_range(), normalized), + track_rect.center().y, + ); + ui.painter().circle( + thumb_center, + SCALAR_THUMB_RADIUS, + palette.text_primary, + egui::Stroke::new(1.0_f32, palette.accent), + ); + + let number_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(number_x, 0.0), + egui::vec2(number_width, 22.0), + ); + let number_response = ui.put( + number_rect, + egui::DragValue::new(value) + .speed(0.01) + .range(range) + .max_decimals(max_decimals), + ); + let mut response = base_response.union(slider_response).union(number_response); + if *value != before { + response.mark_changed(); + } + response +} + +#[cfg(test)] +mod scalar_tests { + use super::*; + + #[test] + fn penpot_scalar_geometry_matches_the_current_export() { + let wide_width = 138.8; + let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH; + let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP; + assert!((wide_track - 93.8).abs() < 0.001); + + let compact_width = 205.3; + let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH; + let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP; + assert!((compact_track - 160.3).abs() < 0.001); + assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0); + assert_eq!(SCALAR_THUMB_RADIUS, 4.0); + } +} + +#[cfg(test)] +mod segmented_tests { + #[test] + fn penpot_segmented_control_has_two_fixed_halves() { + const WIDTH: f32 = 128.0; + const OPTION_WIDTH: f32 = WIDTH * 0.5; + assert_eq!(OPTION_WIDTH, 64.0); + assert_eq!(OPTION_WIDTH * 2.0, WIDTH); + } +} diff --git a/crates/editor_ui/src/design_system/mod.rs b/crates/editor_ui/src/design_system/mod.rs new file mode 100644 index 0000000..4ce7933 --- /dev/null +++ b/crates/editor_ui/src/design_system/mod.rs @@ -0,0 +1,153 @@ +//! Penpot-led editor design-system primitives. +//! +//! The first consumer is the Material Slot panel. These semantic tokens and controls are kept +//! independent from material/world state so later editor panels can migrate without copying style. + +pub mod color_picker; +pub mod controls; +pub mod property_grid; +pub mod typography; + +use egui::{self, Color32, CornerRadius, Stroke}; + +use crate::theme::EditorVisualPalette; + +pub const REFERENCE_WIDTH: f32 = 620.0; +pub const INSPECTOR_MIN_WIDTH: f32 = 420.0; +pub const OUTER_PADDING: f32 = 12.0; +pub const SECTION_GAP: f32 = 8.0; +pub const MATERIALS_HEADING_HEIGHT: f32 = 32.0; +pub const HEADER_HEIGHT: f32 = 82.0; +pub const COLLAPSED_HEADER_HEIGHT: f32 = 52.0; +pub const MATERIAL_PREVIEW_SIZE: f32 = 64.0; +pub const MATERIAL_IDENTITY_WIDTH: f32 = 229.5; +pub const SURFACE_SECTION_HEIGHT: f32 = 46.0; +pub const COMPACT_SURFACE_SECTION_HEIGHT: f32 = 46.0; +pub const INPUT_SECTION_HEADER_HEIGHT: f32 = 24.0; +pub const PARAMETER_ROW_HEIGHT: f32 = 32.0; +pub const COMPACT_PARAMETER_ROW_HEIGHT: f32 = 58.0; +pub const UV_SECTION_HEIGHT: f32 = 54.0; +pub const COMPACT_UV_SECTION_HEIGHT: f32 = 94.0; +pub const ADVANCED_COLLAPSED_HEIGHT: f32 = 30.0; +pub const ADVANCED_EXPANDED_HEIGHT: f32 = 178.0; +pub const CONTROL_HEIGHT: f32 = 24.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DesignPalette { + pub accent: Color32, + pub accent_dark: Color32, + pub panel: Color32, + pub section: Color32, + pub recessed: Color32, + pub control: Color32, + pub elevated: Color32, + pub row_even: Color32, + pub row_odd: Color32, + pub checker: Color32, + pub border: Color32, + pub border_strong: Color32, + pub text_primary: Color32, + pub text_secondary: Color32, + pub text_muted: Color32, + pub healthy: Color32, + pub warning: Color32, + pub error: Color32, +} + +impl From for DesignPalette { + fn from(value: EditorVisualPalette) -> Self { + Self { + accent: value.selection, + accent_dark: value.selection_bg, + panel: value.panel, + section: value.widget, + recessed: value.panel_dark, + control: value.widget, + elevated: value.elevated, + row_even: value.panel, + row_odd: value.widget, + checker: value.elevated, + border: value.border, + border_strong: value.border_strong, + text_primary: value.text, + text_secondary: value.text_dim, + text_muted: value.text_muted, + healthy: value.success, + warning: value.warning, + error: value.error, + } + } +} + +pub fn palette(ui: &egui::Ui) -> DesignPalette { + crate::theme::editor_palette(ui.ctx()).into() +} + +/// Applies the Penpot material-panel visual language locally without restyling the whole editor. +pub fn scope(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R { + let palette = palette(ui); + ui.scope(|ui| { + let style = ui.style_mut(); + style.spacing.item_spacing = egui::vec2(8.0, 4.0); + style.spacing.button_padding = egui::vec2(8.0, 3.0); + style.spacing.interact_size = egui::vec2(31.0, CONTROL_HEIGHT); + style.visuals.widgets.noninteractive.bg_fill = palette.section; + style.visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border); + style.visuals.widgets.noninteractive.fg_stroke = + Stroke::new(1.0_f32, palette.text_secondary); + style.visuals.widgets.inactive.bg_fill = palette.control; + style.visuals.widgets.inactive.weak_bg_fill = palette.control; + style.visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border); + style.visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.widgets.hovered.bg_fill = palette.elevated; + style.visuals.widgets.hovered.weak_bg_fill = palette.elevated; + style.visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong); + style.visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.widgets.active.bg_fill = palette.accent_dark; + style.visuals.widgets.active.weak_bg_fill = palette.accent_dark; + style.visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent); + style.visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_primary); + style.visuals.selection.bg_fill = palette.accent_dark; + style.visuals.selection.stroke = Stroke::new(1.0_f32, palette.accent); + style.visuals.window_fill = palette.panel; + style.visuals.window_stroke = Stroke::new(1.0_f32, palette.border_strong); + style.visuals.window_corner_radius = CornerRadius::same(7); + style.visuals.menu_corner_radius = CornerRadius::same(5); + style.visuals.override_text_color = Some(palette.text_primary); + typography::install_local_text_styles(style); + add_contents(ui) + }) + .inner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_geometry_tokens_are_exact() { + assert_eq!(REFERENCE_WIDTH, 620.0); + assert_eq!(MATERIALS_HEADING_HEIGHT, 32.0); + assert_eq!(HEADER_HEIGHT, 82.0); + assert_eq!(COLLAPSED_HEADER_HEIGHT, 52.0); + assert_eq!(MATERIAL_PREVIEW_SIZE, 64.0); + assert_eq!(MATERIAL_IDENTITY_WIDTH, 229.5); + assert_eq!(SURFACE_SECTION_HEIGHT, 46.0); + assert_eq!(COMPACT_SURFACE_SECTION_HEIGHT, 46.0); + assert_eq!(INPUT_SECTION_HEADER_HEIGHT, 24.0); + assert_eq!(PARAMETER_ROW_HEIGHT, 32.0); + assert_eq!(COMPACT_PARAMETER_ROW_HEIGHT, 58.0); + assert_eq!(UV_SECTION_HEIGHT, 54.0); + assert_eq!(COMPACT_UV_SECTION_HEIGHT, 94.0); + assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0); + assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0); + } + + #[test] + fn material_design_palette_uses_blacksite_semantics() { + let palette = DesignPalette::from(crate::theme::BLACKSITE_PALETTE); + assert_eq!(palette.accent, crate::theme::SELECTION); + assert_eq!(palette.panel, crate::theme::PANEL_BG); + assert_eq!(palette.text_primary, crate::theme::TEXT); + } +} diff --git a/crates/editor_ui/src/design_system/property_grid.rs b/crates/editor_ui/src/design_system/property_grid.rs new file mode 100644 index 0000000..3542bcc --- /dev/null +++ b/crates/editor_ui/src/design_system/property_grid.rs @@ -0,0 +1,294 @@ +//! Testable geometry for dense Penpot material property rows. + +use super::{COMPACT_PARAMETER_ROW_HEIGHT, PARAMETER_ROW_HEIGHT}; +use egui; + +/// Minimum live section width that can safely retain the exported wide-row geometry. +/// +/// The Penpot reference section itself is 569 px wide. In the native Inspector, dock chrome, +/// component-card borders, and the floating scrollbar consume up to ten pixels before the +/// material parameter body is allocated. Treat that 559 px contained canvas as wide so a 640 px +/// Inspector renders the authoritative wide reference instead of incorrectly switching to the +/// 420 px two-line rows. +pub const WIDE_SECTION_WIDTH: f32 = 559.0; +pub const RESPONSIVE_BREAKPOINT: f32 = WIDE_SECTION_WIDTH; +/// The actor component chrome can consume up to twelve pixels of the exported 372 px compact +/// canvas. At 360 px the complete texture row still fits: 12 px inset + 336 px group + 12 px inset. +const COMPACT_INLINE_ACTION_FIT_WIDTH: f32 = 357.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PropertyGridGeometry { + pub stacked: bool, + pub row_height: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PropertyRowRects { + pub row: egui::Rect, + pub label: egui::Rect, + pub value: egui::Rect, + pub color_value: egui::Rect, + pub channel: egui::Rect, + pub texture_group: egui::Rect, + pub texture_field: egui::Rect, + pub locate: egui::Rect, + pub clear: egui::Rect, + pub inline_texture_actions: bool, +} + +impl PropertyGridGeometry { + pub fn for_width(section_width: f32) -> Self { + if section_width + f32::EPSILON >= RESPONSIVE_BREAKPOINT { + Self { + stacked: false, + row_height: PARAMETER_ROW_HEIGHT, + } + } else { + Self { + stacked: true, + row_height: COMPACT_PARAMETER_ROW_HEIGHT, + } + } + } + + pub fn row_rects(self, row: egui::Rect) -> PropertyRowRects { + let inset = 12.0_f32.min(row.width() * 0.5); + let inner_width = (row.width() - inset * 2.0).max(1.0); + if !self.stacked { + let texture_group_width = (inner_width - 286.5).max(60.0); + let texture_field_width = (texture_group_width - 60.0).max(1.0); + let texture_group = egui::Rect::from_min_size( + row.min + egui::vec2(298.5, 5.0), + egui::vec2(texture_group_width, 22.0), + ); + return PropertyRowRects { + row, + label: egui::Rect::from_min_size( + row.min + egui::vec2(12.0, 5.0), + egui::vec2(82.0, 22.0), + ), + value: egui::Rect::from_min_size( + row.min + egui::vec2(103.35, 5.0), + egui::vec2(138.8, 22.0), + ), + color_value: egui::Rect::from_min_size( + row.min + egui::vec2(107.95, 5.0), + egui::vec2(182.55, 22.0), + ), + channel: egui::Rect::from_min_size( + row.min + egui::vec2(242.5, 5.0), + egui::vec2(48.0, 22.0), + ), + texture_group, + texture_field: egui::Rect::from_min_size( + texture_group.min, + egui::vec2(texture_field_width, 22.0), + ), + locate: egui::Rect::from_min_size( + texture_group.min + egui::vec2(texture_field_width + 6.0, 0.0), + egui::vec2(24.0, 22.0), + ), + clear: egui::Rect::from_min_size( + texture_group.min + egui::vec2(texture_field_width + 36.0, 0.0), + egui::vec2(24.0, 22.0), + ), + inline_texture_actions: true, + }; + } + + let channel_width = 48.0_f32.min(inner_width); + let channel_x = inset + inner_width - channel_width; + let value_x = (inset + 91.35).min(channel_x); + // The scalar's visible children finish 0.35 px before the channel. Penpot's transparent + // parent frame is six pixels narrower and is not the actual clipping boundary. + let value_width = (channel_x - value_x - 0.35).max(1.0); + let color_value_x = (inset + 95.95).min(channel_x); + let color_value_width = (inset + inner_width - color_value_x).max(1.0); + let texture_group = egui::Rect::from_min_size( + row.min + egui::vec2(inset, 31.0), + egui::vec2(inner_width, 22.0), + ); + let inline_texture_actions = row.width() + f32::EPSILON >= COMPACT_INLINE_ACTION_FIT_WIDTH; + let texture_field_width = if inline_texture_actions { + (inner_width - 60.0).max(1.0) + } else { + (inner_width - 30.0).max(1.0) + }; + let overflow_or_locate_x = texture_field_width + 6.0; + PropertyRowRects { + row, + label: egui::Rect::from_min_size( + row.min + egui::vec2(inset, 5.0), + egui::vec2(78.0_f32.min(inner_width), 22.0), + ), + value: egui::Rect::from_min_size( + row.min + egui::vec2(value_x, 5.0), + egui::vec2(value_width, 22.0), + ), + color_value: egui::Rect::from_min_size( + row.min + egui::vec2(color_value_x, 5.0), + egui::vec2(color_value_width, 22.0), + ), + channel: egui::Rect::from_min_size( + row.min + egui::vec2(channel_x, 5.0), + egui::vec2(channel_width, 22.0), + ), + texture_group, + texture_field: egui::Rect::from_min_size( + texture_group.min, + egui::vec2(texture_field_width, 22.0), + ), + locate: egui::Rect::from_min_size( + texture_group.min + egui::vec2(overflow_or_locate_x, 0.0), + egui::vec2(24.0, 22.0), + ), + clear: egui::Rect::from_min_size( + texture_group.min + + egui::vec2( + if inline_texture_actions { + texture_field_width + 36.0 + } else { + inner_width + }, + 0.0, + ), + egui::vec2(if inline_texture_actions { 24.0 } else { 0.0 }, 22.0), + ), + inline_texture_actions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_exact_reference_geometry_matches_columns() { + let geometry = PropertyGridGeometry::for_width(569.0); + assert!(!geometry.stacked); + assert_eq!(geometry.row_height, 32.0); + } + + #[test] + fn native_wide_containment_threshold_keeps_reference_rows() { + let geometry = PropertyGridGeometry::for_width(559.0); + assert!(!geometry.stacked); + assert_eq!(geometry.row_height, 32.0); + } + + #[test] + fn below_native_wide_containment_threshold_reflows_without_compression() { + let geometry = PropertyGridGeometry::for_width(558.0); + assert!(geometry.stacked); + assert_eq!(geometry.row_height, 58.0); + } + + #[test] + fn row_rects_match_exported_wide_geometry() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); + let rects = PropertyGridGeometry::for_width(569.0).row_rects(row); + assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); + assert_eq!(rects.value.min, egui::pos2(103.35, 5.0)); + assert!((rects.value.width() - 138.8).abs() < 0.001); + assert_eq!(rects.value.height(), 22.0); + assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0)); + assert!((rects.color_value.width() - 182.55).abs() < 0.001); + assert_eq!( + rects.channel, + egui::Rect::from_min_size(egui::pos2(242.5, 5.0), egui::vec2(48.0, 22.0)) + ); + assert_eq!( + rects.texture_group, + egui::Rect::from_min_size(egui::pos2(298.5, 5.0), egui::vec2(258.5, 22.0)) + ); + assert_eq!(rects.texture_field.size(), egui::vec2(198.5, 22.0)); + assert_eq!(rects.locate.min, egui::pos2(503.0, 5.0)); + assert_eq!(rects.clear.min, egui::pos2(533.0, 5.0)); + assert!(rects.inline_texture_actions); + } + + #[test] + fn row_rects_match_exported_compact_geometry() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(369.0, 58.0)); + let rects = PropertyGridGeometry::for_width(369.0).row_rects(row); + assert_eq!(rects.label.min, egui::pos2(12.0, 5.0)); + assert_eq!(rects.value.min, egui::pos2(103.35, 5.0)); + assert!((rects.value.width() - 205.3).abs() < 0.001); + assert_eq!(rects.color_value.min, egui::pos2(107.95, 5.0)); + assert!((rects.color_value.width() - 249.05).abs() < 0.001); + assert_eq!( + rects.channel, + egui::Rect::from_min_size(egui::pos2(309.0, 5.0), egui::vec2(48.0, 22.0)) + ); + assert_eq!( + rects.texture_group, + egui::Rect::from_min_size(egui::pos2(12.0, 31.0), egui::vec2(345.0, 22.0)) + ); + assert_eq!(rects.texture_field.size(), egui::vec2(285.0, 22.0)); + assert_eq!(rects.locate.min, egui::pos2(303.0, 31.0)); + assert_eq!(rects.clear.min, egui::pos2(333.0, 31.0)); + assert!(rects.inline_texture_actions); + } + + #[test] + fn transient_row_rects_are_finite_and_contained() { + for width in [356.0, 300.0, 240.0] { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 58.0)); + let rects = PropertyGridGeometry::for_width(width).row_rects(row); + for rect in [ + rects.label, + rects.value, + rects.color_value, + rects.channel, + rects.texture_group, + rects.texture_field, + rects.locate, + rects.clear, + ] { + assert!(rect.is_finite()); + assert!(row.contains_rect(rect)); + } + assert!(!rects.inline_texture_actions); + } + } + + #[test] + fn inspector_chrome_reduced_floor_keeps_compact_texture_actions() { + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 58.0)); + let rects = PropertyGridGeometry::for_width(363.0).row_rects(row); + assert!(rects.inline_texture_actions); + assert_eq!(rects.texture_group.min, egui::pos2(12.0, 31.0)); + assert_eq!(rects.texture_group.max, egui::pos2(351.0, 53.0)); + assert!(row.contains_rect(rects.locate)); + assert!(row.contains_rect(rects.clear)); + } + + #[test] + fn penpot_wide_growth_only_expands_texture_field() { + let reference_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(569.0, 32.0)); + let wide_row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(669.0, 32.0)); + let reference = PropertyGridGeometry::for_width(569.0).row_rects(reference_row); + let wide = PropertyGridGeometry::for_width(669.0).row_rects(wide_row); + assert_eq!(wide.label, reference.label); + assert_eq!(wide.value, reference.value); + assert_eq!(wide.color_value, reference.color_value); + assert_eq!(wide.channel, reference.channel); + assert_eq!( + wide.texture_field.width(), + reference.texture_field.width() + 100.0 + ); + } + + #[test] + fn penpot_very_narrow_first_line_never_exceeds_inner_width() { + let geometry = PropertyGridGeometry::for_width(300.0); + assert!(geometry.stacked); + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 58.0)); + let rects = geometry.row_rects(row); + assert!(row.contains_rect(rects.label)); + assert!(row.contains_rect(rects.value)); + assert!(row.contains_rect(rects.channel)); + assert_eq!(rects.texture_group.width(), 276.0); + } +} diff --git a/crates/editor_ui/src/design_system/typography.rs b/crates/editor_ui/src/design_system/typography.rs new file mode 100644 index 0000000..c6c35d1 --- /dev/null +++ b/crates/editor_ui/src/design_system/typography.rs @@ -0,0 +1,94 @@ +//! Source Sans typography roles used by the Penpot-led editor visual system. + +use egui::{self, FontFamily, FontId, RichText, Style, TextStyle}; + +use crate::fonts::{SOURCE_SANS_BOLD, SOURCE_SANS_REGULAR}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TypeRole { + Caption, + Eyebrow, + Small, + Body, + Control, + Section, + AssetLabel, + AssetTitle, + Title, +} + +impl TypeRole { + pub const fn size(self) -> f32 { + match self { + Self::Caption | Self::Eyebrow => 9.0, + Self::Small => 10.0, + Self::Body => 11.0, + Self::Control | Self::AssetLabel => 10.0, + Self::Section => 11.0, + Self::AssetTitle => 13.0, + Self::Title => 14.0, + } + } + + pub const fn bold(self) -> bool { + matches!( + self, + Self::Eyebrow | Self::Section | Self::AssetLabel | Self::AssetTitle | Self::Title + ) + } + + pub fn font(self) -> FontId { + let family = if self.bold() { + SOURCE_SANS_BOLD + } else { + SOURCE_SANS_REGULAR + }; + FontId::new(self.size(), FontFamily::Name(family.into())) + } + + pub fn text(self, value: impl Into) -> RichText { + RichText::new(value).font(self.font()) + } +} + +pub fn install_local_text_styles(style: &mut Style) { + style + .text_styles + .insert(TextStyle::Body, TypeRole::Body.font()); + style + .text_styles + .insert(TextStyle::Button, TypeRole::Control.font()); + style + .text_styles + .insert(TextStyle::Small, TypeRole::Small.font()); + style + .text_styles + .insert(TextStyle::Heading, TypeRole::Title.font()); + style.text_styles.insert( + TextStyle::Monospace, + FontId::new(11.0, egui::FontFamily::Monospace), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_type_scale_and_weights_are_stable() { + assert_eq!(TypeRole::Caption.size(), 9.0); + assert_eq!(TypeRole::Eyebrow.size(), 9.0); + assert_eq!(TypeRole::Small.size(), 10.0); + assert_eq!(TypeRole::Body.size(), 11.0); + assert_eq!(TypeRole::Control.size(), 10.0); + assert_eq!(TypeRole::Section.size(), 11.0); + assert_eq!(TypeRole::AssetLabel.size(), 10.0); + assert_eq!(TypeRole::AssetTitle.size(), 13.0); + assert_eq!(TypeRole::Title.size(), 14.0); + assert!(!TypeRole::Body.bold()); + assert!(TypeRole::Eyebrow.bold()); + assert!(TypeRole::Section.bold()); + assert!(TypeRole::AssetLabel.bold()); + assert!(TypeRole::AssetTitle.bold()); + } +} diff --git a/crates/editor_ui/src/fonts.rs b/crates/editor_ui/src/fonts.rs new file mode 100644 index 0000000..cc939b9 --- /dev/null +++ b/crates/editor_ui/src/fonts.rs @@ -0,0 +1,54 @@ +//! Source Sans Pro and Phosphor font registration for every Blacksite egui host. + +use std::sync::Arc; + +pub const SOURCE_SANS_REGULAR: &str = "blacksite-source-sans-regular"; +pub const SOURCE_SANS_BOLD: &str = "blacksite-source-sans-bold"; + +pub fn font_definitions() -> egui::FontDefinitions { + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor_icons::add_fonts(&mut fonts); + fonts.font_data.insert( + SOURCE_SANS_REGULAR.to_owned(), + Arc::new(egui::FontData::from_static(include_bytes!( + "../assets/fonts/source-sans/SourceSansPro-Regular.ttf" + ))), + ); + fonts.font_data.insert( + SOURCE_SANS_BOLD.to_owned(), + Arc::new(egui::FontData::from_static(include_bytes!( + "../assets/fonts/source-sans/SourceSansPro-Bold.ttf" + ))), + ); + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, SOURCE_SANS_REGULAR.to_owned()); + fonts.families.insert( + egui::FontFamily::Name(SOURCE_SANS_REGULAR.into()), + vec![SOURCE_SANS_REGULAR.to_owned()], + ); + fonts.families.insert( + egui::FontFamily::Name(SOURCE_SANS_BOLD.into()), + vec![SOURCE_SANS_BOLD.to_owned(), SOURCE_SANS_REGULAR.to_owned()], + ); + let fallbacks = fonts + .families + .get(&egui::FontFamily::Proportional) + .cloned() + .unwrap_or_default(); + for (family, font_names) in &mut fonts.families { + let egui::FontFamily::Name(name) = family else { + continue; + }; + if name.starts_with("phosphor-") { + for fallback in &fallbacks { + if !font_names.contains(fallback) { + font_names.push(fallback.clone()); + } + } + } + } + fonts +} diff --git a/crates/editor_ui/src/inspector.rs b/crates/editor_ui/src/inspector.rs new file mode 100644 index 0000000..6cb122f --- /dev/null +++ b/crates/editor_ui/src/inspector.rs @@ -0,0 +1,262 @@ +//! Action-returning Inspector component-card chrome shared by production and gallery fixtures. + +use egui; +use egui_phosphor_icons::{icons, Icon}; + +use crate::design_system; +use crate::design_system::typography::TypeRole; +use crate::theme::{BORDER, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG}; + +pub const COMPONENT_HEADER_HEIGHT: f32 = 50.0; +const COMPONENT_RAIL_X: f32 = 7.0; +const COMPONENT_CARET_CENTER_X: f32 = 26.0; +const COMPONENT_ICON_CENTER_X: f32 = 51.0; +const COMPONENT_IDENTITY_X: f32 = 69.0; +const COMPONENT_ACTIONS_WIDTH: f32 = 104.0; + +#[derive(Debug, Clone, Copy)] +pub struct ComponentCardOptions { + pub type_name: &'static str, + pub title: &'static str, + pub icon: Icon, + pub active_toggle: bool, + pub removable: bool, + pub reorderable: bool, + pub resettable: bool, + pub copyable: bool, + pub summary: &'static str, + pub body_margin: i8, +} + +impl ComponentCardOptions { + pub const fn removable(type_name: &'static str, title: &'static str, icon: Icon) -> Self { + Self { + type_name, + title, + icon, + active_toggle: true, + removable: true, + reorderable: true, + resettable: true, + copyable: true, + summary: "Authoring component", + body_margin: 12, + } + } + + pub const fn fixed(type_name: &'static str, title: &'static str, icon: Icon) -> Self { + Self { + type_name, + title, + icon, + active_toggle: false, + removable: false, + reorderable: false, + resettable: true, + copyable: false, + summary: "Authoring component", + body_margin: 12, + } + } +} + +#[derive(Debug, Clone)] +pub struct ComponentCardViewModel { + pub options: ComponentCardOptions, + pub active: bool, + pub collapsed: bool, + pub pasteable: bool, + pub can_move_up: bool, + pub can_move_down: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ComponentCardAction { + pub type_name: &'static str, + pub collapsed: Option, + pub active: Option, + pub reset: bool, + pub copy: bool, + pub paste: bool, + pub move_up: bool, + pub move_down: bool, + pub remove: bool, +} + +pub fn component_card( + ui: &mut egui::Ui, + view: &ComponentCardViewModel, + add_contents: impl FnOnce(&mut egui::Ui), +) -> ComponentCardAction { + let mut action = ComponentCardAction { + type_name: view.options.type_name, + ..Default::default() + }; + design_system::scope(ui, |ui| { + egui::Frame::new() + .fill(WIDGET_BG) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .show(ui, |ui| { + let width = ui.available_width().max(1.0); + let (header, response) = ui.allocate_exact_size( + egui::vec2(width, COMPONENT_HEADER_HEIGHT), + egui::Sense::click(), + ); + ui.painter().rect_filled( + egui::Rect::from_min_size( + header.min + egui::vec2(COMPONENT_RAIL_X, 7.0), + egui::vec2(3.0, 36.0), + ), + 2.0, + SELECTION, + ); + let caret_rect = egui::Rect::from_center_size( + egui::pos2(header.left() + COMPONENT_CARET_CENTER_X, header.center().y), + egui::vec2(20.0, 28.0), + ); + if ui + .put( + caret_rect, + egui::Button::new(icon_text( + if view.collapsed { + icons::CARET_RIGHT + } else { + icons::CARET_DOWN + }, + 12.0, + )) + .frame(false), + ) + .clicked() + { + action.collapsed = Some(!view.collapsed); + } + ui.painter().text( + egui::pos2(header.left() + COMPONENT_ICON_CENTER_X, header.center().y), + egui::Align2::CENTER_CENTER, + view.options.icon.as_str(), + egui::FontId::new(18.0, egui::FontFamily::Name("phosphor-bold".into())), + TEXT_DIM, + ); + ui.painter().text( + egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 18.0), + egui::Align2::LEFT_CENTER, + view.options.title, + TypeRole::Title.font(), + TEXT, + ); + ui.painter().text( + egui::pos2(header.left() + COMPONENT_IDENTITY_X, header.top() + 34.0), + egui::Align2::LEFT_CENTER, + view.options.summary, + TypeRole::Small.font(), + TEXT_MUTED, + ); + let actions_rect = egui::Rect::from_min_size( + egui::pos2( + header.right() - COMPONENT_ACTIONS_WIDTH - 8.0, + header.top() + 11.0, + ), + egui::vec2(COMPONENT_ACTIONS_WIDTH, 28.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + actions_menu(ui, view, &mut action); + let color = if view.active { SUCCESS } else { TEXT_MUTED }; + let status_rect = ui + .allocate_exact_size(egui::vec2(8.0, 14.0), egui::Sense::hover()) + .0; + ui.painter().circle_filled(status_rect.center(), 4.0, color); + ui.label( + TypeRole::Small + .text(if view.active { "Active" } else { "Off" }) + .color(TEXT_DIM), + ); + }); + }); + if response.clicked() + && ui + .input(|input| input.pointer.interact_pos()) + .is_some_and(|point| { + !caret_rect.contains(point) && !actions_rect.contains(point) + }) + { + action.collapsed = Some(!view.collapsed); + } + if !view.collapsed { + egui::Frame::new() + .inner_margin(egui::Margin { + left: view.options.body_margin, + right: view.options.body_margin, + top: 10, + bottom: view.options.body_margin, + }) + .show(ui, add_contents); + } + }); + }); + ui.add_space(6.0); + action +} + +fn actions_menu( + ui: &mut egui::Ui, + view: &ComponentCardViewModel, + action: &mut ComponentCardAction, +) { + ui.menu_button( + icon_text(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM), + |ui| { + ui.set_min_width(160.0); + action.reset |= action_button(ui, view.options.resettable, "Reset"); + action.copy |= action_button(ui, view.options.copyable, "Copy Values"); + action.paste |= action_button(ui, view.pasteable, "Paste Values"); + ui.separator(); + action.move_up |= action_button(ui, view.can_move_up, "Move Up"); + action.move_down |= action_button(ui, view.can_move_down, "Move Down"); + ui.separator(); + ui.add_enabled(false, egui::Button::new("Open Documentation")); + if ui + .add_enabled( + view.options.removable, + egui::Button::new(egui::RichText::new("Remove").color(ERROR)), + ) + .clicked() + { + action.remove = true; + ui.close(); + } + }, + ); +} + +fn action_button(ui: &mut egui::Ui, enabled: bool, label: &str) -> bool { + let clicked = ui.add_enabled(enabled, egui::Button::new(label)).clicked(); + if clicked { + ui.close(); + } + clicked +} + +fn icon_text(icon: Icon, size: f32) -> egui::RichText { + egui::RichText::new(icon.as_str()).font(egui::FontId::new( + size, + egui::FontFamily::Name("phosphor-bold".into()), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_component_header_geometry_is_exact() { + assert_eq!(COMPONENT_HEADER_HEIGHT, 50.0); + assert_eq!(COMPONENT_RAIL_X, 7.0); + assert_eq!(COMPONENT_CARET_CENTER_X, 26.0); + assert_eq!(COMPONENT_ICON_CENTER_X, 51.0); + assert_eq!(COMPONENT_IDENTITY_X, 69.0); + assert_eq!(COMPONENT_ACTIONS_WIDTH, 104.0); + } +} diff --git a/crates/editor_ui/src/lib.rs b/crates/editor_ui/src/lib.rs new file mode 100644 index 0000000..1b6879d --- /dev/null +++ b/crates/editor_ui/src/lib.rs @@ -0,0 +1,12 @@ +//! Production egui presentation shared by the Blacksite editor and lightweight UI gallery. +//! +//! This crate owns rendering, deterministic geometry, and action-returning view contracts. It does +//! not own a Bevy world, project state, authored-document publication, scene mutations, or content +//! processing. + +pub mod design_system; +pub mod fonts; +pub mod inspector; +pub mod materials; +pub mod renderer; +pub mod theme; diff --git a/crates/editor_ui/src/materials/advanced_preview.rs b/crates/editor_ui/src/materials/advanced_preview.rs new file mode 100644 index 0000000..06922bb --- /dev/null +++ b/crates/editor_ui/src/materials/advanced_preview.rs @@ -0,0 +1,286 @@ +//! Non-interactive Penpot v2.3.1 renderer-control preview. + +use egui; +use egui_phosphor_icons::icons; + +use crate::design_system::typography::TypeRole; +use crate::design_system::{self, ADVANCED_COLLAPSED_HEIGHT, ADVANCED_EXPANDED_HEIGHT}; + +const PLANNED_TOOLTIP: &str = "Planned — renderer support pending"; + +pub(super) fn unsupported_advanced_preview(ui: &mut egui::Ui) { + let open_id = ui.make_persistent_id("material_unsupported_advanced_open"); + let reveal_id = open_id.with("reveal_complete_body"); + let mut open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(false); + let width = ui.available_width().max(1.0); + let mode = super::layout::ResponsiveMaterialLayout::for_width(width); + let height = if open { + if mode == super::layout::ResponsiveMaterialLayout::Transient { + 254.0 + } else { + ADVANCED_EXPANDED_HEIGHT + } + } else { + ADVANCED_COLLAPSED_HEIGHT + }; + let palette = design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().rect( + rect, + 5.0, + palette.panel, + 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.panel); + 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(26.0, 0.0), + egui::Align2::LEFT_CENTER, + "Advanced", + TypeRole::Section.font(), + palette.text_secondary, + ); + if !open && rect.width() >= 300.0 { + ui.painter().hline( + (header.left() + 82.0)..=(header.right() - 158.0), + header.center().y, + egui::Stroke::new(1.0_f32, palette.border), + ); + ui.painter().text( + header.right_center() - egui::vec2(12.0, 0.0), + egui::Align2::RIGHT_CENTER, + PLANNED_TOOLTIP, + TypeRole::Small.font(), + palette.text_muted, + ); + } + if open { + match mode { + super::layout::ResponsiveMaterialLayout::Reference => reference_controls(ui, rect), + super::layout::ResponsiveMaterialLayout::Compact => { + compact_reference_controls(ui, rect) + } + super::layout::ResponsiveMaterialLayout::Transient => compact_controls(ui, rect), + } + } + let reveal_complete_body = ui + .ctx() + .data_mut(|data| data.get_temp::(reveal_id)) + .unwrap_or(false); + if open && reveal_complete_body && rect.height() > ADVANCED_COLLAPSED_HEIGHT { + ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); + ui.ctx().data_mut(|data| data.remove::(reveal_id)); + } + ui.ctx() + .data_mut(|data| data.insert_persisted(open_id, open)); +} + +fn reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field(ui, rect, 28.0, 42.0, "Culling", "Back"); + disabled_field(ui, rect, 28.0, 76.0, "Render Queue", "From Shader"); + disabled_toggle(ui, rect, 328.0, 42.0, "Depth Write", true); + disabled_toggle(ui, rect, 328.0, 76.0, "Receive Shadows", true); + planned_note(ui, rect, 28.0, 124.0); +} + +fn compact_reference_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field_at(ui, rect, 20.0, 90.0, 90.0, 42.0, "Culling", "Back"); + disabled_field_at( + ui, + rect, + 20.0, + 90.0, + 90.0, + 76.0, + "Render Queue", + "From Shader", + ); + disabled_toggle_at( + ui, + rect, + 196.0, + rect.width() - 38.0, + 42.0, + "Depth Write", + true, + ); + disabled_toggle_at( + ui, + rect, + 196.0, + rect.width() - 38.0, + 76.0, + "Receive Shadows", + true, + ); + planned_note(ui, rect, 20.0, 124.0); +} + +fn compact_controls(ui: &mut egui::Ui, rect: egui::Rect) { + disabled_field(ui, rect, 20.0, 42.0, "Culling", "Back"); + disabled_field(ui, rect, 20.0, 78.0, "Render Queue", "From Shader"); + disabled_toggle(ui, rect, 20.0, 124.0, "Depth Write", true); + disabled_toggle(ui, rect, 20.0, 160.0, "Receive Shadows", true); + planned_note(ui, rect, 20.0, 210.0); +} + +fn disabled_field(ui: &mut egui::Ui, rect: egui::Rect, x: f32, y: f32, label: &str, value: &str) { + disabled_field_at(ui, rect, x, x + 106.0, 150.0, y, label, value); +} + +#[expect( + clippy::too_many_arguments, + reason = "explicit Penpot preview geometry" +)] +fn disabled_field_at( + ui: &mut egui::Ui, + rect: egui::Rect, + label_x: f32, + field_x: f32, + field_width: f32, + y: f32, + label: &str, + value: &str, +) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(label_x, y), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_muted, + ); + let field = egui::Rect::from_min_size( + egui::pos2(rect.left() + field_x, rect.top() + y - 7.0), + egui::vec2(field_width.min(rect.width() - field_x - 8.0).max(1.0), 24.0), + ); + paint_disabled_field(ui, field, value); +} + +fn disabled_toggle( + ui: &mut egui::Ui, + rect: egui::Rect, + x: f32, + y: f32, + label: &str, + enabled: bool, +) { + disabled_toggle_at(ui, rect, x, x + 106.0, y, label, enabled); +} + +fn disabled_toggle_at( + ui: &mut egui::Ui, + rect: egui::Rect, + label_x: f32, + toggle_x: f32, + y: f32, + label: &str, + enabled: bool, +) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(label_x, y), + egui::Align2::LEFT_CENTER, + label, + TypeRole::Body.font(), + palette.text_muted, + ); + let toggle = egui::Rect::from_min_size( + egui::pos2(rect.left() + toggle_x, rect.top() + y - 8.0), + egui::vec2(30.0, 16.0), + ); + ui.painter().rect( + toggle, + 8.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let thumb_x = if enabled { + toggle.right() - 8.0 + } else { + toggle.left() + 8.0 + }; + ui.painter().circle_filled( + egui::pos2(thumb_x, toggle.center().y), + 5.0, + palette.text_muted, + ); + ui.interact( + toggle, + ui.make_persistent_id(("advanced_toggle", label)), + egui::Sense::hover(), + ) + .on_hover_text(PLANNED_TOOLTIP); +} + +fn paint_disabled_field(ui: &mut egui::Ui, rect: egui::Rect, value: &str) { + let palette = design_system::palette(ui); + ui.painter().rect( + rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.left_center() + egui::vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + value, + TypeRole::Body.font(), + palette.text_muted, + ); + ui.interact( + rect, + ui.make_persistent_id(("advanced_field", value)), + egui::Sense::hover(), + ) + .on_hover_text(PLANNED_TOOLTIP); +} + +fn planned_note(ui: &egui::Ui, rect: egui::Rect, x: f32, y: f32) { + let palette = design_system::palette(ui); + ui.painter().text( + rect.min + egui::vec2(x, y), + egui::Align2::LEFT_CENTER, + PLANNED_TOOLTIP, + TypeRole::Small.font(), + palette.text_muted, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_advanced_preview_matches_v231_heights() { + assert_eq!(ADVANCED_COLLAPSED_HEIGHT, 30.0); + assert_eq!(ADVANCED_EXPANDED_HEIGHT, 178.0); + } +} diff --git a/crates/editor_ui/src/materials/inputs.rs b/crates/editor_ui/src/materials/inputs.rs new file mode 100644 index 0000000..62311de --- /dev/null +++ b/crates/editor_ui/src/materials/inputs.rs @@ -0,0 +1,724 @@ +use super::texture_inputs::{ + ensure_material_texture_binding, material_property_texture_binding, + material_texture_channel_ui, texture_binding_ui, texture_channel_label, + upsert_material_texture_binding, +}; +use super::uv_transform::{material_vec2_value, set_material_vec2_value, uv_transform_section}; +use super::*; + +use crate::design_system; +use crate::design_system::color_picker::{color_control, color_control_with_intensity}; +use crate::design_system::controls::{ + scalar_control, section_header, section_header_with_summary, switch, +}; +use crate::design_system::property_grid::PropertyGridGeometry; +use crate::design_system::typography::TypeRole; +use material_schema::MaterialShaderKind; + +pub 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 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(material_schema::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, + material_schema::MaterialInputPresentation::Row + ) + }) + .collect::>(); + let companions = schema + .inputs + .iter() + .filter(|property| property.group == group.id) + .filter_map(|property| match &property.presentation { + material_schema::MaterialInputPresentation::Companion { owner } => { + Some((owner.as_str(), property)) + } + material_schema::MaterialInputPresentation::Row => None, + }) + .collect::>(); + properties.sort_by_key(|property| property.order); + if properties.is_empty() { + continue; + } + if group.id == "uv_transform" { + material_uv_transform_ui(ui, inputs, inherited); + // The design-system scope contributes four pixels between vertical items; add the + // remaining four to reach the authored eight-pixel section gap. + ui.add_space(4.0); + continue; + } + let open_id = ui.make_persistent_id(("material_input_group_open", &group.id)); + let mut open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(!group.advanced); + let context = MaterialInputGroupContext { + properties: &properties, + companions: &companions, + texture_assets, + drop_candidate, + }; + let rows = material_input_group_ui(ui, context, &mut open, inputs, |ui, open| { + if group.id == "surface_inputs" { + let summary = format!("{} bindings", properties.len()); + section_header_with_summary(ui, "Inputs", Some(&summary), open); + } else { + section_header(ui, &group.display_name, open); + } + }); + 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(4.0); + } + if has_standard_packed_inputs { + super::advanced_preview::unsupported_advanced_preview(ui); + } + response +} + +#[derive(Clone, Copy)] +struct MaterialInputGroupContext<'schema, 'view> { + properties: &'view [&'schema MaterialInputDesc], + companions: &'view [(&'schema str, &'schema MaterialInputDesc)], + texture_assets: &'view [MaterialTextureCandidate], + drop_candidate: Option<&'view MaterialTextureCandidate>, +} + +fn material_input_group_ui( + ui: &mut egui::Ui, + context: MaterialInputGroupContext<'_, '_>, + open: &mut bool, + inputs: &mut MaterialInputSet, + header_ui: impl FnOnce(&mut egui::Ui, &mut bool), +) -> MaterialInputEditorResponse { + let width = ui.available_width().max(1.0); + let geometry = PropertyGridGeometry::for_width(width); + let body_open = *open; + let height = material_input_group_height(width, body_open, context.properties.len()); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let palette = design_system::palette(ui); + ui.painter().rect( + rect, + 5.0, + palette.section, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + + // The parent owns the complete section rect. Child controls may manage local layout state, + // but they cannot alter where the next material section begins. + let mut section = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + section.set_clip_rect(rect.intersect(ui.clip_rect())); + let header_rect = egui::Rect::from_min_size( + rect.min, + egui::vec2(width, design_system::INPUT_SECTION_HEADER_HEIGHT), + ); + let mut header = section.new_child(egui::UiBuilder::new().max_rect(header_rect)); + header.set_clip_rect(header_rect.intersect(section.clip_rect())); + header_ui(&mut header, open); + + if body_open { + let rows_rect = + egui::Rect::from_min_max(egui::pos2(rect.left(), header_rect.bottom()), rect.max); + material_input_rows(&mut section, rows_rect, geometry, context, inputs) + } else { + MaterialInputEditorResponse::default() + } +} + +fn material_input_group_height(width: f32, open: bool, rows: usize) -> f32 { + let body = if open { + PropertyGridGeometry::for_width(width).row_height * rows as f32 + } else { + 0.0 + }; + design_system::INPUT_SECTION_HEADER_HEIGHT + body +} + +fn material_uv_transform_ui( + 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 enum PackedMapMode { + Arm, + Separate, +} + +pub 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 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 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; + } +} + +fn material_input_rows( + ui: &mut egui::Ui, + rows_rect: egui::Rect, + geometry: PropertyGridGeometry, + context: MaterialInputGroupContext<'_, '_>, + inputs: &mut MaterialInputSet, +) -> MaterialInputEditorResponse { + let palette = design_system::palette(ui); + let mut response = MaterialInputEditorResponse::default(); + for (row, property) in context.properties.iter().enumerate() { + let companion = context + .companions + .iter() + .find_map(|(owner, companion)| (*owner == property.name).then_some(*companion)); + ui.push_id(("material_input_row", &property.name), |ui| { + let row_rect = egui::Rect::from_min_size( + rows_rect.min + egui::vec2(0.0, row as f32 * geometry.row_height), + egui::vec2(rows_rect.width(), geometry.row_height), + ); + 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, + context.texture_assets, + context.drop_candidate, + rects.inline_texture_actions, + ) + }); + response.accepted_drop |= texture.accepted_drop; + response.locate = response.locate.take().or(texture.locate); + response.browse_library |= texture.browse_library; + } + }); + } + response +} + +fn rect_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + add_contents(ui) + }) + .inner +} + +pub(super) fn material_input_label(ui: &mut egui::Ui, property: &MaterialInputDesc) { + let palette = design_system::palette(ui); + ui.add( + egui::Label::new( + TypeRole::Body + .text(&property.display_name) + .color(palette.text_secondary), + ) + .truncate(), + ) + .on_hover_text(&property.tooltip); +} + +pub(super) fn material_input_uses_texture(property: &MaterialInputDesc) -> bool { + property.texture.is_some() || matches!(property.property_type, ShaderPropertyType::Texture) +} + +pub(super) fn material_input_value_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + companion: Option<&MaterialInputDesc>, + inputs: &mut MaterialInputSet, +) { + let MaterialInputSet { values, .. } = inputs; + ui.horizontal(|ui| { + if matches!(property.property_type, ShaderPropertyType::Texture) { + ui.label(egui::RichText::new("Texture").small().color(TEXT_DIM)); + } else { + let default = property + .default_value + .clone() + .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); + let mut value = values + .iter() + .find(|value| value.name == property.name) + .map(|parameter| parameter.value.clone()) + .unwrap_or(default); + if !parameter_value_matches_property(&value, &property.property_type) { + value = default_value_for_shader_property(&property.property_type); + } + let original = value.clone(); + if matches!(property.property_type, ShaderPropertyType::Color) + && companion.is_some_and(|input| input.name == "emissive_intensity") + { + let companion = companion.expect("checked companion"); + let companion_default = companion + .default_value + .clone() + .unwrap_or(MaterialParameterValue::Float(0.0)); + let mut companion_value = values + .iter() + .find(|value| value.name == companion.name) + .map(|parameter| parameter.value.clone()) + .unwrap_or(companion_default); + if !parameter_value_matches_property(&companion_value, &companion.property_type) { + companion_value = default_value_for_shader_property(&companion.property_type); + } + let original_companion = companion_value.clone(); + if let ( + MaterialParameterValue::Color(color), + MaterialParameterValue::Float(intensity), + ) = (&mut value, &mut companion_value) + { + let mut rgba = [color.r, color.g, color.b, color.a]; + let picker = color_control_with_intensity( + ui, + "material_emissive_color", + &property.display_name, + &mut rgba, + intensity, + ); + if picker.changed { + *color = ColorDesc { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + }; + } + } + if value != original { + upsert_material_parameter(values, &property.name, value); + } + if companion_value != original_companion { + upsert_material_parameter(values, &companion.name, companion_value); + } + } else { + material_parameter_control_ui( + ui, + &property.display_name, + &property.property_type, + &mut value, + ); + if value != original { + upsert_material_parameter(values, &property.name, value); + } + } + } + }); +} + +fn upsert_material_parameter( + values: &mut Vec, + name: &str, + value: MaterialParameterValue, +) { + if let Some(parameter) = values.iter_mut().find(|parameter| parameter.name == name) { + parameter.value = value; + } else { + values.push(MaterialParameter { + name: name.to_string(), + value, + }); + } +} + +fn material_input_channel_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + inputs: &mut MaterialInputSet, +) { + let palette = design_system::palette(ui); + if property + .texture + .as_ref() + .is_some_and(|texture| texture.allow_channel_override) + { + let original = material_property_texture_binding(&inputs.textures, property); + let mut binding = original.clone(); + material_texture_channel_ui(ui, property, &mut binding); + if binding != original { + upsert_material_texture_binding(&mut inputs.textures, binding); + } + } else { + let channel = property.texture.as_ref().map(|texture| { + if matches!(texture.semantic, material_schema::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 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 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 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 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 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 fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option) { + ui.horizontal(|ui| { + ui.add_sized([92.0, 20.0], egui::Label::new(label)); + let mut text = value.clone().unwrap_or_default(); + if ui + .add_sized( + [fit_width(ui, 80.0, f32::INFINITY), 22.0], + egui::TextEdit::singleline(&mut text), + ) + .changed() + { + *value = if text.trim().is_empty() { + None + } else { + Some(text) + }; + } + if ui.button("Clear").clicked() { + *value = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_group_parent_owns_exact_reference_and_compact_heights() { + assert_eq!(material_input_group_height(569.0, true, 6), 216.0); + assert_eq!(material_input_group_height(369.0, true, 6), 372.0); + assert_eq!(material_input_group_height(569.0, false, 6), 24.0); + } + + #[test] + fn rendering_missing_standard_defaults_does_not_author_or_dirty_inputs() { + let context = egui::Context::default(); + context.set_fonts(crate::fonts::font_definitions()); + let mut inputs = MaterialInputSet::default(); + let original = inputs.clone(); + let schema = material_schema::standard_lit_input_schema(); + let _ = context.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 900.0), + )), + ..Default::default() + }, + |ui| { + material_input_schema_editor(ui, &schema, &mut inputs, &[], None); + }, + ); + assert_eq!(inputs, original); + } +} diff --git a/crates/editor_ui/src/materials/layout.rs b/crates/editor_ui/src/materials/layout.rs new file mode 100644 index 0000000..4460a43 --- /dev/null +++ b/crates/editor_ui/src/materials/layout.rs @@ -0,0 +1,344 @@ +//! Numeric Penpot v2.3.1 material-panel geometry shared by rendering and tests. + +use egui; + +use crate::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::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::design_system::COMPACT_SURFACE_SECTION_HEIGHT, + title: local(10.0, 10.0, 48.0, 26.0), + blend: local(66.0, 10.0, 50.0, 26.0), + packed: local(124.0, 11.0, 128.0, 24.0), + double_sided: local(260.0, 12.0, 99.0, 22.0), + }, + ResponsiveMaterialLayout::Transient => Self { + height: 100.0, + title: local(10.0, 10.0, 48.0, 26.0), + blend: local(66.0, 10.0, (rect.width() - 76.0).clamp(1.0, 250.0), 26.0), + packed: local(10.0, 42.0, (rect.width() - 20.0).clamp(0.0, 128.0), 24.0), + double_sided: local(146.0, 43.0, (rect.width() - 156.0).clamp(0.0, 99.0), 22.0), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct UvGroupGeometry { + pub label: egui::Rect, + pub x: egui::Rect, + pub y: egui::Rect, + pub reset: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct UvSectionGeometry { + pub height: f32, + pub title: egui::Rect, + pub offset: UvGroupGeometry, + pub tiling: UvGroupGeometry, +} + +impl UvSectionGeometry { + pub(super) fn for_rect(rect: egui::Rect) -> Self { + let mode = ResponsiveMaterialLayout::for_width(rect.width()); + let title = egui::Rect::from_min_size( + rect.min + egui::vec2(12.0, 7.0), + egui::vec2(20.0_f32.min(rect.width()), 14.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::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::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.title.height(), 14.0); + assert_eq!(geometry.offset.x.size(), egui::vec2(50.0, 24.0)); + assert_eq!(geometry.offset.y.size(), egui::vec2(50.0, 24.0)); + assert!(geometry.offset.reset.is_some()); + assert!(geometry.tiling.reset.is_some()); + for rect in [ + geometry.title, + geometry.offset.label, + geometry.offset.x, + geometry.offset.y, + geometry.offset.reset.expect("reference reset"), + geometry.tiling.label, + geometry.tiling.x, + geometry.tiling.y, + geometry.tiling.reset.expect("reference reset"), + ] { + assert!(section.contains_rect(rect)); + assert!(rect.is_finite()); + } + } + } + + #[test] + fn transient_geometry_stays_finite_and_bounded() { + let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 100.0)); + let surface = SurfaceSectionGeometry::for_rect(section); + let uv = UvSectionGeometry::for_rect(section); + for rect in [ + surface.title, + surface.blend, + surface.packed, + surface.double_sided, + ] { + assert!(rect.is_finite()); + assert!(section.contains_rect(rect)); + } + for rect in [ + uv.title, + uv.offset.label, + uv.offset.x, + uv.offset.y, + uv.tiling.label, + uv.tiling.x, + uv.tiling.y, + ] { + assert!(rect.is_finite()); + assert!(section.contains_rect(rect)); + } + } + + #[test] + fn inspector_chrome_reduced_floor_keeps_the_penpot_compact_surface() { + let section = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(363.0, 46.0)); + let surface = SurfaceSectionGeometry::for_rect(section); + assert_eq!(surface.height, 46.0); + assert_eq!(surface.packed.min, egui::pos2(124.0, 11.0)); + assert_eq!(surface.packed.max, egui::pos2(252.0, 35.0)); + assert_eq!(surface.double_sided.min, egui::pos2(260.0, 12.0)); + } +} diff --git a/crates/editor_ui/src/materials/mod.rs b/crates/editor_ui/src/materials/mod.rs new file mode 100644 index 0000000..f202aad --- /dev/null +++ b/crates/editor_ui/src/materials/mod.rs @@ -0,0 +1,96 @@ +//! Material-slot and schema-driven parameter widgets shared by production and the gallery. + +use bevy_math::{Vec2, Vec3}; +use egui; +use egui_phosphor_icons::Icon; +use material_schema::{ + ColorDesc, EditorAssetRef, MaterialInputDesc, MaterialInputSchema, MaterialInputSet, + MaterialParameter, MaterialParameterValue, MaterialTextureBinding, ShaderPropertyType, + TextureChannel, +}; + +use crate::theme::TEXT_DIM; + +mod advanced_preview; +mod inputs; +mod layout; +mod panel; +mod pickers; +mod surface; +mod texture_inputs; +mod uv_transform; + +pub use inputs::{ + apply_arm_texture_preset, apply_separate_texture_preset, compact_text_edit, + default_value_for_shader_property, material_input_schema_editor, + material_input_schema_editor_with_inheritance, optional_path_edit, packed_map_mode, + parameter_value_matches_property, shader_kind_combo, shader_kind_picker, PackedMapMode, +}; +pub use panel::{ + material_slot_panel, materials_section, MaterialHealth, MaterialLayerBadge, + MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelAction, + MaterialSlotPanelResponse, MaterialSlotPanelViewModel, MaterialsSectionViewModel, +}; +pub use surface::material_surface_options_ui; +pub use texture_inputs::ensure_material_texture_binding; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MaterialLocateTarget { + File(String), + SubAsset { + parent_path: String, + sub_asset_id: String, + label: String, + source_path: Option, + }, +} + +#[derive(Clone, Debug)] +pub struct MaterialTextureCandidate { + pub label: String, + pub detail: String, + pub reference: EditorAssetRef, + pub locate: MaterialLocateTarget, + pub thumbnail: MaterialThumbnailPresentation, +} + +#[derive(Default)] +pub struct MaterialInputEditorResponse { + pub accepted_drop: bool, + pub locate: Option, + pub browse_library: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MaterialThumbnailPresentation { + Ready(egui::TextureId), + Pending, + Failed, + Empty, +} + +#[derive(Clone, Copy)] +enum TextureSlotSelection<'a> { + Reference(Option<&'a EditorAssetRef>), +} + +#[derive(Default)] +struct TextureSlotResponse { + selected: Option, + locate: Option, + clear: bool, + accepted_drop: bool, + browse_library: bool, +} + +fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { + let available = ui.available_width().max(1.0); + available.min(max).max(min.min(available)) +} + +fn icon_text(icon: Icon, size: f32) -> egui::RichText { + egui::RichText::new(icon.as_str()).font(egui::FontId::new( + size, + egui::FontFamily::Name("phosphor-bold".into()), + )) +} diff --git a/crates/editor_ui/src/materials/panel.rs b/crates/editor_ui/src/materials/panel.rs new file mode 100644 index 0000000..dc85bd0 --- /dev/null +++ b/crates/editor_ui/src/materials/panel.rs @@ -0,0 +1,773 @@ +//! Action-returning Penpot material-slot shell shared by primitive and mesh inspectors. + +use egui; +use egui_phosphor_icons::icons; +use material_schema::{EditorAssetRef, MaterialShaderKind}; + +use super::layout::MaterialPanelGeometry; +use super::MaterialThumbnailPresentation; +use crate::design_system::controls::status; +use crate::design_system::typography::TypeRole; +use crate::design_system::{self, DesignPalette, HEADER_HEIGHT}; + +mod header_actions; + +use header_actions::{expanded_status_actions, overflow_menu, SLOT_STATUS_ACTION_WIDTH}; + +// `Frame` reserves one pixel for the slot stroke before handing its child Ui to us. These local +// origins compensate for that inset so the painted rail/caret/preview retain the exported board +// coordinates relative to the outer slot rectangle. +const SLOT_ACCENT_X: f32 = 7.0; +const SLOT_CARET_CENTER_X: f32 = 22.0; +const SLOT_PREVIEW_X: f32 = 34.0; +const SHADER_CONTROL_WIDTH: f32 = 220.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaterialLayerBadge { + ModelSource, + ModelDefault, + ProjectDefault, + DefaultGrid, +} + +impl MaterialLayerBadge { + pub 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 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.text_secondary, + "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 struct MaterialPickerCandidate { + pub reference: EditorAssetRef, + pub label: String, + pub detail: String, + pub thumbnail: MaterialThumbnailPresentation, +} + +#[derive(Debug, Clone)] +pub struct MaterialSlotPanelViewModel { + pub slot_id: String, + pub label: String, + pub path: Option, + pub shader: String, + pub shader_kind: Option, + pub can_edit_shader: bool, + pub thumbnail: MaterialThumbnailPresentation, + pub inherited: Option, + pub health: MaterialHealth, + pub assigned: Option, + pub effective: Option, + pub candidates: Vec, + pub drop_candidate: Option, + pub invalid_drop_reason: Option, + pub can_locate: bool, + pub can_clear: bool, + pub can_extract: bool, + pub can_create_instance: bool, + /// Initial disclosure used only until this slot has persisted UI state. + pub parameters_initially_open: bool, + /// Renderer arrays keep the full asset/action header visible when only parameters collapse. + pub expanded_header_when_parameters_closed: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MaterialSlotAction { + Assign { + reference: EditorAssetRef, + from_drop: bool, + }, + BrowseLibrary, + Locate, + Clear, + ExtractEditable, + CreateInstance, + SetShader(MaterialShaderKind), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MaterialSlotPanelAction { + pub slot_id: String, + pub action: MaterialSlotAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MaterialsSectionViewModel { + pub slot_ids: Vec, +} + +pub struct MaterialSlotPanelResponse { + pub actions: Vec, +} + +pub fn materials_section( + ui: &mut egui::Ui, + _view: &MaterialsSectionViewModel, + add_slots: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let start = ui.next_widget_position(); + let expanded = actor_materials_rect(ui.max_rect(), start.y); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(expanded) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(ui.clip_rect()); + let result = design_system::scope(&mut child, |ui| { + let palette = design_system::palette(ui); + ui.allocate_ui_with_layout( + egui::vec2( + ui.available_width().max(1.0), + design_system::MATERIALS_HEADING_HEIGHT, + ), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label( + TypeRole::Title + .text("Materials") + .color(palette.text_primary), + ); + }, + ); + // The top-down layout already contributes the design system's four-pixel item gap after + // the 32 px heading. Add only the remaining four pixels so the slot begins at the Penpot + // board's authored eight-pixel offset instead of drifting four pixels lower. + ui.add_space(4.0); + add_slots(ui) + }); + // `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 fn material_slot_panel( + ui: &mut egui::Ui, + id: impl std::hash::Hash, + view: &MaterialSlotPanelViewModel, + content_ui: impl FnOnce(&mut egui::Ui), + diagnostics_ui: impl FnOnce(&mut egui::Ui), +) -> MaterialSlotPanelResponse { + design_system::scope(ui, |ui| { + let palette = design_system::palette(ui); + let id = ui.make_persistent_id(id); + let open_id = id.with("parameters_open"); + let mut parameters_open = ui + .ctx() + .data_mut(|data| data.get_persisted::(open_id)) + .unwrap_or(view.parameters_initially_open); + let mut slot_actions = Vec::new(); + + egui::Frame::new() + .fill(palette.recessed) + .stroke(egui::Stroke::new(1.0_f32, palette.border_strong)) + .corner_radius(egui::CornerRadius::same(8)) + .show(ui, |ui| { + slot_header( + ui, + id, + view, + &mut parameters_open, + &mut slot_actions, + diagnostics_ui, + ); + if parameters_open { + parameter_body(ui, content_ui); + } + ui.ctx() + .data_mut(|data| data.insert_persisted(open_id, parameters_open)); + }); + MaterialSlotPanelResponse { + actions: slot_actions + .into_iter() + .map(|action| MaterialSlotPanelAction { + slot_id: view.slot_id.clone(), + action, + }) + .collect(), + } + }) +} + +fn slot_header( + ui: &mut egui::Ui, + id: egui::Id, + view: &MaterialSlotPanelViewModel, + open: &mut bool, + actions: &mut Vec, + diagnostics_ui: impl FnOnce(&mut egui::Ui), +) { + let palette = design_system::palette(ui); + let expanded_header = *open || view.expanded_header_when_parameters_closed; + let height = if expanded_header { + 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 expanded_header { 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 expanded_header { 8.0 } else { 7.0 }, + ), + egui::pos2( + identity_right, + rect.bottom() - if expanded_header { 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 expanded_header { + 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 expanded_header { 9.0 } else { 7.0 }, + ), + egui::vec2( + action_width, + if expanded_header { 64.0 } else { height - 14.0 }, + ), + ); + if expanded_header { + expanded_status_actions(ui, actions_rect, view, actions, diagnostics_ui, palette); + } else { + ui.scope_builder(egui::UiBuilder::new().max_rect(actions_rect), |ui| { + ui.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::AssetTitle + .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.recessed) + .inner_margin(egui::Margin { + // The parent slot stroke already contributes one pixel on each side. Reclaim it here + // so the parameter sections are the authored 569/369 px instead of 567/367 px. + left: 18, + right: 7, + // Own the exported PARAMETERS origin here so every primitive/static/skinned caller + // receives the same position independent of its surrounding component chrome. + top: 21, + bottom: 8, + }) + .show(ui, |ui| { + let mut measure = egui::text::LayoutJob::default(); + measure.append( + "PARAMETERS", + 0.0, + egui::TextFormat { + font_id: TypeRole::Eyebrow.font(), + extra_letter_spacing: 0.6, + color: egui::Color32::TRANSPARENT, + ..Default::default() + }, + ); + // Penpot positions the 9 px eyebrow two raster pixels above egui's default label + // baseline. Keep the label's normal allocation so section flow remains unchanged, + // then paint the visible glyphs at the authored baseline. + let response = ui.label(measure); + let mut visible = egui::text::LayoutJob::default(); + visible.append( + "PARAMETERS", + 0.0, + egui::TextFormat { + font_id: TypeRole::Eyebrow.font(), + extra_letter_spacing: 0.6, + color: palette.accent, + ..Default::default() + }, + ); + let galley = ui.painter().layout_job(visible); + ui.painter().galley( + response.rect.min - egui::vec2(0.0, 5.0), + galley, + palette.accent, + ); + let spine_x = ui.min_rect().left() - 11.0; + let spine_top = ui.cursor().top(); + 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_dark), + ); + }); +} + +fn material_identity( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, +) -> egui::Response { + let palette = design_system::palette(ui); + let name = ui + .add( + egui::Label::new( + TypeRole::AssetTitle + .text(&view.label) + .color(palette.text_primary), + ) + .truncate() + .sense(egui::Sense::click()), + ) + .on_hover_text(&view.label); + let unassigned = view.assigned.is_none() && view.effective.is_none(); + let path = view.path.as_deref().unwrap_or(if unassigned { + "Drop a material here or choose from the browser" + } else { + "Engine built-in material" + }); + let identity = view.inherited.map_or_else( + || 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 if !view.shader.is_empty() { + 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, + ); + } + MaterialThumbnailPresentation::Empty => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(22.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_muted, + ); + } + } + ui.painter().rect_stroke( + 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, + parameters_initially_open: true, + expanded_header_when_parameters_closed: false, + } + } + + #[test] + fn slot_header_insets_match_the_current_penpot_export() { + const OUTER_FRAME_STROKE_INSET: f32 = 1.0; + assert_eq!(SLOT_ACCENT_X + OUTER_FRAME_STROKE_INSET, 8.0); + assert_eq!(SLOT_CARET_CENTER_X + OUTER_FRAME_STROKE_INSET, 23.0); + assert_eq!(SLOT_PREVIEW_X + OUTER_FRAME_STROKE_INSET, 35.0); + assert_eq!(SLOT_PREVIEW_X - (SLOT_CARET_CENTER_X + 6.0), 6.0); + assert_eq!(SLOT_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::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().as_str(), + icons::CHECK_CIRCLE.as_str() + ); + assert_eq!( + MaterialHealth::Dirty.icon().as_str(), + icons::PENCIL_SIMPLE.as_str() + ); + assert_eq!( + MaterialHealth::Processing.icon().as_str(), + icons::CIRCLE_NOTCH.as_str() + ); + assert_eq!( + MaterialHealth::Broken.icon().as_str(), + icons::WARNING_CIRCLE.as_str() + ); + assert_eq!( + MaterialHealth::ReadOnly.icon().as_str(), + icons::LOCK_SIMPLE.as_str() + ); + } +} diff --git a/crates/editor_ui/src/materials/panel/header_actions.rs b/crates/editor_ui/src/materials/panel/header_actions.rs new file mode 100644 index 0000000..2d84c8a --- /dev/null +++ b/crates/editor_ui/src/materials/panel/header_actions.rs @@ -0,0 +1,238 @@ +//! Exact Penpot status/action geometry for an expanded material-slot header. + +use egui; +use egui_phosphor_icons::icons; + +use super::{MaterialSlotAction, MaterialSlotPanelViewModel}; +use crate::design_system::controls::{icon_button, icon_label, status}; +use crate::design_system::typography::TypeRole; +use crate::design_system::{self, DesignPalette}; + +pub(super) const SLOT_STATUS_ACTION_WIDTH: f32 = 140.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ExpandedHeaderActionGeometry { + status: egui::Rect, + menu: egui::Rect, + browse: egui::Rect, + locate: egui::Rect, + clear: egui::Rect, +} + +fn geometry(rect: egui::Rect) -> ExpandedHeaderActionGeometry { + let status_row = egui::Rect::from_min_size( + rect.min + egui::vec2(0.0, 5.333), + egui::vec2(SLOT_STATUS_ACTION_WIDTH, 24.0), + ); + let menu = egui::Rect::from_min_size( + status_row.min + egui::vec2(116.0, 0.0), + egui::vec2(24.0, 24.0), + ); + let action_row = rect.min + egui::vec2(0.0, 34.667); + ExpandedHeaderActionGeometry { + status: egui::Rect::from_min_max( + status_row.min, + egui::pos2(menu.left() - 5.0, status_row.bottom()), + ), + menu, + browse: egui::Rect::from_min_size( + action_row + egui::vec2(56.0, 0.0), + egui::vec2(24.0, 24.0), + ), + locate: egui::Rect::from_min_size( + action_row + egui::vec2(86.0, 0.0), + egui::vec2(24.0, 24.0), + ), + clear: egui::Rect::from_min_size( + action_row + egui::vec2(116.0, 0.0), + egui::vec2(24.0, 24.0), + ), + } +} + +pub(super) fn expanded_status_actions( + ui: &mut egui::Ui, + rect: egui::Rect, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, + diagnostics_ui: impl FnOnce(&mut egui::Ui), + palette: DesignPalette, +) { + if view.assigned.is_none() && view.effective.is_none() { + let button_rect = egui::Rect::from_min_size( + rect.right_bottom() - egui::vec2(116.0, 28.0), + egui::vec2(116.0, 28.0), + ); + if rect_scope(ui, button_rect, |ui| { + ui.add( + egui::Button::new(icon_label( + icons::FOLDER_OPEN, + "Choose material", + TypeRole::Control, + 13.0, + palette.text_primary, + )) + .min_size(button_rect.size()), + ) + .clicked() + }) { + actions.push(MaterialSlotAction::BrowseLibrary); + } + return; + } + let geometry = geometry(rect); + rect_scope(ui, geometry.status, |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (color, label, tooltip) = view.health.presentation(palette); + status(ui, view.health.icon(), color, label, tooltip); + }); + }); + rect_scope(ui, geometry.menu, |ui| { + overflow_menu(ui, view, actions, diagnostics_ui); + }); + + for (button_rect, action, enabled, icon, tooltip) in [ + ( + geometry.browse, + MaterialSlotAction::BrowseLibrary, + true, + icons::FOLDER_OPEN, + "Browse project materials", + ), + ( + geometry.locate, + MaterialSlotAction::Locate, + view.can_locate, + icons::CROSSHAIR, + "Locate in Content Browser", + ), + ( + geometry.clear, + MaterialSlotAction::Clear, + view.can_clear, + icons::X, + "Clear actor assignment", + ), + ] { + if !enabled { + continue; + } + let clicked = rect_scope(ui, button_rect, |ui| { + icon_button(ui, icon, tooltip, true).clicked() + }); + if clicked { + actions.push(action); + } + } +} + +pub(super) fn overflow_menu( + ui: &mut egui::Ui, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, + 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( + 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)); + } + } +} diff --git a/crates/editor_ui/src/materials/pickers.rs b/crates/editor_ui/src/materials/pickers.rs new file mode 100644 index 0000000..a42d748 --- /dev/null +++ b/crates/editor_ui/src/materials/pickers.rs @@ -0,0 +1,420 @@ +//! Penpot-styled material and texture asset pickers. + +use egui; +use egui_phosphor_icons::icons; +use material_schema::EditorAssetRef; + +use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel}; +use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse}; +use crate::design_system; +use crate::design_system::controls::icon_label; +use crate::design_system::typography::TypeRole; + +const MATERIAL_PICKER_WIDTH: f32 = 244.0; +const TEXTURE_PICKER_WIDTH: f32 = 420.0; +const PICKER_ROW_HEIGHT: f32 = 44.0; +const PICKER_THUMBNAIL: f32 = 36.0; + +pub(super) fn material_picker_popup( + _ui: &mut egui::Ui, + id: egui::Id, + anchor: &egui::Response, + view: &MaterialSlotPanelViewModel, + actions: &mut Vec, +) { + egui::Popup::menu(anchor) + .id(id) + .width(MATERIAL_PICKER_WIDTH) + .show(|ui| { + ui.label( + TypeRole::Section + .text("Material Assets") + .color(design_system::palette(ui).text_primary), + ); + if let Some(current) = view.effective.as_ref() { + if let Some(candidate) = find_material_candidate(&view.candidates, current) { + material_menu_row(ui, candidate, true, actions); + } else { + unresolved_material_menu_row(ui, current); + } + } + + let recent_keys = recent_keys(ui, "material_picker_recents"); + let recent = candidates_for_recent_keys(&view.candidates, &recent_keys); + for candidate in recent.into_iter().take(4) { + let selected = view + .effective + .as_ref() + .is_some_and(|current| same_reference(current, &candidate.reference)); + if !selected { + material_menu_row(ui, candidate, false, actions); + } + } + picker_footer(ui, view.assigned.is_some(), actions); + }); +} + +pub(super) fn texture_picker_popup( + _ui: &mut egui::Ui, + id: egui::Id, + anchor: &egui::Response, + candidates: &[MaterialTextureCandidate], + selected: Option<&MaterialTextureCandidate>, + response: &mut TextureSlotResponse, +) { + egui::Popup::menu(anchor) + .id(id) + .width(TEXTURE_PICKER_WIDTH) + .show(|ui| { + picker_heading( + ui, + "Select Texture", + "Project textures and imported subassets", + ); + if let Some(current) = selected { + picker_section_label(ui, "CURRENT"); + texture_candidate_row(ui, current, true, response); + } + let recent_keys = recent_keys(ui, "texture_picker_recents"); + let recent = candidates_for_recent_texture_keys(candidates, &recent_keys); + if !recent.is_empty() { + picker_section_label(ui, "RECENT"); + for candidate in recent.into_iter().take(4) { + texture_candidate_row(ui, candidate, false, response); + } + } + picker_section_label(ui, "PROJECT TEXTURES"); + egui::ScrollArea::vertical() + .id_salt(id.with("project_textures")) + .max_height(240.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + if candidates.is_empty() { + empty_picker_row(ui, "No project textures"); + } + for candidate in candidates { + let selected = selected.is_some_and(|current| { + same_reference(¤t.reference, &candidate.reference) + }); + texture_candidate_row(ui, candidate, selected, response); + } + }); + let palette = design_system::palette(ui); + ui.separator(); + if ui + .add(egui::Button::new(icon_label( + icons::FOLDER_OPEN, + "Browse Asset Library", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + response.browse_library = true; + ui.close(); + } + if selected.is_some() + && ui + .add(egui::Button::new(icon_label( + icons::X, + "Clear Texture", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + response.clear = true; + ui.close(); + } + }); +} + +fn picker_heading(ui: &mut egui::Ui, title: &str, subtitle: &str) { + let palette = design_system::palette(ui); + ui.label(TypeRole::Title.text(title).color(palette.text_primary)); + ui.label(TypeRole::Small.text(subtitle).color(palette.text_muted)); + ui.separator(); +} + +fn picker_section_label(ui: &mut egui::Ui, label: &str) { + let palette = design_system::palette(ui); + ui.add_space(4.0); + ui.label(TypeRole::Caption.text(label).color(palette.text_muted)); +} + +fn material_menu_row( + ui: &mut egui::Ui, + candidate: &MaterialPickerCandidate, + selected: bool, + actions: &mut Vec, +) { + let response = picker_asset_row( + ui, + &candidate.label, + &candidate.detail, + candidate.thumbnail, + selected, + ); + if response.clicked() { + remember_recent(ui, "material_picker_recents", &candidate.reference); + actions.push(MaterialSlotAction::Assign { + reference: candidate.reference.clone(), + from_drop: false, + }); + ui.close(); + } +} + +fn texture_candidate_row( + ui: &mut egui::Ui, + candidate: &MaterialTextureCandidate, + selected: bool, + response: &mut TextureSlotResponse, +) { + let row = picker_asset_row( + ui, + &candidate.label, + &candidate.detail, + candidate.thumbnail, + selected, + ); + if row.clicked() { + remember_recent(ui, "texture_picker_recents", &candidate.reference); + response.selected = Some(candidate.clone()); + ui.close(); + } +} + +fn picker_asset_row( + ui: &mut egui::Ui, + label: &str, + detail: &str, + thumbnail: MaterialThumbnailPresentation, + selected: bool, +) -> egui::Response { + let palette = design_system::palette(ui); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), PICKER_ROW_HEIGHT), + egui::Sense::click(), + ); + let fill = if selected { + palette.accent_dark + } else if response.hovered() { + palette.elevated + } else { + palette.recessed + }; + ui.painter().rect( + rect, + 5.0, + fill, + egui::Stroke::new( + 1.0_f32, + if selected { + palette.accent + } else { + palette.border + }, + ), + egui::StrokeKind::Inside, + ); + let thumb = egui::Rect::from_min_size( + rect.left_top() + egui::vec2(4.0, 4.0), + egui::vec2(PICKER_THUMBNAIL, PICKER_THUMBNAIL), + ); + paint_thumbnail(ui, thumb, thumbnail); + let text_rect = egui::Rect::from_min_max( + egui::pos2(thumb.right() + 8.0, rect.top() + 5.0), + egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(text_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + child + .add(egui::Label::new(TypeRole::Body.text(label).color(palette.text_primary)).truncate()) + .on_hover_text(label); + child + .add(egui::Label::new(TypeRole::Small.text(detail).color(palette.text_muted)).truncate()) + .on_hover_text(detail); + response +} + +fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnailPresentation) { + let palette = design_system::palette(ui); + ui.painter().rect_filled(rect, 4.0, palette.control); + match thumbnail { + MaterialThumbnailPresentation::Ready(texture) => { + ui.painter().image( + texture, + rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + MaterialThumbnailPresentation::Pending => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::CIRCLE_NOTCH.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-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, + ); + } + MaterialThumbnailPresentation::Empty => { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.text_muted, + ); + } + } + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); +} + +fn picker_footer(ui: &mut egui::Ui, can_clear: bool, actions: &mut Vec) { + let palette = design_system::palette(ui); + ui.separator(); + if ui + .add(egui::Button::new(icon_label( + icons::FOLDER_OPEN, + "Browse Asset Library", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + actions.push(MaterialSlotAction::BrowseLibrary); + ui.close(); + } + if can_clear + && ui + .add(egui::Button::new(icon_label( + icons::X, + "Clear Material", + TypeRole::Control, + 14.0, + palette.text_primary, + ))) + .clicked() + { + actions.push(MaterialSlotAction::Clear); + ui.close(); + } +} + +fn unresolved_material_menu_row(ui: &mut egui::Ui, current: &EditorAssetRef) { + let palette = design_system::palette(ui); + ui.add_sized( + [ui.available_width().max(1.0), 28.0], + egui::Button::new(TypeRole::Body.text(¤t.label).color(palette.error)).selected(true), + ) + .on_hover_text("The currently assigned material could not be resolved"); +} + +fn empty_picker_row(ui: &mut egui::Ui, message: &str) { + let palette = design_system::palette(ui); + ui.add_sized( + [ui.available_width().max(1.0), 32.0], + egui::Label::new(TypeRole::Body.text(message).color(palette.text_muted)), + ); +} + +fn same_reference(left: &EditorAssetRef, right: &EditorAssetRef) -> bool { + left.asset_id == right.asset_id && left.sub_asset_id == right.sub_asset_id +} + +fn find_material_candidate<'a>( + candidates: &'a [MaterialPickerCandidate], + reference: &EditorAssetRef, +) -> Option<&'a MaterialPickerCandidate> { + candidates + .iter() + .find(|candidate| same_reference(&candidate.reference, reference)) +} + +fn recent_keys(ui: &egui::Ui, salt: &'static str) -> Vec<(String, String)> { + let id = ui.make_persistent_id(salt); + ui.ctx() + .data_mut(|data| data.get_persisted::>(id)) + .unwrap_or_default() +} + +fn remember_recent(ui: &egui::Ui, salt: &'static str, reference: &EditorAssetRef) { + let id = ui.make_persistent_id(salt); + let mut keys = recent_keys(ui, salt); + let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); + keys.retain(|current| current != &key); + keys.insert(0, key); + keys.truncate(8); + ui.ctx().data_mut(|data| data.insert_persisted(id, keys)); +} + +fn candidates_for_recent_keys<'a>( + candidates: &'a [MaterialPickerCandidate], + keys: &[(String, String)], +) -> Vec<&'a MaterialPickerCandidate> { + keys.iter() + .filter_map(|(asset_id, sub_asset_id)| { + candidates.iter().find(|candidate| { + candidate.reference.asset_id == *asset_id + && candidate.reference.sub_asset_id == *sub_asset_id + }) + }) + .collect() +} + +fn candidates_for_recent_texture_keys<'a>( + candidates: &'a [MaterialTextureCandidate], + keys: &[(String, String)], +) -> Vec<&'a MaterialTextureCandidate> { + keys.iter() + .filter_map(|(asset_id, sub_asset_id)| { + candidates.iter().find(|candidate| { + candidate.reference.asset_id == *asset_id + && candidate.reference.sub_asset_id == *sub_asset_id + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn penpot_picker_reference_comparison_uses_stable_identity_not_label() { + let left = EditorAssetRef::new("asset", "material:source", "Left"); + let right = EditorAssetRef::new("asset", "material:source", "Renamed"); + assert!(same_reference(&left, &right)); + } + + #[test] + fn penpot_picker_geometry_matches_penpot_assets() { + assert_eq!(MATERIAL_PICKER_WIDTH, 244.0); + assert_eq!(TEXTURE_PICKER_WIDTH, 420.0); + assert_eq!(PICKER_ROW_HEIGHT, 44.0); + assert_eq!(PICKER_THUMBNAIL, 36.0); + } +} diff --git a/crates/editor_ui/src/materials/surface.rs b/crates/editor_ui/src/materials/surface.rs new file mode 100644 index 0000000..cccca91 --- /dev/null +++ b/crates/editor_ui/src/materials/surface.rs @@ -0,0 +1,120 @@ +//! Penpot Surface section backed by shared material document values. + +use egui; +use material_schema::{MaterialAlphaMode, MaterialInputSet, MaterialRenderState}; + +use super::inputs::{ + apply_arm_texture_preset, apply_separate_texture_preset, packed_map_mode, PackedMapMode, +}; +use super::layout::{ResponsiveMaterialLayout, SurfaceSectionGeometry}; +use crate::design_system; + +pub fn material_surface_options_ui( + ui: &mut egui::Ui, + state: &mut MaterialRenderState, + packed_inputs: Option<&mut MaterialInputSet>, +) { + let palette = design_system::palette(ui); + let width = ui.available_width().max(1.0); + let height = match ResponsiveMaterialLayout::for_width(width) { + ResponsiveMaterialLayout::Reference => design_system::SURFACE_SECTION_HEIGHT, + ResponsiveMaterialLayout::Compact => design_system::COMPACT_SURFACE_SECTION_HEIGHT, + ResponsiveMaterialLayout::Transient => 100.0, + }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().rect( + rect, + 5.0, + palette.panel, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let geometry = SurfaceSectionGeometry::for_rect(rect); + ui.painter().text( + geometry.title.left_center(), + egui::Align2::LEFT_CENTER, + "Surface", + design_system::typography::TypeRole::Section.font(), + palette.text_primary, + ); + clipped_rect_ui(ui, geometry.blend, |ui| surface_blend_controls(ui, state)); + if let Some(inputs) = packed_inputs { + clipped_rect_ui(ui, geometry.packed, |ui| packed_mode_controls(ui, inputs)); + } + ui.painter().text( + geometry.double_sided.left_center() + egui::vec2(7.0, 0.0), + egui::Align2::LEFT_CENTER, + "Double Sided", + design_system::typography::TypeRole::Body.font(), + palette.text_secondary, + ); + let switch_rect = egui::Rect::from_min_size( + geometry.double_sided.min + egui::vec2(76.0, 3.0), + egui::vec2(31.0, 16.0), + ); + clipped_rect_ui(ui, switch_rect, |ui| { + design_system::controls::switch(ui, &mut state.double_sided) + .on_hover_text("Render both sides of the surface"); + }); + // Explicitly placed blend/packing/switch children do not own vertical flow. Reassert the + // Surface rectangle after they render so the following authored section gap starts at the + // true 46/100 px boundary. + ui.advance_cursor_after_rect(rect); +} + +fn packed_mode_controls(ui: &mut egui::Ui, inputs: &mut MaterialInputSet) { + let arm = packed_map_mode(inputs) == PackedMapMode::Arm; + match design_system::controls::two_option_segmented(ui, "Separate", "ORM", arm) { + Some(false) => apply_separate_texture_preset(inputs), + Some(true) => apply_arm_texture_preset(inputs), + None => {} + } +} + +fn surface_blend_controls(ui: &mut egui::Ui, state: &mut MaterialRenderState) { + let width = ui.available_width().max(1.0); + let show_cutoff = state.alpha_mode == MaterialAlphaMode::Cutout; + let combo_width = if show_cutoff { + (width - 58.0).max(1.0) + } else { + width + }; + let combo_rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(combo_width, 24.0)); + clipped_rect_ui(ui, combo_rect, |ui| { + egui::ComboBox::from_id_salt("material_alpha_mode") + .selected_text(match state.alpha_mode { + MaterialAlphaMode::Opaque => "Opaque", + MaterialAlphaMode::Cutout => "Cutout", + }) + .width(combo_width) + .show_ui(ui, |ui| { + ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Opaque, "Opaque"); + ui.selectable_value(&mut state.alpha_mode, MaterialAlphaMode::Cutout, "Cutout"); + }); + }); + if show_cutoff { + let cutoff_rect = egui::Rect::from_min_size( + ui.min_rect().min + egui::vec2(combo_width + 4.0, 1.0), + egui::vec2(54.0_f32.min(width - combo_width - 4.0), 22.0), + ); + ui.put( + cutoff_rect, + egui::DragValue::new(&mut state.alpha_cutoff) + .range(0.0..=1.0) + .speed(0.01), + ) + .on_hover_text("Alpha cutoff"); + } +} + +fn clipped_rect_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + add_contents(ui) + }) + .inner +} diff --git a/crates/editor_ui/src/materials/texture_inputs.rs b/crates/editor_ui/src/materials/texture_inputs.rs new file mode 100644 index 0000000..0f63515 --- /dev/null +++ b/crates/editor_ui/src/materials/texture_inputs.rs @@ -0,0 +1,413 @@ +//! Texture/channel controls shared by Material and Material Instance input tables. + +use super::*; + +use crate::design_system; +use crate::design_system::typography::TypeRole; +use egui_phosphor_icons::icons; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TextureFieldAdornment { + TypedThumbnail, + BrokenReference, + Empty, +} + +fn texture_field_adornment( + presentation: Option, + current_present: bool, +) -> TextureFieldAdornment { + if presentation.is_some() { + TextureFieldAdornment::TypedThumbnail + } else if current_present { + TextureFieldAdornment::BrokenReference + } else { + TextureFieldAdornment::Empty + } +} + +pub(super) fn material_property_texture_binding( + textures: &[MaterialTextureBinding], + property: &MaterialInputDesc, +) -> MaterialTextureBinding { + textures + .iter() + .find(|texture| texture.name == property.name) + .cloned() + .unwrap_or_else(|| MaterialTextureBinding { + name: property.name.clone(), + texture: None, + channel: property + .texture + .as_ref() + .map_or(TextureChannel::Rgba, |desc| desc.default_channel), + }) +} + +pub(super) fn upsert_material_texture_binding( + textures: &mut Vec, + binding: MaterialTextureBinding, +) { + if let Some(existing) = textures + .iter_mut() + .find(|texture| texture.name == binding.name) + { + *existing = binding; + } else { + textures.push(binding); + } +} + +pub(super) fn material_texture_channel_ui( + ui: &mut egui::Ui, + property: &MaterialInputDesc, + binding: &mut MaterialTextureBinding, +) { + let allow_override = property + .texture + .as_ref() + .is_some_and(|texture| texture.allow_channel_override); + if !allow_override { + return; + } + egui::ComboBox::from_id_salt(("texture_channel", &property.name)) + .selected_text(texture_channel_label(binding.channel)) + .width(48.0) + .show_ui(ui, |ui| { + for channel in [ + TextureChannel::R, + TextureChannel::G, + TextureChannel::B, + TextureChannel::A, + ] { + ui.selectable_value( + &mut binding.channel, + channel, + texture_channel_label(channel), + ); + } + }); +} + +pub(super) fn texture_channel_label(channel: TextureChannel) -> &'static str { + match channel { + TextureChannel::R => "R", + TextureChannel::G => "G", + TextureChannel::B => "B", + TextureChannel::A => "A", + TextureChannel::Rgb => "RGB", + TextureChannel::Rgba => "RGBA", + } +} + +pub fn ensure_material_texture_binding<'a>( + textures: &'a mut Vec, + name: &str, +) -> &'a mut MaterialTextureBinding { + let index = textures + .iter() + .position(|texture| texture.name == name) + .unwrap_or_else(|| { + textures.push(MaterialTextureBinding { + name: name.to_string(), + texture: None, + channel: TextureChannel::Rgba, + }); + textures.len() - 1 + }); + &mut textures[index] +} + +pub(super) fn texture_binding_ui( + ui: &mut egui::Ui, + binding: &mut MaterialTextureBinding, + texture_assets: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, + inline_actions: bool, +) -> TextureSlotResponse { + let response = texture_slot_ui( + ui, + format!("binding_{}", binding.name), + TextureSlotSelection::Reference(binding.texture.as_ref()), + "(none)", + texture_assets, + drop_candidate, + inline_actions, + ); + if response.clear { + binding.texture = None; + } else if let Some(candidate) = response.selected.as_ref() { + binding.texture = Some(candidate.reference.clone()); + } + response +} + +pub(super) fn texture_slot_ui( + ui: &mut egui::Ui, + slot_id: impl std::hash::Hash, + current: TextureSlotSelection<'_>, + empty_label: &str, + candidates: &[MaterialTextureCandidate], + drop_candidate: Option<&MaterialTextureCandidate>, + inline_actions: bool, +) -> TextureSlotResponse { + let palette = design_system::palette(ui); + let mut result = TextureSlotResponse::default(); + ui.push_id(slot_id, |ui| { + let width = ui.available_width().max(1.0); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + let group = outer; + let field_width = if inline_actions { + (group.width() - 60.0).max(1.0) + } else { + (group.width() - 30.0).max(1.0) + }; + let field_rect = egui::Rect::from_min_size(group.min, egui::vec2(field_width, 22.0)); + let locate_rect = egui::Rect::from_min_size( + group.min + egui::vec2(field_width + 6.0, 0.0), + egui::vec2(24.0, 22.0), + ); + let clear_rect = inline_actions.then(|| { + egui::Rect::from_min_size( + group.min + egui::vec2(field_width + 36.0, 0.0), + egui::vec2(24.0, 22.0), + ) + }); + let selected_candidate = candidates.iter().find(|candidate| match current { + TextureSlotSelection::Reference(reference) => reference.is_some_and(|reference| { + reference.asset_id == candidate.reference.asset_id + && reference.sub_asset_id == candidate.reference.sub_asset_id + }), + }); + let current_present = match current { + TextureSlotSelection::Reference(reference) => reference.is_some(), + }; + let display_label = selected_candidate + .map(|candidate| candidate.label.as_str()) + .or_else(|| match current { + TextureSlotSelection::Reference(reference) => { + reference.map(|reference| reference.label.as_str()) + } + }) + .unwrap_or(empty_label); + let presentation = selected_candidate.map(|candidate| candidate.thumbnail); + let adornment = texture_field_adornment(presentation, current_present); + let label_color = if current_present { + palette.text_primary + } else { + palette.text_muted + }; + ui.painter().rect( + field_rect, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + let field = ui.interact( + field_rect, + ui.make_persistent_id("texture_field"), + egui::Sense::click(), + ); + let image_rect = egui::Rect::from_center_size( + egui::pos2(field_rect.left() + 14.0, field_rect.center().y), + egui::vec2(16.0, 16.0), + ); + match selected_candidate.map(|candidate| candidate.thumbnail) { + Some(MaterialThumbnailPresentation::Ready(texture)) => { + ui.painter().image( + texture, + image_rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + Some(MaterialThumbnailPresentation::Pending) => { + paint_field_icon(ui, image_rect, icons::CIRCLE_NOTCH, palette.text_muted); + } + Some(MaterialThumbnailPresentation::Failed) => { + paint_field_icon(ui, image_rect, icons::WARNING, palette.error); + } + Some(MaterialThumbnailPresentation::Empty) => { + paint_field_icon(ui, image_rect, icons::IMAGE, palette.text_muted); + } + 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::AssetLabel.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.locate.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.locate.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 + ); + } +} diff --git a/crates/editor_ui/src/materials/uv_transform.rs b/crates/editor_ui/src/materials/uv_transform.rs new file mode 100644 index 0000000..005cac4 --- /dev/null +++ b/crates/editor_ui/src/materials/uv_transform.rs @@ -0,0 +1,131 @@ +//! Shared material UV-transform authoring used by Materials and Material Instances. + +use bevy_math::Vec2; +use egui; +use egui_phosphor_icons::icons; +use material_schema::{MaterialInputSet, MaterialParameter, MaterialParameterValue}; + +use crate::design_system; +use crate::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::design_system::UV_SECTION_HEIGHT + } + super::layout::ResponsiveMaterialLayout::Compact => { + crate::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.panel, + 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), + }); + } +} diff --git a/crates/editor_ui/src/renderer.rs b/crates/editor_ui/src/renderer.rs new file mode 100644 index 0000000..25f0190 --- /dev/null +++ b/crates/editor_ui/src/renderer.rs @@ -0,0 +1,412 @@ +//! Static/skinned renderer-array presentation shared by production and the UI gallery. + +use egui; +use egui_phosphor_icons::icons; + +use crate::design_system; +use crate::design_system::typography::TypeRole; +use crate::theme::{BORDER, PANEL_BG_DARK, WIDGET_BG}; + +pub const RENDERER_ARRAY_HEADER_HEIGHT: f32 = 40.0; +pub const EXPANDED_RENDERER_HEADER_HEIGHT: f32 = 84.0; +pub const COLLAPSED_RENDERER_HEADER_HEIGHT: f32 = 60.0; +pub const RENDERER_PROPERTIES_HEADER_HEIGHT: f32 = 38.0; +pub const MATERIAL_SLOTS_HEADER_HEIGHT: f32 = 50.0; + +#[derive(Clone, Copy)] +pub struct RendererPanelHeaderViewModel<'a> { + pub index: usize, + pub label: &'a str, + pub path: &'a str, + pub slot_count: usize, + pub texture_id: Option, +} + +pub fn renderer_array_header(ui: &mut egui::Ui, count: usize) -> bool { + let palette = design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), RENDERER_ARRAY_HEADER_HEIGHT), + egui::Sense::hover(), + ); + ui.painter().rect_filled(rect, 4.0, palette.elevated); + ui.painter().text( + rect.left_center() + egui::vec2(12.0, 0.0), + egui::Align2::LEFT_CENTER, + icons::SQUARES_FOUR.as_str(), + egui::FontId::new(16.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(32.0, 0.0), + egui::Align2::LEFT_CENTER, + "Mesh renderers", + TypeRole::Section.font(), + palette.text_primary, + ); + let badge = egui::Rect::from_center_size( + rect.left_center() + egui::vec2(124.0, 0.0), + egui::vec2(20.0, 20.0), + ); + ui.painter() + .circle_filled(badge.center(), 10.0, palette.accent.linear_multiply(0.22)); + ui.painter().text( + badge.center(), + egui::Align2::CENTER_CENTER, + format!("{count}"), + TypeRole::Small.font(), + palette.accent, + ); + ui.painter().text( + rect.left_center() + egui::vec2(144.0, 0.0), + egui::Align2::LEFT_CENTER, + "Each renderer owns the slots exposed by its mesh", + TypeRole::Caption.font(), + palette.text_muted, + ); + let add_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 116.0, rect.top() + 7.0), + egui::vec2(104.0, 26.0), + ); + ui.put( + add_rect, + egui::Button::new(design_system::controls::icon_label( + icons::PLUS, + "Add renderer", + TypeRole::Body, + 12.0, + palette.text_primary, + )), + ) + .clicked() +} + +pub fn renderer_panel(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(WIDGET_BG.linear_multiply(0.82)) + .stroke(egui::Stroke::new(1.0_f32, BORDER)) + .corner_radius(egui::CornerRadius::same(5)) + .show(ui, |ui| { + ui.set_max_width(ui.available_width()); + add_contents(ui); + }); + ui.add_space(10.0); +} + +pub fn renderer_panel_header( + ui: &mut egui::Ui, + model: RendererPanelHeaderViewModel<'_>, + open: &mut bool, + add_actions: impl FnOnce(&mut egui::Ui), +) { + let palette = design_system::palette(ui); + let height = if *open { + EXPANDED_RENDERER_HEADER_HEIGHT + } else { + COLLAPSED_RENDERER_HEADER_HEIGHT + }; + let (rect, header_response) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), height), + egui::Sense::click(), + ); + ui.painter().rect_filled( + egui::Rect::from_min_size( + rect.min + egui::vec2(6.0, 8.0), + egui::vec2(3.0, height - 16.0), + ), + 2.0, + palette.accent, + ); + ui.painter().text( + rect.left_center() + egui::vec2(24.0, 0.0), + egui::Align2::CENTER_CENTER, + if *open { + icons::CARET_DOWN.as_str() + } else { + icons::CARET_RIGHT.as_str() + }, + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())), + palette.text_secondary, + ); + let preview_size = if *open { 58.0 } else { 40.0 }; + let preview = egui::Rect::from_center_size( + egui::pos2(rect.left() + 68.0, rect.center().y), + egui::vec2(preview_size, preview_size), + ); + ui.painter().rect( + preview, + 4.0, + PANEL_BG_DARK, + egui::Stroke::new(1.0_f32, BORDER), + egui::StrokeKind::Inside, + ); + if let Some(texture_id) = model.texture_id { + ui.painter().image( + texture_id, + preview.shrink(3.0), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + ui.painter().text( + preview.center(), + egui::Align2::CENTER_CENTER, + icons::CUBE.as_str(), + egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.text_secondary, + ); + } + let label = if model.label.trim().is_empty() { + "Mesh renderer" + } else { + model.label.trim() + }; + let identity_x = rect.left() + 105.0; + if *open { + ui.painter().text( + egui::pos2(identity_x, rect.top() + 28.0), + egui::Align2::LEFT_CENTER, + format!("RENDERER {}", model.index), + TypeRole::Eyebrow.font(), + palette.accent, + ); + } + ui.painter().text( + egui::pos2( + identity_x, + if *open { + rect.top() + 45.0 + } else { + rect.center().y - 7.0 + }, + ), + egui::Align2::LEFT_CENTER, + label, + TypeRole::AssetTitle.font(), + palette.text_primary, + ); + ui.painter().text( + egui::pos2( + identity_x, + if *open { + rect.top() + 62.0 + } else { + rect.center().y + 9.0 + }, + ), + egui::Align2::LEFT_CENTER, + if *open { + model.path.to_owned() + } else { + format!( + "Renderer {} · {} material slot{}", + model.index, + model.slot_count, + if model.slot_count == 1 { "" } else { "s" } + ) + }, + TypeRole::Small.font(), + palette.text_muted, + ); + let slot_badge = egui::Rect::from_min_size( + egui::pos2( + rect.right() - if *open { 60.0 } else { 78.0 }, + rect.top() + if *open { 12.0 } else { 8.0 }, + ), + egui::vec2(40.0, 20.0), + ); + ui.painter() + .rect_filled(slot_badge, 10.0, palette.accent.linear_multiply(0.22)); + ui.painter().text( + slot_badge.center(), + egui::Align2::CENTER_CENTER, + format!( + "{} slot{}", + model.slot_count, + if model.slot_count == 1 { "" } else { "s" } + ), + TypeRole::Eyebrow.font(), + palette.accent, + ); + let action_rect = if *open { + ui.painter().circle_filled( + egui::pos2(rect.right() - 10.0, rect.top() + 22.0), + 4.0, + palette.healthy, + ); + let action_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 132.0, rect.bottom() - 38.0), + egui::vec2(122.0, 28.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + add_actions, + ); + }); + action_rect + } else { + let action_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 20.0, rect.center().y), + egui::vec2(28.0, 28.0), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(action_rect), |ui| { + design_system::controls::icon_button( + ui, + icons::DOTS_THREE_VERTICAL, + "Renderer actions", + true, + ); + }); + action_rect + }; + if header_response.clicked() + && ui + .input(|input| input.pointer.interact_pos()) + .is_some_and(|pointer| !preview.contains(pointer) && !action_rect.contains(pointer)) + { + *open = !*open; + } +} + +pub fn renderer_material_slots_header(ui: &mut egui::Ui, count: usize) { + let palette = design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().max(1.0), MATERIAL_SLOTS_HEADER_HEIGHT), + egui::Sense::hover(), + ); + ui.painter().rect_filled(rect, 0.0, palette.elevated); + ui.painter().text( + rect.left_center() + egui::vec2(15.0, 0.0), + egui::Align2::CENTER_CENTER, + icons::CARET_DOWN.as_str(), + egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.text_secondary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(35.0, 0.0), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.accent, + ); + ui.painter().text( + rect.left_center() + egui::vec2(49.0, 0.0), + egui::Align2::LEFT_CENTER, + "Material slots", + TypeRole::Section.font(), + palette.text_primary, + ); + ui.painter().text( + rect.left_center() + egui::vec2(122.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("{count} exposed by mesh"), + TypeRole::Caption.font(), + palette.text_muted, + ); + ui.painter().line_segment( + [ + egui::pos2(rect.left() + 201.0, rect.center().y), + egui::pos2(rect.right() - 93.0, rect.center().y), + ], + egui::Stroke::new(1.0_f32, palette.border), + ); + ui.painter().text( + rect.right_center() - egui::vec2(76.0, 0.0), + egui::Align2::CENTER_CENTER, + icons::LINK.as_str(), + egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.text_muted, + ); + ui.painter().text( + rect.right_center() - egui::vec2(10.0, 0.0), + egui::Align2::RIGHT_CENTER, + "Index mapped", + TypeRole::Eyebrow.font(), + palette.text_muted, + ); +} + +pub fn renderer_properties_header(ui: &mut egui::Ui) { + let palette = design_system::palette(ui); + let (rect, _) = ui.allocate_exact_size( + egui::vec2( + ui.available_width().max(1.0), + RENDERER_PROPERTIES_HEADER_HEIGHT, + ), + egui::Sense::hover(), + ); + ui.painter().text( + rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::CENTER_CENTER, + icons::SLIDERS_HORIZONTAL.as_str(), + egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-bold".into())), + palette.accent, + ); + ui.painter().text( + rect.left_center() + egui::vec2(20.0, 0.0), + egui::Align2::LEFT_CENTER, + "Renderer properties", + TypeRole::AssetLabel.font(), + palette.accent, + ); + ui.painter().text( + rect.right_center(), + egui::Align2::RIGHT_CENTER, + "Mesh + render flags", + TypeRole::Caption.font(), + palette.text_muted, + ); +} + +pub fn renderer_material_slot_row( + ui: &mut egui::Ui, + index: usize, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let palette = design_system::palette(ui); + let row = ui.horizontal_top(|ui| { + let (index_rect, _) = ui.allocate_exact_size(egui::vec2(39.0, 60.0), egui::Sense::hover()); + ui.painter().text( + index_rect.center_top() + egui::vec2(0.0, 8.0), + egui::Align2::CENTER_CENTER, + "SLOT", + TypeRole::Caption.font(), + palette.text_muted, + ); + ui.painter().text( + index_rect.center_top() + egui::vec2(0.0, 18.0), + egui::Align2::CENTER_CENTER, + index.to_string(), + TypeRole::Body.font(), + palette.text_muted, + ); + ui.vertical(|ui| { + ui.set_width(ui.available_width().max(1.0)); + add_contents(ui) + }) + .inner + }); + let rail_x = row.response.rect.left() + 23.0; + ui.painter().line_segment( + [ + egui::pos2(rail_x, row.response.rect.top() + 35.0), + egui::pos2(rail_x, row.response.rect.bottom() - 8.0), + ], + egui::Stroke::new(1.0_f32, palette.border_strong), + ); + row.inner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renderer_headers_match_current_penpot_heights() { + assert_eq!(RENDERER_ARRAY_HEADER_HEIGHT, 40.0); + assert_eq!(EXPANDED_RENDERER_HEADER_HEIGHT, 84.0); + assert_eq!(COLLAPSED_RENDERER_HEADER_HEIGHT, 60.0); + assert_eq!(RENDERER_PROPERTIES_HEADER_HEIGHT, 38.0); + assert_eq!(MATERIAL_SLOTS_HEADER_HEIGHT, 50.0); + } +} diff --git a/crates/editor_ui/src/theme.rs b/crates/editor_ui/src/theme.rs new file mode 100644 index 0000000..7b2b181 --- /dev/null +++ b/crates/editor_ui/src/theme.rs @@ -0,0 +1,156 @@ +//! Semantic Blacksite editor palette shared by production and the UI gallery. + +use egui::{self, Color32, CornerRadius, FontId, Id, Stroke, Visuals}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditorVisualPalette { + pub canvas: Color32, + pub panel: Color32, + pub panel_dark: Color32, + pub widget: Color32, + pub elevated: Color32, + pub accent: Color32, + pub accent_hover: Color32, + pub selection: Color32, + pub selection_secondary: Color32, + pub selection_bg: Color32, + pub selection_bg_muted: Color32, + pub text: Color32, + pub text_selected: Color32, + pub text_dim: Color32, + pub text_muted: Color32, + pub border: Color32, + pub border_strong: Color32, + pub success: Color32, + pub warning: Color32, + pub error: Color32, +} + +pub const CANVAS_BG: Color32 = Color32::from_rgb(7, 8, 10); +pub const PANEL_BG: Color32 = Color32::from_rgb(13, 15, 18); +pub const PANEL_BG_DARK: Color32 = Color32::from_rgb(9, 11, 13); +pub const WIDGET_BG: Color32 = Color32::from_rgb(21, 24, 28); +pub const ELEVATED_BG: Color32 = Color32::from_rgb(31, 35, 40); +pub const ACCENT: Color32 = Color32::from_rgb(229, 164, 51); +pub const ACCENT_HOVER: Color32 = Color32::from_rgb(249, 193, 79); +pub const SELECTION: Color32 = Color32::from_rgb(65, 198, 207); +pub const SELECTION_SECONDARY: Color32 = Color32::from_rgb(84, 139, 155); +pub const SELECTION_BG: Color32 = Color32::from_rgb(22, 72, 78); +pub const SELECTION_BG_MUTED: Color32 = Color32::from_rgb(23, 48, 54); +pub const TEXT: Color32 = Color32::from_rgb(220, 225, 229); +pub const TEXT_SELECTED: Color32 = Color32::from_rgb(250, 251, 252); +pub const TEXT_DIM: Color32 = Color32::from_rgb(155, 163, 171); +pub const TEXT_MUTED: Color32 = Color32::from_rgb(96, 105, 114); +pub const BORDER: Color32 = Color32::from_rgb(38, 43, 49); +pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 71, 79); +pub const SUCCESS: Color32 = Color32::from_rgb(84, 190, 127); +pub const WARNING: Color32 = Color32::from_rgb(235, 180, 72); +pub const ERROR: Color32 = Color32::from_rgb(226, 94, 94); + +pub const BLACKSITE_PALETTE: EditorVisualPalette = EditorVisualPalette { + canvas: CANVAS_BG, + panel: PANEL_BG, + panel_dark: PANEL_BG_DARK, + widget: WIDGET_BG, + elevated: ELEVATED_BG, + accent: ACCENT, + accent_hover: ACCENT_HOVER, + selection: SELECTION, + selection_secondary: SELECTION_SECONDARY, + selection_bg: SELECTION_BG, + selection_bg_muted: SELECTION_BG_MUTED, + text: TEXT, + text_selected: TEXT_SELECTED, + text_dim: TEXT_DIM, + text_muted: TEXT_MUTED, + border: BORDER, + border_strong: BORDER_STRONG, + success: SUCCESS, + warning: WARNING, + error: ERROR, +}; + +const EDITOR_PALETTE_ID: &str = "blacksite.editor.visual_palette"; + +pub fn editor_palette(ctx: &egui::Context) -> EditorVisualPalette { + ctx.data_mut(|data| { + data.get_temp::(Id::new(EDITOR_PALETTE_ID)) + .unwrap_or(BLACKSITE_PALETTE) + }) +} + +pub fn apply_editor_theme(ctx: &egui::Context) { + apply_editor_theme_with_palette(ctx, editor_palette(ctx)); +} + +pub fn apply_editor_theme_with_palette(ctx: &egui::Context, palette: EditorVisualPalette) { + ctx.data_mut(|data| data.insert_temp(Id::new(EDITOR_PALETTE_ID), palette)); + let mut visuals = Visuals::dark(); + visuals.panel_fill = palette.panel; + visuals.window_fill = palette.widget; + visuals.extreme_bg_color = palette.canvas; + visuals.faint_bg_color = palette.panel_dark; + visuals.code_bg_color = palette.panel_dark; + visuals.text_edit_bg_color = Some(palette.canvas); + visuals.weak_text_color = Some(palette.text_muted); + visuals.widgets.noninteractive.bg_fill = palette.panel; + visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, palette.border); + visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, palette.text_dim); + visuals.widgets.inactive.bg_fill = palette.widget; + visuals.widgets.inactive.weak_bg_fill = palette.widget; + visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, palette.border); + visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, palette.text); + visuals.widgets.hovered.bg_fill = palette.elevated; + visuals.widgets.hovered.weak_bg_fill = palette.elevated; + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, palette.border_strong); + visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, palette.text_selected); + visuals.widgets.active.bg_fill = palette.elevated; + visuals.widgets.active.weak_bg_fill = palette.elevated; + visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, palette.accent); + visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, palette.text_selected); + visuals.widgets.open = visuals.widgets.hovered; + visuals.selection.bg_fill = palette.selection_bg; + visuals.selection.stroke = Stroke::new(1.0_f32, palette.selection); + visuals.hyperlink_color = palette.selection; + visuals.warn_fg_color = palette.warning; + visuals.error_fg_color = palette.error; + visuals.window_stroke = Stroke::new(1.0_f32, palette.border); + visuals.window_corner_radius = CornerRadius::same(4); + visuals.menu_corner_radius = CornerRadius::same(3); + visuals.button_frame = true; + visuals.collapsing_header_frame = false; + visuals.indent_has_left_vline = true; + visuals.striped = true; + visuals.slider_trailing_fill = true; + visuals.interact_cursor = Some(egui::CursorIcon::PointingHand); + + let mut style = (*ctx.global_style()).clone(); + style.visuals = visuals; + style.spacing.item_spacing = egui::vec2(8.0, 7.0); + style.spacing.button_padding = egui::vec2(10.0, 5.0); + style.spacing.interact_size = egui::vec2(40.0, 28.0); + style.spacing.window_margin = egui::Margin::same(10); + style.spacing.menu_margin = egui::Margin::same(8); + style.spacing.indent = 17.0; + style.interaction.tooltip_delay = 0.32; + style.interaction.tooltip_grace_time = 0.18; + style.animation_time = 0.12; + style.compact_menu_style = true; + style.text_styles.insert( + egui::TextStyle::Body, + FontId::new(14.0, egui::FontFamily::Proportional), + ); + style.text_styles.insert( + egui::TextStyle::Button, + FontId::new(13.0, egui::FontFamily::Proportional), + ); + style.text_styles.insert( + egui::TextStyle::Heading, + FontId::new(16.0, egui::FontFamily::Proportional), + ); + style.text_styles.insert( + egui::TextStyle::Small, + FontId::new(12.0, egui::FontFamily::Proportional), + ); + ctx.set_global_style(style); +} diff --git a/crates/material_schema/Cargo.toml b/crates/material_schema/Cargo.toml new file mode 100644 index 0000000..d01af99 --- /dev/null +++ b/crates/material_schema/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "material_schema" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Lightweight authored material schema and value types shared by Blacksite tools" + +[dependencies] +bevy_color = { version = "0.19", features = ["serialize"] } +bevy_math = { version = "0.19", features = ["bevy_reflect", "serialize"] } +bevy_reflect = { version = "0.19", features = ["glam"] } +serde.workspace = true diff --git a/crates/material_schema/src/lib.rs b/crates/material_schema/src/lib.rs new file mode 100644 index 0000000..977c32f --- /dev/null +++ b/crates/material_schema/src/lib.rs @@ -0,0 +1,546 @@ +//! Lightweight authored material values and schemas. +//! +//! This crate intentionally excludes Bevy's app, asset, rendering, scene, and physics layers so +//! headless tooling and the production UI gallery can share the exact authoring contracts without +//! compiling the game runtime. + +use bevy_color::Color; +use bevy_math::{Vec2, Vec3}; +use bevy_reflect::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Reference to an imported content-browser asset or sub-asset. +#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct EditorAssetRef { + /// Stable project asset registry UUID string. + pub asset_id: String, + /// Stable imported sub-asset ID inside the generated artifact. + #[serde(default, alias = "mesh_label")] + pub sub_asset_id: String, + /// UI label cached for inspectors. The asset registry/artifact remains authoritative. + #[serde(default)] + pub label: String, + /// Optional loadable project path cached for runtime hydration. + #[serde(default)] + pub source_path: Option, +} + +impl EditorAssetRef { + pub fn new( + asset_id: impl Into, + sub_asset_id: impl Into, + label: impl Into, + ) -> Self { + Self { + asset_id: asset_id.into(), + sub_asset_id: sub_asset_id.into(), + label: label.into(), + source_path: None, + } + } + + pub fn with_source_path(mut self, source_path: impl Into) -> Self { + self.source_path = Some(source_path.into()); + self + } + + pub fn is_resolved(&self) -> bool { + !self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty() + } +} + +/// How a material should be resolved at runtime. +#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub enum MaterialShaderKind { + #[default] + StandardLit, + Unlit, + Custom, +} + +/// Reference to a built-in or custom shader schema. +#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct ShaderRefDesc { + pub kind: MaterialShaderKind, + #[serde(default)] + pub schema_path: Option, + #[serde(default)] + pub shader_path: Option, +} + +impl Default for ShaderRefDesc { + fn default() -> Self { + Self { + kind: MaterialShaderKind::StandardLit, + schema_path: None, + shader_path: None, + } + } +} + +/// Serializable color description used by authored material documents. +#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct ColorDesc { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl ColorDesc { + pub const fn srgb(r: f32, g: f32, b: f32) -> Self { + Self { r, g, b, a: 1.0 } + } + + pub fn to_color(self) -> Color { + Color::srgba(self.r, self.g, self.b, self.a) + } +} + +impl Default for ColorDesc { + fn default() -> Self { + Self::srgb(0.8, 0.8, 0.8) + } +} + +#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub enum MaterialParameterValue { + Bool(bool), + Float(f32), + Vec2(Vec2), + Vec3(Vec3), + Color(ColorDesc), + Enum(String), +} + +impl Default for MaterialParameterValue { + fn default() -> Self { + Self::Float(0.0) + } +} + +#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialParameter { + pub name: String, + pub value: MaterialParameterValue, +} + +/// Channels sampled from a texture binding. +#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub enum TextureChannel { + R, + G, + B, + A, + Rgb, + #[default] + Rgba, +} + +#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +#[type_path = "shared::components"] +pub struct MaterialTextureBinding { + pub name: String, + pub texture: Option, + #[serde(default)] + pub channel: TextureChannel, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum MaterialAlphaMode { + #[default] + Opaque, + Cutout, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct MaterialRenderState { + #[serde(default)] + pub alpha_mode: MaterialAlphaMode, + #[serde(default = "default_alpha_cutoff")] + pub alpha_cutoff: f32, + #[serde(default)] + pub double_sided: bool, +} + +const fn default_alpha_cutoff() -> f32 { + 0.5 +} + +impl Default for MaterialRenderState { + fn default() -> Self { + Self { + alpha_mode: MaterialAlphaMode::Opaque, + alpha_cutoff: default_alpha_cutoff(), + double_sided: false, + } + } +} + +/// Canonical values and texture bindings consumed by built-in and custom material schemas. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct MaterialInputSet { + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub textures: Vec, +} + +impl MaterialInputSet { + pub fn from_material_desc(source: &T) -> Self { + source.to_material_inputs() + } + + pub fn apply_to_material_desc(&self, target: &mut T) { + target.apply_material_inputs(self); + } + + pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> { + self.textures.iter().find(|binding| binding.name == name) + } + + pub fn validate_unique(&self) -> Result<(), String> { + let mut names = std::collections::HashSet::new(); + for value in &self.values { + if !names.insert(("value", value.name.as_str())) { + return Err(format!( + "material value `{}` is assigned more than once", + value.name + )); + } + } + for texture in &self.textures { + if !names.insert(("texture", texture.name.as_str())) { + return Err(format!( + "material texture `{}` is assigned more than once", + texture.name + )); + } + } + Ok(()) + } +} + +/// Runtime-owned descriptors implement this bridge without making the schema crate depend on the +/// renderer or scene representation. +pub trait MaterialInputSource { + fn to_material_inputs(&self) -> MaterialInputSet; +} + +/// Runtime-owned descriptors implement this bridge without making lightweight authoring tools +/// depend on those descriptors. +pub trait MaterialInputTarget { + fn apply_material_inputs(&mut self, inputs: &MaterialInputSet); +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ShaderPropertyType { + Bool, + Float { min: Option, max: Option }, + Vec2, + Vec3, + Color, + Enum { options: Vec }, + Texture, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MaterialInputGroupDesc { + pub id: String, + pub display_name: String, + #[serde(default)] + pub order: i32, + #[serde(default)] + pub advanced: bool, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum TextureSemantic { + #[default] + Auto, + Color, + Normal, + Scalar, + Mask, + Hdr, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MaterialTextureInputDesc { + #[serde(default)] + pub semantic: TextureSemantic, + #[serde(default)] + pub default_channel: TextureChannel, + #[serde(default)] + pub allow_channel_override: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum MaterialInputPresentation { + #[default] + Row, + Companion { + owner: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MaterialInputDesc { + pub name: String, + pub display_name: String, + #[serde(default)] + pub group: String, + #[serde(default)] + pub order: i32, + pub property_type: ShaderPropertyType, + #[serde(default)] + pub default_value: Option, + #[serde(default)] + pub texture: Option, + #[serde(default)] + pub tooltip: String, + #[serde(default)] + pub advanced: bool, + #[serde(default)] + pub presentation: MaterialInputPresentation, +} + +impl Default for MaterialInputDesc { + fn default() -> Self { + Self { + name: String::new(), + display_name: String::new(), + group: String::new(), + order: 0, + property_type: ShaderPropertyType::Float { + min: None, + max: None, + }, + default_value: None, + texture: None, + tooltip: String::new(), + advanced: false, + presentation: MaterialInputPresentation::Row, + } + } +} + +/// Compatibility source alias for code that has not yet adopted material-input terminology. +pub type ShaderPropertyDesc = MaterialInputDesc; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct MaterialInputSchema { + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub inputs: Vec, +} + +/// Engine-owned Standard Lit authoring schema. Custom Surface schemas use the same shape. +pub fn standard_lit_input_schema() -> MaterialInputSchema { + let groups = vec![ + MaterialInputGroupDesc { + id: "surface_inputs".into(), + display_name: "Surface Inputs".into(), + order: 0, + advanced: false, + }, + MaterialInputGroupDesc { + id: "advanced_inputs".into(), + display_name: "Advanced Inputs".into(), + order: 100, + advanced: true, + }, + MaterialInputGroupDesc { + id: "uv_transform".into(), + display_name: "UV Transform".into(), + order: 90, + advanced: false, + }, + ]; + let scalar_texture = |channel| MaterialTextureInputDesc { + semantic: TextureSemantic::Scalar, + default_channel: channel, + allow_channel_override: true, + }; + let inputs = vec![ + MaterialInputDesc { + name: "base_color".into(), + display_name: "Base Color".into(), + group: "surface_inputs".into(), + order: 0, + property_type: ShaderPropertyType::Color, + default_value: Some(MaterialParameterValue::Color(ColorDesc::default())), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Color, + default_channel: TextureChannel::Rgba, + allow_channel_override: false, + }), + tooltip: "Color multiplier applied to the base-color texture.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "metallic".into(), + display_name: "Metallic".into(), + group: "surface_inputs".into(), + order: 10, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.0)), + texture: Some(scalar_texture(TextureChannel::B)), + tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "roughness".into(), + display_name: "Roughness".into(), + group: "surface_inputs".into(), + order: 20, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.65)), + texture: Some(scalar_texture(TextureChannel::G)), + tooltip: "Scalar multiplier; ARM/ORM uses the green channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "occlusion".into(), + display_name: "Occlusion".into(), + group: "surface_inputs".into(), + order: 30, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(1.0)), + texture: Some(scalar_texture(TextureChannel::R)), + tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "normal".into(), + display_name: "Normal".into(), + group: "surface_inputs".into(), + order: 40, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(1.0), + }, + default_value: Some(MaterialParameterValue::Float(0.85)), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Normal, + default_channel: TextureChannel::Rgb, + allow_channel_override: false, + }), + tooltip: String::new(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "emissive_color".into(), + display_name: "Emissive".into(), + group: "surface_inputs".into(), + order: 50, + property_type: ShaderPropertyType::Color, + default_value: Some(MaterialParameterValue::Color(ColorDesc::srgb( + 1.0, 1.0, 1.0, + ))), + texture: Some(MaterialTextureInputDesc { + semantic: TextureSemantic::Color, + default_channel: TextureChannel::Rgb, + allow_channel_override: false, + }), + tooltip: "Emissive color multiplied by the texture and intensity.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "emissive_intensity".into(), + display_name: "Emissive Intensity".into(), + group: "surface_inputs".into(), + order: 60, + property_type: ShaderPropertyType::Float { + min: Some(0.0), + max: Some(50_000.0), + }, + default_value: Some(MaterialParameterValue::Float(0.0)), + texture: None, + tooltip: "Emissive luminance multiplier in nits.".into(), + advanced: false, + presentation: MaterialInputPresentation::Companion { + owner: "emissive_color".into(), + }, + }, + MaterialInputDesc { + name: "uv_offset".into(), + display_name: "Offset".into(), + group: "uv_transform".into(), + order: 0, + property_type: ShaderPropertyType::Vec2, + default_value: Some(MaterialParameterValue::Vec2(Vec2::ZERO)), + texture: None, + tooltip: "Shared UV translation for every texture on this material.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + MaterialInputDesc { + name: "uv_tiling".into(), + display_name: "Tiling".into(), + group: "uv_transform".into(), + order: 10, + property_type: ShaderPropertyType::Vec2, + default_value: Some(MaterialParameterValue::Vec2(Vec2::ONE)), + texture: None, + tooltip: "Shared UV scale for every texture on this material.".into(), + advanced: false, + presentation: MaterialInputPresentation::Row, + }, + ]; + MaterialInputSchema { groups, inputs } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_lit_has_six_primary_surface_rows() { + let schema = standard_lit_input_schema(); + let surface = schema + .inputs + .iter() + .filter(|input| input.group == "surface_inputs") + .collect::>(); + assert_eq!(surface.len(), 7); + assert_eq!( + surface + .iter() + .filter(|input| matches!(input.presentation, MaterialInputPresentation::Row)) + .count(), + 6 + ); + } +} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 11ff97c..a5f74be 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared reflectable authoring types and scene hydration for the FP avian3d.workspace = true bevy.workspace = true blake3 = "1" +material_schema.workspace = true ron = "0.8" serde.workspace = true settings.workspace = true diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index 44dd717..1c9d828 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -2,7 +2,7 @@ use avian3d::prelude::ColliderConstructor; use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{ColorDesc, MaterialDesc}; +use crate::{ColorDesc, EditorAssetRef, MaterialDesc}; /// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only. #[derive( @@ -412,48 +412,6 @@ pub const COMPONENT_AUDIO_LISTENER_DESC: &str = "shared::components::AudioListen /// Stable imported sub-asset identifier used by authored audio clip references. pub const AUDIO_CLIP_SUB_ASSET_ID: &str = "audio:source"; -/// Reference to an imported content-browser asset or sub-asset. -#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct EditorAssetRef { - /// Stable project asset registry UUID string. - pub asset_id: String, - /// Stable imported sub-asset ID inside the generated artifact. - #[serde(default, alias = "mesh_label")] - pub sub_asset_id: String, - /// UI label cached for inspectors. The asset registry/artifact remains authoritative. - #[serde(default)] - pub label: String, - /// Optional loadable project path cached for runtime hydration when the - /// referenced asset is not available through an imported artifact lookup. - #[serde(default)] - pub source_path: Option, -} - -impl EditorAssetRef { - pub fn new( - asset_id: impl Into, - sub_asset_id: impl Into, - label: impl Into, - ) -> Self { - Self { - asset_id: asset_id.into(), - sub_asset_id: sub_asset_id.into(), - label: label.into(), - source_path: None, - } - } - - pub fn with_source_path(mut self, source_path: impl Into) -> Self { - self.source_path = Some(source_path.into()); - self - } - - pub fn is_resolved(&self) -> bool { - !self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty() - } -} - /// Distance curve used by an authored spatial audio source. #[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] @@ -1237,37 +1195,6 @@ fn default_true() -> bool { true } -/// How a material should be resolved at runtime. -#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub enum MaterialShaderKind { - #[default] - StandardLit, - Unlit, - Custom, -} - -/// Reference to a built-in or custom shader schema. -#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct ShaderRefDesc { - pub kind: MaterialShaderKind, - #[serde(default)] - pub schema_path: Option, - #[serde(default)] - pub shader_path: Option, -} - -impl Default for ShaderRefDesc { - fn default() -> Self { - Self { - kind: MaterialShaderKind::StandardLit, - schema_path: None, - shader_path: None, - } - } -} - /// Typed material parameter value exposed by a shader schema. /// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX). /// Hydration turns this into a `WorldAssetRoot` on the entity. diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 345f4e2..6270f63 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -19,6 +19,14 @@ mod prefab_overrides; mod renderer_material; mod rendering_profile_asset; +pub use material_schema::{ + standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialAlphaMode, MaterialInputDesc, + MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, MaterialInputSet, + MaterialInputSource, MaterialInputTarget, MaterialParameter, MaterialParameterValue, + MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, MaterialTextureInputDesc, + ShaderPropertyDesc, ShaderPropertyType, ShaderRefDesc, TextureChannel, TextureSemantic, +}; + pub use actor::{infer_actor_kind, validate_actor, ActorValidationError}; pub use animation::*; pub use asset_fingerprint::AssetSourceFingerprint; @@ -33,12 +41,9 @@ pub use hydration::{ StaticMeshArtifactCache, }; pub use material_asset::{ - load_resolved_material_from_path, standard_lit_input_schema, MaterialAlphaMode, MaterialAsset, - MaterialInputDesc, MaterialInputGroupDesc, MaterialInputPresentation, MaterialInputSchema, - MaterialInputSet, MaterialInstanceAsset, MaterialProvenance, MaterialRenderState, - MaterialTextureInputDesc, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, - TextureSemantic, MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, - SURFACE_SHADER_SCHEMA_VERSION, + apply_material_inputs_to_desc, load_resolved_material_from_path, material_inputs_from_desc, + MaterialAsset, MaterialInstanceAsset, MaterialProvenance, ShaderSchemaAsset, + MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION, }; pub use material_desc::*; pub use navigation::*; diff --git a/crates/shared/src/material_asset.rs b/crates/shared/src/material_asset.rs index 04c5000..f059748 100644 --- a/crates/shared/src/material_asset.rs +++ b/crates/shared/src/material_asset.rs @@ -4,8 +4,9 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ - ColorDesc, EditorAssetRef, MaterialDesc, MaterialParameter, MaterialParameterValue, - MaterialRef, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel, + EditorAssetRef, MaterialDesc, MaterialInputSchema, MaterialInputSet, MaterialInputSource, + MaterialInputTarget, MaterialParameter, MaterialParameterValue, MaterialRef, + MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel, }; pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2; @@ -24,182 +25,124 @@ const fn default_surface_shader_schema_version() -> u32 { SURFACE_SHADER_SCHEMA_VERSION } -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum MaterialAlphaMode { - #[default] - Opaque, - Cutout, +/// Converts the runtime/legacy descriptor into the lightweight authored input representation. +pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet { + let mut values = vec![ + MaterialParameter { + name: "base_color".into(), + value: MaterialParameterValue::Color(material.base_color), + }, + MaterialParameter { + name: "metallic".into(), + value: MaterialParameterValue::Float(material.metallic), + }, + MaterialParameter { + name: "roughness".into(), + value: MaterialParameterValue::Float(material.roughness), + }, + MaterialParameter { + name: "emissive_color".into(), + value: MaterialParameterValue::Color(material.emissive_color), + }, + MaterialParameter { + name: "emissive_intensity".into(), + value: MaterialParameterValue::Float(material.emissive_intensity), + }, + MaterialParameter { + name: "uv_offset".into(), + value: MaterialParameterValue::Vec2(material.uv_offset), + }, + MaterialParameter { + name: "uv_tiling".into(), + value: MaterialParameterValue::Vec2(material.uv_tiling), + }, + ]; + for parameter in &material.parameters { + if !is_standard_value_name(¶meter.name) { + upsert_parameter(&mut values, parameter.clone()); + } + } + + let mut textures = Vec::new(); + if let Some(path) = material.base_color_texture.as_deref() { + textures.push(path_texture_binding( + "base_color", + path, + TextureChannel::Rgba, + )); + } + if let Some(path) = material.normal_map_texture.as_deref() { + textures.push(path_texture_binding("normal", path, TextureChannel::Rgb)); + } + if let Some(path) = material.metallic_roughness_texture.as_deref() { + textures.push(path_texture_binding("occlusion", path, TextureChannel::R)); + textures.push(path_texture_binding("roughness", path, TextureChannel::G)); + textures.push(path_texture_binding("metallic", path, TextureChannel::B)); + } + if let Some(path) = material.occlusion_texture.as_deref() { + upsert_texture( + &mut textures, + path_texture_binding("occlusion", path, TextureChannel::R), + ); + } + if let Some(path) = material.emissive_texture.as_deref() { + textures.push(path_texture_binding("emissive", path, TextureChannel::Rgb)); + } + for texture in &material.textures { + if !is_standard_texture_name(&texture.name) { + upsert_texture(&mut textures, texture.clone()); + } + } + MaterialInputSet { values, textures } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -pub struct MaterialRenderState { - #[serde(default)] - pub alpha_mode: MaterialAlphaMode, - #[serde(default = "default_alpha_cutoff")] - pub alpha_cutoff: f32, - #[serde(default)] - pub double_sided: bool, -} - -const fn default_alpha_cutoff() -> f32 { - 0.5 -} - -impl Default for MaterialRenderState { - fn default() -> Self { - Self { - alpha_mode: MaterialAlphaMode::Opaque, - alpha_cutoff: default_alpha_cutoff(), - double_sided: false, +/// Applies authored values to the runtime/legacy material representation. +pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut MaterialDesc) { + material.parameters.clear(); + material.textures.clear(); + for parameter in &inputs.values { + match (parameter.name.as_str(), ¶meter.value) { + ("base_color", MaterialParameterValue::Color(value)) => material.base_color = *value, + ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, + ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value, + ("emissive_color", MaterialParameterValue::Color(value)) => { + material.emissive_color = *value + } + ("emissive_intensity", MaterialParameterValue::Float(value)) => { + material.emissive_intensity = *value + } + ("uv_offset", MaterialParameterValue::Vec2(value)) => material.uv_offset = *value, + ("uv_tiling", MaterialParameterValue::Vec2(value)) => material.uv_tiling = *value, + _ => upsert_parameter(&mut material.parameters, parameter.clone()), + } + } + material.base_color_texture = binding_path(inputs.texture("base_color")); + material.normal_map_texture = binding_path(inputs.texture("normal")); + material.emissive_texture = binding_path(inputs.texture("emissive")); + let roughness = binding_path(inputs.texture("roughness")); + let metallic = binding_path(inputs.texture("metallic")); + material.metallic_roughness_texture = match (roughness, metallic) { + (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness), + (Some(path), None) | (None, Some(path)) => Some(path), + _ => None, + }; + material.occlusion_texture = binding_path(inputs.texture("occlusion")); + for texture in &inputs.textures { + if !is_standard_texture_name(&texture.name) { + upsert_texture(&mut material.textures, texture.clone()); } } } -/// Canonical values and texture bindings consumed by both built-in and custom material schemas. -/// -/// Values are keyed by stable input names. Validation rejects duplicates before publication; -/// vectors are retained instead of maps so shader-defined presentation order remains serializable. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct MaterialInputSet { - #[serde(default)] - pub values: Vec, - #[serde(default)] - pub textures: Vec, +impl MaterialInputSource for MaterialDesc { + fn to_material_inputs(&self) -> MaterialInputSet { + material_inputs_from_desc(self) + } } -impl MaterialInputSet { - pub fn from_material_desc(material: &MaterialDesc) -> Self { - let mut values = vec![ - MaterialParameter { - name: "base_color".into(), - value: MaterialParameterValue::Color(material.base_color), - }, - MaterialParameter { - name: "metallic".into(), - value: MaterialParameterValue::Float(material.metallic), - }, - MaterialParameter { - name: "roughness".into(), - value: MaterialParameterValue::Float(material.roughness), - }, - MaterialParameter { - name: "emissive_color".into(), - value: MaterialParameterValue::Color(material.emissive_color), - }, - MaterialParameter { - name: "emissive_intensity".into(), - value: MaterialParameterValue::Float(material.emissive_intensity), - }, - MaterialParameter { - name: "uv_offset".into(), - value: MaterialParameterValue::Vec2(material.uv_offset), - }, - MaterialParameter { - name: "uv_tiling".into(), - value: MaterialParameterValue::Vec2(material.uv_tiling), - }, - ]; - for parameter in &material.parameters { - if !is_standard_value_name(¶meter.name) { - upsert_parameter(&mut values, parameter.clone()); - } - } - - let mut textures = Vec::new(); - if let Some(path) = material.base_color_texture.as_deref() { - textures.push(path_texture_binding( - "base_color", - path, - TextureChannel::Rgba, - )); - } - if let Some(path) = material.normal_map_texture.as_deref() { - textures.push(path_texture_binding("normal", path, TextureChannel::Rgb)); - } - if let Some(path) = material.metallic_roughness_texture.as_deref() { - textures.push(path_texture_binding("occlusion", path, TextureChannel::R)); - textures.push(path_texture_binding("roughness", path, TextureChannel::G)); - textures.push(path_texture_binding("metallic", path, TextureChannel::B)); - } - if let Some(path) = material.occlusion_texture.as_deref() { - upsert_texture( - &mut textures, - path_texture_binding("occlusion", path, TextureChannel::R), - ); - } - if let Some(path) = material.emissive_texture.as_deref() { - textures.push(path_texture_binding("emissive", path, TextureChannel::Rgb)); - } - for texture in &material.textures { - if !is_standard_texture_name(&texture.name) { - upsert_texture(&mut textures, texture.clone()); - } - } - Self { values, textures } - } - - pub fn apply_to_material_desc(&self, material: &mut MaterialDesc) { - material.parameters.clear(); - material.textures.clear(); - for parameter in &self.values { - match (parameter.name.as_str(), ¶meter.value) { - ("base_color", MaterialParameterValue::Color(value)) => { - material.base_color = *value - } - ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, - ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value, - ("emissive_color", MaterialParameterValue::Color(value)) => { - material.emissive_color = *value - } - ("emissive_intensity", MaterialParameterValue::Float(value)) => { - material.emissive_intensity = *value - } - ("uv_offset", MaterialParameterValue::Vec2(value)) => material.uv_offset = *value, - ("uv_tiling", MaterialParameterValue::Vec2(value)) => material.uv_tiling = *value, - _ => upsert_parameter(&mut material.parameters, parameter.clone()), - } - } - material.base_color_texture = binding_path(self.texture("base_color")); - material.normal_map_texture = binding_path(self.texture("normal")); - material.emissive_texture = binding_path(self.texture("emissive")); - let roughness = binding_path(self.texture("roughness")); - let metallic = binding_path(self.texture("metallic")); - material.metallic_roughness_texture = match (roughness, metallic) { - (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness), - (Some(path), None) | (None, Some(path)) => Some(path), - _ => None, - }; - material.occlusion_texture = binding_path(self.texture("occlusion")); - for texture in &self.textures { - if !is_standard_texture_name(&texture.name) { - upsert_texture(&mut material.textures, texture.clone()); - } - } - } - - pub fn texture(&self, name: &str) -> Option<&MaterialTextureBinding> { - self.textures.iter().find(|binding| binding.name == name) - } - - pub fn validate_unique(&self) -> Result<(), String> { - let mut names = std::collections::HashSet::new(); - for value in &self.values { - if !names.insert(("value", value.name.as_str())) { - return Err(format!( - "material value `{}` is assigned more than once", - value.name - )); - } - } - for texture in &self.textures { - if !names.insert(("texture", texture.name.as_str())) { - return Err(format!( - "material texture `{}` is assigned more than once", - texture.name - )); - } - } - Ok(()) +impl MaterialInputTarget for MaterialDesc { + fn apply_material_inputs(&mut self, inputs: &MaterialInputSet) { + apply_material_inputs_to_desc(inputs, self); } } @@ -420,115 +363,6 @@ fn upsert_texture(values: &mut Vec, value: MaterialTextu } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ShaderPropertyType { - Bool, - Float { min: Option, max: Option }, - Vec2, - Vec3, - Color, - Enum { options: Vec }, - Texture, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct MaterialInputGroupDesc { - pub id: String, - pub display_name: String, - #[serde(default)] - pub order: i32, - #[serde(default)] - pub advanced: bool, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum TextureSemantic { - #[default] - Auto, - Color, - Normal, - Scalar, - Mask, - Hdr, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MaterialTextureInputDesc { - #[serde(default)] - pub semantic: TextureSemantic, - #[serde(default)] - pub default_channel: TextureChannel, - #[serde(default)] - pub allow_channel_override: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct MaterialInputDesc { - pub name: String, - pub display_name: String, - #[serde(default)] - pub group: String, - #[serde(default)] - pub order: i32, - pub property_type: ShaderPropertyType, - #[serde(default)] - pub default_value: Option, - #[serde(default)] - pub texture: Option, - #[serde(default)] - pub tooltip: String, - #[serde(default)] - pub advanced: bool, - /// Authoring presentation only. Runtime evaluation continues to address inputs by `name`. - #[serde(default)] - pub presentation: MaterialInputPresentation, -} - -impl Default for MaterialInputDesc { - fn default() -> Self { - Self { - name: String::new(), - display_name: String::new(), - group: String::new(), - order: 0, - property_type: ShaderPropertyType::Float { - min: None, - max: None, - }, - default_value: None, - texture: None, - tooltip: String::new(), - advanced: false, - presentation: MaterialInputPresentation::Row, - } - } -} - -/// Declares whether an input owns a visible row or is edited through another input's row. -/// -/// Companion values remain independent in [`MaterialInputSet`]. This keeps the schema suitable for -/// generated/custom shaders while allowing related controls such as emissive color and intensity -/// to share one coherent Inspector row. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum MaterialInputPresentation { - #[default] - Row, - Companion { - owner: String, - }, -} - -/// Compatibility source alias for code that has not yet adopted the material-input terminology. -pub type ShaderPropertyDesc = MaterialInputDesc; - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct MaterialInputSchema { - #[serde(default)] - pub groups: Vec, - #[serde(default)] - pub inputs: Vec, -} - /// RON shader schema describing material inspector parameters. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -552,181 +386,12 @@ impl ShaderSchemaAsset { } } -/// Engine-owned Standard Lit authoring schema. It is consumed by the same renderer used for -/// project Surface schemas, so a future graph compiler only needs to emit this descriptor shape. -pub fn standard_lit_input_schema() -> MaterialInputSchema { - let groups = vec![ - MaterialInputGroupDesc { - id: "surface_inputs".into(), - display_name: "Surface Inputs".into(), - order: 0, - advanced: false, - }, - MaterialInputGroupDesc { - id: "advanced_inputs".into(), - display_name: "Advanced Inputs".into(), - order: 100, - advanced: true, - }, - MaterialInputGroupDesc { - id: "uv_transform".into(), - display_name: "UV Transform".into(), - order: 90, - advanced: false, - }, - ]; - let scalar_texture = |channel| MaterialTextureInputDesc { - semantic: TextureSemantic::Scalar, - default_channel: channel, - allow_channel_override: true, - }; - let inputs = vec![ - MaterialInputDesc { - name: "base_color".into(), - display_name: "Base Color".into(), - group: "surface_inputs".into(), - order: 0, - property_type: ShaderPropertyType::Color, - default_value: Some(MaterialParameterValue::Color(ColorDesc::default())), - texture: Some(MaterialTextureInputDesc { - semantic: TextureSemantic::Color, - default_channel: TextureChannel::Rgba, - allow_channel_override: false, - }), - tooltip: "Color multiplier applied to the base-color texture.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "metallic".into(), - display_name: "Metallic".into(), - group: "surface_inputs".into(), - order: 10, - property_type: ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - default_value: Some(MaterialParameterValue::Float(0.0)), - texture: Some(scalar_texture(TextureChannel::B)), - tooltip: "Scalar multiplier; ARM/ORM uses the blue channel.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "roughness".into(), - display_name: "Roughness".into(), - group: "surface_inputs".into(), - order: 20, - property_type: ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - default_value: Some(MaterialParameterValue::Float(0.65)), - texture: Some(scalar_texture(TextureChannel::G)), - tooltip: "Scalar multiplier; ARM/ORM uses the green channel.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "occlusion".into(), - display_name: "Occlusion".into(), - group: "surface_inputs".into(), - order: 30, - property_type: ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - default_value: Some(MaterialParameterValue::Float(1.0)), - texture: Some(scalar_texture(TextureChannel::R)), - tooltip: "Ambient-occlusion strength; ARM/ORM uses the red channel.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "normal".into(), - display_name: "Normal".into(), - group: "surface_inputs".into(), - order: 40, - property_type: ShaderPropertyType::Float { - min: Some(0.0), - max: Some(1.0), - }, - default_value: Some(MaterialParameterValue::Float(0.85)), - texture: Some(MaterialTextureInputDesc { - semantic: TextureSemantic::Normal, - default_channel: TextureChannel::Rgb, - allow_channel_override: false, - }), - tooltip: String::new(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "emissive_color".into(), - display_name: "Emissive".into(), - group: "surface_inputs".into(), - order: 50, - property_type: ShaderPropertyType::Color, - default_value: Some(MaterialParameterValue::Color(ColorDesc::srgb( - 1.0, 1.0, 1.0, - ))), - texture: Some(MaterialTextureInputDesc { - semantic: TextureSemantic::Color, - default_channel: TextureChannel::Rgb, - allow_channel_override: false, - }), - tooltip: "Emissive color multiplied by the texture and intensity.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "emissive_intensity".into(), - display_name: "Emissive Intensity".into(), - group: "surface_inputs".into(), - order: 60, - property_type: ShaderPropertyType::Float { - min: Some(0.0), - max: Some(50_000.0), - }, - default_value: Some(MaterialParameterValue::Float(0.0)), - texture: None, - tooltip: "Emissive luminance multiplier in nits.".into(), - advanced: false, - presentation: MaterialInputPresentation::Companion { - owner: "emissive_color".into(), - }, - }, - MaterialInputDesc { - name: "uv_offset".into(), - display_name: "Offset".into(), - group: "uv_transform".into(), - order: 0, - property_type: ShaderPropertyType::Vec2, - default_value: Some(MaterialParameterValue::Vec2(Vec2::ZERO)), - texture: None, - tooltip: "Shared UV translation for every texture on this material.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - MaterialInputDesc { - name: "uv_tiling".into(), - display_name: "Tiling".into(), - group: "uv_transform".into(), - order: 10, - property_type: ShaderPropertyType::Vec2, - default_value: Some(MaterialParameterValue::Vec2(Vec2::ONE)), - texture: None, - tooltip: "Shared UV scale for every texture on this material.".into(), - advanced: false, - presentation: MaterialInputPresentation::Row, - }, - ]; - MaterialInputSchema { groups, inputs } -} - #[cfg(test)] mod tests { use super::*; + use crate::{ + standard_lit_input_schema, MaterialAlphaMode, MaterialInputPresentation, ShaderPropertyType, + }; #[test] fn material_v2_defaults_to_opaque() { diff --git a/crates/shared/src/material_desc.rs b/crates/shared/src/material_desc.rs index 8e3e889..c7e38df 100644 --- a/crates/shared/src/material_desc.rs +++ b/crates/shared/src/material_desc.rs @@ -3,58 +3,10 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{ComponentInstanceId, EditorAssetRef, ShaderRefDesc}; - -#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -#[type_path = "shared::components"] -pub enum MaterialParameterValue { - Bool(bool), - Float(f32), - Vec2(Vec2), - Vec3(Vec3), - Color(ColorDesc), - Enum(String), -} - -impl Default for MaterialParameterValue { - fn default() -> Self { - Self::Float(0.0) - } -} - -#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -#[type_path = "shared::components"] -pub struct MaterialParameter { - pub name: String, - pub value: MaterialParameterValue, -} - -/// Channels sampled from a texture binding. Scalar material inputs use one channel while -/// color/normal inputs use RGB or RGBA. The default preserves the pre-v2 whole-texture behavior. -#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -#[type_path = "shared::components"] -pub enum TextureChannel { - R, - G, - B, - A, - Rgb, - #[default] - Rgba, -} - -#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] -#[type_path = "shared::components"] -pub struct MaterialTextureBinding { - pub name: String, - pub texture: Option, - #[serde(default)] - pub channel: TextureChannel, -} +use crate::{ + ColorDesc, ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding, + ShaderRefDesc, +}; /// Per-render-slot material override stored on scene actors. #[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -76,34 +28,6 @@ pub struct MaterialOverride { pub slots: Vec, } -/// Serializable color description. This avoids coupling saved scenes to any internal color -/// representation details. -#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[reflect(Default, Debug, Serialize, Deserialize)] -#[type_path = "shared::components"] -pub struct ColorDesc { - pub r: f32, - pub g: f32, - pub b: f32, - pub a: f32, -} - -impl ColorDesc { - pub const fn srgb(r: f32, g: f32, b: f32) -> Self { - Self { r, g, b, a: 1.0 } - } - - pub fn to_color(self) -> Color { - Color::srgba(self.r, self.g, self.b, self.a) - } -} - -impl Default for ColorDesc { - fn default() -> Self { - Self::srgb(0.8, 0.8, 0.8) - } -} - /// Resolved runtime and legacy-compatible material representation. Project authoring uses /// `MaterialAsset`/`MaterialInputSet`; hydration maps this descriptor to renderer materials. #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/ui_gallery/Cargo.toml b/crates/ui_gallery/Cargo.toml new file mode 100644 index 0000000..eedff21 --- /dev/null +++ b/crates/ui_gallery/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ui_gallery" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Focused Blacksite editor UI gallery and Penpot comparison harness" + +[dependencies] +eframe.workspace = true +editor_ui.workspace = true +egui.workspace = true +egui_phosphor_icons.workspace = true +image = { version = "0.25", default-features = false, features = ["png"] } +material_schema.workspace = true +serde.workspace = true +serde_json = "1" diff --git a/crates/ui_gallery/fixtures/material.json b/crates/ui_gallery/fixtures/material.json new file mode 100644 index 0000000..86a255a --- /dev/null +++ b/crates/ui_gallery/fixtures/material.json @@ -0,0 +1,10 @@ +{ + "material_label": "M_Wood_Planks", + "material_path": "materials/wood/M_Wood_Planks.mat", + "metallic": 0.72, + "roughness": 0.72, + "occlusion": 0.72, + "normal_strength": 0.72, + "emissive_intensity": 0.0, + "uv_tiling": [1.0, 1.0] +} diff --git a/crates/ui_gallery/fixtures/theme.json b/crates/ui_gallery/fixtures/theme.json new file mode 100644 index 0000000..19e92d2 --- /dev/null +++ b/crates/ui_gallery/fixtures/theme.json @@ -0,0 +1,16 @@ +{ + "accent": "#E5A433", + "selection": "#41C6CF", + "panel": "#0D0F12", + "panel_dark": "#090B0D", + "widget": "#15181C", + "elevated": "#1F2328", + "text": "#DCE1E5", + "text_dim": "#9BA3AB", + "text_muted": "#606972", + "border": "#262B31", + "border_strong": "#40474F", + "success": "#54BE7F", + "warning": "#EBB448", + "error": "#E25E5E" +} diff --git a/crates/ui_gallery/src/app.rs b/crates/ui_gallery/src/app.rs new file mode 100644 index 0000000..fd87d2f --- /dev/null +++ b/crates/ui_gallery/src/app.rs @@ -0,0 +1,818 @@ +use std::path::PathBuf; + +use editor_ui::inspector::{ComponentCardOptions, ComponentCardViewModel}; +use editor_ui::materials::{ + material_input_schema_editor, material_slot_panel, material_surface_options_ui, + materials_section, MaterialHealth, MaterialSlotAction, MaterialThumbnailPresentation, + MaterialsSectionViewModel, +}; +use editor_ui::renderer::{ + renderer_array_header, renderer_material_slot_row, renderer_material_slots_header, + renderer_panel, renderer_panel_header, renderer_properties_header, + RendererPanelHeaderViewModel, +}; +use egui_phosphor_icons::icons; +use material_schema::{standard_lit_input_schema, MaterialShaderKind}; + +use crate::fixtures::{FixtureId, FixtureModel}; +use crate::reference::{ + difference_image, ComparisonMode, DifferenceStats, GalleryPreset, ReferenceImage, +}; +use crate::reload::RuntimeDocuments; + +pub struct UiGalleryApp { + preset: GalleryPreset, + comparison: ComparisonMode, + overlay_opacity: f32, + fixture_id: FixtureId, + fixture: FixtureModel, + fixture_textures: Vec, + documents: RuntimeDocuments, + references: Vec<(GalleryPreset, ReferenceImage)>, + reference_error: Option, + last_live_rect: Option, + last_reference_rect: Option, + difference_texture: Option, + difference_stats: Option, + difference_dirty: bool, + capture: Option, + renderer_open: [bool; 3], +} + +#[derive(Clone, Copy)] +struct CaptureRequest { + preset: GalleryPreset, + live_rect: egui::Rect, + reference_rect: egui::Rect, + pixels_per_point: f32, +} + +impl UiGalleryApp { + pub fn new(creation: &eframe::CreationContext<'_>) -> Self { + creation + .egui_ctx + .set_fonts(editor_ui::fonts::font_definitions()); + editor_ui::theme::apply_editor_theme(&creation.egui_ctx); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")); + let mut documents = RuntimeDocuments::new(&root); + documents.poll(0.0); + if let Ok(palette) = documents.theme_value.palette() { + editor_ui::theme::apply_editor_theme_with_palette(&creation.egui_ctx, palette); + } + let fixture_textures = make_fixture_textures(&creation.egui_ctx); + let (material_thumbnail, texture_thumbnails) = fixture_thumbnail_ids(&fixture_textures); + let fixture_id = FixtureId::Reference; + let fixture = FixtureModel::new( + fixture_id, + &documents.fixture_value, + material_thumbnail, + texture_thumbnails, + ); + let mut references = Vec::new(); + let mut reference_error = None; + for preset in GalleryPreset::ALL { + match ReferenceImage::load(&creation.egui_ctx, &root, preset) { + Ok(reference) => references.push((preset, reference)), + Err(error) => reference_error = Some(error), + } + } + Self { + preset: GalleryPreset::Materials620, + comparison: ComparisonMode::SideBySide, + overlay_opacity: 0.5, + fixture_id, + fixture, + fixture_textures, + documents, + references, + reference_error, + last_live_rect: None, + last_reference_rect: None, + difference_texture: None, + difference_stats: None, + difference_dirty: true, + capture: None, + renderer_open: [true, false, false], + } + } + + fn rebuild_fixture(&mut self, ctx: &egui::Context) { + let (material_thumbnail, texture_thumbnails) = + fixture_thumbnail_ids(&self.fixture_textures); + self.fixture = FixtureModel::new( + self.fixture_id, + &self.documents.fixture_value, + material_thumbnail, + texture_thumbnails, + ); + ctx.request_repaint(); + self.difference_dirty = true; + } + + fn select_preset(&mut self, ctx: &egui::Context, preset: GalleryPreset) { + if self.preset == preset { + return; + } + self.preset = preset; + if preset.is_renderer() && !self.fixture_id.is_renderer() { + self.fixture_id = FixtureId::StaticMesh; + self.rebuild_fixture(ctx); + } + self.difference_dirty = true; + } + + fn select_fixture(&mut self, ctx: &egui::Context, fixture: FixtureId) { + if self.fixture_id == fixture { + return; + } + self.fixture_id = fixture; + self.rebuild_fixture(ctx); + } + + fn select_comparison(&mut self, comparison: ComparisonMode) { + if self.comparison == comparison { + return; + } + self.comparison = comparison; + self.difference_dirty = true; + } + + fn handle_shortcuts(&mut self, ctx: &egui::Context) { + let (function_key, shift, previous_fixture, next_fixture) = ctx.input(|input| { + let function_key = [egui::Key::F1, egui::Key::F2, egui::Key::F3, egui::Key::F4] + .into_iter() + .position(|key| input.key_pressed(key)); + ( + function_key, + input.modifiers.shift, + input.key_pressed(egui::Key::PageUp), + input.key_pressed(egui::Key::PageDown), + ) + }); + if let Some(index) = function_key { + if shift { + self.select_comparison(ComparisonMode::ALL[index]); + } else { + self.select_preset(ctx, GalleryPreset::ALL[index]); + } + } + if previous_fixture || next_fixture { + let current = FixtureId::ALL + .iter() + .position(|fixture| *fixture == self.fixture_id) + .unwrap_or_default(); + let next = if previous_fixture { + current.checked_sub(1).unwrap_or(FixtureId::ALL.len() - 1) + } else { + (current + 1) % FixtureId::ALL.len() + }; + self.select_fixture(ctx, FixtureId::ALL[next]); + } + } + + fn reference(&self) -> Option<&ReferenceImage> { + self.references + .iter() + .find_map(|(preset, image)| (*preset == self.preset).then_some(image)) + } + + fn handle_screenshot_events(&mut self, ctx: &egui::Context) { + let screenshots = ctx.input(|input| { + input + .raw + .events + .iter() + .filter_map(|event| match event { + egui::Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + .collect::>() + }); + let Some(request) = self.capture.take() else { + return; + }; + let Some(screenshot) = screenshots.last() else { + self.capture = Some(request); + return; + }; + let (difference, stats) = difference_image( + screenshot, + request.live_rect, + request.reference_rect, + request.pixels_per_point, + request.preset, + ); + if let Some(texture) = &mut self.difference_texture { + texture.set(difference, egui::TextureOptions::NEAREST); + } else { + self.difference_texture = Some(ctx.load_texture( + "blacksite-ui-gallery-difference", + difference, + egui::TextureOptions::NEAREST, + )); + } + self.difference_stats = Some(stats); + self.difference_dirty = false; + } + + fn toolbar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + ui.strong("Blacksite UI Gallery"); + ui.separator(); + egui::ComboBox::from_id_salt("gallery_preset") + .selected_text(self.preset.label()) + .show_ui(ui, |ui| { + for preset in GalleryPreset::ALL { + let selected = self.preset == preset; + if ui.selectable_label(selected, preset.label()).clicked() { + self.select_preset(ctx, preset); + } + } + }); + egui::ComboBox::from_id_salt("gallery_fixture") + .selected_text(self.fixture_id.label()) + .show_ui(ui, |ui| { + for fixture in FixtureId::ALL { + let selected = self.fixture_id == fixture; + if ui.selectable_label(selected, fixture.label()).clicked() { + self.select_fixture(ctx, fixture); + } + } + }); + for mode in ComparisonMode::ALL { + let selected = self.comparison == mode; + if ui.selectable_label(selected, mode.label()).clicked() { + self.select_comparison(mode); + } + } + if self.comparison == ComparisonMode::Overlay { + ui.add( + egui::Slider::new(&mut self.overlay_opacity, 0.0..=1.0) + .text("Reference opacity") + .show_value(false), + ); + } + if self.comparison == ComparisonMode::Difference + && ui.button("Refresh difference").clicked() + { + self.difference_dirty = true; + } + }); + ui.horizontal_wrapped(|ui| { + ui.small(format!( + "Canvas: {} px · reference: {}", + self.preset.width() as u32, + self.preset.file_name() + )); + ui.separator(); + ui.small("Theme and fixture JSON reload without rebuilding"); + ui.separator(); + ui.small("F1–F4 presets · Shift+F1–F4 modes · PgUp/PgDn fixtures"); + if let Some(stats) = self.difference_stats { + ui.separator(); + ui.small(format!( + "Δ mean {:.2} · max {} · {:.1}% pixels > 8", + stats.mean_channel_delta, stats.max_channel_delta, stats.changed_percent + )); + } + if let Some(error) = self + .documents + .error + .as_deref() + .or(self.reference_error.as_deref()) + { + ui.colored_label(editor_ui::theme::ERROR, error); + } + }); + } + + fn render_comparison(&mut self, ui: &mut egui::Ui) { + let preset = self.preset; + match self.comparison { + ComparisonMode::Live => { + self.last_live_rect = Some(self.render_live_canvas(ui)); + } + ComparisonMode::SideBySide => { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.strong("Penpot reference"); + if let Some(reference) = self.reference() { + reference.paint(ui, preset, 1.0); + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.strong("Production component"); + self.last_live_rect = Some(self.render_live_canvas(ui)); + }); + }); + } + ComparisonMode::Overlay => { + self.last_live_rect = Some(self.render_live_canvas(ui)); + if let (Some(rect), Some(reference)) = (self.last_live_rect, self.reference()) { + let mut overlay_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + reference.paint(&mut overlay_ui, preset, self.overlay_opacity); + } + } + ComparisonMode::Difference => { + ui.horizontal_top(|ui| { + self.last_live_rect = Some(self.render_live_canvas(ui)); + ui.add_space(16.0); + if self.difference_dirty || self.capture.is_some() { + self.last_reference_rect = self + .reference() + .map(|reference| reference.paint(ui, preset, 1.0).rect); + } else if let Some(texture) = &self.difference_texture { + self.last_reference_rect = None; + ui.image(( + texture.id(), + egui::vec2(preset.width(), preset.target_height()), + )); + } else { + ui.allocate_ui(egui::vec2(preset.width(), preset.target_height()), |ui| { + ui.centered_and_justified(|ui| { + ui.label("Difference capture pending…"); + }); + }); + } + }); + } + } + } + + fn render_live_canvas(&mut self, ui: &mut egui::Ui) -> egui::Rect { + let size = egui::vec2(self.preset.width(), self.preset.target_height()); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let palette = editor_ui::design_system::palette(ui); + let (canvas_fill, canvas_radius) = if self.preset.is_renderer() { + (palette.recessed, 4.0) + } else { + (palette.panel, 7.0) + }; + ui.painter().rect( + rect, + canvas_radius, + canvas_fill, + egui::Stroke::new(1.0_f32, palette.border_strong), + egui::StrokeKind::Inside, + ); + // Penpot material boards are complete Inspector canvases, while renderer boards include a + // authored top inset before the component card. The preset owns both cases. + let content_rect = self.preset.content_rect(rect); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(content_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let previous_inputs = self.fixture.inputs.clone(); + let previous_renderer_inputs = self + .fixture + .renderer_slots + .iter() + .map(|fixture| fixture.inputs.clone()) + .collect::>(); + if self.preset.is_renderer() { + self.render_renderer_fixture(&mut child); + } else { + self.render_material_fixture(&mut child); + } + if previous_inputs != self.fixture.inputs + || previous_renderer_inputs + != self + .fixture + .renderer_slots + .iter() + .map(|fixture| fixture.inputs.clone()) + .collect::>() + { + self.difference_dirty = true; + } + rect + } + + fn render_renderer_fixture(&mut self, ui: &mut egui::Ui) { + let title = if self.fixture_id == FixtureId::SkinnedMesh { + "Skinned Mesh Renderer" + } else { + "Static Mesh Renderer" + }; + let component = ComponentCardViewModel { + options: ComponentCardOptions { + type_name: "gallery::Renderer", + title, + icon: icons::CUBE, + active_toggle: true, + removable: true, + reorderable: true, + resettable: true, + copyable: true, + summary: "3 renderers · 5 material slots", + body_margin: 0, + }, + active: true, + collapsed: false, + pasteable: false, + can_move_up: false, + can_move_down: true, + }; + editor_ui::inspector::component_card(ui, &component, |ui| { + let _ = renderer_array_header(ui, self.renderer_open.len()); + ui.add_space(26.0); + for renderer_index in 0..self.renderer_open.len() { + let renderer_label = match renderer_index { + 0 => "metal_office_desk / Primitive 0", + 1 => "metal_office_desk_drawer_06 / Primitive 0", + _ => "metal_office_desk_tray_01 / Primitive 0", + }; + renderer_panel(ui, |ui| { + renderer_panel_header( + ui, + RendererPanelHeaderViewModel { + index: renderer_index, + label: renderer_label, + path: "draw:scene0:node2:mesh2:primitive0", + slot_count: if renderer_index == 0 { 3 } else { 1 }, + texture_id: None, + }, + &mut self.renderer_open[renderer_index], + |ui| { + editor_ui::design_system::controls::icon_button( + ui, + icons::DOTS_THREE_VERTICAL, + "Renderer actions", + true, + ); + editor_ui::design_system::controls::icon_button( + ui, + icons::X, + "Remove renderer", + true, + ); + editor_ui::design_system::controls::icon_button( + ui, + icons::CROSSHAIR, + "Locate mesh", + true, + ); + editor_ui::design_system::controls::icon_button( + ui, + icons::FOLDER_OPEN, + "Browse mesh", + true, + ); + }, + ); + if !self.renderer_open[renderer_index] { + return; + } + renderer_properties_fixture(ui, renderer_label); + let slot_count = if renderer_index == 0 { 3 } else { 1 }; + renderer_material_slots_header(ui, slot_count); + if renderer_index == 0 { + for slot_index in 0..self.fixture.renderer_slots.len() { + renderer_material_slot_row(ui, slot_index, |ui| { + self.render_renderer_material_slot(ui, slot_index); + }); + } + } + }); + } + }); + } + + fn render_material_fixture(&mut self, ui: &mut egui::Ui) { + let section = MaterialsSectionViewModel { + slot_ids: vec![self.fixture.slot.slot_id.clone()], + }; + materials_section(ui, §ion, |ui| { + let slot = &mut self.fixture.slot; + let render_state = &mut self.fixture.render_state; + let inputs = &mut self.fixture.inputs; + let textures = &self.fixture.textures; + let response = material_slot_panel( + ui, + ("gallery-slot", &slot.slot_id), + slot, + |ui| { + material_surface_options_ui(ui, render_state, Some(inputs)); + ui.add_space(4.0); + material_input_schema_editor( + ui, + &standard_lit_input_schema(), + inputs, + textures, + None, + ); + }, + |ui| { + ui.label(format!("Stable slot: {}", slot.slot_id)); + ui.label("Gallery fixture — no project state is loaded"); + }, + ); + for action in response.actions { + apply_slot_action(slot, action.action); + } + }); + } + + fn render_renderer_material_slot(&mut self, ui: &mut egui::Ui, slot_index: usize) { + let preset_label = self.preset.label(); + let fixture = &mut self.fixture.renderer_slots[slot_index]; + let slot_id = fixture.slot.slot_id.clone(); + fixture.slot.expanded_header_when_parameters_closed = true; + fixture.slot.parameters_initially_open = + slot_index == 0 && self.preset == GalleryPreset::Renderer760; + let textures = &self.fixture.textures; + ui.push_id( + ( + "gallery-renderer-slot-scope", + preset_label, + slot_id.as_str(), + ), + |ui| { + let response = material_slot_panel( + ui, + "material-panel", + &fixture.slot, + |ui| { + material_surface_options_ui( + ui, + &mut fixture.render_state, + Some(&mut fixture.inputs), + ); + ui.add_space(4.0); + material_input_schema_editor( + ui, + &standard_lit_input_schema(), + &mut fixture.inputs, + textures, + None, + ); + }, + |ui| { + ui.label(format!("Stable slot: {}", fixture.slot.slot_id)); + ui.label("Gallery renderer fixture — no project state is loaded"); + }, + ); + for action in response.actions { + apply_slot_action(&mut fixture.slot, action.action); + } + }, + ); + } +} + +impl eframe::App for UiGalleryApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.handle_screenshot_events(ctx); + self.handle_shortcuts(ctx); + let now = ctx.input(|input| input.time); + if self.documents.poll(now) { + if let Ok(palette) = self.documents.theme_value.palette() { + editor_ui::theme::apply_editor_theme_with_palette(ctx, palette); + } + self.rebuild_fixture(ctx); + } + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + + fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = root.ctx().clone(); + egui::CentralPanel::default().show_inside(root, |ui| { + self.toolbar(&ctx, ui); + ui.separator(); + egui::ScrollArea::both() + .id_salt("gallery_canvas_scroll") + .auto_shrink([false, false]) + .show(ui, |ui| self.render_comparison(ui)); + if self.comparison == ComparisonMode::Difference + && self.difference_dirty + && self.capture.is_none() + { + if let (Some(live_rect), Some(reference_rect)) = + (self.last_live_rect, self.last_reference_rect) + { + self.capture = Some(CaptureRequest { + preset: self.preset, + live_rect, + reference_rect, + pixels_per_point: ctx.pixels_per_point(), + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new( + "blacksite-ui-gallery-difference", + ))); + } + } + }); + } +} + +fn renderer_properties_fixture(ui: &mut egui::Ui, name: &str) { + let palette = editor_ui::design_system::palette(ui); + let mut name_buffer = name.to_owned(); + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + renderer_properties_header(ui); + ui.add_space(2.0); + property_fixture_row(ui, "Name", |ui| { + ui.add_sized( + [ui.available_width().max(1.0), 24.0], + egui::TextEdit::singleline(&mut name_buffer), + ); + }); + ui.add_space(4.0); + property_fixture_row(ui, "Mesh", |ui| { + let field = ui.available_rect_before_wrap(); + let field = + egui::Rect::from_min_size(field.min, egui::vec2(field.width().max(1.0), 38.0)); + ui.allocate_rect(field, egui::Sense::hover()); + ui.painter().rect( + field, + 4.0, + palette.control, + egui::Stroke::new(1.0_f32, palette.border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + field.left_center() + egui::vec2(10.0, -7.0), + egui::Align2::LEFT_CENTER, + name, + editor_ui::design_system::typography::TypeRole::Body.font(), + palette.text_primary, + ); + ui.painter().text( + field.left_center() + egui::vec2(10.0, 8.0), + egui::Align2::LEFT_CENTER, + "assets/Furniture/Office/metal_office_desk.mesh", + editor_ui::design_system::typography::TypeRole::Small.font(), + palette.text_muted, + ); + }); + ui.add_space(4.0); + property_fixture_row(ui, "Flags", |ui| { + let mut visible = true; + let mut cast_shadows = true; + let mut receive_shadows = true; + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut visible, "Visible"); + ui.checkbox(&mut cast_shadows, "Cast shadows"); + ui.checkbox(&mut receive_shadows, "Receive shadows"); + }); + }); + ui.add_space(4.0); + }); +} + +fn property_fixture_row(ui: &mut egui::Ui, label: &str, add_value: impl FnOnce(&mut egui::Ui)) { + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(72.0, 28.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(label); + }, + ); + ui.vertical(add_value); + }); +} + +fn apply_slot_action( + slot: &mut editor_ui::materials::MaterialSlotPanelViewModel, + action: MaterialSlotAction, +) { + match action { + MaterialSlotAction::Assign { reference, .. } => { + slot.label = reference.label.clone(); + slot.path = reference.source_path.clone(); + slot.assigned = Some(reference); + slot.health = MaterialHealth::Dirty; + } + MaterialSlotAction::Clear => { + slot.assigned = None; + slot.health = MaterialHealth::Dirty; + } + MaterialSlotAction::SetShader(kind) => { + slot.shader_kind = Some(kind); + slot.shader = match kind { + MaterialShaderKind::StandardLit => "Standard Lit", + MaterialShaderKind::Unlit => "Unlit", + MaterialShaderKind::Custom => "Custom Surface", + } + .to_owned(); + slot.health = MaterialHealth::Dirty; + } + MaterialSlotAction::BrowseLibrary + | MaterialSlotAction::Locate + | MaterialSlotAction::ExtractEditable + | MaterialSlotAction::CreateInstance => {} + } +} + +fn make_fixture_textures(ctx: &egui::Context) -> Vec { + let mut handles = vec![make_material_thumbnail(ctx)]; + handles.extend([ + checker_thumbnail(ctx, "gallery-base-color", [126, 100, 74], [87, 69, 51]), + checker_thumbnail(ctx, "gallery-orm", [198, 238, 18], [103, 164, 8]), + checker_thumbnail(ctx, "gallery-normal", [116, 92, 232], [73, 145, 242]), + ]); + handles +} + +fn fixture_thumbnail_ids( + handles: &[egui::TextureHandle], +) -> ( + MaterialThumbnailPresentation, + [MaterialThumbnailPresentation; 3], +) { + ( + MaterialThumbnailPresentation::Ready(handles[0].id()), + [ + MaterialThumbnailPresentation::Ready(handles[1].id()), + MaterialThumbnailPresentation::Ready(handles[2].id()), + MaterialThumbnailPresentation::Ready(handles[3].id()), + ], + ) +} + +fn make_material_thumbnail(ctx: &egui::Context) -> egui::TextureHandle { + let size = 64; + let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]); + for y in 0..size { + for x in 0..size { + image[(x, y)] = if (x / 16 + y / 16) % 2 == 0 { + egui::Color32::from_rgb(32, 37, 43) + } else { + egui::Color32::from_rgb(43, 50, 58) + }; + } + } + paint_fixture_ellipse(&mut image, [37.0, 42.0], [19.0, 19.0], [17, 22, 27], 0.8); + paint_fixture_ellipse(&mut image, [34.0, 38.0], [20.0, 20.0], [109, 123, 138], 1.0); + paint_fixture_ellipse(&mut image, [27.0, 28.5], [6.0, 4.5], [199, 211, 221], 0.85); + paint_fixture_ellipse(&mut image, [38.0, 42.0], [9.0, 9.0], [63, 74, 86], 1.0); + ctx.load_texture( + "gallery-material-sphere", + image, + egui::TextureOptions::NEAREST, + ) +} + +fn paint_fixture_ellipse( + image: &mut egui::ColorImage, + center: [f32; 2], + radius: [f32; 2], + color: [u8; 3], + opacity: f32, +) { + for y in 0..image.size[1] { + for x in 0..image.size[0] { + let dx = (x as f32 + 0.5 - center[0]) / radius[0]; + let dy = (y as f32 + 0.5 - center[1]) / radius[1]; + let distance = (dx * dx + dy * dy).sqrt(); + let coverage = ((1.0 - distance) * radius[0].min(radius[1])).clamp(0.0, 1.0); + let alpha = coverage * opacity; + if alpha <= 0.0 { + continue; + } + let previous = image[(x, y)]; + let blend = |source: u8, destination: u8| { + (source as f32 * alpha + destination as f32 * (1.0 - alpha)).round() as u8 + }; + image[(x, y)] = egui::Color32::from_rgb( + blend(color[0], previous.r()), + blend(color[1], previous.g()), + blend(color[2], previous.b()), + ); + } + } +} + +fn checker_thumbnail( + ctx: &egui::Context, + name: &str, + first: [u8; 3], + second: [u8; 3], +) -> egui::TextureHandle { + let size = 18; + let mut image = egui::ColorImage::new([size, size], vec![egui::Color32::BLACK; size * size]); + for y in 0..size { + for x in 0..size { + let color = if (x / 3 + y / 3) % 2 == 0 { + first + } else { + second + }; + image[(x, y)] = egui::Color32::from_rgb(color[0], color[1], color[2]); + } + } + ctx.load_texture(name, image, egui::TextureOptions::NEAREST) +} diff --git a/crates/ui_gallery/src/fixtures.rs b/crates/ui_gallery/src/fixtures.rs new file mode 100644 index 0000000..8e045df --- /dev/null +++ b/crates/ui_gallery/src/fixtures.rs @@ -0,0 +1,457 @@ +use editor_ui::materials::{ + MaterialHealth, MaterialLayerBadge, MaterialLocateTarget, MaterialPickerCandidate, + MaterialSlotPanelViewModel, MaterialTextureCandidate, MaterialThumbnailPresentation, +}; +use material_schema::{ + standard_lit_input_schema, ColorDesc, EditorAssetRef, MaterialInputSet, MaterialParameter, + MaterialParameterValue, MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, + TextureChannel, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FixtureId { + Reference, + Primitive, + StaticMesh, + SkinnedMesh, + Inherited, + Imported, + Dirty, + Processing, + Failed, + ReadOnly, + BuiltIn, +} + +impl FixtureId { + pub const ALL: [Self; 11] = [ + Self::Reference, + Self::Primitive, + Self::StaticMesh, + Self::SkinnedMesh, + Self::Inherited, + Self::Imported, + Self::Dirty, + Self::Processing, + Self::Failed, + Self::ReadOnly, + Self::BuiltIn, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Reference => "Penpot Layout", + Self::Primitive => "Primitive", + Self::StaticMesh => "Static Mesh", + Self::SkinnedMesh => "Skinned Mesh", + Self::Inherited => "Inherited", + Self::Imported => "Imported Source", + Self::Dirty => "Dirty", + Self::Processing => "Processing", + Self::Failed => "Failed", + Self::ReadOnly => "Read only", + Self::BuiltIn => "Built-in DefaultGrid", + } + } + + pub const fn is_renderer(self) -> bool { + matches!(self, Self::StaticMesh | Self::SkinnedMesh) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FixtureOverrides { + pub material_label: Option, + pub material_path: Option, + pub metallic: Option, + pub roughness: Option, + pub occlusion: Option, + pub normal_strength: Option, + pub emissive_intensity: Option, + pub uv_tiling: Option<[f32; 2]>, +} + +pub struct FixtureModel { + pub slot: MaterialSlotPanelViewModel, + pub render_state: MaterialRenderState, + pub inputs: MaterialInputSet, + pub renderer_slots: Vec, + pub textures: Vec, +} + +pub struct RendererMaterialFixture { + pub slot: MaterialSlotPanelViewModel, + pub render_state: MaterialRenderState, + pub inputs: MaterialInputSet, +} + +impl FixtureModel { + pub fn new( + id: FixtureId, + overrides: &FixtureOverrides, + material_thumbnail: MaterialThumbnailPresentation, + texture_thumbnails: [MaterialThumbnailPresentation; 3], + ) -> Self { + let material_ref = EditorAssetRef::new( + "c07a5b05-f27d-46c6-8b8c-9de9cdd1472c", + "material:source", + "M_Wood_Planks", + ) + .with_source_path("materials/wood/M_Wood_Planks.mat"); + let label = overrides + .material_label + .clone() + .unwrap_or_else(|| fixture_material_label(id).to_owned()); + let path = overrides + .material_path + .clone() + .unwrap_or_else(|| fixture_material_path(id).to_owned()); + let (health, inherited, can_edit_shader, can_clear) = fixture_state(id); + let slot = MaterialSlotPanelViewModel { + slot_id: fixture_slot_id(id).to_owned(), + label, + path: Some(path), + shader: "Standard Lit".to_owned(), + shader_kind: Some(MaterialShaderKind::StandardLit), + can_edit_shader, + thumbnail: material_thumbnail, + inherited, + health, + assigned: (!matches!(id, FixtureId::Inherited | FixtureId::BuiltIn)) + .then_some(material_ref.clone()), + effective: Some(material_ref), + candidates: material_candidates(material_thumbnail), + drop_candidate: None, + invalid_drop_reason: None, + can_locate: !matches!(id, FixtureId::BuiltIn), + can_clear, + can_extract: matches!(id, FixtureId::Imported), + can_create_instance: !matches!(id, FixtureId::Imported | FixtureId::BuiltIn), + parameters_initially_open: true, + expanded_header_when_parameters_closed: false, + }; + let textures = texture_candidates(texture_thumbnails); + let inputs = fixture_inputs(id, overrides, &textures); + let renderer_slots = renderer_material_fixtures(&slot, &inputs); + Self { + slot, + render_state: MaterialRenderState::default(), + inputs, + renderer_slots, + textures, + } + } +} + +fn renderer_material_fixtures( + primary: &MaterialSlotPanelViewModel, + inputs: &MaterialInputSet, +) -> Vec { + let mut painted = primary.clone(); + painted.slot_id = "slot:draw:scene0:node2:mesh2:primitive1".to_owned(); + painted.label = "M_Painted_Metal".to_owned(); + painted.path = Some("assets/materials/office/M_Painted_Metal.mat".to_owned()); + painted.assigned = painted.assigned.as_ref().map(|_| { + EditorAssetRef::new( + "gallery-painted-metal", + "material:source", + "M_Painted_Metal", + ) + .with_source_path("assets/materials/office/M_Painted_Metal.mat") + }); + painted.effective = painted.assigned.clone(); + painted.parameters_initially_open = false; + + let mut empty = primary.clone(); + empty.slot_id = "slot:draw:scene0:node2:mesh2:primitive2".to_owned(); + empty.label = "No material assigned".to_owned(); + empty.path = None; + empty.shader.clear(); + empty.shader_kind = None; + empty.can_edit_shader = false; + empty.thumbnail = MaterialThumbnailPresentation::Empty; + empty.health = MaterialHealth::Healthy; + empty.assigned = None; + empty.effective = None; + empty.can_locate = false; + empty.can_clear = false; + empty.can_create_instance = false; + empty.parameters_initially_open = false; + + [primary.clone(), painted, empty] + .into_iter() + .map(|slot| RendererMaterialFixture { + slot, + render_state: MaterialRenderState::default(), + inputs: inputs.clone(), + }) + .collect() +} + +fn fixture_state(id: FixtureId) -> (MaterialHealth, Option, bool, bool) { + match id { + FixtureId::Inherited => ( + MaterialHealth::Healthy, + Some(MaterialLayerBadge::ModelDefault), + false, + false, + ), + FixtureId::Imported => ( + MaterialHealth::ReadOnly, + Some(MaterialLayerBadge::ModelSource), + false, + false, + ), + FixtureId::Dirty => (MaterialHealth::Dirty, None, true, true), + FixtureId::Processing => (MaterialHealth::Processing, None, true, true), + FixtureId::Failed => (MaterialHealth::Broken, None, false, true), + FixtureId::ReadOnly => (MaterialHealth::ReadOnly, None, false, false), + FixtureId::BuiltIn => ( + MaterialHealth::ReadOnly, + Some(MaterialLayerBadge::DefaultGrid), + false, + false, + ), + FixtureId::Reference + | FixtureId::Primitive + | FixtureId::StaticMesh + | FixtureId::SkinnedMesh => (MaterialHealth::Healthy, None, true, true), + } +} + +fn fixture_slot_id(id: FixtureId) -> &'static str { + match id { + FixtureId::Reference => "slot:gallery:penpot-reference", + FixtureId::Primitive => "slot:primitive:surface", + FixtureId::StaticMesh => "slot:draw:scene0:node2:mesh2:primitive0", + FixtureId::SkinnedMesh => "slot:skin:scene0:node1:mesh0:primitive0", + _ => "slot:gallery:surface", + } +} + +fn fixture_material_label(id: FixtureId) -> &'static str { + match id { + FixtureId::Imported => "metal_office_desk", + FixtureId::BuiltIn => "Default Grid — Engine Built-in", + FixtureId::Failed => "Missing explicit material", + _ => "M_Wood_Planks", + } +} + +fn fixture_material_path(id: FixtureId) -> &'static str { + match id { + FixtureId::Imported => "assets/Furniture/Office/metal_office_desk_2k.gltf#material:0", + FixtureId::BuiltIn => "builtin://materials/default-grid", + FixtureId::Failed => "assets/materials/missing.material.ron", + _ => "materials/wood/M_Wood_Planks.mat", + } +} + +fn material_candidates(thumbnail: MaterialThumbnailPresentation) -> Vec { + ["M_Wood_Planks", "M_Painted_Metal", "M_Concrete"] + .into_iter() + .enumerate() + .map(|(index, label)| MaterialPickerCandidate { + reference: EditorAssetRef::new( + format!("gallery-material-{index}"), + "material:source", + label, + ) + .with_source_path(format!("assets/materials/{label}.material.ron")), + label: label.to_owned(), + detail: format!("assets/materials/{label}.material.ron"), + thumbnail, + }) + .collect() +} + +fn texture_candidates( + thumbnails: [MaterialThumbnailPresentation; 3], +) -> Vec { + [ + ("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"), + ("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"), + ("T_Wood_ORM", "materials/textures/T_Wood_ORM.png"), + ] + .into_iter() + .enumerate() + .map(|(index, (label, path))| MaterialTextureCandidate { + label: label.to_owned(), + detail: path.to_owned(), + reference: EditorAssetRef::new(format!("gallery-texture-{index}"), "texture:source", label) + .with_source_path(path), + locate: MaterialLocateTarget::File(path.to_owned()), + thumbnail: thumbnails[index], + }) + .collect() +} + +fn fixture_inputs( + id: FixtureId, + overrides: &FixtureOverrides, + textures: &[MaterialTextureCandidate], +) -> MaterialInputSet { + let schema = standard_lit_input_schema(); + let mut set = MaterialInputSet::default(); + for input in &schema.inputs { + if let Some(value) = input.default_value.clone() { + set.values.push(MaterialParameter { + name: input.name.clone(), + value, + }); + } + if let Some(texture) = input.texture.as_ref() { + set.textures.push(MaterialTextureBinding { + name: input.name.clone(), + texture: None, + channel: texture.default_channel, + }); + } + } + set_value( + &mut set, + "base_color", + MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)), + ); + set_value( + &mut set, + "emissive_color", + MaterialParameterValue::Color(ColorDesc::srgb(231.0 / 255.0, 212.0 / 255.0, 180.0 / 255.0)), + ); + set_float(&mut set, "metallic", overrides.metallic.unwrap_or(0.72)); + set_float(&mut set, "roughness", overrides.roughness.unwrap_or(0.72)); + set_float(&mut set, "occlusion", overrides.occlusion.unwrap_or(0.72)); + set_float( + &mut set, + "normal", + overrides.normal_strength.unwrap_or(0.72), + ); + set_float( + &mut set, + "emissive_intensity", + overrides.emissive_intensity.unwrap_or(0.0), + ); + if let Some([x, y]) = overrides.uv_tiling { + if let Some(parameter) = set + .values + .iter_mut() + .find(|value| value.name == "uv_tiling") + { + if let MaterialParameterValue::Vec2(value) = &mut parameter.value { + value.x = x; + value.y = y; + } + } + } + bind(&mut set, "base_color", &textures[0], TextureChannel::Rgba); + for (name, channel) in [ + ("occlusion", TextureChannel::R), + ("roughness", TextureChannel::G), + ("metallic", TextureChannel::B), + ] { + bind(&mut set, name, &textures[1], channel); + } + bind(&mut set, "normal", &textures[2], TextureChannel::Rgb); + if id == FixtureId::Reference { + // Penpot's sample binds the same mock texture to Emissive. Its all-G scalar labels conflict + // with the simultaneously selected ORM mode; keep Blacksite's valid R/G/B contract and + // document those three sample glyphs as an intentional handoff delta. + bind( + &mut set, + "emissive_color", + &textures[1], + TextureChannel::Rgb, + ); + } + set +} + +fn set_float(inputs: &mut MaterialInputSet, name: &str, value: f32) { + set_value(inputs, name, MaterialParameterValue::Float(value)); +} + +fn set_value(inputs: &mut MaterialInputSet, name: &str, value: MaterialParameterValue) { + if let Some(parameter) = inputs.values.iter_mut().find(|item| item.name == name) { + parameter.value = value; + } +} + +fn bind( + inputs: &mut MaterialInputSet, + name: &str, + candidate: &MaterialTextureCandidate, + channel: TextureChannel, +) { + if let Some(binding) = inputs.textures.iter_mut().find(|item| item.name == name) { + binding.texture = Some(candidate.reference.clone()); + binding.channel = channel; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_textures() -> Vec { + texture_candidates([MaterialThumbnailPresentation::Pending; 3]) + } + + #[test] + fn reference_fixture_keeps_valid_orm_channels_and_adds_penpot_emissive_sample() { + let textures = test_textures(); + let reference = fixture_inputs( + FixtureId::Reference, + &FixtureOverrides::default(), + &textures, + ); + assert_eq!( + reference + .texture("occlusion") + .map(|binding| binding.channel), + Some(TextureChannel::R) + ); + assert_eq!( + reference + .texture("roughness") + .map(|binding| binding.channel), + Some(TextureChannel::G) + ); + assert_eq!( + reference.texture("metallic").map(|binding| binding.channel), + Some(TextureChannel::B) + ); + assert!(reference + .texture("emissive_color") + .and_then(|binding| binding.texture.as_ref()) + .is_some()); + + let primitive = fixture_inputs( + FixtureId::Primitive, + &FixtureOverrides::default(), + &textures, + ); + assert_eq!( + primitive + .texture("occlusion") + .map(|binding| binding.channel), + Some(TextureChannel::R) + ); + assert_eq!( + primitive + .texture("roughness") + .map(|binding| binding.channel), + Some(TextureChannel::G) + ); + assert_eq!( + primitive.texture("metallic").map(|binding| binding.channel), + Some(TextureChannel::B) + ); + assert!(primitive + .texture("emissive_color") + .and_then(|binding| binding.texture.as_ref()) + .is_none()); + } +} diff --git a/crates/ui_gallery/src/main.rs b/crates/ui_gallery/src/main.rs new file mode 100644 index 0000000..c2aefa1 --- /dev/null +++ b/crates/ui_gallery/src/main.rs @@ -0,0 +1,19 @@ +mod app; +mod fixtures; +mod reference; +mod reload; + +fn main() -> eframe::Result { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("Blacksite UI Gallery") + .with_inner_size([1420.0, 940.0]) + .with_min_inner_size([900.0, 620.0]), + ..Default::default() + }; + eframe::run_native( + "Blacksite UI Gallery", + options, + Box::new(|creation| Ok(Box::new(app::UiGalleryApp::new(creation)))), + ) +} diff --git a/crates/ui_gallery/src/reference.rs b/crates/ui_gallery/src/reference.rs new file mode 100644 index 0000000..380823e --- /dev/null +++ b/crates/ui_gallery/src/reference.rs @@ -0,0 +1,318 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GalleryPreset { + Materials620, + Materials420, + Renderer760, + Renderer480, +} + +impl GalleryPreset { + pub const ALL: [Self; 4] = [ + Self::Materials620, + Self::Materials420, + Self::Renderer760, + Self::Renderer480, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Materials620 => "Materials · 620", + Self::Materials420 => "Materials · 420", + Self::Renderer760 => "Renderer · 760", + Self::Renderer480 => "Renderer · 480", + } + } + + pub const fn width(self) -> f32 { + match self { + Self::Materials620 => 620.0, + Self::Materials420 => 420.0, + Self::Renderer760 => 760.0, + Self::Renderer480 => 480.0, + } + } + + pub const fn target_height(self) -> f32 { + match self { + Self::Materials620 => 539.0, + Self::Materials420 => 735.0, + Self::Renderer760 => 1233.0, + Self::Renderer480 => 828.0, + } + } + + pub const fn file_name(self) -> &'static str { + match self { + Self::Materials620 => "materials-wide-620.png", + Self::Materials420 => "materials-narrow-420.png", + Self::Renderer760 => "static-mesh-renderer-wide-760.png", + Self::Renderer480 => "static-mesh-renderer-narrow-480.png", + } + } + + pub const fn is_renderer(self) -> bool { + matches!(self, Self::Renderer760 | Self::Renderer480) + } + + /// Production-content canvas inside the exported Penpot reference. + /// + /// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset; + /// renderer boards are already complete component canvases. + pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect { + if self.is_renderer() { + egui::Rect::from_min_max( + canvas.min + egui::vec2(0.0, 12.0), + egui::pos2(canvas.right(), canvas.bottom()), + ) + } else { + egui::Rect::from_min_max( + canvas.min + egui::vec2(12.0, 10.0), + egui::pos2(canvas.right() - 12.0, canvas.bottom()), + ) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComparisonMode { + Live, + SideBySide, + Overlay, + Difference, +} + +impl ComparisonMode { + pub const ALL: [Self; 4] = [ + Self::Live, + Self::SideBySide, + Self::Overlay, + Self::Difference, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Live => "Live", + Self::SideBySide => "Side by side", + Self::Overlay => "Overlay", + Self::Difference => "Difference", + } + } +} + +pub struct ReferenceImage { + pub source_size: [usize; 2], + pub texture: egui::TextureHandle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ReferenceCrop { + origin: [usize; 2], + size: [usize; 2], +} + +fn reference_crop(source_size: [usize; 2], preset: GalleryPreset) -> ReferenceCrop { + let target_width = preset.width().round().max(1.0) as usize; + let target_height = preset.target_height().round().max(1.0) as usize; + // Penpot's material exports include the frame's one-pixel outer stroke in their viewBox, + // producing 622/422 px PNGs for 620/420 px boards. That stroke is export overscan, not part of + // the authored canvas. Cropping it preserves direct 1:1 pixels; scaling it into the canvas + // blurs text and introduces a false global geometry delta. + let overscan = source_size[0].saturating_sub(target_width); + let inset = usize::from(overscan == 2); + ReferenceCrop { + origin: [inset, inset], + size: [ + target_width.min(source_size[0].saturating_sub(inset)), + target_height.min(source_size[1].saturating_sub(inset)), + ], + } +} + +impl ReferenceImage { + pub fn load(ctx: &egui::Context, root: &Path, preset: GalleryPreset) -> Result { + let path = reference_path(root, preset); + let bytes = std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; + let decoded = image::load_from_memory(&bytes) + .map_err(|error| format!("{}: {error}", path.display()))? + .to_rgba8(); + let source_size = [decoded.width() as usize, decoded.height() as usize]; + let pixels = egui::ColorImage::from_rgba_unmultiplied(source_size, decoded.as_raw()); + let texture = ctx.load_texture( + format!("penpot-reference-{}", preset.label()), + pixels.clone(), + egui::TextureOptions::NEAREST, + ); + Ok(Self { + source_size, + texture, + }) + } + + pub fn paint(&self, ui: &mut egui::Ui, preset: GalleryPreset, opacity: f32) -> egui::Response { + let size = egui::vec2(preset.width(), preset.target_height()); + let crop = reference_crop(self.source_size, preset); + let uv_min = egui::pos2( + crop.origin[0] as f32 / self.source_size[0] as f32, + crop.origin[1] as f32 / self.source_size[1] as f32, + ); + let uv_max = egui::pos2( + (crop.origin[0] + crop.size[0]) as f32 / self.source_size[0] as f32, + (crop.origin[1] + crop.size[1]) as f32 / self.source_size[1] as f32, + ); + ui.add( + egui::Image::new((self.texture.id(), size)) + .uv(egui::Rect::from_min_max(uv_min, uv_max)) + .tint(egui::Color32::from_white_alpha((opacity * 255.0) as u8)), + ) + } +} + +pub fn difference_image( + screenshot: &egui::ColorImage, + live_rect: egui::Rect, + reference_rect: egui::Rect, + pixels_per_point: f32, + preset: GalleryPreset, +) -> (egui::ColorImage, DifferenceStats) { + let width = preset.width().round().max(1.0) as usize; + let height = preset.target_height().round().max(1.0) as usize; + let mut output = + egui::ColorImage::new([width, height], vec![egui::Color32::BLACK; width * height]); + let mut total = 0_u64; + let mut max_delta = 0_u8; + let mut changed = 0_u64; + for y in 0..height { + for x in 0..width { + let live = + sample_screenshot(screenshot, live_rect, pixels_per_point, x, y, width, height); + let reference_color = sample_screenshot( + screenshot, + reference_rect, + pixels_per_point, + x, + y, + width, + height, + ); + let delta = [ + live.r().abs_diff(reference_color.r()), + live.g().abs_diff(reference_color.g()), + live.b().abs_diff(reference_color.b()), + ]; + let peak = *delta.iter().max().unwrap_or(&0); + max_delta = max_delta.max(peak); + total += delta.into_iter().map(u64::from).sum::(); + changed += u64::from(peak > 8); + let amplified = |value: u8| value.saturating_mul(4); + output[(x, y)] = egui::Color32::from_rgb( + amplified(delta[0]), + amplified(delta[1]), + amplified(delta[2]), + ); + } + } + let samples = (width * height * 3).max(1) as f32; + let pixels = (width * height).max(1) as f32; + ( + output, + DifferenceStats { + mean_channel_delta: total as f32 / samples, + max_channel_delta: max_delta, + changed_percent: changed as f32 * 100.0 / pixels, + }, + ) +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DifferenceStats { + pub mean_channel_delta: f32, + pub max_channel_delta: u8, + pub changed_percent: f32, +} + +fn reference_path(root: &Path, preset: GalleryPreset) -> PathBuf { + root.join("docs/editor/evaluations/content-workspace-m2/penpot") + .join(preset.file_name()) +} + +fn sample_screenshot( + image: &egui::ColorImage, + rect: egui::Rect, + pixels_per_point: f32, + x: usize, + y: usize, + width: usize, + height: usize, +) -> egui::Color32 { + let px = ((rect.left() + (x as f32 + 0.5) * rect.width() / width as f32) * pixels_per_point) + .floor() as isize; + let py = ((rect.top() + (y as f32 + 0.5) * rect.height() / height as f32) * pixels_per_point) + .floor() as isize; + if px < 0 || py < 0 || px as usize >= image.size[0] || py as usize >= image.size[1] { + return egui::Color32::BLACK; + } + image[(px as usize, py as usize)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn material_presets_supply_exported_slot_widths() { + for (preset, expected_width) in [ + (GalleryPreset::Materials620, 596.0), + (GalleryPreset::Materials420, 396.0), + ] { + let canvas = egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(preset.width(), preset.target_height()), + ); + let content = preset.content_rect(canvas); + assert_eq!(content.left(), 12.0); + assert_eq!(content.top(), 10.0); + assert_eq!(content.width(), expected_width); + } + } + + #[test] + fn renderer_presets_preserve_the_exported_top_inset() { + for preset in [GalleryPreset::Renderer760, GalleryPreset::Renderer480] { + let canvas = egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(preset.width(), preset.target_height()), + ); + let content = preset.content_rect(canvas); + assert_eq!(content.left(), 0.0); + assert_eq!(content.top(), 12.0); + assert_eq!(content.width(), preset.width()); + } + } + + #[test] + fn material_export_overscan_is_cropped_instead_of_scaled() { + assert_eq!( + reference_crop([622, 661], GalleryPreset::Materials620), + ReferenceCrop { + origin: [1, 1], + size: [620, 539], + } + ); + assert_eq!( + reference_crop([422, 857], GalleryPreset::Materials420), + ReferenceCrop { + origin: [1, 1], + size: [420, 735], + } + ); + assert_eq!( + reference_crop([760, 1233], GalleryPreset::Renderer760), + ReferenceCrop { + origin: [0, 0], + size: [760, 1233], + } + ); + } +} diff --git a/crates/ui_gallery/src/reload.rs b/crates/ui_gallery/src/reload.rs new file mode 100644 index 0000000..2744599 --- /dev/null +++ b/crates/ui_gallery/src/reload.rs @@ -0,0 +1,243 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use editor_ui::theme::{EditorVisualPalette, BLACKSITE_PALETTE}; +use serde::{Deserialize, Serialize}; + +use crate::fixtures::FixtureOverrides; + +const POLL_INTERVAL: Duration = Duration::from_millis(300); + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ThemeOverrides { + pub canvas: Option, + pub panel: Option, + pub panel_dark: Option, + pub widget: Option, + pub elevated: Option, + pub accent: Option, + pub selection: Option, + pub text: Option, + pub text_dim: Option, + pub text_muted: Option, + pub border: Option, + pub border_strong: Option, + pub success: Option, + pub warning: Option, + pub error: Option, +} + +impl ThemeOverrides { + pub fn palette(&self) -> Result { + let mut palette = BLACKSITE_PALETTE; + macro_rules! apply { + ($field:ident) => { + if let Some(value) = self.$field.as_deref() { + palette.$field = parse_color(value)?; + } + }; + } + apply!(canvas); + apply!(panel); + apply!(panel_dark); + apply!(widget); + apply!(elevated); + apply!(accent); + apply!(selection); + apply!(text); + apply!(text_dim); + apply!(text_muted); + apply!(border); + apply!(border_strong); + apply!(success); + apply!(warning); + apply!(error); + Ok(palette) + } +} + +pub struct RuntimeDocuments { + theme: ReloadableDocument, + fixture: ReloadableDocument, + pub theme_value: ThemeOverrides, + pub fixture_value: FixtureOverrides, + pub error: Option, +} + +impl RuntimeDocuments { + pub fn new(root: &Path) -> Self { + Self::from_directory(root.join("crates/ui_gallery/fixtures")) + } + + fn from_directory(directory: PathBuf) -> Self { + Self { + theme: ReloadableDocument::new(directory.join("theme.json")), + fixture: ReloadableDocument::new(directory.join("material.json")), + theme_value: ThemeOverrides::default(), + fixture_value: FixtureOverrides::default(), + error: None, + } + } + + pub fn poll(&mut self, now: f64) -> bool { + let mut changed = false; + match self.theme.poll(now) { + Ok(Some(value)) => { + self.theme_value = value; + self.error = None; + changed = true; + } + Err(error) => self.error = Some(error), + Ok(None) => {} + } + match self.fixture.poll(now) { + Ok(Some(value)) => { + self.fixture_value = value; + self.error = None; + changed = true; + } + Err(error) => self.error = Some(error), + Ok(None) => {} + } + changed + } +} + +struct ReloadableDocument { + path: PathBuf, + modified: Option, + next_poll: f64, + marker: std::marker::PhantomData, +} + +impl ReloadableDocument +where + T: for<'de> Deserialize<'de>, +{ + fn new(path: PathBuf) -> Self { + Self { + path, + modified: None, + next_poll: 0.0, + marker: std::marker::PhantomData, + } + } + + fn poll(&mut self, now: f64) -> Result, String> { + if now < self.next_poll { + return Ok(None); + } + self.next_poll = now + POLL_INTERVAL.as_secs_f64(); + let metadata = fs::metadata(&self.path) + .map_err(|error| format!("{}: {error}", self.path.display()))?; + let modified = metadata.modified().ok(); + if self.modified == modified { + return Ok(None); + } + let bytes = + fs::read(&self.path).map_err(|error| format!("{}: {error}", self.path.display()))?; + let value = serde_json::from_slice(&bytes) + .map_err(|error| format!("{}: {error}", self.path.display()))?; + self.modified = modified; + Ok(Some(value)) + } +} + +fn parse_color(value: &str) -> Result { + let value = value.trim().trim_start_matches('#'); + if value.len() != 6 && value.len() != 8 { + return Err(format!("expected #RRGGBB or #RRGGBBAA, got {value}")); + } + let byte = |offset| { + u8::from_str_radix(&value[offset..offset + 2], 16) + .map_err(|error| format!("invalid color {value}: {error}")) + }; + Ok(egui::Color32::from_rgba_unmultiplied( + byte(0)?, + byte(2)?, + byte(4)?, + if value.len() == 8 { byte(6)? } else { 255 }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temporary_directory() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "blacksite-ui-gallery-reload-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&directory).expect("create temporary fixture directory"); + directory + } + + #[test] + fn runtime_documents_reload_fixture_without_reconstruction() { + let directory = temporary_directory(); + fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture"); + fs::write(directory.join("material.json"), "{}\n").expect("write material fixture"); + let mut documents = RuntimeDocuments::from_directory(directory.clone()); + assert!(documents.poll(0.0)); + assert_eq!(documents.fixture_value.material_label, None); + + fs::write( + directory.join("material.json"), + r#"{"material_label":"LIVE_RELOAD_PROOF"}"#, + ) + .expect("update material fixture"); + assert!(documents.poll(1.0)); + assert_eq!( + documents.fixture_value.material_label.as_deref(), + Some("LIVE_RELOAD_PROOF") + ); + assert_eq!(documents.error, None); + fs::remove_dir_all(directory).expect("remove temporary fixture directory"); + } + + #[test] + fn invalid_reload_keeps_the_last_valid_document_and_reports_the_error() { + let directory = temporary_directory(); + fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture"); + fs::write( + directory.join("material.json"), + r#"{"material_label":"baseline"}"#, + ) + .expect("write material fixture"); + let mut documents = RuntimeDocuments::from_directory(directory.clone()); + assert!(documents.poll(0.0)); + + fs::write(directory.join("material.json"), "{ invalid json\n") + .expect("write invalid material fixture"); + assert!(!documents.poll(1.0)); + assert_eq!( + documents.fixture_value.material_label.as_deref(), + Some("baseline") + ); + assert!(documents + .error + .as_deref() + .is_some_and(|error| error.contains("material.json"))); + fs::remove_dir_all(directory).expect("remove temporary fixture directory"); + } + + #[test] + fn theme_colors_accept_rgb_and_rgba_hex() { + assert_eq!( + parse_color("#41C6CF").expect("valid RGB"), + egui::Color32::from_rgb(65, 198, 207) + ); + assert_eq!( + parse_color("E25E5E80").expect("valid RGBA"), + egui::Color32::from_rgba_unmultiplied(226, 94, 94, 128) + ); + assert!(parse_color("#not-a-color").is_err()); + } +} diff --git a/docs/adr/0049-penpot-led-editor-visual-system.md b/docs/adr/0049-penpot-led-editor-visual-system.md index 72a6c62..9e22511 100644 --- a/docs/adr/0049-penpot-led-editor-visual-system.md +++ b/docs/adr/0049-penpot-led-editor-visual-system.md @@ -75,11 +75,27 @@ Standard Lit declares seven independently stored inputs but six primary presenta restores both values as one interaction without merging their authored/runtime identities. Custom shader binding counts likewise count primary rows while rendering companions through their owner. -Bounded scalar material inputs use one 132 px control composed from a 78 px track, 6 px gap, and -48 x 22 numeric field. Expanding the 178 px Advanced preview requests a bounded +Bounded scalar material inputs use the exported visible-child geometry rather than the narrower +transparent Penpot parent frame. At the 620 px reference that is a 138.8 px group composed from a +6 px leading inset, 93.8 px track, 6 px gap, and 33 x 22 numeric field. At 420 px the track expands +to 160.3 px while the number remains fixed. Expanding the 178 px Advanced preview requests a bounded Inspector scroll reveal so its note and lower boundary are visible. The same no-implicit-persistence rule applies to sliders, texture assignments, and shader controls. +Focused visual iteration uses a standalone `ui_gallery` binary that links the same `editor_ui` and +`material_schema` production crates as the editor. It owns no project, scene, renderer, or gameplay +runtime. Exact reference presets, direct Penpot PNG comparison, runtime fixture/theme reload, and a +managed last-good rebuild/restart loop shorten the edit cycle without creating a second widget +implementation. Gallery evidence is diagnostic until the same component passes a named full-editor +interaction scenario. + +Renderer comparison uses the same rule. The renderer array, renderer identity, property heading, +indexed material heading, and material slots are production `editor_ui` components. A material +slot's asset header and parameter disclosure are related but distinct presentation state: renderer +arrays may keep the complete asset/action header visible while the expensive parameter body is +collapsed. Empty assignments use a first-class empty presentation instead of reusing pending or +failed thumbnail state. + Inspector overflow is part of the shared visual contract. A floating scrollbar keeps component width stable as content crosses the vertical overflow threshold, and every nested component clip is an intersection with the owning Inspector body. Ready texture fields use the typed thumbnail as diff --git a/docs/editor/design-system.md b/docs/editor/design-system.md index 1d154ec..98f1459 100644 --- a/docs/editor/design-system.md +++ b/docs/editor/design-system.md @@ -56,15 +56,14 @@ body. A 32 px **Materials** heading precedes repeated slots. Expanded headers ar expanded. An expanded wide row keeps its label/value/channel geometry fixed while only the texture field grows above the reference width. -| Column | Width | -|--------|-------| -| Parameter label | 82 px | -| Value | 132 px | -| Value/channel gap | 6 px | -| Channel | 48 px | -| Channel/texture gap | 16.5 px | -| Texture field | 198.5 px at the reference width | -| Locate / Clear | 24 px each, separated by 6 px | +| Element | Exported geometry | +|---------|-------------------| +| Parameter label | x12, 82 px | +| Scalar group | x103.35, 138.8 px visible ownership | +| Scalar internals | 6 px leading inset, 93.8 px track, 6 px gap, 33 px number | +| Channel | x242.5, 48 px | +| Texture group | x298.5; 198.5 px field at the reference width | +| Locate / Clear | 24 px each, separated from the field by 6 px | At the 420 px Inspector floor, the slot is 396 px and parameter sections are 369 px. Surface remains 46 px and Inputs is 372 px: each of the six rows uses a 58 px two-line layout with parameter, @@ -72,9 +71,10 @@ value, and channel above a 345 px asset picker. UV reflows from 54 px to 94 px. scrolling, clipped buttons, or height-dependent width changes. These reference dimensions are implemented as owning rectangles, not nested `horizontal` flow. -At 569 px, each input row begins its value at x96, channel at x234, and texture group at x298.5. -At 369 px, the parameter line is `x12/y5/w345` and the texture line is -`x12/y31/w345`. Texture names are clipped and ellipsized inside the field, so content cannot move +At 569 px, a scalar row begins its value group at x103.35, channel at x242.5, and texture group at +x298.5. At 369 px, the scalar group remains at x103.35 and expands to 205.3 px (a 160.3 px track), +the channel begins at x309, and the texture line is `x12/y31/w345`. Texture names are clipped and +ellipsized inside the field, so content cannot move the fixed Locate/Clear anchors. A transient width below 372 px retains finite clipped rectangles and replaces the two low-frequency texture actions with one overflow control until the Inspector minimum is restored. @@ -138,11 +138,44 @@ non-interactive preview labelled `Planned — screen sampling pending`. Every preview updates the live material document. Apply keeps the dirty in-memory value; Cancel, Escape, Close, and outside dismissal restore the exact value captured on open. No picker action -writes source or queues derived processing. Material scalar inputs use the reusable 130 x 22 -Penpot control: a 70 x 14 track, 10 px thumb, 8 px separation, and 48 x 22 numeric field. Expanding +writes source or queues derived processing. At the 620 px reference, Material scalar inputs use a +138.8 x 22 visible group: 6 px leading inset, 93.8 x 10 interaction track with a 3 px painted rail +and 4 px thumb radius, 6 px separation, and a 33 x 22 numeric field. At the 420 px reference only +the track grows, to 160.3 px. Expanding Advanced scrolls its complete 178 px disabled-preview body into view rather than revealing a clipped partial section. +## Focused UI gallery workflow + +Use the lightweight production-component gallery for routine design iteration: + +```bash +python scripts/editor/ui_gallery.py watch +``` + +The gallery links `editor_ui` and `material_schema` directly and does not open a project, scene, +renderer, or gameplay runtime. F1–F4 select Materials 620, Materials 420, Renderer 760, and Renderer +480; Shift+F1–F4 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 +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 +build leaves the last-good process available. + +Renderer presets are not screenshots of a gallery-only approximation. `editor_ui::renderer` owns +the shared renderer-array header, 84/60 px renderer identity headers, 38 px property heading, 50 px +indexed material heading, and slot rail used by both the gallery and the full Static Mesh Renderer +Inspector. Renderer material slots reuse `MaterialSlotPanel`; the first slot may retain its full +82 px identity/action header while only its parameter body is collapsed, secondary slots use the +compact state, and an actually empty slot has an explicit drop/Choose Material presentation. + +Difference statistics are diagnostic, not a standalone acceptance threshold: truthful runtime +semantics and renderer rasterization can intentionally differ from an authored mock. Every such +difference is recorded in the [M2 UI gallery delta matrix](evaluations/content-workspace-m2/ui-gallery-delta-matrix.md). +Final acceptance still requires a paired full-editor capture and interaction scenario. If the +build-storage hard limit blocks a rebuild, keep the current last-good binary and evidence, record +the gate as pending, and resolve storage through the managed lane workflow rather than deleting +individual Cargo files. + ## Review checklist - Verify the 620 px geometry numerically and in a native capture. diff --git a/docs/editor/evaluations/content-workspace-m2/README.md b/docs/editor/evaluations/content-workspace-m2/README.md index cd42d6e..f710f03 100644 --- a/docs/editor/evaluations/content-workspace-m2/README.md +++ b/docs/editor/evaluations/content-workspace-m2/README.md @@ -14,10 +14,12 @@ **Release-candidate commit:** Pending publication commit -**Native interaction acceptance:** **Pass for the workflows recorded below** +**Native interaction acceptance:** **Pass for the recorded content workflows; current Inspector UI acceptance remains in progress** **Pinned Penpot acceptance targets:** [export index](penpot/README.md) +**Current UI comparison record:** [UI gallery delta matrix](ui-gallery-delta-matrix.md) + This feature-level record covers the Content Browser interaction pass added during M2 completion. It does not close the milestone or replace the exact-commit, clean-checkout, import/reimport, forward/Solari, packaging, and independent signoff gates in the M2 plan. diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/README.md b/docs/editor/evaluations/content-workspace-m2/penpot/README.md index 420add8..8d26b91 100644 --- a/docs/editor/evaluations/content-workspace-m2/penpot/README.md +++ b/docs/editor/evaluations/content-workspace-m2/penpot/README.md @@ -4,6 +4,10 @@ The current Penpot README makes pages 01–30 authoritative, pages 90–91 implementation masters only, and page 99 forbidden as current guidance. The `CODEGEN MAP` block is handoff documentation and never renders in Blacksite. +Measured production/reference differences and intentional semantic exceptions are tracked in the +[UI gallery delta matrix](../ui-gallery-delta-matrix.md). A raw Difference score never overrides a +runtime invariant or substitutes for the paired full-editor acceptance capture. + ## Responsive Inspector targets | Export | Penpot page and shape | Required match | diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.png b/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.png new file mode 100644 index 0000000..bbc514d --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/array-header-40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56e4b13c8a5d758b3957b4249d14ef4aa190d134a469f5fbb6ac4a5e08d3603a +size 5604 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.png b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.png new file mode 100644 index 0000000..d8a54ba --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-adjusted.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e66203d7228af4b9e460d876bd0f94d487ec0b2d95e202bdfdaca3451e90d44 +size 39865 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.png b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.png new file mode 100644 index 0000000..13a9fa6 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-presets.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9028e422739d556f1fc3b48f373237699179ba92ed32a16349e46ea49d8e1c4e +size 28260 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.png b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.png new file mode 100644 index 0000000..27c3767 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-sliders.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:198d1c61c382656b544e5c58bfe90af3f260a89cfb5ded8774c9f048410b5438 +size 31937 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.png b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.png new file mode 100644 index 0000000..c5f62c1 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/color-picker-wheel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a4b4bb8d1da91a9f7d92b933f2d3c57777125840c8578e66b156d32a71de6d2 +size 39420 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.png b/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.png new file mode 100644 index 0000000..d067123 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/component-composition-rule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2f12d48302a0255102787c3d5dd4f9d624ead8b573a1ec06dcb5cd156f7b7c4 +size 7509 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.png b/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.png new file mode 100644 index 0000000..fe97f3e --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/component-header-50.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a70df7c633d61f34877e4da6313ab26550644222cb290ee2560099804fc0dcf +size 6140 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.png b/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.png new file mode 100644 index 0000000..f66f006 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/foundation-components-catalog.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74d68b347e942db0d02c8aa7b2c6e46eb0477ab27c7eb1dc5c264976deae5a51 +size 153524 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.png b/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.png new file mode 100644 index 0000000..b7dd970 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/material-components-states.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52628e55834fb1be62edecdcbdc19a69d335bc7891493ae28a9d746cbc262dbc +size 101709 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.png b/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.png new file mode 100644 index 0000000..739c7dd --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/materials-narrow-420.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f01a7850bd945fff8e9a0fb6a5e910b92cc5cc26918f3f523ae073eed5f1a58 +size 73359 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.png b/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.png new file mode 100644 index 0000000..baf4891 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/materials-wide-620.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c680cfb1973f560b0697417add4c142b0770aa31e8734ff3cb38835e59ff082c +size 75333 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.png b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.png new file mode 100644 index 0000000..44408a2 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-narrow-480.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e8ac6d21085be1b53d370667134df148d0e7bcff54a4d70d07978a54713aa28 +size 83303 diff --git a/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.png b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.png new file mode 100644 index 0000000..87b7df0 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/penpot/static-mesh-renderer-wide-760.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bf57919c8271a7b4e47a745a9de771bbe9bf7435624c5d34053d329d9ea1a3b +size 133940 diff --git a/docs/editor/evaluations/content-workspace-m2/ui-gallery-delta-matrix.md b/docs/editor/evaluations/content-workspace-m2/ui-gallery-delta-matrix.md new file mode 100644 index 0000000..830e0f0 --- /dev/null +++ b/docs/editor/evaluations/content-workspace-m2/ui-gallery-delta-matrix.md @@ -0,0 +1,58 @@ +# M2 UI Gallery Delta Matrix + +> **Evidence record — not current product guidance.** Use the [documentation index](../../../README.md) +> for current guidance. The canonical visual contract is the [Editor Design System](../../design-system.md), +> with architecture in [ADR 0049](../../../adr/0049-penpot-led-editor-visual-system.md). + +This matrix distinguishes correctable production drift from deliberate differences between an +authored Penpot mock and a truthful interactive editor. The direct PNG is the visual reference; +the SVG is used to inspect numeric child geometry because it does not embed Source Sans Pro. + +| Area | Penpot reference | Production/gallery truth | Classification | Evidence/state | +|------|------------------|--------------------------|----------------|----------------| +| Materials canvases | 620 x 539 and 420 x 735 | Exact presets render the shared `editor_ui` components | Required match | Geometry/unit tests pass; fresh paired gallery captures and full-editor primitive captures at 620/420 pass | +| Renderer canvases | 760 x 1233 and 480 x 828 | Exact gallery presets use shared renderer/component chrome | Required match | Fresh paired gallery captures cover both widths; the rebuilt full editor now has managed static-renderer captures at 760/480 | +| Renderer hierarchy | 50 px component header, 40 px array header, 84/60 px entry headers, 38 px properties heading, and 50 px indexed-material heading | The gallery and full editor call the same `editor_ui::renderer` view-model/chrome functions | Resolved structural drift | Focused checks pass and the fresh full-editor static renderer shows the same hierarchy without the former duplicate slot presentation | +| Renderer slot states | Full primary slot, compact secondary slot, explicit unassigned drop target, and collapsed sibling renderers | The shared material panel supports a full header with collapsed parameters and a first-class empty thumbnail/Choose Material action | Resolved structural drift | Fresh 760/480 gallery pairs show all states without gallery-only material cards | +| Scalar geometry | 6 px inset, 93.8/160.3 px track, 6 px gap, 33 px number | Same exported child coordinates at 620/420 | Resolved drift | Focused tests and 620 overlay pass | +| Scalar sample value | Text reads `0.72`; thumb is positioned near 0.55 | Thumb position is computed from the displayed/runtime value | Intentional semantic delta | Keep truthful 0.72 normalization | +| ORM channels | ORM selected while AO, Roughness, and Metallic sample glyphs all read G | Canonical ORM is AO=R, Roughness=G, Metallic=B | Intentional semantic delta | Runtime/schema invariant and fixture test | +| Material preview | Authored static sample sphere | Gallery uses a deterministic fixture approximation; full editor uses the real typed thumbnail | Intentional content delta | Full-editor thumbnail was observed pending and then resolved in place without layout movement | +| Texture identity | Mock fields use generic/synthetic asset art | Ready project textures show their typed thumbnail without a redundant generic icon | Intentional product delta | Texture-field test passes | +| Borders/rasterization | Browser-rendered SVG strokes can antialias across pixel boundaries | egui uses deterministic inside strokes | Renderer-level delta | Do not alter semantic colors or geometry to mimic browser antialiasing | +| Typography | Direct PNG preserves Source Sans Pro | Production bundles Source Sans Pro Regular/Bold and maps explicit 400/700 families | Required match | Font/type-role tests and fresh gallery/full-editor captures pass | +| Difference metric | Raw per-pixel comparison includes all sample and raster deltas | Latest Materials 620 diagnostic: mean 7.41, max 231, 22.8% of pixels above 8 | Diagnostic only | Must be interpreted through this matrix, never used alone for acceptance | +| Runtime reload | Not applicable to the design mock | Theme and material fixture JSON reload without rebuild/restart | Framework requirement | Focused reload tests pass | +| Rebuild/restart | Not applicable to the design mock | Focused managed build reports bounded progress; replacement launches before last-good exits; failures preserve last-good | Framework requirement | Eight workflow tests pass | + +## Remaining acceptance + +- Primitive Materials pass full-editor 620/420 and deep-scroll containment. The rebuilt static + renderer passes the native 760/480 hierarchy and containment check. The current checked-in scene + has no placed skinned actor, so skinned presentation remains proven through the shared production + component, renderer gallery fixture, focused tests, and editor compile rather than a separate + native actor capture. +- The broad `penpot-material-inspector` scenario intentionally remains incomplete because picker, + color, invalid-drop, dirty/save/relaunch, and broken-reference assertions are outside this gallery + framework boundary. Its fixture was restored byte-for-byte after the two managed integration + captures. +- Attach the paired reference/native evidence to #68 at the next authorized tracker synchronization. + Keep #68, #59, #65, and M2 in Acceptance-in-progress; this framework slice is evidence for the + next visual pass, not permission to publish or close the milestone. + +## Fresh local evidence — 2026-07-18 10:20 UTC + +- Materials 620: `ui-gallery-materials-620-20260718T102015Z.png` + (`cc918c567522204f9dc8d8f179e24e49ba802fe03a81d3a65fa1e5b6058623c9`) +- Materials 420: `ui-gallery-materials-420-20260718T102015Z.png` + (`70dea081df62ab38d589ea559d73a6e8d3685f26fee54dbc87cc8682ebed8474`) +- Renderer 760: `ui-gallery-renderer-760-20260718T102015Z.png` + (`724d9cedf25753de9315993f9e6ea656949f54e5b54c44f1f8ab46f1db88b0d6`) +- Renderer 480: `ui-gallery-renderer-480-20260718T102015Z.png` + (`f90c3f72b757649f5c11362ed6d776e5e85288751ffd74f08f8cc386f162fc4f`) +- Full-editor primitive Inspector 620, 420, and deep-scroll captures are recorded under the same + local `.codex/evidence/` store with timestamp `20260718T095413Z`. +- Full-editor static renderer 480: `penpot-material-inspector-20260718T104504Z.png` + (`3c5c6585822afffa6c29994aa757adf645c81d653e369e059bbee08c26c1aa26`) +- Full-editor static renderer 760: `penpot-material-inspector-20260718T104550Z.png` + (`95ef7366d1671ac494cb5fd389d3332d51a0d5636cd0a02f66e9faf0399eb82f`) diff --git a/docs/editor/material-system.md b/docs/editor/material-system.md index 39b6b0b..187bcf0 100644 --- a/docs/editor/material-system.md +++ b/docs/editor/material-system.md @@ -181,6 +181,17 @@ section stacks the texture field beneath that line. A bounded transient fallback Locate/Clear into overflow instead of allowing negative or out-of-clip geometry. Slot IDs, source paths, fingerprints, and usage counts are diagnostics rather than material parameters. +Routine visual iteration uses `python scripts/editor/ui_gallery.py watch`, which renders these same +production components against the direct 620/420 Penpot exports without loading material runtime +state. The gallery is diagnostic; final behavior and persistence acceptance still runs in the full +editor. Intentional mock/runtime differences are listed in the +[M2 UI gallery delta matrix](evaluations/content-workspace-m2/ui-gallery-delta-matrix.md). + +In renderer arrays, collapsing Parameters does not have to erase material identity or assignment +actions. The shared slot can retain its complete asset header while the parameter body is closed; +secondary compact rows and genuinely empty drop targets are explicit states of that same component, +not separate renderer-only material widgets. + Base Color is a multiplier over its albedo texture. Metallic and Roughness are scalar multipliers over their selected channel; Occlusion is a scalar texture input. The **ARM / glTF ORM** preset uses R=ambient occlusion, G=roughness, and B=metallic. **Separate Maps** samples each map's R channel, diff --git a/scripts/codex/native_qa.sh b/scripts/codex/native_qa.sh index c802406..e43cdc8 100755 --- a/scripts/codex/native_qa.sh +++ b/scripts/codex/native_qa.sh @@ -24,6 +24,10 @@ field() { LANE="$(field build_lane)" [[ -n "$LANE" ]] || LANE="dev" +PACKAGE="$(field package)" +[[ -n "$PACKAGE" ]] || PACKAGE="editor" +BINARY_NAME="$(field binary)" +[[ -n "$BINARY_NAME" ]] || BINARY_NAME="editor" FIXTURE="$(field fixture)" mkdir -p "$STATE_ROOT" "$EVIDENCE_ROOT" @@ -102,14 +106,15 @@ PY case "$ACTION" in plan) - LANE_JSON="$(lane_json)" python - "$SCENARIO" "$FIXTURE" <<'PY' + LANE_JSON="$(lane_json)" PACKAGE="$PACKAGE" BINARY_NAME="$BINARY_NAME" python - "$SCENARIO" "$FIXTURE" <<'PY' import json, os, pathlib, sys layout = json.loads(os.environ["LANE_JSON"]) scenario = pathlib.Path(sys.argv[1]) fixture = sys.argv[2] -binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / "editor" +binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / os.environ["BINARY_NAME"] print(f"PASS native-qa-plan — {scenario.stem}") print(f"Lane: {layout['lane']} ({layout['mode']})") +print(f"Package: {os.environ['PACKAGE']}") print(f"Binary: {binary}") print(f"Runtime deps: {layout['runtime_deps']}") print(f"Fixture: {fixture or 'scenario-defined'}") @@ -131,10 +136,14 @@ PY --gate "native-$NAME-build" \ --log "$ROOT/.codex/logs/native-$NAME-build.log" \ --json-result "$ROOT/.codex/logs/native-$NAME-build.json" \ - -- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p editor --bin editor + -- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p "$PACKAGE" --bin "$BINARY_NAME" python "$ROOT/scripts/codex/build_storage.py" enforce --phase post + LAUNCH_ARGS=() + if [[ "$BINARY_NAME" = "editor" ]]; then + LAUNCH_ARGS=(--project "$ROOT") + fi nohup python "$ROOT/scripts/codex/cargo_lane.py" run "$LANE" -- \ - target/debug/editor --project "$ROOT" \ + "target/debug/$BINARY_NAME" "${LAUNCH_ARGS[@]}" \ >"$ROOT/.codex/logs/native-$NAME-launch.log" 2>&1 & PID=$! python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' @@ -151,7 +160,7 @@ PY PID="$3" kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; } CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" - [[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; } + [[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; } snapshot_fixture python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY' import json, pathlib, sys, time @@ -328,7 +337,7 @@ PY SPAWNED="$(python -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("spawned_by_runner"))).lower())' "$RECORD")" if [[ "$SPAWNED" = "true" ]] && kill -0 "$PID" 2>/dev/null; then CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")" - [[ "$CMDLINE" == *"editor"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; } + [[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; } kill -TERM "$PID" fi python - "$RECORD" <<'PY' diff --git a/scripts/editor/test_ui_gallery.py b/scripts/editor/test_ui_gallery.py new file mode 100755 index 0000000..b9fdda8 --- /dev/null +++ b/scripts/editor/test_ui_gallery.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("ui_gallery.py") +SPEC = importlib.util.spec_from_file_location("ui_gallery", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +UI_GALLERY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(UI_GALLERY) + + +class UiGalleryWorkflowTests(unittest.TestCase): + def test_compiler_artifact_reports_identity_target_and_freshness(self) -> None: + message = { + "reason": "compiler-artifact", + "package_id": "path+file:///workspace#editor_ui@0.1.0", + "target": {"name": "editor_ui"}, + "fresh": True, + } + self.assertEqual( + UI_GALLERY.compiler_artifact(message), + ( + "path+file:///workspace#editor_ui@0.1.0", + "editor_ui", + True, + ), + ) + + def test_compiler_artifact_rejects_non_artifact_messages(self) -> None: + self.assertIsNone(UI_GALLERY.compiler_artifact({"reason": "build-finished"})) + + def test_bounded_output_rejects_oversized_machine_documents(self) -> None: + with self.assertRaises(RuntimeError): + UI_GALLERY.bounded_command_output( + [ + "python3", + "-c", + "import sys; sys.stdout.write('x' * 4097)", + ], + 4096, + ) + + def test_bounded_output_returns_small_json(self) -> None: + document = {"packages": [], "resolve": {"nodes": []}} + output = UI_GALLERY.bounded_command_output( + ["python3", "-c", f"print({json.dumps(json.dumps(document))})"], + 4096, + ) + self.assertEqual(json.loads(output), document) + + def test_source_snapshot_tracks_only_rust_and_manifests(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "src" + source.mkdir() + (source / "panel.rs").write_text("fn panel() {}\n", encoding="utf-8") + (source / "theme.json").write_text("{}\n", encoding="utf-8") + original_roots = UI_GALLERY.WATCH_ROOTS + original_files = UI_GALLERY.WATCH_FILES + try: + UI_GALLERY.WATCH_ROOTS = (source,) + UI_GALLERY.WATCH_FILES = () + snapshot = UI_GALLERY.source_snapshot() + finally: + UI_GALLERY.WATCH_ROOTS = original_roots + UI_GALLERY.WATCH_FILES = original_files + self.assertEqual(list(snapshot), [source / "panel.rs"]) + + def test_build_graph_cache_requires_matching_manifest_key(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + cache = Path(temporary) / "graph.json" + original = UI_GALLERY.BUILD_GRAPH_CACHE + UI_GALLERY.BUILD_GRAPH_CACHE = cache + try: + UI_GALLERY.save_build_graph("current", {"package-a", "package-b"}) + self.assertEqual( + UI_GALLERY.cached_build_graph("current"), + {"package-a", "package-b"}, + ) + self.assertIsNone(UI_GALLERY.cached_build_graph("stale")) + finally: + UI_GALLERY.BUILD_GRAPH_CACHE = original + + def test_failed_rebuild_preserves_the_last_good_gallery(self) -> None: + current = mock.Mock() + with ( + mock.patch.object(UI_GALLERY, "build_gallery", return_value=1), + mock.patch.object(UI_GALLERY, "launch_gallery") as launch, + mock.patch.object(UI_GALLERY, "stop_gallery") as stop, + ): + replacement = UI_GALLERY.rebuild_and_replace_gallery(current) + self.assertIs(replacement, current) + launch.assert_not_called() + stop.assert_not_called() + + def test_successful_rebuild_launches_before_retiring_the_last_good_gallery(self) -> None: + current = mock.Mock() + next_gallery = mock.Mock() + order: list[str] = [] + with ( + mock.patch.object(UI_GALLERY, "build_gallery", return_value=0), + mock.patch.object( + UI_GALLERY, + "launch_gallery", + side_effect=lambda: order.append("launch") or next_gallery, + ), + mock.patch.object( + UI_GALLERY, + "stop_gallery", + side_effect=lambda process: order.append("stop"), + ) as stop, + ): + replacement = UI_GALLERY.rebuild_and_replace_gallery(current) + self.assertIs(replacement, next_gallery) + self.assertEqual(order, ["launch", "stop"]) + stop.assert_called_once_with(current) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/editor/ui_gallery.py b/scripts/editor/ui_gallery.py new file mode 100755 index 0000000..926cd42 --- /dev/null +++ b/scripts/editor/ui_gallery.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Build, launch, and automatically restart the lightweight Blacksite UI gallery.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +from typing import Any, Iterable, Sequence + + +ROOT = Path(__file__).resolve().parents[2] +CARGO_LANE = ROOT / "scripts/codex/cargo_lane.py" +BUILD_STORAGE = ROOT / "scripts/codex/build_storage.py" +LOG_DIR = ROOT / ".codex/logs" +BUILD_GRAPH_CACHE = LOG_DIR / "ui-gallery-build-units.json" +METADATA_LIMIT_BYTES = 32 * 1024 * 1024 +POLL_SECONDS = 0.25 +DEBOUNCE_SECONDS = 0.35 +WATCH_ROOTS = ( + ROOT / "crates/editor_ui/src", + ROOT / "crates/material_schema/src", + ROOT / "crates/ui_gallery/src", +) +WATCH_FILES = ( + ROOT / "Cargo.toml", + ROOT / "Cargo.lock", + ROOT / "crates/editor_ui/Cargo.toml", + ROOT / "crates/material_schema/Cargo.toml", + ROOT / "crates/ui_gallery/Cargo.toml", +) + + +def managed_command(*command: str) -> list[str]: + return [sys.executable, str(CARGO_LANE), "exec", "dev", *command] + + +def storage_enforce(phase: str) -> int: + return subprocess.run( + [sys.executable, str(BUILD_STORAGE), "enforce", "--phase", phase], + cwd=ROOT, + check=False, + ).returncode + + +def host_target() -> str: + completed = subprocess.run( + ["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 bounded_command_output(command: Sequence[str], limit: int) -> bytes: + """Capture a known-small machine document without ever buffering unbounded stdout.""" + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + with subprocess.Popen( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=errors, + ) as process: + assert process.stdout is not None + size = 0 + while chunk := process.stdout.read(64 * 1024): + size += len(chunk) + if size > limit: + process.kill() + process.wait() + raise RuntimeError( + f"machine output exceeded the {limit // (1024 * 1024)} MiB safety cap" + ) + output.write(chunk) + return_code = process.wait() + if return_code != 0: + errors.seek(0) + detail = errors.read()[-4000:].decode("utf-8", errors="replace") + raise RuntimeError(f"command failed ({return_code}): {detail}") + output.seek(0) + return output.read() + + +def build_graph_key() -> str: + digest = hashlib.sha256() + for path in WATCH_FILES: + if path.is_file(): + digest.update(path.relative_to(ROOT).as_posix().encode("utf-8")) + digest.update(path.read_bytes()) + digest.update(host_target().encode("utf-8")) + return digest.hexdigest() + + +def cached_build_graph(key: str) -> set[str] | None: + try: + document = json.loads(BUILD_GRAPH_CACHE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + package_ids = document.get("package_ids") + if document.get("key") != key or not isinstance(package_ids, list): + return None + if not all(isinstance(package_id, str) for package_id in package_ids): + return None + return set(package_ids) + + +def save_build_graph(key: str, package_ids: set[str]) -> None: + BUILD_GRAPH_CACHE.parent.mkdir(parents=True, exist_ok=True) + BUILD_GRAPH_CACHE.write_text( + json.dumps( + {"key": key, "package_ids": sorted(package_ids)}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def compiler_artifact(message: object) -> tuple[str, str, bool] | None: + if not isinstance(message, dict) or message.get("reason") != "compiler-artifact": + return None + package_id = message.get("package_id") + if not isinstance(package_id, str): + return None + target = message.get("target") + if isinstance(target, dict) and isinstance(target.get("name"), str): + target_name = target["name"] + else: + target_name = package_id.rsplit("#", 1)[-1] + return package_id, target_name, bool(message.get("fresh", False)) + + +def diagnostic_lines(message: object, raw_line: str) -> Iterable[str]: + if not isinstance(message, dict): + yield raw_line + return + if message.get("reason") == "compiler-message": + diagnostic = message.get("message") + if isinstance(diagnostic, dict): + rendered = diagnostic.get("rendered") + if isinstance(rendered, str) and rendered: + yield rendered.rstrip("\n") + + +def build_gallery() -> int: + graph_key = build_graph_key() + graph = cached_build_graph(graph_key) + total = len(graph) if graph else None + LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = LOG_DIR / "ui-gallery-build.log" + command = managed_command( + "cargo", + "build", + "-p", + "ui_gallery", + "--message-format=json-render-diagnostics", + "--color", + "never", + ) + process = subprocess.Popen( + command, + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + assert process.stdout is not None + completed: set[str] = set() + rebuilt: set[str] = set() + last_progress_bucket = -1 + with log_path.open("w", encoding="utf-8") as log: + for raw_line in process.stdout: + stripped = raw_line.rstrip("\n") + try: + message: object = json.loads(stripped) + except json.JSONDecodeError: + message = stripped + for diagnostic in diagnostic_lines(message, stripped): + log.write(diagnostic + "\n") + log.flush() + artifact = compiler_artifact(message) + if artifact is None: + continue + package_id, target_name, fresh = artifact + if not fresh: + rebuilt.add(package_id) + if package_id in completed: + continue + completed.add(package_id) + if graph is None: + in_graph = len(completed) + if in_graph != 1 and in_graph % 5 != 0: + continue + counter = f"{in_graph}/? discovering · {len(rebuilt)} rebuilt" + else: + in_graph = len(completed.intersection(graph)) + percent = min(100, (in_graph * 100) // max(1, total)) + progress_bucket = percent // 5 + if progress_bucket == last_progress_bucket and in_graph != total: + continue + last_progress_bucket = progress_bucket + counter = f"{min(in_graph, total)}/{total} resolved · {len(rebuilt)} rebuilt" + print( + f"\rResolving ui_gallery ({counter}) {target_name:<36}", + end="", + flush=True, + ) + return_code = process.wait() + print() + if return_code == 0: + save_build_graph(graph_key, completed) + expected = len(graph) if graph else len(completed) + resolved = len(completed.intersection(graph)) if graph else len(completed) + print( + f"ui_gallery ready ({resolved}/{expected} resolved · " + f"{len(rebuilt)} rebuilt)" + ) + else: + print(f"ui_gallery build failed; see {log_path}", file=sys.stderr) + return return_code + + +def lane_runtime_environment() -> dict[str, str]: + completed = subprocess.run( + [sys.executable, str(CARGO_LANE), "env", "--json", "dev"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + check=True, + ) + document = json.loads(completed.stdout) + environment = os.environ.copy() + environment.update(document.get("environment", {})) + runtime_deps = document.get("runtime_deps") + if isinstance(runtime_deps, str) and runtime_deps: + previous = environment.get("LD_LIBRARY_PATH") + environment["LD_LIBRARY_PATH"] = ( + runtime_deps if not previous else runtime_deps + os.pathsep + previous + ) + return environment + + +def gallery_binary() -> Path: + completed = subprocess.run( + [sys.executable, str(CARGO_LANE), "env", "--json", "dev"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + check=True, + ) + document = json.loads(completed.stdout) + return Path(document["target_dir"]) / "debug/ui_gallery" + + +def launch_gallery() -> subprocess.Popen[bytes]: + binary = gallery_binary() + if not binary.is_file(): + raise RuntimeError(f"gallery binary is missing after build: {binary}") + return subprocess.Popen([str(binary)], cwd=ROOT, env=lane_runtime_environment()) + + +def stop_gallery(process: subprocess.Popen[bytes] | None) -> None: + if process is None or process.poll() is not None: + return + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def rebuild_and_replace_gallery( + current: subprocess.Popen[bytes] | None, +) -> subprocess.Popen[bytes] | None: + """Keep the last-good gallery alive until its replacement has built and launched.""" + if build_gallery() != 0: + return current + replacement = launch_gallery() + stop_gallery(current) + return replacement + + +def source_snapshot() -> dict[Path, int]: + files = list(WATCH_FILES) + for root in WATCH_ROOTS: + if root.is_dir(): + files.extend(root.rglob("*.rs")) + return { + path: path.stat().st_mtime_ns + for path in files + if path.is_file() + } + + +def watch() -> int: + if storage_enforce("pre") != 0: + return 1 + child: subprocess.Popen[bytes] | None = None + try: + if build_gallery() != 0: + return 1 + child = launch_gallery() + print("Watching material_schema, editor_ui, and ui_gallery Rust sources (Ctrl+C to stop)") + snapshot = source_snapshot() + changed_at: float | None = None + while True: + time.sleep(POLL_SECONDS) + current = source_snapshot() + if current != snapshot: + snapshot = current + changed_at = time.monotonic() + if changed_at is None or time.monotonic() - changed_at < DEBOUNCE_SECONDS: + continue + changed_at = None + child = rebuild_and_replace_gallery(child) + except KeyboardInterrupt: + print("\nStopping ui_gallery") + return 0 + finally: + stop_gallery(child) + storage_enforce("post") + + +def run_once(*, build_only: bool) -> int: + if storage_enforce("pre") != 0: + return 1 + try: + result = build_gallery() + if result != 0 or build_only: + return result + return launch_gallery().wait() + finally: + storage_enforce("post") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "command", + choices=("watch", "run", "build"), + nargs="?", + default="watch", + ) + arguments = parser.parse_args() + if arguments.command == "watch": + return watch() + return run_once(build_only=arguments.command == "build") + + +if __name__ == "__main__": + raise SystemExit(main())