Implement stable serialization and restoration for dock layouts, including auto-hidden and floating panels
- Add `persistence.rs` for stable serialization of dock layouts without runtime identifiers. - Introduce `preview.rs` for geometry handling of dock drop previews. - Create `sizing.rs` to manage content-aware sizing policies and constraint solving for dock panels. - Implement `welcome.rs` to provide a welcome surface when no documents are open, featuring New and Open actions.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
# ICED Regressions and VS-Style Dock Manager Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Fix the three reported interaction regressions and introduce a modern Visual Studio / DevExpress-style dock manager around the existing Iced `PaneGrid`. The fixed toolbox/sidebar remain outside the dock system. Tool panels can move, split, auto-hide, float inside the application viewport, and redock. Documents remain in the center document area and cannot dock to tool-panel edges.
|
||||
|
||||
Reference images:
|
||||
|
||||
- `/home/dev-user/Pictures/Screenshots/vs-like-panels.png`
|
||||
- `/home/dev-user/Pictures/Screenshots/vs-like-panels-minimized.png`
|
||||
|
||||
All implementation stays in `hcie-iced-app/crates/hcie-iced-gui`; locked engine crates and protected shader upload/rendering mechanisms remain unchanged.
|
||||
|
||||
## Phase 1: Regression Fixes
|
||||
|
||||
### 1. Last-document close opens the welcome/new-image state
|
||||
|
||||
Files:
|
||||
|
||||
- `src/app.rs`
|
||||
- `src/dock/view.rs`
|
||||
- `tests/feature_scorecard.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Replace `DocumentCloseDisposition::Window` with `Welcome`.
|
||||
2. Resolve modified state before final-document behavior so the last dirty document still receives Save / Discard / Cancel.
|
||||
3. Change `close_document_tab` so closing the final document creates a clean placeholder document, resets active document/vector/filter transient state, and opens `ActiveDialog::NewImage` instead of closing the native window.
|
||||
4. Keep the application process and fixed shell alive after Save or Discard closes the final document.
|
||||
5. Update the title/document strip to show the centered welcome/new-image state without exposing the discarded image beneath it.
|
||||
6. Add tests for clean-last-tab, dirty-last-tab Save, Discard, Cancel, multiple-tab index safety, and native window-close remaining distinct from tab close.
|
||||
|
||||
### 2. Responsive realtime vector transforms
|
||||
|
||||
Files:
|
||||
|
||||
- `src/app.rs`
|
||||
- `src/vector_edit.rs`
|
||||
- `tests/feature_scorecard.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add `vector_drag_last_render: Option<Instant>` and a 16 ms frame-cadence helper.
|
||||
2. On every pointer move, calculate the transformed shape from the immutable drag snapshot and update the public mutable shape collection immediately. This keeps overlay bounds and handles responsive without waiting for rerasterization.
|
||||
3. At most once per frame cadence, route the current bounds and angle through `set_vector_shape_bounds` and `set_vector_shape_angle`, then call `refresh_composite_if_needed`. These public setters mark the vector layer dirty and rerasterize actual vector content.
|
||||
4. On release, always perform one final dirtying bounds/angle update and composite refresh before clearing the drag session.
|
||||
5. Avoid duplicate setters when the bounds/angle did not change beyond a small epsilon.
|
||||
6. Preserve Shift aspect lock, Alt center-resize, rotation math, minimum dimensions, and final modified state.
|
||||
7. Add pure tests for frame throttling and final commit, plus a source/integration assertion that direct preview mutation is paired with periodic and final dirtying setters.
|
||||
|
||||
### 3. Right-click selects secondary color
|
||||
|
||||
Files:
|
||||
|
||||
- `src/color_picker.rs`
|
||||
- `src/sidebar/mod.rs`
|
||||
- `src/app.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add `.on_right_press(Message::BgColorChanged(...))` to recent colors, hue cells, saturation/lightness cells, palette-grid swatches, and other selectable color cells.
|
||||
2. Preserve alpha instead of forcing `255` when selecting from HSL/wheel controls.
|
||||
3. Make the main foreground/background swatches open the shared color popup for their corresponding target; right-clicking a normal palette swatch updates only secondary color.
|
||||
4. Add visible primary and secondary selection indicators and tooltips stating left-click/right-click behavior.
|
||||
5. Add tests proving left and right actions route to distinct color state and selected-vector synchronization remains realtime.
|
||||
|
||||
## Phase 2: Dock Manager Domain Model
|
||||
|
||||
Add files:
|
||||
|
||||
- `src/dock/manager.rs`
|
||||
- `src/dock/persistence.rs`
|
||||
|
||||
Update files:
|
||||
|
||||
- `src/dock/mod.rs`
|
||||
- `src/dock/state.rs`
|
||||
- `src/settings.rs`
|
||||
|
||||
### Roles and restrictions
|
||||
|
||||
Define:
|
||||
|
||||
- `DockRole::Document`: the canvas/document host only.
|
||||
- `DockRole::Tool`: Layers, History, Brushes, Filters, Color Palette, Properties, Script, AI, Geometry, details, and settings.
|
||||
- `DockRole::Fixed`: toolbox and fixed sidebar regions, which never enter `PaneGrid`.
|
||||
- `DockEdge`: Left, Right, Top, Bottom.
|
||||
- `DockPlacement`: Docked, AutoHidden(edge), Floating(rect), Closed.
|
||||
- `DockDropZone`: OuterEdge, PaneEdge, Center.
|
||||
- `DockDragState`: source pane/type, pointer, candidate target, accepted preview rectangle.
|
||||
|
||||
Policy:
|
||||
|
||||
1. Tool panels may drop on outer edges or another pane's edge.
|
||||
2. Tool panels may not drop on a pane center; center targets are rejected and never previewed.
|
||||
3. Documents remain in the center document host. Document-tab dragging may reorder center tabs but may not create side splits, float, or auto-hide.
|
||||
4. Canvas/document host is unique, cannot close, auto-hide, float, or start a PaneGrid drag.
|
||||
5. Toolbox and fixed sidebar remain outside dock calculations and cannot be dragged.
|
||||
6. Every accepted mutation must preserve exactly one Canvas pane and one instance per tool-panel type.
|
||||
|
||||
### Persistence
|
||||
|
||||
Create serializable stable DTOs independent of runtime `Pane` IDs:
|
||||
|
||||
- `DockLayoutNode::{Split { axis, ratio, first, second }, Panel(PaneType)}`
|
||||
- `PersistedFloatingPanel { panel, rect }`
|
||||
- `PersistedDockLayout { tree, auto_hidden, floating, focused }`
|
||||
|
||||
Store it under `PanelLayout` with `#[serde(default)]` compatibility. Restore ratios with safe clamping, ignore unknown/duplicate panels, and inject Canvas if missing. Persist after accepted drop, resize end, auto-hide, float, close, and restore operations.
|
||||
|
||||
## Phase 3: Accepted-Target Preview Layer
|
||||
|
||||
Add file:
|
||||
|
||||
- `src/dock/preview.rs`
|
||||
|
||||
Update files:
|
||||
|
||||
- `src/app.rs`
|
||||
- `src/dock/view.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Track global pointer position while `DragEvent::Picked` is active.
|
||||
2. Derive actual logical pane rectangles from `pane_grid.layout().pane_regions(...)`, accounting for title bar, toolbar, status bar, fixed toolbox width, rail widths, and scale-independent logical coordinates.
|
||||
3. Convert pointer position to a policy-approved target and preview rectangle.
|
||||
4. Make PaneGrid's built-in hovered-region fill transparent while the custom preview is active, preventing invalid center previews.
|
||||
5. Render a modern drop compass near the hovered panel plus outer-edge targets. Use accent-blue icons, translucent accepted-region fill, a crisp 2px border, and a disabled visual for prohibited center targets.
|
||||
6. On `DragEvent::Dropped`, ignore Iced's raw target unless it equals the current approved target. Apply only through `DockManager::drop_panel`.
|
||||
7. Cancel cleanly when dropped outside accepted zones; retain the original layout.
|
||||
8. Add policy and geometry tests for every source/target/edge combination and narrow-window clamping.
|
||||
|
||||
## Phase 4: Modern Panel Chrome
|
||||
|
||||
Update files:
|
||||
|
||||
- `src/dock/view.rs`
|
||||
- `src/panels/styles.rs`
|
||||
- `src/theme.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Replace Photoshop-style headers with modern compact desktop headers: 28-30px height, panel icon, 12-13px title, focus accent, subtle separator, and clear hover/pressed states.
|
||||
2. Add title controls with SVG/tooltips: auto-hide pin, float/restore, maximize, close.
|
||||
3. Make the entire non-control header area the drag handle; controls must not begin a drag.
|
||||
4. Use 1px split lines with larger invisible resize hit areas and accent hover feedback.
|
||||
5. Use consistent panel minimum sizes and clamp split ratios so narrow panels do not collapse into vertical text.
|
||||
6. Keep document chrome visually distinct: center tab well, active-document accent, modified marker, close button, and no panel pin/float controls.
|
||||
|
||||
## Phase 5: Auto-Hide Rails
|
||||
|
||||
Add file:
|
||||
|
||||
- `src/dock/auto_hide.rs`
|
||||
|
||||
Update files:
|
||||
|
||||
- `src/app.rs`
|
||||
- `src/dock/state.rs`
|
||||
- `src/dock/view.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Pin control removes a tool pane from PaneGrid and adds a vertical/horizontal tab to the selected edge rail.
|
||||
2. Rails are fixed around the dock area, inside the application shell but outside PaneGrid. They do not displace the fixed toolbox.
|
||||
3. Rail tabs use panel icon plus rotated/vertical title where supported; prefer readable icon-first compact tabs over character-by-character wrapping.
|
||||
4. Clicking or hovering a rail tab opens a temporary opaque panel overlay from that edge with shadow and border, matching the reference minimized screenshot.
|
||||
5. Clicking outside, pressing Escape, or activating another panel closes the temporary overlay without changing its auto-hide placement.
|
||||
6. Pinning the temporary panel restores it to its last dock target and ratio.
|
||||
7. Auto-hide rail and overlay bounds remain responsive to window resize.
|
||||
|
||||
## Phase 6: Floating Panels Within the Application Viewport
|
||||
|
||||
Add file:
|
||||
|
||||
- `src/dock/floating.rs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Float control removes a tool pane from PaneGrid and renders it as a high-z-order card over the dock workspace.
|
||||
2. Floating card headers support pointer dragging with a deadband, viewport clamping, focus/z-order, close, pin/redock, and resize handles.
|
||||
3. While dragging a floating panel over the dock, show the same accepted drop compass and preview. Releasing on an accepted target redocks it; otherwise preserve floating position.
|
||||
4. Floating panels cannot cover the application title/menu bar permanently; clamp enough header area onscreen for recovery.
|
||||
5. Documents and fixed toolbox/sidebar cannot float.
|
||||
6. Persist floating rectangles in logical pixels and clamp them when restored on a smaller monitor.
|
||||
|
||||
## Phase 7: Panel Content Reuse and Document Center Behavior
|
||||
|
||||
Refactor `src/dock/view.rs`:
|
||||
|
||||
1. Extract `panel_body(PaneType, &HcieIcedApp) -> Element` so docked, auto-hidden, and floating containers render exactly the same panel implementation.
|
||||
2. Keep Canvas rendering exclusively in the unique center PaneGrid pane to preserve shader texture lifetime and partial uploads.
|
||||
3. Add center-only document-tab drag/reorder state. A dragged document previews only within the document tab strip/center host and cannot generate side target previews.
|
||||
4. Empty/final-close center host shows the welcome/new-image state while retaining the shell, dock panels, toolbox, and menu.
|
||||
5. Ensure auto-hide/floating overlays do not consume canvas wheel/drawing events outside their visible rectangles.
|
||||
|
||||
## Phase 8: Validation
|
||||
|
||||
### Automated tests
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo check -p hcie-iced-gui --all-targets
|
||||
cargo test -p hcie-iced-gui
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Required new tests:
|
||||
|
||||
- Last clean/dirty document close never exits the app.
|
||||
- Native window close still exits after confirmation.
|
||||
- Vector preview updates every event, raster refresh is frame-throttled, and release forces final commit.
|
||||
- Right-click routes palette color to secondary only.
|
||||
- Dock policy matrix rejects tool-center and every document-edge/floating/auto-hide action.
|
||||
- Canvas uniqueness survives all drops, close, float, auto-hide, restore, and malformed persisted layouts.
|
||||
- Split ratios and floating rectangles clamp under small windows.
|
||||
- Persisted layouts round-trip without runtime Pane IDs.
|
||||
|
||||
### Visual/interaction evidence
|
||||
|
||||
Capture and inspect:
|
||||
|
||||
- Default 1280x800 layout.
|
||||
- 1024x600 compact layout.
|
||||
- Tool panel dragged over all five target zones, showing only accepted edge previews.
|
||||
- Outer-edge split preview and resulting layout.
|
||||
- Auto-hidden right rail and expanded temporary panel matching the references.
|
||||
- Floating Layers and Properties panels, then redocked.
|
||||
- Document drag showing center-only behavior.
|
||||
- Final document closed to welcome/new-image state.
|
||||
- Vector before, during, and after move/rotate with responsive content.
|
||||
- Palette left-click foreground and right-click secondary indicators.
|
||||
|
||||
Store screenshots under `target/screenshots/dock-manager/` and include before/after paths in the completion report.
|
||||
|
||||
## Non-Negotiable Protections
|
||||
|
||||
- Do not modify locked engine crates.
|
||||
- Do not change persistent WGPU texture ownership, partial dirty uploads, viewport boundary mapping, nearest-neighbor sampling, or the `RefCell` dirty-bounds accumulator.
|
||||
- Do not move toolbox/sidebar into PaneGrid.
|
||||
- Do not permit more than one Canvas pane.
|
||||
- Do not render tool panels into document center drops or documents into tool-panel splits.
|
||||
- Do not accept a drop that was not shown as an approved preview.
|
||||
@@ -2,7 +2,7 @@
|
||||
use eframe::egui;
|
||||
use eframe::egui::Color32;
|
||||
use egui::ColorImage;
|
||||
use hcie_brush_engine::{BrushStyle, presets::BrushPreset};
|
||||
use hcie_brush_engine::{presets::BrushPreset, BrushStyle};
|
||||
|
||||
// ── tunables ─────────────────────────────────────────────────────────────────
|
||||
const TILE_SIZE: u32 = 512;
|
||||
@@ -23,38 +23,55 @@ struct SliderCaps {
|
||||
fn slider_caps(style: BrushStyle) -> SliderCaps {
|
||||
match style {
|
||||
BrushStyle::Calligraphy => SliderCaps {
|
||||
angle: true, roundness: true, jitter: false, scatter: false,
|
||||
spray_particle_size: false, spray_density: false,
|
||||
angle: true,
|
||||
roundness: true,
|
||||
jitter: false,
|
||||
scatter: false,
|
||||
spray_particle_size: false,
|
||||
spray_density: false,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Pencil | BrushStyle::InkPen => SliderCaps {
|
||||
jitter: true, ..SliderCaps::common()
|
||||
jitter: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Spray | BrushStyle::Airbrush => SliderCaps {
|
||||
jitter: true, scatter: true,
|
||||
spray_particle_size: true, spray_density: true,
|
||||
jitter: true,
|
||||
scatter: true,
|
||||
spray_particle_size: true,
|
||||
spray_density: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Charcoal => SliderCaps {
|
||||
jitter: true, scatter: true, ..SliderCaps::common()
|
||||
jitter: true,
|
||||
scatter: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Crayon => SliderCaps {
|
||||
jitter: true, scatter: true, ..SliderCaps::common()
|
||||
jitter: true,
|
||||
scatter: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Tree => SliderCaps {
|
||||
jitter: true, angle: true, ..SliderCaps::common()
|
||||
jitter: true,
|
||||
angle: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Meadow | BrushStyle::Rock | BrushStyle::Dirt => SliderCaps {
|
||||
jitter: true, ..SliderCaps::common()
|
||||
jitter: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Bristle => SliderCaps {
|
||||
roundness: true, ..SliderCaps::common()
|
||||
roundness: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::HardRound | BrushStyle::SoftRound => SliderCaps {
|
||||
hardness: false, ..SliderCaps::common()
|
||||
hardness: false,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Mixer | BrushStyle::Blender => SliderCaps {
|
||||
flow: false, ..SliderCaps::common()
|
||||
flow: false,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
_ => SliderCaps::common(),
|
||||
}
|
||||
@@ -63,20 +80,32 @@ fn slider_caps(style: BrushStyle) -> SliderCaps {
|
||||
impl SliderCaps {
|
||||
fn common() -> Self {
|
||||
Self {
|
||||
hardness: true, opacity: true, spacing: true, flow: true,
|
||||
jitter: false, scatter: false, angle: false, roundness: false,
|
||||
spray_particle_size: false, spray_density: false,
|
||||
hardness: true,
|
||||
opacity: true,
|
||||
spacing: true,
|
||||
flow: true,
|
||||
jitter: false,
|
||||
scatter: false,
|
||||
angle: false,
|
||||
roundness: false,
|
||||
spray_particle_size: false,
|
||||
spray_density: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum CanvasPreset { S512, S1024, S2048, S4096 }
|
||||
enum CanvasPreset {
|
||||
S512,
|
||||
S1024,
|
||||
S2048,
|
||||
S4096,
|
||||
}
|
||||
|
||||
impl CanvasPreset {
|
||||
fn size(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::S512 => (512, 512),
|
||||
Self::S512 => (512, 512),
|
||||
Self::S1024 => (1024, 1024),
|
||||
Self::S2048 => (2048, 2048),
|
||||
Self::S4096 => (4096, 4096),
|
||||
@@ -84,7 +113,7 @@ impl CanvasPreset {
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::S512 => "512",
|
||||
Self::S512 => "512",
|
||||
Self::S1024 => "1024",
|
||||
Self::S2048 => "2048",
|
||||
Self::S4096 => "4096",
|
||||
@@ -93,21 +122,26 @@ impl CanvasPreset {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum FpsCap { Unlimited, Fps30, Fps60, Fps165 }
|
||||
enum FpsCap {
|
||||
Unlimited,
|
||||
Fps30,
|
||||
Fps60,
|
||||
Fps165,
|
||||
}
|
||||
|
||||
impl FpsCap {
|
||||
fn value(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::Fps30 => Some(30.0),
|
||||
Self::Fps60 => Some(60.0),
|
||||
Self::Fps30 => Some(30.0),
|
||||
Self::Fps60 => Some(60.0),
|
||||
Self::Fps165 => Some(165.0),
|
||||
Self::Unlimited => None,
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fps30 => "30",
|
||||
Self::Fps60 => "60",
|
||||
Self::Fps30 => "30",
|
||||
Self::Fps60 => "60",
|
||||
Self::Fps165 => "165",
|
||||
Self::Unlimited => "∞",
|
||||
}
|
||||
@@ -137,7 +171,12 @@ impl TiledCanvas {
|
||||
let total = (cols * rows) as usize;
|
||||
Self {
|
||||
pixels: vec![255; (w * h * 4) as usize],
|
||||
tiles: (0..total).map(|_| Tile { texture: None, dirty: true }).collect(),
|
||||
tiles: (0..total)
|
||||
.map(|_| Tile {
|
||||
texture: None,
|
||||
dirty: true,
|
||||
})
|
||||
.collect(),
|
||||
w,
|
||||
h,
|
||||
cols,
|
||||
@@ -181,18 +220,28 @@ impl TiledCanvas {
|
||||
break;
|
||||
}
|
||||
let e2 = 2.0 * err;
|
||||
if e2 > -dy { err -= dy; x0 += sx; }
|
||||
if e2 < dx { err += dx; y0 += sy; }
|
||||
if e2 > -dy {
|
||||
err -= dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_all_dirty(&mut self) {
|
||||
for t in &mut self.tiles { t.dirty = true; }
|
||||
for t in &mut self.tiles {
|
||||
t.dirty = true;
|
||||
}
|
||||
self.dirty_count = self.tiles.len();
|
||||
}
|
||||
|
||||
fn upload_dirty(&mut self, ctx: &egui::Context) {
|
||||
if self.dirty_count == 0 { return; }
|
||||
if self.dirty_count == 0 {
|
||||
return;
|
||||
}
|
||||
let rows = self.h.div_ceil(TILE_SIZE);
|
||||
for ty in 0..rows {
|
||||
let tile_h = if ty == rows - 1 {
|
||||
@@ -202,7 +251,9 @@ impl TiledCanvas {
|
||||
} as usize;
|
||||
for tx in 0..self.cols {
|
||||
let idx = (ty * self.cols + tx) as usize;
|
||||
if !self.tiles[idx].dirty { continue; }
|
||||
if !self.tiles[idx].dirty {
|
||||
continue;
|
||||
}
|
||||
self.tiles[idx].dirty = false;
|
||||
self.dirty_count = self.dirty_count.saturating_sub(1);
|
||||
|
||||
@@ -223,10 +274,7 @@ impl TiledCanvas {
|
||||
.copy_from_slice(&self.pixels[src..src + tile_w * 4]);
|
||||
}
|
||||
|
||||
let img = ColorImage::from_rgba_unmultiplied(
|
||||
[tile_w, tile_h],
|
||||
&data,
|
||||
);
|
||||
let img = ColorImage::from_rgba_unmultiplied([tile_w, tile_h], &data);
|
||||
let key = format!("t{}_{}", tx, ty);
|
||||
if let Some(tex) = &mut self.tiles[idx].texture {
|
||||
tex.set(img, egui::TextureOptions::NEAREST);
|
||||
@@ -274,7 +322,9 @@ impl App {
|
||||
}
|
||||
|
||||
fn set_canvas_size(&mut self, p: CanvasPreset) {
|
||||
if self.canvas_preset == p { return; }
|
||||
if self.canvas_preset == p {
|
||||
return;
|
||||
}
|
||||
self.canvas_preset = p;
|
||||
let (w, h) = p.size();
|
||||
self.canvas = TiledCanvas::new(w, h);
|
||||
@@ -325,8 +375,16 @@ impl eframe::App for App {
|
||||
egui::ComboBox::from_id_salt("canvas_preset")
|
||||
.selected_text(self.canvas_preset.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for p in &[CanvasPreset::S512, CanvasPreset::S1024, CanvasPreset::S2048, CanvasPreset::S4096] {
|
||||
if ui.selectable_label(self.canvas_preset == *p, p.name()).clicked() {
|
||||
for p in &[
|
||||
CanvasPreset::S512,
|
||||
CanvasPreset::S1024,
|
||||
CanvasPreset::S2048,
|
||||
CanvasPreset::S4096,
|
||||
] {
|
||||
if ui
|
||||
.selectable_label(self.canvas_preset == *p, p.name())
|
||||
.clicked()
|
||||
{
|
||||
self.set_canvas_size(*p);
|
||||
}
|
||||
}
|
||||
@@ -336,7 +394,12 @@ impl eframe::App for App {
|
||||
egui::ComboBox::from_id_salt("fps_cap")
|
||||
.selected_text(self.fps_cap.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for c in &[FpsCap::Fps30, FpsCap::Fps60, FpsCap::Fps165, FpsCap::Unlimited] {
|
||||
for c in &[
|
||||
FpsCap::Fps30,
|
||||
FpsCap::Fps60,
|
||||
FpsCap::Fps165,
|
||||
FpsCap::Unlimited,
|
||||
] {
|
||||
if ui.selectable_label(self.fps_cap == *c, c.name()).clicked() {
|
||||
self.fps_cap = *c;
|
||||
}
|
||||
@@ -355,16 +418,48 @@ impl eframe::App for App {
|
||||
|
||||
ui.add(egui::Slider::new(&mut self.preset.tip.size, 1.0..=500.0).text("Size"));
|
||||
let caps = slider_caps(self.preset.style);
|
||||
ui.add_enabled(caps.hardness, egui::Slider::new(&mut self.preset.tip.hardness, 0.0..=1.0).text("Hardness"));
|
||||
ui.add_enabled(caps.opacity, egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity"));
|
||||
ui.add_enabled(caps.spacing, egui::Slider::new(&mut self.preset.tip.spacing, 0.01..=1.0).text("Spacing"));
|
||||
ui.add_enabled(caps.flow, egui::Slider::new(&mut self.preset.tip.flow, 0.01..=1.0).text("Flow"));
|
||||
ui.add_enabled(caps.jitter, egui::Slider::new(&mut self.preset.tip.jitter_amount, 0.0..=5.0).text("Jitter"));
|
||||
ui.add_enabled(caps.scatter, egui::Slider::new(&mut self.preset.tip.scatter_amount, 0.0..=1.0).text("Scatter"));
|
||||
ui.add_enabled(caps.angle, egui::Slider::new(&mut self.preset.tip.angle, 0.0..=360.0).text("Angle"));
|
||||
ui.add_enabled(caps.roundness, egui::Slider::new(&mut self.preset.tip.roundness, 0.0..=1.0).text("Roundness"));
|
||||
ui.add_enabled(caps.spray_particle_size, egui::Slider::new(&mut self.preset.tip.spray_particle_size, 0.5..=20.0).text("Spray Size"));
|
||||
ui.add_enabled(caps.spray_density, egui::Slider::new(&mut self.preset.tip.spray_density, 1..=500).text("Spray Density"));
|
||||
ui.add_enabled(
|
||||
caps.hardness,
|
||||
egui::Slider::new(&mut self.preset.tip.hardness, 0.0..=1.0).text("Hardness"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.opacity,
|
||||
egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.spacing,
|
||||
egui::Slider::new(&mut self.preset.tip.spacing, 0.01..=1.0).text("Spacing"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.flow,
|
||||
egui::Slider::new(&mut self.preset.tip.flow, 0.01..=1.0).text("Flow"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.jitter,
|
||||
egui::Slider::new(&mut self.preset.tip.jitter_amount, 0.0..=5.0).text("Jitter"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.scatter,
|
||||
egui::Slider::new(&mut self.preset.tip.scatter_amount, 0.0..=1.0).text("Scatter"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.angle,
|
||||
egui::Slider::new(&mut self.preset.tip.angle, 0.0..=360.0).text("Angle"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.roundness,
|
||||
egui::Slider::new(&mut self.preset.tip.roundness, 0.0..=1.0).text("Roundness"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.spray_particle_size,
|
||||
egui::Slider::new(&mut self.preset.tip.spray_particle_size, 0.5..=20.0)
|
||||
.text("Spray Size"),
|
||||
);
|
||||
ui.add_enabled(
|
||||
caps.spray_density,
|
||||
egui::Slider::new(&mut self.preset.tip.spray_density, 1..=500)
|
||||
.text("Spray Density"),
|
||||
);
|
||||
|
||||
let mut rgba = [
|
||||
self.color[0] as f32 / 255.,
|
||||
@@ -374,8 +469,10 @@ impl eframe::App for App {
|
||||
];
|
||||
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
|
||||
self.color = [
|
||||
(rgba[0] * 255.) as u8, (rgba[1] * 255.) as u8,
|
||||
(rgba[2] * 255.) as u8, (rgba[3] * 255.) as u8,
|
||||
(rgba[0] * 255.) as u8,
|
||||
(rgba[1] * 255.) as u8,
|
||||
(rgba[2] * 255.) as u8,
|
||||
(rgba[3] * 255.) as u8,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -399,7 +496,13 @@ impl eframe::App for App {
|
||||
ui.allocate_exact_size(egui::vec2(disp_w, disp_h), egui::Sense::drag());
|
||||
|
||||
// paint tiles
|
||||
if self.canvas.tiles.first().and_then(|t| t.texture.as_ref()).is_some() {
|
||||
if self
|
||||
.canvas
|
||||
.tiles
|
||||
.first()
|
||||
.and_then(|t| t.texture.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
let rows = self.canvas.h.div_ceil(TILE_SIZE);
|
||||
for ty in 0..rows {
|
||||
for tx in 0..self.canvas.cols {
|
||||
@@ -438,7 +541,8 @@ impl eframe::App for App {
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let cx = (pos.x - rect.min.x) / scale;
|
||||
let cy = (pos.y - rect.min.y) / scale;
|
||||
if cx >= 0.0 && cy >= 0.0
|
||||
if cx >= 0.0
|
||||
&& cy >= 0.0
|
||||
&& cx < self.canvas.w as f32
|
||||
&& cy < self.canvas.h as f32
|
||||
{
|
||||
@@ -450,8 +554,10 @@ impl eframe::App for App {
|
||||
};
|
||||
self.last_pt = Some(pt);
|
||||
self.canvas.mark_line_dirty(
|
||||
seg[0].0, seg[0].1,
|
||||
seg[seg.len() - 1].0, seg[seg.len() - 1].1,
|
||||
seg[0].0,
|
||||
seg[0].1,
|
||||
seg[seg.len() - 1].0,
|
||||
seg[seg.len() - 1].1,
|
||||
self.preset.tip.size,
|
||||
);
|
||||
hcie_brush_engine::draw_specialized_stroke(
|
||||
|
||||
@@ -5,9 +5,9 @@ use hcie_protocol::BrushTip;
|
||||
/// Per-stroke dynamic parameters computed from input state.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct StrokeDynamics {
|
||||
pub pressure: f32, // 0.0–1.0
|
||||
pub velocity: f32, // Pixels per frame
|
||||
pub tilt: (f32, f32), // (x_tilt, y_tilt) in degrees
|
||||
pub pressure: f32, // 0.0–1.0
|
||||
pub velocity: f32, // Pixels per frame
|
||||
pub tilt: (f32, f32), // (x_tilt, y_tilt) in degrees
|
||||
}
|
||||
|
||||
/// Compute effective brush size from base size, dynamics, and pressure.
|
||||
@@ -22,7 +22,11 @@ pub fn effective_size(base_size: f32, dynamics: &StrokeDynamics, pressure_sensit
|
||||
}
|
||||
|
||||
/// Compute effective opacity from base opacity, dynamics, and pressure.
|
||||
pub fn effective_opacity(base_opacity: f32, dynamics: &StrokeDynamics, pressure_sensitive: bool) -> f32 {
|
||||
pub fn effective_opacity(
|
||||
base_opacity: f32,
|
||||
dynamics: &StrokeDynamics,
|
||||
pressure_sensitive: bool,
|
||||
) -> f32 {
|
||||
if !pressure_sensitive {
|
||||
return base_opacity;
|
||||
}
|
||||
@@ -47,7 +51,12 @@ pub fn velocity_size_modifier(velocity: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// Build a `BrushTip` with dynamics applied.
|
||||
pub fn build_dynamic_tip(base: &BrushTip, dynamics: &StrokeDynamics, pressure_size: bool, pressure_opacity: bool) -> BrushTip {
|
||||
pub fn build_dynamic_tip(
|
||||
base: &BrushTip,
|
||||
dynamics: &StrokeDynamics,
|
||||
pressure_size: bool,
|
||||
pressure_opacity: bool,
|
||||
) -> BrushTip {
|
||||
let mut tip = base.clone();
|
||||
tip.size = effective_size(base.size, dynamics, pressure_size);
|
||||
tip.opacity = effective_opacity(base.opacity, dynamics, pressure_opacity);
|
||||
@@ -70,7 +79,9 @@ pub struct VelocityTracker {
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn update(&mut self, x: f32, y: f32, time: f32) -> f32 {
|
||||
let velocity = match (self.last_pos, self.last_time) {
|
||||
@@ -96,8 +107,11 @@ impl VelocityTracker {
|
||||
pub fn jitter_color(color: [u8; 4], variation: u8, rng: &mut rand::rngs::ThreadRng) -> [u8; 4] {
|
||||
use rand::Rng;
|
||||
let jitter = variation as f32 / 255.0;
|
||||
let r = (color[0] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let g = (color[1] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let b = (color[2] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let r = (color[0] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0)
|
||||
as u8;
|
||||
let g = (color[1] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0)
|
||||
as u8;
|
||||
let b = (color[2] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0)
|
||||
as u8;
|
||||
[r, g, b, color[3]]
|
||||
}
|
||||
|
||||
+1169
-202
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
//! Brush presets factory — ported from V2 `hcie-core/src/brush.rs`.
|
||||
|
||||
use hcie_protocol::{BrushTip, BrushStyle};
|
||||
use hcie_protocol::{BrushStyle, BrushTip};
|
||||
|
||||
fn round_soft(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
@@ -59,7 +59,8 @@ fn pencil(size: f32) -> BrushTip {
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.05,
|
||||
flow: 1.0, jitter_amount: 0.15,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.15,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -83,7 +84,8 @@ fn spray(size: f32) -> BrushTip {
|
||||
opacity: 0.6,
|
||||
hardness: 0.3,
|
||||
spacing: 0.35,
|
||||
flow: 1.0, jitter_amount: 0.4,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.4,
|
||||
scatter_amount: 0.6,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -107,7 +109,8 @@ fn calligraphy(size: f32) -> BrushTip {
|
||||
opacity: 0.9,
|
||||
hardness: 0.7,
|
||||
spacing: 0.08,
|
||||
flow: 1.0, jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 45.0_f32.to_radians(),
|
||||
roundness: 0.15,
|
||||
@@ -196,8 +199,8 @@ impl BrushPreset {
|
||||
opacity: 0.4,
|
||||
hardness: 0.0,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -247,8 +250,8 @@ impl BrushPreset {
|
||||
opacity: 1.0,
|
||||
hardness: 0.95,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -283,8 +286,8 @@ impl BrushPreset {
|
||||
opacity: 0.7,
|
||||
hardness: 0.5,
|
||||
spacing: 0.15,
|
||||
flow: 1.0,
|
||||
jitter_amount: 2.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 2.0,
|
||||
scatter_amount: 0.5,
|
||||
angle: 0.0,
|
||||
roundness: 0.8,
|
||||
@@ -319,8 +322,8 @@ impl BrushPreset {
|
||||
opacity: 0.85,
|
||||
hardness: 0.9,
|
||||
spacing: 0.08,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -355,8 +358,8 @@ impl BrushPreset {
|
||||
opacity: 0.25,
|
||||
hardness: 0.0,
|
||||
spacing: 0.08,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -391,8 +394,8 @@ impl BrushPreset {
|
||||
opacity: 0.35,
|
||||
hardness: 0.0,
|
||||
spacing: 0.06,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -427,8 +430,8 @@ impl BrushPreset {
|
||||
opacity: 0.95,
|
||||
hardness: 0.85,
|
||||
spacing: 0.04,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -463,8 +466,8 @@ impl BrushPreset {
|
||||
opacity: 0.95,
|
||||
hardness: 0.7,
|
||||
spacing: 0.04,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -499,8 +502,8 @@ impl BrushPreset {
|
||||
opacity: 0.85,
|
||||
hardness: 0.5,
|
||||
spacing: 0.12,
|
||||
flow: 1.0,
|
||||
jitter_amount: 1.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 1.0,
|
||||
scatter_amount: 0.2,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -612,8 +615,8 @@ impl BrushPreset {
|
||||
opacity: 0.9,
|
||||
hardness: 0.8,
|
||||
spacing: 0.40,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.8,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.8,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -648,8 +651,8 @@ impl BrushPreset {
|
||||
opacity: 0.75,
|
||||
hardness: 0.6,
|
||||
spacing: 0.35,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -699,8 +702,8 @@ impl BrushPreset {
|
||||
opacity: 0.85,
|
||||
hardness: 0.7,
|
||||
spacing: 0.18,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.5,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.5,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -735,8 +738,8 @@ impl BrushPreset {
|
||||
opacity: 0.9,
|
||||
hardness: 0.6,
|
||||
spacing: 0.30,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
scatter_amount: 0.0,
|
||||
angle: 90.0,
|
||||
roundness: 1.0,
|
||||
@@ -773,8 +776,8 @@ impl BrushPreset {
|
||||
opacity: 0.8,
|
||||
hardness: 1.0,
|
||||
spacing: 0.40,
|
||||
flow: 1.0,
|
||||
jitter_amount: 4.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 4.0,
|
||||
scatter_amount: 0.6,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
@@ -839,8 +842,8 @@ impl BrushPreset {
|
||||
opacity: 0.9,
|
||||
hardness: 0.8,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 0.8,
|
||||
@@ -943,4 +946,4 @@ impl BrushPreset {
|
||||
Self::sketch(),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use hcie_brush_engine::{BrushTip, BrushStyle};
|
||||
use hcie_brush_engine::{BrushStyle, BrushTip};
|
||||
|
||||
fn make_round_tip(size: f32, hardness: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
@@ -14,7 +14,11 @@ fn test_generate_brush_stamp_size() {
|
||||
let tip = make_round_tip(10.0, 0.5);
|
||||
let stamp = hcie_brush_engine::generate_brush_stamp(&tip);
|
||||
let expected_d = 20usize;
|
||||
assert_eq!(stamp.len(), expected_d * expected_d, "stamp should be square");
|
||||
assert_eq!(
|
||||
stamp.len(),
|
||||
expected_d * expected_d,
|
||||
"stamp should be square"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +34,11 @@ fn test_generate_brush_stamp_center_nonzero() {
|
||||
fn test_generate_brush_stamp_hardness_gradient() {
|
||||
let soft = hcie_brush_engine::generate_brush_stamp(&make_round_tip(10.0, 0.0));
|
||||
let hard = hcie_brush_engine::generate_brush_stamp(&make_round_tip(10.0, 1.0));
|
||||
assert_eq!(soft.len(), hard.len(), "same size stamp should have same length");
|
||||
assert_eq!(
|
||||
soft.len(),
|
||||
hard.len(),
|
||||
"same size stamp should have same length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -38,7 +46,11 @@ fn test_brush_spacing_pixels() {
|
||||
let spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.5);
|
||||
assert_eq!(spacing, 10.0, "spacing should be size * ratio");
|
||||
let min_spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.0);
|
||||
assert!((min_spacing - 0.2).abs() < 0.001, "min spacing should be ~0.2, got {}", min_spacing);
|
||||
assert!(
|
||||
(min_spacing - 0.2).abs() < 0.001,
|
||||
"min spacing should be ~0.2, got {}",
|
||||
min_spacing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,8 +15,20 @@ pub fn composite_layers_region(
|
||||
layers: &[Layer],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
x0: u32, y0: u32, x1: u32, y1: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
output: &mut [u8],
|
||||
) {
|
||||
parallel::composite_layers_region_parallel(layers, canvas_width, canvas_height, x0, y0, x1, y1, output)
|
||||
}
|
||||
parallel::composite_layers_region_parallel(
|
||||
layers,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
+291
-99
@@ -1,39 +1,51 @@
|
||||
use hcie_protocol::Layer;
|
||||
use hcie_blend::blend_pixels;
|
||||
use hcie_fx::protocol_to_hcie_fx_effect;
|
||||
use hcie_protocol::Layer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub(crate) fn apply_curves(r: u8, g: u8, b: u8, lut_r: &[u8], lut_g: &[u8], lut_b: &[u8]) -> [u8; 3] {
|
||||
[
|
||||
lut_r[r as usize],
|
||||
lut_g[g as usize],
|
||||
lut_b[b as usize],
|
||||
]
|
||||
pub(crate) fn apply_curves(
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8,
|
||||
lut_r: &[u8],
|
||||
lut_g: &[u8],
|
||||
lut_b: &[u8],
|
||||
) -> [u8; 3] {
|
||||
[lut_r[r as usize], lut_g[g as usize], lut_b[b as usize]]
|
||||
}
|
||||
|
||||
pub(crate) fn apply_gradient_map(r: u8, g: u8, b: u8, lut_r: &[u8], lut_g: &[u8], lut_b: &[u8]) -> [u8; 3] {
|
||||
pub(crate) fn apply_gradient_map(
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8,
|
||||
lut_r: &[u8],
|
||||
lut_g: &[u8],
|
||||
lut_b: &[u8],
|
||||
) -> [u8; 3] {
|
||||
let dr = r as f32;
|
||||
let dg = g as f32;
|
||||
let db = b as f32;
|
||||
let l = (0.30 * dr + 0.59 * dg + 0.11 * db).clamp(0.0, 255.0).round() as usize;
|
||||
[
|
||||
lut_r[l],
|
||||
lut_g[l],
|
||||
lut_b[l],
|
||||
]
|
||||
let l = (0.30 * dr + 0.59 * dg + 0.11 * db)
|
||||
.clamp(0.0, 255.0)
|
||||
.round() as usize;
|
||||
[lut_r[l], lut_g[l], lut_b[l]]
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let c_max = r.max(g).max(b);
|
||||
let c_min = r.min(g).min(b);
|
||||
let delta = c_max - c_min;
|
||||
|
||||
|
||||
let mut h = 0.0;
|
||||
let mut s = 0.0;
|
||||
let l = (c_max + c_min) / 2.0;
|
||||
|
||||
|
||||
if delta > 1e-5 {
|
||||
s = if l < 0.5 { delta / (c_max + c_min) } else { delta / (2.0 - c_max - c_min) };
|
||||
s = if l < 0.5 {
|
||||
delta / (c_max + c_min)
|
||||
} else {
|
||||
delta / (2.0 - c_max - c_min)
|
||||
};
|
||||
if c_max == r {
|
||||
h = (g - b) / delta + (if g < b { 6.0 } else { 0.0 });
|
||||
} else if c_max == g {
|
||||
@@ -73,14 +85,18 @@ pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i3
|
||||
let fr = r as f32 / 255.0;
|
||||
let fg = g as f32 / 255.0;
|
||||
let fb = b as f32 / 255.0;
|
||||
|
||||
|
||||
let (h, s, l) = rgb_to_hsl(fr, fg, fb);
|
||||
|
||||
|
||||
// 1. Hue shift
|
||||
let mut new_h = h + dh as f32;
|
||||
while new_h < 0.0 { new_h += 360.0; }
|
||||
while new_h >= 360.0 { new_h -= 360.0; }
|
||||
|
||||
while new_h < 0.0 {
|
||||
new_h += 360.0;
|
||||
}
|
||||
while new_h >= 360.0 {
|
||||
new_h -= 360.0;
|
||||
}
|
||||
|
||||
// 2. Saturation relative/clamped shift
|
||||
let s_factor = ds as f32 / 100.0;
|
||||
let new_s = if s_factor >= 0.0 {
|
||||
@@ -89,7 +105,7 @@ pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i3
|
||||
s + s_factor * s
|
||||
};
|
||||
let new_s = new_s.clamp(0.0, 1.0);
|
||||
|
||||
|
||||
// 3. Lightness relative/clamped shift
|
||||
let l_factor = dl as f32 / 100.0;
|
||||
let new_l = if l_factor >= 0.0 {
|
||||
@@ -98,7 +114,7 @@ pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i3
|
||||
l + l_factor * l
|
||||
};
|
||||
let new_l = new_l.clamp(0.0, 1.0);
|
||||
|
||||
|
||||
let (out_r, out_g, out_b) = hsl_to_rgb(new_h, new_s, new_l);
|
||||
[
|
||||
(out_r * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
@@ -107,12 +123,21 @@ pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i3
|
||||
]
|
||||
}
|
||||
|
||||
pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_height: u32) -> Vec<u8> {
|
||||
pub fn composite_layers_parallel(
|
||||
layers: &[Layer],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
) -> Vec<u8> {
|
||||
let px_count = (canvas_width * canvas_height) as usize;
|
||||
let mut output = vec![0u8; px_count * 4];
|
||||
|
||||
log::debug!("[composite_layers_parallel] START: {} layers, canvas={}x{}, px_count={}",
|
||||
layers.len(), canvas_width, canvas_height, px_count);
|
||||
log::debug!(
|
||||
"[composite_layers_parallel] START: {} layers, canvas={}x{}, px_count={}",
|
||||
layers.len(),
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
px_count
|
||||
);
|
||||
for (i, l) in layers.iter().enumerate() {
|
||||
log::debug!("[composite_layers_parallel] input layer[{}]: name='{}' visible={} w={} h={} opacity={} blend={:?} clip={} pixels_len={}",
|
||||
i, l.name, l.visible, l.width, l.height, l.opacity, l.blend_mode, l.clipping_mask, l.pixels.len());
|
||||
@@ -136,13 +161,17 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
}
|
||||
|
||||
for (rev_i, layer) in layers.iter().enumerate() {
|
||||
if !layer.visible { continue; }
|
||||
if !layer.visible {
|
||||
continue;
|
||||
}
|
||||
if layer.layer_type == hcie_protocol::LayerType::Group {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
let is_adj = layer.adjustment.is_some();
|
||||
if !is_adj && (layer.width == 0 || layer.height == 0) { continue; }
|
||||
if !is_adj && (layer.width == 0 || layer.height == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let i = rev_i;
|
||||
let blend: hcie_blend::BlendMode = layer.blend_mode.into();
|
||||
@@ -151,7 +180,8 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
let base_idx = base_indices[i];
|
||||
|
||||
// Apply layer effects if present
|
||||
let has_effects = !layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let has_effects =
|
||||
!layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let effect_pixels = if has_effects && !is_adj {
|
||||
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
|
||||
if cached.width == layer.width && cached.height == layer.height {
|
||||
@@ -160,9 +190,24 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
None
|
||||
}
|
||||
} else {
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect();
|
||||
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
||||
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity))
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer
|
||||
.effects
|
||||
.iter()
|
||||
.map(hcie_fx::protocol_to_hcie_fx_effect)
|
||||
.collect();
|
||||
fx_effects.extend(
|
||||
layer
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| hcie_fx::layer_style_to_effect(s)),
|
||||
);
|
||||
Some(hcie_fx::apply_layer_effects(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&fx_effects,
|
||||
layer.fill_opacity,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -170,35 +215,69 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
let pixels = effect_pixels.as_deref().unwrap_or(&layer.pixels);
|
||||
|
||||
if is_adj {
|
||||
output.par_chunks_exact_mut(canvas_width as usize * 4)
|
||||
output
|
||||
.par_chunks_exact_mut(canvas_width as usize * 4)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
for x in 0..canvas_width {
|
||||
let out_idx = x as usize * 4;
|
||||
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
|
||||
if dst[3] == 0 { continue; }
|
||||
let dst = [
|
||||
row[out_idx],
|
||||
row[out_idx + 1],
|
||||
row[out_idx + 2],
|
||||
row[out_idx + 3],
|
||||
];
|
||||
if dst[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mask_val = layer.get_mask_value(x, gy);
|
||||
if mask_val == 0 { continue; }
|
||||
if mask_val == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
|
||||
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
|
||||
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
|
||||
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
|
||||
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
|
||||
}
|
||||
hcie_blend::Adjustment::Curves {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::GradientMap {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::HueSaturation {
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
} => apply_hue_saturation(
|
||||
dst[0],
|
||||
dst[1],
|
||||
dst[2],
|
||||
*hue,
|
||||
*saturation,
|
||||
*lightness,
|
||||
),
|
||||
};
|
||||
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
|
||||
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
|
||||
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
|
||||
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
|
||||
let blended_opaque = blend_pixels(
|
||||
[dst[0], dst[1], dst[2], 255],
|
||||
[adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
|
||||
blend,
|
||||
1.0,
|
||||
);
|
||||
let out_r = ((1.0 - eff_opacity) * dst[0] as f32
|
||||
+ eff_opacity * blended_opaque[0] as f32)
|
||||
.round() as u8;
|
||||
let out_g = ((1.0 - eff_opacity) * dst[1] as f32
|
||||
+ eff_opacity * blended_opaque[1] as f32)
|
||||
.round() as u8;
|
||||
let out_b = ((1.0 - eff_opacity) * dst[2] as f32
|
||||
+ eff_opacity * blended_opaque[2] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx] = out_r;
|
||||
row[out_idx + 1] = out_g;
|
||||
row[out_idx + 2] = out_b;
|
||||
@@ -206,21 +285,34 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
});
|
||||
} else {
|
||||
let (offset_x, offset_y) = match &layer.data {
|
||||
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
|
||||
hcie_protocol::LayerData::Text {
|
||||
offset_x, offset_y, ..
|
||||
} => (*offset_x as i32, *offset_y as i32),
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
let cx0 = (0).max(offset_x).max(0) as u32;
|
||||
let cx1 = (canvas_width as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
|
||||
let cx1 = (canvas_width as i32)
|
||||
.min(offset_x + layer.width as i32)
|
||||
.min(canvas_width as i32)
|
||||
.max(0) as u32;
|
||||
let cy0 = (0).max(offset_y).max(0) as u32;
|
||||
let cy1 = (canvas_height as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
|
||||
if cx0 >= cx1 || cy0 >= cy1 { continue; }
|
||||
let cy1 = (canvas_height as i32)
|
||||
.min(offset_y + layer.height as i32)
|
||||
.min(canvas_height as i32)
|
||||
.max(0) as u32;
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.par_chunks_exact_mut(canvas_width as usize * 4)
|
||||
output
|
||||
.par_chunks_exact_mut(canvas_width as usize * 4)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < cy0 || gy >= cy1 { return; }
|
||||
if gy < cy0 || gy >= cy1 {
|
||||
return;
|
||||
}
|
||||
let ly = (y as i32 - offset_y) as u32;
|
||||
for x in cx0 as usize..cx1 as usize {
|
||||
let lx = (x as i32 - offset_x) as u32;
|
||||
@@ -233,7 +325,9 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
};
|
||||
|
||||
let mask_val = layer.get_mask_value(x as u32, gy);
|
||||
if mask_val == 0 { continue; }
|
||||
if mask_val == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
@@ -244,8 +338,15 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { continue; }
|
||||
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
|
||||
if src[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let dst = [
|
||||
row[out_idx],
|
||||
row[out_idx + 1],
|
||||
row[out_idx + 2],
|
||||
row[out_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[out_idx..out_idx + 4].copy_from_slice(&blended);
|
||||
@@ -255,8 +356,16 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
|
||||
}
|
||||
|
||||
let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
||||
log::debug!("[composite_layers_parallel] DONE: output non_zero_alpha={}/{} ({}%)",
|
||||
non_zero_alpha, px_count, if px_count > 0 { non_zero_alpha * 100 / px_count } else { 0 });
|
||||
log::debug!(
|
||||
"[composite_layers_parallel] DONE: output non_zero_alpha={}/{} ({}%)",
|
||||
non_zero_alpha,
|
||||
px_count,
|
||||
if px_count > 0 {
|
||||
non_zero_alpha * 100 / px_count
|
||||
} else {
|
||||
0
|
||||
}
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
@@ -264,12 +373,17 @@ pub fn composite_layers_region_parallel(
|
||||
layers: &[Layer],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
x0: u32, y0: u32, x1: u32, y1: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
output: &mut [u8],
|
||||
) {
|
||||
let w = x1.saturating_sub(x0);
|
||||
let h = y1.saturating_sub(y0);
|
||||
if w == 0 || h == 0 { return; }
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut base_indices = Vec::with_capacity(layers.len());
|
||||
for i in 0..layers.len() {
|
||||
@@ -288,11 +402,13 @@ pub fn composite_layers_region_parallel(
|
||||
}
|
||||
|
||||
for (rev_i, layer) in layers.iter().enumerate() {
|
||||
if !layer.visible { continue; }
|
||||
if !layer.visible {
|
||||
continue;
|
||||
}
|
||||
if layer.layer_type == hcie_protocol::LayerType::Group {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
let blend: hcie_blend::BlendMode = layer.blend_mode.into();
|
||||
let opacity = layer.opacity.clamp(0.0, 1.0);
|
||||
let is_clipping = layer.clipping_mask;
|
||||
@@ -302,7 +418,8 @@ pub fn composite_layers_region_parallel(
|
||||
let is_adj = layer.adjustment.is_some();
|
||||
|
||||
// Apply layer effects if present
|
||||
let has_effects = !layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let has_effects =
|
||||
!layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let effect_pixels = if has_effects && !is_adj {
|
||||
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
|
||||
if cached.width == layer.width && cached.height == layer.height {
|
||||
@@ -311,9 +428,24 @@ pub fn composite_layers_region_parallel(
|
||||
None
|
||||
}
|
||||
} else {
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect();
|
||||
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
||||
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity))
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer
|
||||
.effects
|
||||
.iter()
|
||||
.map(hcie_fx::protocol_to_hcie_fx_effect)
|
||||
.collect();
|
||||
fx_effects.extend(
|
||||
layer
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| hcie_fx::layer_style_to_effect(s)),
|
||||
);
|
||||
Some(hcie_fx::apply_layer_effects(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&fx_effects,
|
||||
layer.fill_opacity,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -328,62 +460,113 @@ pub fn composite_layers_region_parallel(
|
||||
|
||||
let row_stride = canvas_width as usize * 4;
|
||||
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < region_y0 || gy >= region_y1 { return; }
|
||||
if gy < region_y0 || gy >= region_y1 {
|
||||
return;
|
||||
}
|
||||
for x in region_x0..region_x1 {
|
||||
let out_idx = (x as usize) * 4;
|
||||
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
|
||||
if dst[3] == 0 { continue; }
|
||||
let dst = [
|
||||
row[out_idx],
|
||||
row[out_idx + 1],
|
||||
row[out_idx + 2],
|
||||
row[out_idx + 3],
|
||||
];
|
||||
if dst[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mask_val = layer.get_mask_value(x, gy);
|
||||
if mask_val == 0 { continue; }
|
||||
if mask_val == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
|
||||
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
|
||||
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
|
||||
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
|
||||
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
|
||||
}
|
||||
hcie_blend::Adjustment::Curves {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::GradientMap {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::HueSaturation {
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
} => apply_hue_saturation(
|
||||
dst[0],
|
||||
dst[1],
|
||||
dst[2],
|
||||
*hue,
|
||||
*saturation,
|
||||
*lightness,
|
||||
),
|
||||
};
|
||||
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
|
||||
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
|
||||
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
|
||||
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
|
||||
let blended_opaque = blend_pixels(
|
||||
[dst[0], dst[1], dst[2], 255],
|
||||
[adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
|
||||
blend,
|
||||
1.0,
|
||||
);
|
||||
let out_r = ((1.0 - eff_opacity) * dst[0] as f32
|
||||
+ eff_opacity * blended_opaque[0] as f32)
|
||||
.round() as u8;
|
||||
let out_g = ((1.0 - eff_opacity) * dst[1] as f32
|
||||
+ eff_opacity * blended_opaque[1] as f32)
|
||||
.round() as u8;
|
||||
let out_b = ((1.0 - eff_opacity) * dst[2] as f32
|
||||
+ eff_opacity * blended_opaque[2] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx] = out_r;
|
||||
row[out_idx + 1] = out_g;
|
||||
row[out_idx + 2] = out_b;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if layer.width == 0 || layer.height == 0 { continue; }
|
||||
|
||||
if layer.width == 0 || layer.height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (offset_x, offset_y) = match &layer.data {
|
||||
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
|
||||
hcie_protocol::LayerData::Text {
|
||||
offset_x, offset_y, ..
|
||||
} => (*offset_x as i32, *offset_y as i32),
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
let cx0 = (x0 as i32).max(offset_x).max(0) as u32;
|
||||
let cx1 = (x1 as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
|
||||
let cx1 = (x1 as i32)
|
||||
.min(offset_x + layer.width as i32)
|
||||
.min(canvas_width as i32)
|
||||
.max(0) as u32;
|
||||
let cy0 = (y0 as i32).max(offset_y).max(0) as u32;
|
||||
let cy1 = (y1 as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
|
||||
if cx0 >= cx1 || cy0 >= cy1 { continue; }
|
||||
let cy1 = (y1 as i32)
|
||||
.min(offset_y + layer.height as i32)
|
||||
.min(canvas_height as i32)
|
||||
.max(0) as u32;
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let row_stride = canvas_width as usize * 4;
|
||||
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < cy0 || gy >= cy1 { return; }
|
||||
if gy < cy0 || gy >= cy1 {
|
||||
return;
|
||||
}
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for x in cx0..cx1 {
|
||||
let lx = (x as i32 - offset_x) as u32;
|
||||
@@ -396,7 +579,9 @@ pub fn composite_layers_region_parallel(
|
||||
};
|
||||
|
||||
let mask_val = layer.get_mask_value(x, gy);
|
||||
if mask_val == 0 { continue; }
|
||||
if mask_val == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
@@ -407,8 +592,15 @@ pub fn composite_layers_region_parallel(
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { continue; }
|
||||
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
|
||||
if src[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let dst = [
|
||||
row[out_idx],
|
||||
row[out_idx + 1],
|
||||
row[out_idx + 2],
|
||||
row[out_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[out_idx..out_idx + 4].copy_from_slice(&blended);
|
||||
|
||||
@@ -9,7 +9,9 @@ fn test_composite_basic() {
|
||||
name: "Red".into(),
|
||||
layer_type: hcie_protocol::LayerType::Raster,
|
||||
data: hcie_protocol::LayerData::Raster,
|
||||
pixels: vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255],
|
||||
pixels: vec![
|
||||
255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
|
||||
],
|
||||
width: 2,
|
||||
height: 2,
|
||||
visible: true,
|
||||
@@ -37,13 +39,18 @@ fn test_composite_basic() {
|
||||
};
|
||||
let layers = vec![layer];
|
||||
let out = crate::composite_layers(&layers, 2, 2);
|
||||
assert_eq!(out, vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255]);
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255]
|
||||
);
|
||||
|
||||
let layer0 = hcie_protocol::Layer {
|
||||
name: "Red".into(),
|
||||
layer_type: hcie_protocol::LayerType::Raster,
|
||||
data: hcie_protocol::LayerData::Raster,
|
||||
pixels: vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255],
|
||||
pixels: vec![
|
||||
255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
|
||||
],
|
||||
width: 2,
|
||||
height: 2,
|
||||
visible: true,
|
||||
@@ -73,7 +80,9 @@ fn test_composite_basic() {
|
||||
name: "Blue".into(),
|
||||
layer_type: hcie_protocol::LayerType::Raster,
|
||||
data: hcie_protocol::LayerData::Raster,
|
||||
pixels: vec![0,0,255,255, 0,0,255,255, 0,0,255,255, 0,0,255,255],
|
||||
pixels: vec![
|
||||
0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255,
|
||||
],
|
||||
width: 2,
|
||||
height: 2,
|
||||
visible: true,
|
||||
@@ -101,5 +110,8 @@ fn test_composite_basic() {
|
||||
};
|
||||
let layers2 = vec![layer0, layer1];
|
||||
let out2 = crate::composite_layers(&layers2, 2, 2);
|
||||
assert_eq!(out2, vec![0,0,255,255, 0,0,255,255, 0,0,255,255, 0,0,255,255]);
|
||||
}
|
||||
assert_eq!(
|
||||
out2,
|
||||
vec![0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255]
|
||||
);
|
||||
}
|
||||
|
||||
+369
-122
@@ -1,8 +1,8 @@
|
||||
use hcie_tile::{TiledLayer, TILE_SIZE};
|
||||
use hcie_protocol::Layer;
|
||||
use hcie_blend::blend_pixels;
|
||||
use hcie_fx::apply_layer_effects;
|
||||
use hcie_fx::protocol_to_hcie_fx_effect;
|
||||
use hcie_protocol::Layer;
|
||||
use hcie_tile::{TiledLayer, TILE_SIZE};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Pixel-area threshold below which the compositor uses a sequential row
|
||||
@@ -24,7 +24,17 @@ pub fn composite_tiled(
|
||||
) -> Vec<u8> {
|
||||
let px_count = (canvas_width * canvas_height) as usize;
|
||||
let mut output = vec![0u8; px_count * 4];
|
||||
composite_tiled_into(layers, tile_layers, canvas_width, canvas_height, 0, 0, canvas_width, canvas_height, &mut output);
|
||||
composite_tiled_into(
|
||||
layers,
|
||||
tile_layers,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
0,
|
||||
0,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
&mut output,
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
@@ -34,12 +44,17 @@ pub fn composite_tiled_into(
|
||||
tile_layers: &[Option<TiledLayer>],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
x0: u32, y0: u32, x1: u32, y1: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
output: &mut [u8],
|
||||
) {
|
||||
let region_w = x1.saturating_sub(x0);
|
||||
let region_h = y1.saturating_sub(y0);
|
||||
if region_w == 0 || region_h == 0 { return; }
|
||||
if region_w == 0 || region_h == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let use_sequential = region_w.saturating_mul(region_h) <= SEQUENTIAL_PIXEL_THRESHOLD;
|
||||
let row_stride = canvas_width as usize * 4;
|
||||
@@ -63,7 +78,9 @@ pub fn composite_tiled_into(
|
||||
}
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
if !layer.visible { continue; }
|
||||
if !layer.visible {
|
||||
continue;
|
||||
}
|
||||
if layer.layer_type == hcie_protocol::LayerType::Group {
|
||||
continue;
|
||||
}
|
||||
@@ -73,14 +90,22 @@ pub fn composite_tiled_into(
|
||||
let base_idx = base_indices[i];
|
||||
|
||||
let (offset_x, offset_y) = match &layer.data {
|
||||
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
|
||||
hcie_protocol::LayerData::Text {
|
||||
offset_x, offset_y, ..
|
||||
} => (*offset_x as i32, *offset_y as i32),
|
||||
_ => (0, 0),
|
||||
};
|
||||
|
||||
let cx0 = (x0 as i32).max(offset_x).max(0) as u32;
|
||||
let cx1 = (x1 as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
|
||||
let cx1 = (x1 as i32)
|
||||
.min(offset_x + layer.width as i32)
|
||||
.min(canvas_width as i32)
|
||||
.max(0) as u32;
|
||||
let cy0 = (y0 as i32).max(offset_y).max(0) as u32;
|
||||
let cy1 = (y1 as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
|
||||
let cy1 = (y1 as i32)
|
||||
.min(offset_y + layer.height as i32)
|
||||
.min(canvas_height as i32)
|
||||
.max(0) as u32;
|
||||
|
||||
let layer_w = layer.width.min(canvas_width);
|
||||
let layer_h = layer.height.min(canvas_height);
|
||||
@@ -101,11 +126,14 @@ pub fn composite_tiled_into(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < region_y0 || gy >= region_y1 { return; }
|
||||
if gy < region_y0 || gy >= region_y1 {
|
||||
return;
|
||||
}
|
||||
for x in region_x0..region_x1 {
|
||||
blend_adjustment_pixel(row, x, gy, layer, blend, opacity);
|
||||
}
|
||||
@@ -117,9 +145,19 @@ pub fn composite_tiled_into(
|
||||
// If layer has effects or non-default fill_opacity, compute effects buffer.
|
||||
// Prefer the cached `effects_cache` when available to avoid re-running the
|
||||
// expensive effects pipeline on every partial composite.
|
||||
let has_effects = !layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect();
|
||||
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
||||
let has_effects =
|
||||
!layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
|
||||
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer
|
||||
.effects
|
||||
.iter()
|
||||
.map(hcie_fx::protocol_to_hcie_fx_effect)
|
||||
.collect();
|
||||
fx_effects.extend(
|
||||
layer
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| hcie_fx::layer_style_to_effect(s)),
|
||||
);
|
||||
let effect_buf = if has_effects {
|
||||
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
|
||||
if cached.width == layer.width && cached.height == layer.height {
|
||||
@@ -129,94 +167,167 @@ pub fn composite_tiled_into(
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}.unwrap_or_else(|| apply_layer_effects(
|
||||
&layer.pixels, layer.width, layer.height,
|
||||
&fx_effects, layer.fill_opacity,
|
||||
))
|
||||
}
|
||||
.unwrap_or_else(|| {
|
||||
apply_layer_effects(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&fx_effects,
|
||||
layer.fill_opacity,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Try tile path first (only if no effects — effects need full dense buffer)
|
||||
if !has_effects {
|
||||
if let Some(Some(tl)) = tile_layers.get(i).filter(|_| layer.layer_type != hcie_protocol::LayerType::Text) {
|
||||
if tl.tile_count() == 0 && i > 0 {
|
||||
continue; // fully transparent layer with no tiles
|
||||
}
|
||||
let t_start_x = x0 / TILE_SIZE;
|
||||
let _t_start_y = y0 / TILE_SIZE;
|
||||
let t_end_x = div_ceil(x1, TILE_SIZE);
|
||||
let _t_end_y = div_ceil(y1, TILE_SIZE);
|
||||
|
||||
if use_sequential {
|
||||
for y in y0..y1.min(layer_h) {
|
||||
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
|
||||
let gy = y;
|
||||
let ty = gy / TILE_SIZE;
|
||||
let ly = (gy % TILE_SIZE) as usize;
|
||||
for tx in t_start_x..t_end_x {
|
||||
let gx_start = tx * TILE_SIZE;
|
||||
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w);
|
||||
if gx_end <= gx_start { continue; }
|
||||
if let Some(tile) = tl.get_tile(tx, ty) {
|
||||
let start_x = gx_start.max(x0);
|
||||
for gx in start_x..gx_end {
|
||||
blend_tile_pixel(row, gx, gy, ly, tile, layer, is_clipping, base_idx, layers, blend, opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Some(tl)) = tile_layers
|
||||
.get(i)
|
||||
.filter(|_| layer.layer_type != hcie_protocol::LayerType::Text)
|
||||
{
|
||||
if tl.tile_count() == 0 && i > 0 {
|
||||
continue; // fully transparent layer with no tiles
|
||||
}
|
||||
} else {
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < y0 || gy >= y1 || gy >= layer_h { return; }
|
||||
let t_start_x = x0 / TILE_SIZE;
|
||||
let _t_start_y = y0 / TILE_SIZE;
|
||||
let t_end_x = div_ceil(x1, TILE_SIZE);
|
||||
let _t_end_y = div_ceil(y1, TILE_SIZE);
|
||||
|
||||
if use_sequential {
|
||||
for y in y0..y1.min(layer_h) {
|
||||
let row =
|
||||
&mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
|
||||
let gy = y;
|
||||
let ty = gy / TILE_SIZE;
|
||||
let ly = (gy % TILE_SIZE) as usize;
|
||||
for tx in t_start_x..t_end_x {
|
||||
let gx_start = tx * TILE_SIZE;
|
||||
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w);
|
||||
if gx_end <= gx_start { continue; }
|
||||
if gx_end <= gx_start {
|
||||
continue;
|
||||
}
|
||||
if let Some(tile) = tl.get_tile(tx, ty) {
|
||||
let start_x = gx_start.max(x0);
|
||||
for gx in start_x..gx_end {
|
||||
blend_tile_pixel(row, gx, gy, ly, tile, layer, is_clipping, base_idx, layers, blend, opacity);
|
||||
blend_tile_pixel(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
ly,
|
||||
tile,
|
||||
layer,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to dense pixel path (no effects)
|
||||
if cx0 >= cx1 || cy0 >= cy1 { continue; }
|
||||
if use_sequential {
|
||||
for y in cy0..cy1 {
|
||||
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
|
||||
let gy = y;
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for gx in cx0..cx1 {
|
||||
let lx = (gx as i32 - offset_x) as u32;
|
||||
blend_dense_pixel_offset(row, gx, gy, lx, ly, layer, is_clipping, base_idx, layers, blend, opacity);
|
||||
}
|
||||
} else {
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < y0 || gy >= y1 || gy >= layer_h {
|
||||
return;
|
||||
}
|
||||
let ty = gy / TILE_SIZE;
|
||||
let ly = (gy % TILE_SIZE) as usize;
|
||||
for tx in t_start_x..t_end_x {
|
||||
let gx_start = tx * TILE_SIZE;
|
||||
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w);
|
||||
if gx_end <= gx_start {
|
||||
continue;
|
||||
}
|
||||
if let Some(tile) = tl.get_tile(tx, ty) {
|
||||
let start_x = gx_start.max(x0);
|
||||
for gx in start_x..gx_end {
|
||||
blend_tile_pixel(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
ly,
|
||||
tile,
|
||||
layer,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < cy0 || gy >= cy1 { return; }
|
||||
// Fallback to dense pixel path (no effects)
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
continue;
|
||||
}
|
||||
if use_sequential {
|
||||
for y in cy0..cy1 {
|
||||
let row =
|
||||
&mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
|
||||
let gy = y;
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for gx in cx0..cx1 {
|
||||
let lx = (gx as i32 - offset_x) as u32;
|
||||
blend_dense_pixel_offset(row, gx, gy, lx, ly, layer, is_clipping, base_idx, layers, blend, opacity);
|
||||
blend_dense_pixel_offset(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
lx,
|
||||
ly,
|
||||
layer,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < cy0 || gy >= cy1 {
|
||||
return;
|
||||
}
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for gx in cx0..cx1 {
|
||||
let lx = (gx as i32 - offset_x) as u32;
|
||||
blend_dense_pixel_offset(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
lx,
|
||||
ly,
|
||||
layer,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Effects path — use pre-computed effects buffer
|
||||
if cx0 >= cx1 || cy0 >= cy1 { continue; }
|
||||
if cx0 >= cx1 || cy0 >= cy1 {
|
||||
continue;
|
||||
}
|
||||
let eb = effect_buf.as_slice();
|
||||
if use_sequential {
|
||||
for y in cy0..cy1 {
|
||||
@@ -225,19 +336,48 @@ pub fn composite_tiled_into(
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for gx in cx0..cx1 {
|
||||
let lx = (gx as i32 - offset_x) as u32;
|
||||
blend_effects_pixel_offset(row, gx, gy, lx, ly, layer, eb, is_clipping, base_idx, layers, blend, opacity);
|
||||
blend_effects_pixel_offset(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
lx,
|
||||
ly,
|
||||
layer,
|
||||
eb,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.par_chunks_exact_mut(row_stride)
|
||||
output
|
||||
.par_chunks_exact_mut(row_stride)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let gy = y as u32;
|
||||
if gy < cy0 || gy >= cy1 { return; }
|
||||
if gy < cy0 || gy >= cy1 {
|
||||
return;
|
||||
}
|
||||
let ly = (gy as i32 - offset_y) as u32;
|
||||
for gx in cx0..cx1 {
|
||||
let lx = (gx as i32 - offset_x) as u32;
|
||||
blend_effects_pixel_offset(row, gx, gy, lx, ly, layer, eb, is_clipping, base_idx, layers, blend, opacity);
|
||||
blend_effects_pixel_offset(
|
||||
row,
|
||||
gx,
|
||||
gy,
|
||||
lx,
|
||||
ly,
|
||||
layer,
|
||||
eb,
|
||||
is_clipping,
|
||||
base_idx,
|
||||
layers,
|
||||
blend,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -255,29 +395,61 @@ fn blend_adjustment_pixel(
|
||||
opacity: f32,
|
||||
) {
|
||||
let out_idx = (x as usize) * 4;
|
||||
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
|
||||
if dst[3] == 0 { return; }
|
||||
let dst = [
|
||||
row[out_idx],
|
||||
row[out_idx + 1],
|
||||
row[out_idx + 2],
|
||||
row[out_idx + 3],
|
||||
];
|
||||
if dst[3] == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mask_val = layer.get_mask_value(x, gy);
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
|
||||
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
|
||||
crate::parallel::apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
|
||||
crate::parallel::apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
|
||||
}
|
||||
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
|
||||
crate::parallel::apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
|
||||
}
|
||||
hcie_blend::Adjustment::Curves {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => crate::parallel::apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::GradientMap {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
} => crate::parallel::apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
|
||||
hcie_blend::Adjustment::HueSaturation {
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
} => crate::parallel::apply_hue_saturation(
|
||||
dst[0],
|
||||
dst[1],
|
||||
dst[2],
|
||||
*hue,
|
||||
*saturation,
|
||||
*lightness,
|
||||
),
|
||||
};
|
||||
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
|
||||
row[out_idx] = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
|
||||
row[out_idx + 1] = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
|
||||
row[out_idx + 2] = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
|
||||
let blended_opaque = blend_pixels(
|
||||
[dst[0], dst[1], dst[2], 255],
|
||||
[adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
|
||||
blend,
|
||||
1.0,
|
||||
);
|
||||
row[out_idx] = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx + 1] = ((1.0 - eff_opacity) * dst[1] as f32
|
||||
+ eff_opacity * blended_opaque[1] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx + 2] = ((1.0 - eff_opacity) * dst[2] as f32
|
||||
+ eff_opacity * blended_opaque[2] as f32)
|
||||
.round() as u8;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -305,33 +477,50 @@ fn blend_tile_pixel(
|
||||
|
||||
// Inline get_mask_value — hot path
|
||||
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
|
||||
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
|
||||
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
|
||||
mask.get(mask_idx)
|
||||
.copied()
|
||||
.unwrap_or(layer.mask_default_color)
|
||||
} else {
|
||||
layer.mask_default_color
|
||||
}
|
||||
} else {
|
||||
255u8
|
||||
};
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
// Inline get_pixel for clipping base layer
|
||||
let base_i = (gy * layers[bi].width + gx) as usize * 4;
|
||||
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
|
||||
let base = [
|
||||
layers[bi].pixels[base_i],
|
||||
layers[bi].pixels[base_i + 1],
|
||||
layers[bi].pixels[base_i + 2],
|
||||
layers[bi].pixels[base_i + 3],
|
||||
];
|
||||
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
|
||||
} else {
|
||||
src[3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { return; }
|
||||
if src[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let dst_idx = (gx as usize) * 4;
|
||||
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
|
||||
let dst = [
|
||||
row[dst_idx],
|
||||
row[dst_idx + 1],
|
||||
row[dst_idx + 2],
|
||||
row[dst_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
|
||||
@@ -363,33 +552,50 @@ fn blend_dense_pixel(
|
||||
|
||||
// Inline get_mask_value — hot path
|
||||
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
|
||||
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
|
||||
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
|
||||
mask.get(mask_idx)
|
||||
.copied()
|
||||
.unwrap_or(layer.mask_default_color)
|
||||
} else {
|
||||
layer.mask_default_color
|
||||
}
|
||||
} else {
|
||||
255u8
|
||||
};
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
// Inline get_pixel for clipping base layer
|
||||
let base_i = (gy * layers[bi].width + gx) as usize * 4;
|
||||
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
|
||||
let base = [
|
||||
layers[bi].pixels[base_i],
|
||||
layers[bi].pixels[base_i + 1],
|
||||
layers[bi].pixels[base_i + 2],
|
||||
layers[bi].pixels[base_i + 3],
|
||||
];
|
||||
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
|
||||
} else {
|
||||
src[3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { return; }
|
||||
if src[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let dst_idx = (gx as usize) * 4;
|
||||
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
|
||||
let dst = [
|
||||
row[dst_idx],
|
||||
row[dst_idx + 1],
|
||||
row[dst_idx + 2],
|
||||
row[dst_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
|
||||
@@ -418,33 +624,50 @@ fn blend_effects_pixel(
|
||||
|
||||
// Inline get_mask_value — hot path
|
||||
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
|
||||
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
|
||||
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
|
||||
mask.get(mask_idx)
|
||||
.copied()
|
||||
.unwrap_or(layer.mask_default_color)
|
||||
} else {
|
||||
layer.mask_default_color
|
||||
}
|
||||
} else {
|
||||
255u8
|
||||
};
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
// Inline get_pixel for clipping base layer
|
||||
let base_i = (gy * layers[bi].width + gx) as usize * 4;
|
||||
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
|
||||
let base = [
|
||||
layers[bi].pixels[base_i],
|
||||
layers[bi].pixels[base_i + 1],
|
||||
layers[bi].pixels[base_i + 2],
|
||||
layers[bi].pixels[base_i + 3],
|
||||
];
|
||||
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
|
||||
} else {
|
||||
src[3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { return; }
|
||||
if src[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let dst_idx = (gx as usize) * 4;
|
||||
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
|
||||
let dst = [
|
||||
row[dst_idx],
|
||||
row[dst_idx + 1],
|
||||
row[dst_idx + 2],
|
||||
row[dst_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
|
||||
@@ -476,18 +699,23 @@ fn blend_dense_pixel_offset(
|
||||
];
|
||||
|
||||
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
|
||||
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
|
||||
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
|
||||
mask.get(mask_idx)
|
||||
.copied()
|
||||
.unwrap_or(layer.mask_default_color)
|
||||
} else {
|
||||
layer.mask_default_color
|
||||
}
|
||||
} else {
|
||||
255u8
|
||||
};
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
@@ -498,9 +726,16 @@ fn blend_dense_pixel_offset(
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { return; }
|
||||
if src[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let dst_idx = (gx as usize) * 4;
|
||||
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
|
||||
let dst = [
|
||||
row[dst_idx],
|
||||
row[dst_idx + 1],
|
||||
row[dst_idx + 2],
|
||||
row[dst_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
|
||||
@@ -530,18 +765,23 @@ fn blend_effects_pixel_offset(
|
||||
];
|
||||
|
||||
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
|
||||
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
|
||||
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
|
||||
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
|
||||
mask.get(mask_idx)
|
||||
.copied()
|
||||
.unwrap_or(layer.mask_default_color)
|
||||
} else {
|
||||
layer.mask_default_color
|
||||
}
|
||||
} else {
|
||||
255u8
|
||||
};
|
||||
if mask_val == 0 { return; }
|
||||
if mask_val == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_clipping {
|
||||
if let Some(bi) = base_idx {
|
||||
@@ -552,9 +792,16 @@ fn blend_effects_pixel_offset(
|
||||
}
|
||||
}
|
||||
|
||||
if src[3] == 0 { return; }
|
||||
if src[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let dst_idx = (gx as usize) * 4;
|
||||
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
|
||||
let dst = [
|
||||
row[dst_idx],
|
||||
row[dst_idx + 1],
|
||||
row[dst_idx + 2],
|
||||
row[dst_idx + 3],
|
||||
];
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended = blend_pixels(dst, src, blend, eff_opacity);
|
||||
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)]
|
||||
#![allow(
|
||||
dead_code,
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_macros,
|
||||
unreachable_patterns
|
||||
)]
|
||||
use hcie_composite::composite_layers;
|
||||
use hcie_protocol::Layer as ProtoLayer;
|
||||
|
||||
@@ -13,30 +19,48 @@ fn proto_to_comp(l: &ProtoLayer) -> ProtoLayer {
|
||||
#[test]
|
||||
fn test_composite_visibility_from_protocol_layer() {
|
||||
let _ = env_logger::try_init();
|
||||
let w = 100u32; let h = 100u32;
|
||||
let w = 100u32;
|
||||
let h = 100u32;
|
||||
|
||||
// Build layers using hcie_protocol::Layer (like Engine does)
|
||||
let mut bg = ProtoLayer::new_transparent("bg", w, h);
|
||||
bg.id = 1;
|
||||
bg.pixels = vec![255u8; (w*h*4) as usize];
|
||||
bg.pixels = vec![255u8; (w * h * 4) as usize];
|
||||
|
||||
let mut top = ProtoLayer::new_transparent("top", w, h);
|
||||
top.id = 2;
|
||||
top.pixels = vec![0u8; (w*h*4) as usize];
|
||||
for y in 20..80 { for x in 20..80 {
|
||||
let i = (y*w+x) as usize * 4;
|
||||
top.pixels[i]=0; top.pixels[i+1]=0; top.pixels[i+2]=0; top.pixels[i+3]=255;
|
||||
}}
|
||||
top.pixels = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 20..80 {
|
||||
for x in 20..80 {
|
||||
let i = (y * w + x) as usize * 4;
|
||||
top.pixels[i] = 0;
|
||||
top.pixels[i + 1] = 0;
|
||||
top.pixels[i + 2] = 0;
|
||||
top.pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let run_composite = |layers: &[ProtoLayer]| -> Vec<u8> {
|
||||
composite_layers(layers, w, h)
|
||||
};
|
||||
let run_composite = |layers: &[ProtoLayer]| -> Vec<u8> { composite_layers(layers, w, h) };
|
||||
|
||||
eprintln!("BOTH VISIBLE: opaque={}/10000", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
|
||||
eprintln!(
|
||||
"BOTH VISIBLE: opaque={}/10000",
|
||||
count_opaque(&run_composite(&[bg.clone(), top.clone()]))
|
||||
);
|
||||
bg.visible = false;
|
||||
eprintln!("BG HIDDEN: opaque={}/10000 (expect 3600)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
|
||||
bg.visible = true; top.visible = false;
|
||||
eprintln!("TOP HIDDEN: opaque={}/10000 (expect 10000)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
|
||||
bg.visible = false; top.visible = false;
|
||||
eprintln!("BOTH HIDDEN: opaque={}/10000 (expect 0)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
|
||||
eprintln!(
|
||||
"BG HIDDEN: opaque={}/10000 (expect 3600)",
|
||||
count_opaque(&run_composite(&[bg.clone(), top.clone()]))
|
||||
);
|
||||
bg.visible = true;
|
||||
top.visible = false;
|
||||
eprintln!(
|
||||
"TOP HIDDEN: opaque={}/10000 (expect 10000)",
|
||||
count_opaque(&run_composite(&[bg.clone(), top.clone()]))
|
||||
);
|
||||
bg.visible = false;
|
||||
top.visible = false;
|
||||
eprintln!(
|
||||
"BOTH HIDDEN: opaque={}/10000 (expect 0)",
|
||||
count_opaque(&run_composite(&[bg.clone(), top.clone()]))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
|
||||
use hcie_composite::composite_layers;
|
||||
use hcie_protocol::{BlendMode, Layer, LayerType};
|
||||
|
||||
fn make_layer(name: &str, w: u32, h: u32, rgba: [u8; 4]) -> Layer {
|
||||
let mut l = Layer::from_rgba(name, w, h, vec![rgba; (w * h) as usize].into_iter().flatten().collect());
|
||||
let mut l = Layer::from_rgba(
|
||||
name,
|
||||
w,
|
||||
h,
|
||||
vec![rgba; (w * h) as usize].into_iter().flatten().collect(),
|
||||
);
|
||||
l.blend_mode = BlendMode::Normal;
|
||||
l
|
||||
}
|
||||
@@ -39,9 +43,13 @@ fn test_pass_through_group_skipped_in_composite() {
|
||||
// PassThrough: child blends directly onto bg.
|
||||
// Multiply: 255 * 128 / 255 = 128 for each channel.
|
||||
// Expected: every pixel [128, 128, 128, 255]
|
||||
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255,
|
||||
128, 128, 128, 255, 128, 128, 128, 255];
|
||||
assert_eq!(out, expected, "PassThrough group should let child blend directly onto backdrop");
|
||||
let expected = vec![
|
||||
128u8, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255,
|
||||
];
|
||||
assert_eq!(
|
||||
out, expected,
|
||||
"PassThrough group should let child blend directly onto backdrop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -60,8 +68,9 @@ fn test_pass_through_group_invisible_children() {
|
||||
let out = composite_layers(&layers, w, h);
|
||||
|
||||
// Child hidden → only bg visible
|
||||
let expected = vec![255u8, 255, 255, 255, 255, 255, 255, 255,
|
||||
255, 255, 255, 255, 255, 255, 255, 255];
|
||||
let expected = vec![
|
||||
255u8, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
|
||||
];
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
|
||||
@@ -90,7 +99,8 @@ fn test_pass_through_group_with_opacity() {
|
||||
let out = composite_layers(&layers, w, h);
|
||||
|
||||
// Multiply full-strength
|
||||
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255,
|
||||
128, 128, 128, 255, 128, 128, 128, 255];
|
||||
let expected = vec![
|
||||
128u8, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255,
|
||||
];
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ fn test_composite_visibility_toggle() {
|
||||
let layers = vec![bg_hidden, top.clone()];
|
||||
let p = composite_layers(&layers, w, h);
|
||||
let opaque = count_opaque_pixels(&p);
|
||||
eprintln!("Test 2 (bg hidden): opaque={}/10000 (expected 3600)", opaque);
|
||||
eprintln!(
|
||||
"Test 2 (bg hidden): opaque={}/10000 (expected 3600)",
|
||||
opaque
|
||||
);
|
||||
assert_eq!(opaque, 3600, "only black square should be visible");
|
||||
|
||||
// Test 3: hide top → only white background visible
|
||||
@@ -58,12 +61,17 @@ fn test_composite_visibility_toggle() {
|
||||
let layers = vec![bg.clone(), top_hidden];
|
||||
let p = composite_layers(&layers, w, h);
|
||||
let opaque = count_opaque_pixels(&p);
|
||||
eprintln!("Test 3 (top hidden): opaque={}/10000 (expected 10000)", opaque);
|
||||
eprintln!(
|
||||
"Test 3 (top hidden): opaque={}/10000 (expected 10000)",
|
||||
opaque
|
||||
);
|
||||
assert_eq!(opaque, 10000, "white background should be visible");
|
||||
|
||||
// Test 4: hide both → fully transparent
|
||||
let mut bg_h = bg.clone(); bg_h.visible = false;
|
||||
let mut top_h = top.clone(); top_h.visible = false;
|
||||
let mut bg_h = bg.clone();
|
||||
bg_h.visible = false;
|
||||
let mut top_h = top.clone();
|
||||
top_h.visible = false;
|
||||
let layers = vec![bg_h, top_h];
|
||||
let p = composite_layers(&layers, w, h);
|
||||
let opaque = count_opaque_pixels(&p);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Compiles only when running `cargo run --example draw_tester`.
|
||||
|
||||
use eframe::egui;
|
||||
use hcie_draw::{draw_line, draw_filled_rect, draw_filled_circle, flood_fill};
|
||||
use hcie_draw::{draw_filled_circle, draw_filled_rect, draw_line, flood_fill};
|
||||
use hcie_protocol::Layer;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
@@ -86,7 +86,8 @@ impl eframe::App for DrawTesterApp {
|
||||
[self.layer.width as usize, self.layer.height as usize],
|
||||
&self.layer.pixels,
|
||||
);
|
||||
self.texture = Some(ctx.load_texture("canvas_preview", color_image, Default::default()));
|
||||
self.texture =
|
||||
Some(ctx.load_texture("canvas_preview", color_image, Default::default()));
|
||||
}
|
||||
let texture = self.texture.as_ref().unwrap().clone();
|
||||
|
||||
@@ -99,22 +100,29 @@ impl eframe::App for DrawTesterApp {
|
||||
ui.label("Click & drag to draw on this canvas:");
|
||||
ui.add_space(5.0);
|
||||
|
||||
let image_widget = egui::Image::new((texture.id(), texture.size_vec2())).sense(egui::Sense::click_and_drag());
|
||||
let image_widget = egui::Image::new((texture.id(), texture.size_vec2()))
|
||||
.sense(egui::Sense::click_and_drag());
|
||||
let resp = ui.add(image_widget);
|
||||
|
||||
if resp.dragged() || resp.clicked() {
|
||||
if let Some(hover_pos) = resp.hover_pos() {
|
||||
let rect = resp.rect;
|
||||
let px = ((hover_pos.x - rect.min.x) / rect.width() * self.layer.width as f32).clamp(0.0, self.layer.width as f32 - 1.0);
|
||||
let py = ((hover_pos.y - rect.min.y) / rect.height() * self.layer.height as f32).clamp(0.0, self.layer.height as f32 - 1.0);
|
||||
let px = ((hover_pos.x - rect.min.x) / rect.width()
|
||||
* self.layer.width as f32)
|
||||
.clamp(0.0, self.layer.width as f32 - 1.0);
|
||||
let py = ((hover_pos.y - rect.min.y) / rect.height()
|
||||
* self.layer.height as f32)
|
||||
.clamp(0.0, self.layer.height as f32 - 1.0);
|
||||
|
||||
match self.selected_tool {
|
||||
"Line" => {
|
||||
if let Some(last_pos) = self.last_drag_pos {
|
||||
draw_line(
|
||||
&mut self.layer,
|
||||
last_pos.x, last_pos.y,
|
||||
px, py,
|
||||
last_pos.x,
|
||||
last_pos.y,
|
||||
px,
|
||||
py,
|
||||
self.current_color,
|
||||
self.thickness,
|
||||
None,
|
||||
@@ -127,7 +135,8 @@ impl eframe::App for DrawTesterApp {
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
draw_filled_circle(
|
||||
&mut self.layer,
|
||||
px, py,
|
||||
px,
|
||||
py,
|
||||
self.thickness * 2.0,
|
||||
self.current_color,
|
||||
None,
|
||||
@@ -140,8 +149,10 @@ impl eframe::App for DrawTesterApp {
|
||||
let size = self.thickness * 3.0;
|
||||
draw_filled_rect(
|
||||
&mut self.layer,
|
||||
px - size, py - size,
|
||||
px + size, py + size,
|
||||
px - size,
|
||||
py - size,
|
||||
px + size,
|
||||
py + size,
|
||||
self.current_color,
|
||||
None,
|
||||
);
|
||||
@@ -152,7 +163,8 @@ impl eframe::App for DrawTesterApp {
|
||||
if resp.clicked() {
|
||||
flood_fill(
|
||||
&mut self.layer,
|
||||
px as u32, py as u32,
|
||||
px as u32,
|
||||
py as u32,
|
||||
self.current_color,
|
||||
20,
|
||||
None,
|
||||
@@ -195,7 +207,10 @@ impl eframe::App for DrawTesterApp {
|
||||
self.current_color[2] as f32 / 255.0,
|
||||
self.current_color[3] as f32 / 255.0,
|
||||
];
|
||||
if ui.color_edit_button_rgba_unmultiplied(&mut color_f32).changed() {
|
||||
if ui
|
||||
.color_edit_button_rgba_unmultiplied(&mut color_f32)
|
||||
.changed()
|
||||
{
|
||||
self.current_color = [
|
||||
(color_f32[0] * 255.0).round() as u8,
|
||||
(color_f32[1] * 255.0).round() as u8,
|
||||
@@ -213,7 +228,10 @@ impl eframe::App for DrawTesterApp {
|
||||
ui.add_space(35.0);
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "Layer-based drawing API");
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "Decoupled from hcie-protocol");
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "Operating with 100% GUI neutrality");
|
||||
ui.colored_label(
|
||||
egui::Color32::LIGHT_GREEN,
|
||||
"Operating with 100% GUI neutrality",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+125
-42
@@ -1,13 +1,15 @@
|
||||
#![allow(dead_code)]
|
||||
pub use hcie_protocol::{Layer, BlendMode};
|
||||
pub use hcie_brush_engine::{BrushTip, BrushStyle};
|
||||
use hcie_blend::blend_pixels;
|
||||
use hcie_brush_engine::{brush_spacing_pixels, jitter_offset};
|
||||
pub use hcie_brush_engine::{BrushStyle, BrushTip};
|
||||
pub use hcie_protocol::{BlendMode, Layer};
|
||||
|
||||
pub fn draw_brush_stamp(
|
||||
layer: &mut Layer,
|
||||
cx: f32, cy: f32,
|
||||
stamp: &[u8], stamp_diameter: usize,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
stamp: &[u8],
|
||||
stamp_diameter: usize,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
@@ -27,15 +29,21 @@ pub fn draw_brush_stamp(
|
||||
|
||||
if let Some(m) = mask {
|
||||
let idx = (py_u * layer.width + px_u) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let stamp_alpha = stamp[sy * stamp_diameter + sx] as f32 / 255.0;
|
||||
if stamp_alpha <= 0.0 { continue; }
|
||||
if stamp_alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dst = layer.get_pixel(px_u, py_u);
|
||||
let src_color: [u8; 4] = [
|
||||
color[0], color[1], color[2],
|
||||
color[0],
|
||||
color[1],
|
||||
color[2],
|
||||
(color[3] as f32 * stamp_alpha).round() as u8,
|
||||
];
|
||||
let out = blend_pixels(dst, src_color, hcie_protocol::BlendMode::Normal.into(), 1.0);
|
||||
@@ -54,7 +62,9 @@ pub fn draw_brush_stroke(
|
||||
mut stroke_mask: Option<&mut [u8]>,
|
||||
bg_pixels: Option<&[u8]>,
|
||||
) {
|
||||
if points.is_empty() { return; }
|
||||
if points.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let spacing = brush_spacing_pixels(tip.size, tip.spacing);
|
||||
let use_color_variant = tip.color_variant && tip.variant_amount > 0.0;
|
||||
@@ -89,13 +99,26 @@ pub fn draw_brush_stroke(
|
||||
color
|
||||
};
|
||||
let first_px_color = [
|
||||
first_color[0], first_color[1], first_color[2],
|
||||
first_color[0],
|
||||
first_color[1],
|
||||
first_color[2],
|
||||
(first_color[3] as f32 * first_opacity).round() as u8,
|
||||
];
|
||||
hcie_brush_engine::draw_dab_capped_with_stamp(
|
||||
&mut layer.pixels, layer.width, layer.height,
|
||||
first_x, first_y, first_size, tip.hardness, first_px_color, 1.0, is_eraser, mask,
|
||||
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
|
||||
&mut layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
first_x,
|
||||
first_y,
|
||||
first_size,
|
||||
tip.hardness,
|
||||
first_px_color,
|
||||
1.0,
|
||||
is_eraser,
|
||||
mask,
|
||||
stroke_mask.as_deref_mut(),
|
||||
bg_pixels,
|
||||
tip.opacity,
|
||||
stamp_ref,
|
||||
);
|
||||
|
||||
@@ -103,7 +126,9 @@ pub fn draw_brush_stroke(
|
||||
let dx = p.0 - last_p.0;
|
||||
let dy = p.1 - last_p.1;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist == 0.0 { continue; }
|
||||
if dist == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let steps = ((dist / spacing).max(1.0)).ceil() as i32;
|
||||
let step_x = dx / steps as f32;
|
||||
@@ -127,13 +152,26 @@ pub fn draw_brush_stroke(
|
||||
color
|
||||
};
|
||||
let px_color = [
|
||||
dab_color[0], dab_color[1], dab_color[2],
|
||||
dab_color[0],
|
||||
dab_color[1],
|
||||
dab_color[2],
|
||||
(dab_color[3] as f32 * pressure_opacity).round() as u8,
|
||||
];
|
||||
hcie_brush_engine::draw_dab_capped_with_stamp(
|
||||
&mut layer.pixels, layer.width, layer.height,
|
||||
final_x, final_y, effective_size, tip.hardness, px_color, 1.0, is_eraser, mask,
|
||||
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
|
||||
&mut layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
final_x,
|
||||
final_y,
|
||||
effective_size,
|
||||
tip.hardness,
|
||||
px_color,
|
||||
1.0,
|
||||
is_eraser,
|
||||
mask,
|
||||
stroke_mask.as_deref_mut(),
|
||||
bg_pixels,
|
||||
tip.opacity,
|
||||
stamp_ref,
|
||||
);
|
||||
}
|
||||
@@ -144,7 +182,10 @@ pub fn draw_brush_stroke(
|
||||
|
||||
pub fn draw_line(
|
||||
layer: &mut Layer,
|
||||
x0: f32, y0: f32, x1: f32, y1: f32,
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
color: [u8; 4],
|
||||
width: f32,
|
||||
mask: Option<&[u8]>,
|
||||
@@ -152,7 +193,9 @@ pub fn draw_line(
|
||||
let dx = x1 - x0;
|
||||
let dy = y1 - y0;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist == 0.0 { return; }
|
||||
if dist == 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let steps = (dist * 2.0).max(1.0).ceil() as i32;
|
||||
for i in 0..=steps {
|
||||
@@ -165,29 +208,44 @@ pub fn draw_line(
|
||||
|
||||
pub fn draw_filled_circle(
|
||||
layer: &mut Layer,
|
||||
cx: f32, cy: f32, r: f32,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
r: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
let r_i = r.ceil() as i32;
|
||||
for dy in -r_i..=r_i {
|
||||
let py = (cy + dy as f32).round() as i32;
|
||||
if py < 0 || py >= layer.height as i32 { continue; }
|
||||
if py < 0 || py >= layer.height as i32 {
|
||||
continue;
|
||||
}
|
||||
let dx_max = (r * r - dy as f32 * dy as f32).sqrt();
|
||||
let dx_i = dx_max.ceil() as i32;
|
||||
for dx in -dx_i..=dx_i {
|
||||
let px = (cx + dx as f32).round() as i32;
|
||||
if px < 0 || px >= layer.width as i32 { continue; }
|
||||
if px < 0 || px >= layer.width as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(m) = mask {
|
||||
let idx = (py as u32 * layer.width + px as u32) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let dist = ((dx as f32).powi(2) + (dy as f32).powi(2)).sqrt();
|
||||
let alpha_t = 1.0 - (dist - (r - 1.0)).max(0.0).min(1.0);
|
||||
if alpha_t <= 0.0 { continue; }
|
||||
let src = [color[0], color[1], color[2], (color[3] as f32 * alpha_t).round() as u8];
|
||||
if alpha_t <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let src = [
|
||||
color[0],
|
||||
color[1],
|
||||
color[2],
|
||||
(color[3] as f32 * alpha_t).round() as u8,
|
||||
];
|
||||
let dst = layer.get_pixel(px as u32, py as u32);
|
||||
let out = blend_pixels(dst, src, hcie_protocol::BlendMode::Normal.into(), 1.0);
|
||||
layer.set_pixel(px as u32, py as u32, out);
|
||||
@@ -197,20 +255,25 @@ pub fn draw_filled_circle(
|
||||
|
||||
pub fn draw_filled_rect(
|
||||
layer: &mut Layer,
|
||||
x1: f32, y1: f32, x2: f32, y2: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
y2: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
let x_start = x1.min(x2).max(0.0).floor() as u32;
|
||||
let x_end = x1.max(x2).min(layer.width as f32).ceil() as u32;
|
||||
let x_end = x1.max(x2).min(layer.width as f32).ceil() as u32;
|
||||
let y_start = y1.min(y2).max(0.0).floor() as u32;
|
||||
let y_end = y1.max(y2).min(layer.height as f32).ceil() as u32;
|
||||
let y_end = y1.max(y2).min(layer.height as f32).ceil() as u32;
|
||||
|
||||
for y in y_start..y_end {
|
||||
for x in x_start..x_end {
|
||||
if let Some(m) = mask {
|
||||
let idx = (y * layer.width + x) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let dst = layer.get_pixel(x, y);
|
||||
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0);
|
||||
@@ -221,14 +284,17 @@ pub fn draw_filled_rect(
|
||||
|
||||
pub fn draw_filled_ellipse(
|
||||
layer: &mut Layer,
|
||||
cx: f32, cy: f32, rx: f32, ry: f32,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
let x_start = (cx - rx).max(0.0).floor() as u32;
|
||||
let x_end = (cx + rx).min(layer.width as f32).ceil() as u32;
|
||||
let x_end = (cx + rx).min(layer.width as f32).ceil() as u32;
|
||||
let y_start = (cy - ry).max(0.0).floor() as u32;
|
||||
let y_end = (cy + ry).min(layer.height as f32).ceil() as u32;
|
||||
let y_end = (cy + ry).min(layer.height as f32).ceil() as u32;
|
||||
|
||||
for y in y_start..y_end {
|
||||
for x in x_start..x_end {
|
||||
@@ -238,7 +304,9 @@ pub fn draw_filled_ellipse(
|
||||
if dist <= 1.0 {
|
||||
if let Some(m) = mask {
|
||||
let idx = (y * layer.width + x) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let dst = layer.get_pixel(x, y);
|
||||
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0);
|
||||
@@ -250,14 +318,17 @@ pub fn draw_filled_ellipse(
|
||||
|
||||
pub fn flood_fill(
|
||||
layer: &mut Layer,
|
||||
x: u32, y: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
fill_color: [u8; 4],
|
||||
tolerance: u8,
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
if let Some(m) = mask {
|
||||
let idx = (y * layer.width + x) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { return; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let start_color = layer.get_pixel(x, y);
|
||||
@@ -271,19 +342,31 @@ pub fn flood_fill(
|
||||
|
||||
while let Some((px, py)) = stack.pop() {
|
||||
let c = layer.get_pixel(px, py);
|
||||
if !color_match(&c, &start_color, tolerance) { continue; }
|
||||
if !color_match(&c, &start_color, tolerance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(m) = mask {
|
||||
let idx = (py * w + px) as usize;
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
|
||||
if m.get(idx).copied().unwrap_or(0) == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
layer.set_pixel(px, py, fill_color);
|
||||
|
||||
if px > 0 { stack.push((px - 1, py)); }
|
||||
if px + 1 < w { stack.push((px + 1, py)); }
|
||||
if py > 0 { stack.push((px, py - 1)); }
|
||||
if py + 1 < h { stack.push((px, py + 1)); }
|
||||
if px > 0 {
|
||||
stack.push((px - 1, py));
|
||||
}
|
||||
if px + 1 < w {
|
||||
stack.push((px + 1, py));
|
||||
}
|
||||
if py > 0 {
|
||||
stack.push((px, py - 1));
|
||||
}
|
||||
if py + 1 < h {
|
||||
stack.push((px, py + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,4 +377,4 @@ fn color_match(a: &[u8; 4], b: &[u8; 4], tolerance: u8) -> bool {
|
||||
let diff_b = (a[2] as i32 - b[2] as i32).abs();
|
||||
let diff_a = (a[3] as i32 - b[3] as i32).abs();
|
||||
diff_r <= tol && diff_g <= tol && diff_b <= tol && diff_a <= tol
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,16 @@ fn test_draw_filled_ellipse() {
|
||||
#[test]
|
||||
fn test_draw_line() {
|
||||
let mut layer = Layer::new_transparent("test", 10, 10);
|
||||
hcie_draw::draw_line(&mut layer, 1.0, 5.0, 8.0, 5.0, [255, 255, 0, 255], 1.0, None);
|
||||
hcie_draw::draw_line(
|
||||
&mut layer,
|
||||
1.0,
|
||||
5.0,
|
||||
8.0,
|
||||
5.0,
|
||||
[255, 255, 0, 255],
|
||||
1.0,
|
||||
None,
|
||||
);
|
||||
let px = layer.get_pixel(5, 5);
|
||||
assert_eq!(px[0], 255, "line pixel should be yellow");
|
||||
assert_eq!(px[1], 255);
|
||||
@@ -56,7 +65,15 @@ fn test_draw_with_mask_restricts_pixels() {
|
||||
let mut layer = Layer::new_transparent("test", 10, 10);
|
||||
let mut mask = vec![0u8; 100];
|
||||
mask[0] = 255;
|
||||
hcie_draw::draw_filled_rect(&mut layer, 0.0, 0.0, 9.0, 9.0, [255, 0, 0, 255], Some(&mask));
|
||||
hcie_draw::draw_filled_rect(
|
||||
&mut layer,
|
||||
0.0,
|
||||
0.0,
|
||||
9.0,
|
||||
9.0,
|
||||
[255, 0, 0, 255],
|
||||
Some(&mask),
|
||||
);
|
||||
let in_mask = layer.get_pixel(0, 0);
|
||||
assert_eq!(in_mask[0], 255, "masked pixel should be red");
|
||||
let out_mask = layer.get_pixel(5, 5);
|
||||
|
||||
@@ -59,23 +59,38 @@ fn init_templates() -> Vec<ShapeTemplateDef> {
|
||||
}
|
||||
|
||||
pub fn get_template_def(name: &str) -> Option<ShapeTemplateDef> {
|
||||
TEMPLATES.get_or_init(init_templates).iter().find(|t| t.name == name).cloned()
|
||||
TEMPLATES
|
||||
.get_or_init(init_templates)
|
||||
.iter()
|
||||
.find(|t| t.name == name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn all_names() -> Vec<String> {
|
||||
TEMPLATES.get_or_init(init_templates).iter().map(|t| t.name.clone()).collect()
|
||||
TEMPLATES
|
||||
.get_or_init(init_templates)
|
||||
.iter()
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_templates(query: &str) -> Vec<ShapeTemplateDef> {
|
||||
let q_lower = query.to_lowercase();
|
||||
TEMPLATES.get_or_init(init_templates)
|
||||
TEMPLATES
|
||||
.get_or_init(init_templates)
|
||||
.iter()
|
||||
.filter(|t| t.name.to_lowercase().contains(&q_lower) || t.description.to_lowercase().contains(&q_lower))
|
||||
.filter(|t| {
|
||||
t.name.to_lowercase().contains(&q_lower)
|
||||
|| t.description.to_lowercase().contains(&q_lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, String>) -> Vec<TemplateComponent> {
|
||||
pub fn eval_template(
|
||||
def: &ShapeTemplateDef,
|
||||
provided_params: &HashMap<String, String>,
|
||||
) -> Vec<TemplateComponent> {
|
||||
let mut num_vars = HashMap::new();
|
||||
let mut str_vars = HashMap::new();
|
||||
|
||||
@@ -89,7 +104,9 @@ pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, S
|
||||
}
|
||||
|
||||
let eval = |expr: &str| -> f32 {
|
||||
if let Ok(num) = expr.parse::<f32>() { return num; }
|
||||
if let Ok(num) = expr.parse::<f32>() {
|
||||
return num;
|
||||
}
|
||||
math_eval(expr, &num_vars).unwrap_or(0.0)
|
||||
};
|
||||
|
||||
@@ -116,8 +133,16 @@ pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, S
|
||||
"circle" => {
|
||||
let cx = eval(&c.cx);
|
||||
let cy = eval(&c.cy);
|
||||
let rx_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.rx) };
|
||||
let ry_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.ry) };
|
||||
let rx_val = if !c.r.is_empty() {
|
||||
eval(&c.r)
|
||||
} else {
|
||||
eval(&c.rx)
|
||||
};
|
||||
let ry_val = if !c.r.is_empty() {
|
||||
eval(&c.r)
|
||||
} else {
|
||||
eval(&c.ry)
|
||||
};
|
||||
pts = circle_points(cx, cy, rx_val, ry_val, c.n.max(8));
|
||||
}
|
||||
"star" => {
|
||||
@@ -129,7 +154,12 @@ pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, S
|
||||
}
|
||||
|
||||
if !pts.is_empty() {
|
||||
out.push(TemplateComponent { pts, closed: c.closed, fill, fill_color: fill_str });
|
||||
out.push(TemplateComponent {
|
||||
pts,
|
||||
closed: c.closed,
|
||||
fill,
|
||||
fill_color: fill_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -145,7 +175,9 @@ fn tokenize(expr: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
for c in expr.chars() {
|
||||
if c.is_whitespace() { continue; }
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if "+-*/()".contains(c) {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
@@ -156,7 +188,9 @@ fn tokenize(expr: &str) -> Vec<String> {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() { tokens.push(current); }
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
@@ -164,9 +198,15 @@ fn parse_expr(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -
|
||||
let mut left = parse_term(tokens, pos, vars)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = &tokens[*pos];
|
||||
if op == "+" { *pos += 1; left += parse_term(tokens, pos, vars)?; }
|
||||
else if op == "-" { *pos += 1; left -= parse_term(tokens, pos, vars)?; }
|
||||
else { break; }
|
||||
if op == "+" {
|
||||
*pos += 1;
|
||||
left += parse_term(tokens, pos, vars)?;
|
||||
} else if op == "-" {
|
||||
*pos += 1;
|
||||
left -= parse_term(tokens, pos, vars)?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
@@ -175,20 +215,30 @@ fn parse_term(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -
|
||||
let mut left = parse_factor(tokens, pos, vars)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = &tokens[*pos];
|
||||
if op == "*" { *pos += 1; left *= parse_factor(tokens, pos, vars)?; }
|
||||
else if op == "/" { *pos += 1; left /= parse_factor(tokens, pos, vars)?; }
|
||||
else { break; }
|
||||
if op == "*" {
|
||||
*pos += 1;
|
||||
left *= parse_factor(tokens, pos, vars)?;
|
||||
} else if op == "/" {
|
||||
*pos += 1;
|
||||
left /= parse_factor(tokens, pos, vars)?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
|
||||
fn parse_factor(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
|
||||
if *pos >= tokens.len() { return None; }
|
||||
if *pos >= tokens.len() {
|
||||
return None;
|
||||
}
|
||||
let t = &tokens[*pos];
|
||||
if t == "(" {
|
||||
*pos += 1;
|
||||
let val = parse_expr(tokens, pos, vars)?;
|
||||
if *pos < tokens.len() && tokens[*pos] == ")" { *pos += 1; }
|
||||
if *pos < tokens.len() && tokens[*pos] == ")" {
|
||||
*pos += 1;
|
||||
}
|
||||
return Some(val);
|
||||
}
|
||||
if let Ok(n) = t.parse::<f32>() {
|
||||
@@ -220,4 +270,4 @@ fn star_points(n: usize, outer: f32, inner: f32) -> Vec<[f32; 2]> {
|
||||
pts.push([a2.cos() * inner, a2.sin() * inner]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)]
|
||||
use std::sync::{OnceLock, atomic::{AtomicU8, Ordering}};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::os::raw::c_char;
|
||||
use std::ffi::CString;
|
||||
#![allow(
|
||||
dead_code,
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_macros,
|
||||
unreachable_patterns
|
||||
)]
|
||||
use libloading::Library;
|
||||
use std::ffi::CString;
|
||||
use std::os::raw::c_char;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
atomic::{AtomicU8, Ordering},
|
||||
OnceLock,
|
||||
};
|
||||
|
||||
/// Global vision backend preference mirrored into the dynamic `hcie-vision` plugin.
|
||||
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
|
||||
@@ -48,15 +57,25 @@ pub fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::B
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] {
|
||||
|
||||
pub fn blend_pixels(
|
||||
dst: [u8; 4],
|
||||
src: [u8; 4],
|
||||
mode: hcie_protocol::BlendMode,
|
||||
opacity: f32,
|
||||
) -> [u8; 4] {
|
||||
hcie_blend::blend_pixels(dst, src, mode.into(), opacity)
|
||||
}
|
||||
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) {
|
||||
hcie_blend::blend_buffers(dst, src, mode.into(), opacity)
|
||||
}
|
||||
|
||||
pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> {
|
||||
pub fn apply_filter(
|
||||
filter_id: &str,
|
||||
params: &serde_json::Value,
|
||||
pixels: &mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), String> {
|
||||
let mut fl = hcie_protocol::Layer::new_transparent("", width, height);
|
||||
fl.pixels.copy_from_slice(pixels);
|
||||
hcie_filter::apply_filter(&mut fl, filter_id, params);
|
||||
@@ -65,69 +84,255 @@ pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true;
|
||||
pub fn draw_line(
|
||||
layer: &mut hcie_protocol::Layer,
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
color: [u8; 4],
|
||||
stroke_size: f32,
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask);
|
||||
layer.dirty = true;
|
||||
}
|
||||
pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true;
|
||||
pub fn draw_filled_rect(
|
||||
layer: &mut hcie_protocol::Layer,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
y2: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask);
|
||||
layer.dirty = true;
|
||||
}
|
||||
pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true;
|
||||
pub fn draw_filled_ellipse(
|
||||
layer: &mut hcie_protocol::Layer,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
color: [u8; 4],
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask);
|
||||
layer.dirty = true;
|
||||
}
|
||||
pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) {
|
||||
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true;
|
||||
pub fn flood_fill(
|
||||
layer: &mut hcie_protocol::Layer,
|
||||
x: u32,
|
||||
y: u32,
|
||||
color: [u8; 4],
|
||||
tolerance: u8,
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask);
|
||||
layer.dirty = true;
|
||||
}
|
||||
pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) {
|
||||
pub fn draw_brush_stroke(
|
||||
layer: &mut hcie_protocol::Layer,
|
||||
points: &[(f32, f32, f32)],
|
||||
color: [u8; 4],
|
||||
tip: &hcie_protocol::BrushTip,
|
||||
is_eraser: bool,
|
||||
mask: Option<&[u8]>,
|
||||
stroke_mask: Option<&mut [u8]>,
|
||||
bg_pixels: Option<&[u8]>,
|
||||
) {
|
||||
let style = map_brush_style(tip.style);
|
||||
let ct = hcie_brush_engine::BrushTip {
|
||||
style,
|
||||
size: tip.size, opacity: tip.opacity, hardness: tip.hardness,
|
||||
spacing: tip.spacing, flow: tip.flow,
|
||||
jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount,
|
||||
angle: tip.angle, roundness: tip.roundness,
|
||||
spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density,
|
||||
bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h,
|
||||
color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density,
|
||||
size: tip.size,
|
||||
opacity: tip.opacity,
|
||||
hardness: tip.hardness,
|
||||
spacing: tip.spacing,
|
||||
flow: tip.flow,
|
||||
jitter_amount: tip.jitter_amount,
|
||||
scatter_amount: tip.scatter_amount,
|
||||
angle: tip.angle,
|
||||
roundness: tip.roundness,
|
||||
spray_particle_size: tip.spray_particle_size,
|
||||
spray_density: tip.spray_density,
|
||||
bitmap_pixels: tip.bitmap_pixels.clone(),
|
||||
bitmap_w: tip.bitmap_w,
|
||||
bitmap_h: tip.bitmap_h,
|
||||
color_variant: tip.color_variant,
|
||||
variant_amount: tip.variant_amount,
|
||||
density: tip.density,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
};
|
||||
hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true;
|
||||
hcie_draw::draw_brush_stroke(
|
||||
layer,
|
||||
points,
|
||||
color,
|
||||
&ct,
|
||||
is_eraser,
|
||||
mask,
|
||||
stroke_mask,
|
||||
bg_pixels,
|
||||
);
|
||||
layer.dirty = true;
|
||||
}
|
||||
pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) {
|
||||
pub fn draw_specialized_stroke(
|
||||
pixels: &mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
points: &[(f32, f32, f32)],
|
||||
brush_style: hcie_protocol::BrushStyle,
|
||||
size: f32,
|
||||
hardness: f32,
|
||||
color: [u8; 4],
|
||||
opacity: f32,
|
||||
spacing_ratio: f32,
|
||||
is_eraser: bool,
|
||||
sketch_history: Option<&mut Vec<(f32, f32)>>,
|
||||
spray_particle_size: f32,
|
||||
spray_density: u32,
|
||||
mask: Option<&[u8]>,
|
||||
stroke_mask: Option<&mut [u8]>,
|
||||
bg_pixels: Option<&[u8]>,
|
||||
color_variant: bool,
|
||||
variant_amount: f32,
|
||||
density: f32,
|
||||
) {
|
||||
let style = map_brush_style(brush_style);
|
||||
hcie_brush_engine::draw_specialized_stroke(pixels, width, height, points, style, size, hardness, color, opacity, spacing_ratio, is_eraser, sketch_history, spray_particle_size, spray_density, mask, stroke_mask, bg_pixels, color_variant, variant_amount, density)
|
||||
hcie_brush_engine::draw_specialized_stroke(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
points,
|
||||
style,
|
||||
size,
|
||||
hardness,
|
||||
color,
|
||||
opacity,
|
||||
spacing_ratio,
|
||||
is_eraser,
|
||||
sketch_history,
|
||||
spray_particle_size,
|
||||
spray_density,
|
||||
mask,
|
||||
stroke_mask,
|
||||
bg_pixels,
|
||||
color_variant,
|
||||
variant_amount,
|
||||
density,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn composite_layers(layers: &[hcie_protocol::Layer], cw: u32, ch: u32) -> Vec<u8> { hcie_composite::composite_layers(layers, cw, ch) }
|
||||
pub fn composite_layers_region(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, x0: u32, y0: u32, x1: u32, y1: u32, out: &mut [u8]) { hcie_composite::composite_layers_region(layers, cw, ch, x0, y0, x1, y1, out) }
|
||||
pub fn composite_layers(layers: &[hcie_protocol::Layer], cw: u32, ch: u32) -> Vec<u8> {
|
||||
hcie_composite::composite_layers(layers, cw, ch)
|
||||
}
|
||||
pub fn composite_layers_region(
|
||||
layers: &[hcie_protocol::Layer],
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
out: &mut [u8],
|
||||
) {
|
||||
hcie_composite::composite_layers_region(layers, cw, ch, x0, y0, x1, y1, out)
|
||||
}
|
||||
pub mod tiled {
|
||||
|
||||
pub fn composite_tiled_into(layers: &[hcie_protocol::Layer], tile_layers: &[Option<hcie_tile::TiledLayer>], cw: u32, ch: u32, x0: u32, y0: u32, x1: u32, y1: u32, out: &mut [u8]) {
|
||||
hcie_composite::tiled::composite_tiled_into(layers, tile_layers, cw, ch, x0, y0, x1, y1, out)
|
||||
|
||||
pub fn composite_tiled_into(
|
||||
layers: &[hcie_protocol::Layer],
|
||||
tile_layers: &[Option<hcie_tile::TiledLayer>],
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
out: &mut [u8],
|
||||
) {
|
||||
hcie_composite::tiled::composite_tiled_into(
|
||||
layers,
|
||||
tile_layers,
|
||||
cw,
|
||||
ch,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_image(path: &Path) -> Result<hcie_protocol::Layer, String> { hcie_io::load_image(path) }
|
||||
pub fn save_image(layer: &hcie_protocol::Layer, path: &Path, format: &str) -> Result<(), String> { hcie_io::save_image(layer, path, format) }
|
||||
pub fn import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_psd::import_psd(path) }
|
||||
pub fn export_psd(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, composited: &[u8], path: &Path) -> Result<(), String> { hcie_psd::save_psd(layers, cw, ch, composited, path) }
|
||||
pub fn import_kra(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_kra::import_kra(path) }
|
||||
pub fn export_kra(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, composited: &[u8], path: &Path) -> Result<(), String> { hcie_kra::export_kra(layers, cw, ch, composited, path) }
|
||||
pub fn load_native(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_native::load_native(path) }
|
||||
pub fn save_native(layers: &[hcie_protocol::Layer], path: &Path) -> Result<(), String> { hcie_native::save_native(layers, path) }
|
||||
pub fn load_image(path: &Path) -> Result<hcie_protocol::Layer, String> {
|
||||
hcie_io::load_image(path)
|
||||
}
|
||||
pub fn save_image(layer: &hcie_protocol::Layer, path: &Path, format: &str) -> Result<(), String> {
|
||||
hcie_io::save_image(layer, path, format)
|
||||
}
|
||||
pub fn import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
hcie_psd::import_psd(path)
|
||||
}
|
||||
pub fn export_psd(
|
||||
layers: &[hcie_protocol::Layer],
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
composited: &[u8],
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
hcie_psd::save_psd(layers, cw, ch, composited, path)
|
||||
}
|
||||
pub fn import_kra(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
hcie_kra::import_kra(path)
|
||||
}
|
||||
pub fn export_kra(
|
||||
layers: &[hcie_protocol::Layer],
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
composited: &[u8],
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
hcie_kra::export_kra(layers, cw, ch, composited, path)
|
||||
}
|
||||
pub fn load_native(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
hcie_native::load_native(path)
|
||||
}
|
||||
pub fn save_native(layers: &[hcie_protocol::Layer], path: &Path) -> Result<(), String> {
|
||||
hcie_native::save_native(layers, path)
|
||||
}
|
||||
|
||||
pub mod svg_import {
|
||||
|
||||
pub fn import_svg(data: &[u8]) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_io::svg_import::import_svg(data) }
|
||||
|
||||
pub fn import_svg(data: &[u8]) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
hcie_io::svg_import::import_svg(data)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod vector {
|
||||
|
||||
pub fn render_vector_shapes(layer: &mut hcie_protocol::Layer) { hcie_vector::render_vector_shapes(layer) }
|
||||
|
||||
pub fn render_vector_shapes(layer: &mut hcie_protocol::Layer) {
|
||||
hcie_vector::render_vector_shapes(layer)
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BooleanOp { Union, Intersect, Subtract, Xor }
|
||||
pub fn boolean_shapes(op: BooleanOp, a: &hcie_protocol::VectorShape, b: &hcie_protocol::VectorShape) -> Option<hcie_protocol::VectorShape> {
|
||||
let vop = match op { BooleanOp::Union => hcie_vector::path_boolean::BooleanOp::Union, BooleanOp::Intersect => hcie_vector::path_boolean::BooleanOp::Intersect, BooleanOp::Subtract => hcie_vector::path_boolean::BooleanOp::Subtract, BooleanOp::Xor => hcie_vector::path_boolean::BooleanOp::Xor };
|
||||
pub enum BooleanOp {
|
||||
Union,
|
||||
Intersect,
|
||||
Subtract,
|
||||
Xor,
|
||||
}
|
||||
pub fn boolean_shapes(
|
||||
op: BooleanOp,
|
||||
a: &hcie_protocol::VectorShape,
|
||||
b: &hcie_protocol::VectorShape,
|
||||
) -> Option<hcie_protocol::VectorShape> {
|
||||
let vop = match op {
|
||||
BooleanOp::Union => hcie_vector::path_boolean::BooleanOp::Union,
|
||||
BooleanOp::Intersect => hcie_vector::path_boolean::BooleanOp::Intersect,
|
||||
BooleanOp::Subtract => hcie_vector::path_boolean::BooleanOp::Subtract,
|
||||
BooleanOp::Xor => hcie_vector::path_boolean::BooleanOp::Xor,
|
||||
};
|
||||
hcie_vector::path_boolean::boolean_shapes(vop, a, b)
|
||||
}
|
||||
}
|
||||
@@ -137,17 +342,29 @@ pub mod vector {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn find_plugin_path(name: &str) -> Result<PathBuf, String> {
|
||||
let filename = if cfg!(target_os = "windows") { format!("{}.dll", name) } else if cfg!(target_os = "macos") { format!("lib{}.dylib", name) } else { format!("lib{}.so", name) };
|
||||
let filename = if cfg!(target_os = "windows") {
|
||||
format!("{}.dll", name)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
format!("lib{}.dylib", name)
|
||||
} else {
|
||||
format!("lib{}.so", name)
|
||||
};
|
||||
let candidates: Vec<PathBuf> = {
|
||||
let mut v = Vec::new();
|
||||
// 1. Traditional plugins directory next to the executable or cwd.
|
||||
v.push(PathBuf::from("plugins"));
|
||||
if let Some(exe_plugins) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("plugins"))) {
|
||||
if let Some(exe_plugins) = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("plugins")))
|
||||
{
|
||||
v.push(exe_plugins.clone());
|
||||
}
|
||||
// 2. Cargo workspace layout: the dynamic library is placed directly in
|
||||
// the target profile directory (e.g. target/debug/libhcie_ai.so).
|
||||
if let Some(exe_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
if let Some(exe_dir) = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
{
|
||||
v.push(exe_dir.clone());
|
||||
// 3. Also check the deps subdirectory used by Cargo.
|
||||
v.push(exe_dir.join("deps"));
|
||||
@@ -175,23 +392,35 @@ fn find_plugin_path(name: &str) -> Result<PathBuf, String> {
|
||||
v.push(c.join("target").join("debug").join("deps"));
|
||||
v.push(c.join("target").join("release"));
|
||||
v.push(c.join("target").join("release").join("deps"));
|
||||
if !c.pop() { break; }
|
||||
if !c.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
for base in candidates {
|
||||
let p = base.join(&filename);
|
||||
if p.exists() { return Ok(p); }
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
Err(format!("Plugin '{}' not found", name))
|
||||
}
|
||||
|
||||
macro_rules! sym { ($lib:ident, $name:literal) => { *$lib.get(concat!($name, "\0").as_bytes()).expect(concat!("Failed to resolve ", $name)) }; }
|
||||
macro_rules! sym {
|
||||
($lib:ident, $name:literal) => {
|
||||
*$lib
|
||||
.get(concat!($name, "\0").as_bytes())
|
||||
.expect(concat!("Failed to resolve ", $name))
|
||||
};
|
||||
}
|
||||
|
||||
struct AiPlugins {
|
||||
_lib_ai: Library, _lib_vision: Library,
|
||||
ai_chat_complete_fn: unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
_lib_ai: Library,
|
||||
_lib_vision: Library,
|
||||
ai_chat_complete_fn:
|
||||
unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_session_create_fn: unsafe extern "C" fn(*const c_char) -> i64,
|
||||
ai_session_push_user_fn: unsafe extern "C" fn(i64, *const c_char) -> bool,
|
||||
ai_session_push_assistant_fn: unsafe extern "C" fn(i64, *const c_char) -> bool,
|
||||
@@ -201,15 +430,43 @@ struct AiPlugins {
|
||||
ai_template_names_fn: unsafe extern "C" fn(*mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_get_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_search_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_execute_fn: unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_execute_fn:
|
||||
unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_execute_action_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_free_buffer_fn: unsafe extern "C" fn(*mut u8, usize),
|
||||
vision_background_removal_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_object_segment_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, u32, u32, u32, u32, u32, u32, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_super_resolution_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_inpaint_fn: unsafe extern "C" fn(*mut u8, usize, u32, u32, *const u8, usize, i32) -> bool,
|
||||
vision_build_polygon_mask_fn: unsafe extern "C" fn(*const c_char, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_build_stroke_mask_fn: unsafe extern "C" fn(*const c_char, *const u8, usize, f32, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_background_removal_fn:
|
||||
unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_object_segment_fn: unsafe extern "C" fn(
|
||||
*const u8,
|
||||
usize,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
u32,
|
||||
*const c_char,
|
||||
*mut *mut u8,
|
||||
*mut usize,
|
||||
) -> bool,
|
||||
vision_super_resolution_fn:
|
||||
unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_inpaint_fn:
|
||||
unsafe extern "C" fn(*mut u8, usize, u32, u32, *const u8, usize, i32) -> bool,
|
||||
vision_build_polygon_mask_fn:
|
||||
unsafe extern "C" fn(*const c_char, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_build_stroke_mask_fn: unsafe extern "C" fn(
|
||||
*const c_char,
|
||||
*const u8,
|
||||
usize,
|
||||
f32,
|
||||
u32,
|
||||
u32,
|
||||
*mut *mut u8,
|
||||
*mut usize,
|
||||
) -> bool,
|
||||
vision_set_backend_fn: unsafe extern "C" fn(u8),
|
||||
vision_free_buffer_fn: unsafe extern "C" fn(*mut u8, usize),
|
||||
}
|
||||
@@ -226,7 +483,8 @@ fn ai_plugins() -> &'static AiPlugins {
|
||||
let ai_chat_complete_fn = *la.get(b"hcie_ai_chat_complete_c\0").unwrap();
|
||||
let ai_session_create_fn = *la.get(b"hcie_ai_session_create_c\0").unwrap();
|
||||
let ai_session_push_user_fn = *la.get(b"hcie_ai_session_push_user_c\0").unwrap();
|
||||
let ai_session_push_assistant_fn = *la.get(b"hcie_ai_session_push_assistant_c\0").unwrap();
|
||||
let ai_session_push_assistant_fn =
|
||||
*la.get(b"hcie_ai_session_push_assistant_c\0").unwrap();
|
||||
let ai_session_clear_fn = *la.get(b"hcie_ai_session_clear_c\0").unwrap();
|
||||
let ai_session_destroy_fn = *la.get(b"hcie_ai_session_destroy_c\0").unwrap();
|
||||
let ai_session_serialize_fn = *la.get(b"hcie_ai_session_serialize_c\0").unwrap();
|
||||
@@ -236,26 +494,46 @@ fn ai_plugins() -> &'static AiPlugins {
|
||||
let ai_template_execute_fn = *la.get(b"hcie_ai_template_execute_c\0").unwrap();
|
||||
let ai_execute_action_fn = *la.get(b"hcie_ai_execute_action_c\0").unwrap();
|
||||
let ai_free_buffer_fn = *la.get(b"free_buffer_c\0").unwrap();
|
||||
let vision_background_removal_fn = *lv.get(b"hcie_vision_background_removal_c\0").unwrap();
|
||||
let vision_background_removal_fn =
|
||||
*lv.get(b"hcie_vision_background_removal_c\0").unwrap();
|
||||
let vision_object_segment_fn = *lv.get(b"hcie_vision_object_segment_c\0").unwrap();
|
||||
let vision_super_resolution_fn = *lv.get(b"hcie_vision_super_resolution_c\0").unwrap();
|
||||
let vision_inpaint_fn = *lv.get(b"hcie_vision_inpaint_c\0").unwrap();
|
||||
let vision_build_polygon_mask_fn = *lv.get(b"hcie_vision_build_polygon_mask_c\0").unwrap();
|
||||
let vision_build_stroke_mask_fn = *lv.get(b"hcie_vision_build_stroke_mask_c\0").unwrap();
|
||||
let vision_set_backend_fn: unsafe extern "C" fn(u8) = *lv.get(b"hcie_vision_set_backend_c\0").unwrap();
|
||||
let vision_build_polygon_mask_fn =
|
||||
*lv.get(b"hcie_vision_build_polygon_mask_c\0").unwrap();
|
||||
let vision_build_stroke_mask_fn =
|
||||
*lv.get(b"hcie_vision_build_stroke_mask_c\0").unwrap();
|
||||
let vision_set_backend_fn: unsafe extern "C" fn(u8) =
|
||||
*lv.get(b"hcie_vision_set_backend_c\0").unwrap();
|
||||
let vision_free_buffer_fn = *lv.get(b"free_buffer_c\0").unwrap();
|
||||
// Mirror the current backend preference into the freshly loaded plugin.
|
||||
let backend = VISION_BACKEND.load(Ordering::Relaxed);
|
||||
(vision_set_backend_fn)(backend);
|
||||
|
||||
AiPlugins {
|
||||
_lib_ai: la, _lib_vision: lv,
|
||||
ai_chat_complete_fn, ai_session_create_fn, ai_session_push_user_fn, ai_session_push_assistant_fn,
|
||||
ai_session_clear_fn, ai_session_destroy_fn, ai_session_serialize_fn, ai_template_names_fn,
|
||||
ai_template_get_fn, ai_template_search_fn, ai_template_execute_fn, ai_execute_action_fn, ai_free_buffer_fn,
|
||||
vision_background_removal_fn, vision_object_segment_fn, vision_super_resolution_fn,
|
||||
vision_inpaint_fn, vision_build_polygon_mask_fn, vision_build_stroke_mask_fn,
|
||||
vision_set_backend_fn, vision_free_buffer_fn,
|
||||
_lib_ai: la,
|
||||
_lib_vision: lv,
|
||||
ai_chat_complete_fn,
|
||||
ai_session_create_fn,
|
||||
ai_session_push_user_fn,
|
||||
ai_session_push_assistant_fn,
|
||||
ai_session_clear_fn,
|
||||
ai_session_destroy_fn,
|
||||
ai_session_serialize_fn,
|
||||
ai_template_names_fn,
|
||||
ai_template_get_fn,
|
||||
ai_template_search_fn,
|
||||
ai_template_execute_fn,
|
||||
ai_execute_action_fn,
|
||||
ai_free_buffer_fn,
|
||||
vision_background_removal_fn,
|
||||
vision_object_segment_fn,
|
||||
vision_super_resolution_fn,
|
||||
vision_inpaint_fn,
|
||||
vision_build_polygon_mask_fn,
|
||||
vision_build_stroke_mask_fn,
|
||||
vision_set_backend_fn,
|
||||
vision_free_buffer_fn,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -275,26 +553,73 @@ pub fn vision_set_backend(backend: u8) {
|
||||
|
||||
pub mod ai {
|
||||
use super::*;
|
||||
macro_rules! c { ($e:expr) => { CString::new($e).unwrap() }; }
|
||||
macro_rules! c {
|
||||
($e:expr) => {
|
||||
CString::new($e).unwrap()
|
||||
};
|
||||
}
|
||||
pub fn chat_complete(config: &str, messages: &str) -> Result<Vec<u8>, String> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_chat_complete_fn, c!(config).as_ptr(), c!(messages).as_ptr(), &mut op, &mut ol) {
|
||||
let data = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Ok(data)
|
||||
} else { Err("AI chat failed".into()) }
|
||||
if ai!(
|
||||
ai_chat_complete_fn,
|
||||
c!(config).as_ptr(),
|
||||
c!(messages).as_ptr(),
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let data = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Ok(data)
|
||||
} else {
|
||||
Err("AI chat failed".into())
|
||||
}
|
||||
}
|
||||
pub fn session_create(s: &str) -> i64 {
|
||||
ai!(ai_session_create_fn, c!(s).as_ptr())
|
||||
}
|
||||
pub fn session_push_user(sid: i64, t: &str) -> bool {
|
||||
ai!(ai_session_push_user_fn, sid, c!(t).as_ptr())
|
||||
}
|
||||
pub fn session_push_assistant(sid: i64, t: &str) -> bool {
|
||||
ai!(ai_session_push_assistant_fn, sid, c!(t).as_ptr())
|
||||
}
|
||||
pub fn session_clear(sid: i64) -> bool {
|
||||
ai!(ai_session_clear_fn, sid)
|
||||
}
|
||||
pub fn session_destroy(sid: i64) {
|
||||
ai!(ai_session_destroy_fn, sid)
|
||||
}
|
||||
pub fn session_create(s: &str) -> i64 { ai!(ai_session_create_fn, c!(s).as_ptr()) }
|
||||
pub fn session_push_user(sid: i64, t: &str) -> bool { ai!(ai_session_push_user_fn, sid, c!(t).as_ptr()) }
|
||||
pub fn session_push_assistant(sid: i64, t: &str) -> bool { ai!(ai_session_push_assistant_fn, sid, c!(t).as_ptr()) }
|
||||
pub fn session_clear(sid: i64) -> bool { ai!(ai_session_clear_fn, sid) }
|
||||
pub fn session_destroy(sid: i64) { ai!(ai_session_destroy_fn, sid) }
|
||||
pub fn session_serialize(sid: i64) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_session_serialize_fn, sid, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
if ai!(ai_session_serialize_fn, sid, &mut op, &mut ol) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn template_names() -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_template_names_fn, &mut op, &mut ol) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn template_get(name: &str) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let n = CString::new(name).ok()?;
|
||||
if ai!(ai_template_get_fn, n.as_ptr(), &mut op, &mut ol) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn template_names() -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_template_names_fn, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
pub fn template_get(name: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0); let n = CString::new(name).ok()?;
|
||||
if ai!(ai_template_get_fn, n.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
/// # Purpose
|
||||
/// Searches for AI prompt/action templates matching a specific query or tag.
|
||||
///
|
||||
@@ -322,39 +647,164 @@ pub mod ai {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn template_execute(name: &str, params: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
pub fn template_execute(name: &str, params: &str) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let (n, j) = (CString::new(name).ok()?, CString::new(params).ok()?);
|
||||
if ai!(ai_template_execute_fn, n.as_ptr(), j.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
pub fn execute_action(json: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(ai_execute_action_fn, j.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
if ai!(
|
||||
ai_template_execute_fn,
|
||||
n.as_ptr(),
|
||||
j.as_ptr(),
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn execute_action(json: &str) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let j = CString::new(json).ok()?;
|
||||
if ai!(ai_execute_action_fn, j.as_ptr(), &mut op, &mut ol) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod vision {
|
||||
use super::*;
|
||||
pub fn background_removal(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(vision_background_removal_fn, img.as_ptr(), img.len(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
if ai!(
|
||||
vision_background_removal_fn,
|
||||
img.as_ptr(),
|
||||
img.len(),
|
||||
w,
|
||||
h,
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(vision_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn object_segment(img: &[u8], w: u32, h: u32, px: u32, py: u32, bx0: u32, by0: u32, bx1: u32, by1: u32, model_path: &str) -> Option<Vec<u8>> {
|
||||
pub fn object_segment(
|
||||
img: &[u8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
px: u32,
|
||||
py: u32,
|
||||
bx0: u32,
|
||||
by0: u32,
|
||||
bx1: u32,
|
||||
by1: u32,
|
||||
model_path: &str,
|
||||
) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let mp = CString::new(model_path).unwrap_or_default();
|
||||
if ai!(vision_object_segment_fn, img.as_ptr(), img.len(), w, h, px, py, bx0, by0, bx1, by1, mp.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
if ai!(
|
||||
vision_object_segment_fn,
|
||||
img.as_ptr(),
|
||||
img.len(),
|
||||
w,
|
||||
h,
|
||||
px,
|
||||
py,
|
||||
bx0,
|
||||
by0,
|
||||
bx1,
|
||||
by1,
|
||||
mp.as_ptr(),
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(vision_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn super_resolution(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(vision_super_resolution_fn, img.as_ptr(), img.len(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
if ai!(
|
||||
vision_super_resolution_fn,
|
||||
img.as_ptr(),
|
||||
img.len(),
|
||||
w,
|
||||
h,
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(vision_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn inpaint(pixels: &mut [u8], w: u32, h: u32, mask: &[u8], r: i32) -> bool {
|
||||
ai!(
|
||||
vision_inpaint_fn,
|
||||
pixels.as_mut_ptr(),
|
||||
pixels.len(),
|
||||
w,
|
||||
h,
|
||||
mask.as_ptr(),
|
||||
mask.len(),
|
||||
r
|
||||
)
|
||||
}
|
||||
pub fn inpaint(pixels: &mut [u8], w: u32, h: u32, mask: &[u8], r: i32) -> bool { ai!(vision_inpaint_fn, pixels.as_mut_ptr(), pixels.len(), w, h, mask.as_ptr(), mask.len(), r) }
|
||||
pub fn build_polygon_mask(json: &str, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(vision_build_polygon_mask_fn, j.as_ptr(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let j = CString::new(json).ok()?;
|
||||
if ai!(
|
||||
vision_build_polygon_mask_fn,
|
||||
j.as_ptr(),
|
||||
w,
|
||||
h,
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(vision_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn build_stroke_mask(json: &str, sel: &[u8], r: f32, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(vision_build_stroke_mask_fn, j.as_ptr(), sel.as_ptr(), sel.len(), r, w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let j = CString::new(json).ok()?;
|
||||
if ai!(
|
||||
vision_build_stroke_mask_fn,
|
||||
j.as_ptr(),
|
||||
sel.as_ptr(),
|
||||
sel.len(),
|
||||
r,
|
||||
w,
|
||||
h,
|
||||
&mut op,
|
||||
&mut ol
|
||||
) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(vision_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Forward the CPU/GPU backend preference to the dynamic `hcie-vision` plugin.
|
||||
pub fn set_backend(backend: u8) {
|
||||
crate::dynamic_loader::vision_set_backend(backend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-58
@@ -55,7 +55,9 @@ where
|
||||
#[no_mangle]
|
||||
pub extern "C" fn hcie_engine_create(w: u32, h: u32) -> *mut EngineHandle {
|
||||
let engine = Engine::new(w, h);
|
||||
let handle = Box::new(EngineHandle { engine: Box::new(engine) });
|
||||
let handle = Box::new(EngineHandle {
|
||||
engine: Box::new(engine),
|
||||
});
|
||||
Box::into_raw(handle)
|
||||
}
|
||||
|
||||
@@ -206,19 +208,25 @@ pub unsafe extern "C" fn hcie_engine_move_layer(
|
||||
/// Merge the active layer down into the layer below.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_merge_down(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.merge_down(); });
|
||||
with_engine(handle, |e| {
|
||||
e.merge_down();
|
||||
});
|
||||
}
|
||||
|
||||
/// Flatten all layers into a single background.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_flatten_image(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.flatten_image(); });
|
||||
with_engine(handle, |e| {
|
||||
e.flatten_image();
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear the active layer's pixels (make transparent).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_clear_active_layer(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.clear_active_layer_pixels(); });
|
||||
with_engine(handle, |e| {
|
||||
e.clear_active_layer_pixels();
|
||||
});
|
||||
}
|
||||
|
||||
/// Rename a layer.
|
||||
@@ -228,9 +236,13 @@ pub unsafe extern "C" fn hcie_engine_rename_layer(
|
||||
id: u64,
|
||||
name: *const c_char,
|
||||
) {
|
||||
if name.is_null() { return; }
|
||||
if name.is_null() {
|
||||
return;
|
||||
}
|
||||
let name_str = CStr::from_ptr(name).to_string_lossy().into_owned();
|
||||
with_engine(handle, |e| { e.rename_layer(id, &name_str); });
|
||||
with_engine(handle, |e| {
|
||||
e.rename_layer(id, &name_str);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -282,32 +294,44 @@ pub unsafe extern "C" fn hcie_engine_end_stroke(handle: *mut EngineHandle) {
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_select_all(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_all(); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_all();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_deselect(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_clear(); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_clear();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_invert_selection(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_invert(); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_invert();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_grow_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_grow(amount); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_grow(amount);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_shrink_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_shrink(amount); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_shrink(amount);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_feather_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_feather(amount as f32); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_feather(amount as f32);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -320,7 +344,9 @@ pub unsafe extern "C" fn hcie_engine_erode_selection(handle: *mut EngineHandle,
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_fade_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_feather(amount as f32); });
|
||||
with_engine(handle, |e| {
|
||||
e.selection_feather(amount as f32);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -336,48 +362,49 @@ pub unsafe extern "C" fn hcie_engine_apply_filter(
|
||||
filter_name: *const c_char,
|
||||
params_json: *const c_char,
|
||||
) {
|
||||
if filter_name.is_null() { return; }
|
||||
if filter_name.is_null() {
|
||||
return;
|
||||
}
|
||||
let name = CStr::from_ptr(filter_name).to_string_lossy().into_owned();
|
||||
let params_str = if params_json.is_null() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
CStr::from_ptr(params_json).to_string_lossy().into_owned()
|
||||
};
|
||||
let params: serde_json::Value = serde_json::from_str(¶ms_str).unwrap_or(serde_json::json!({}));
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(¶ms_str).unwrap_or(serde_json::json!({}));
|
||||
|
||||
// Internal bridge commands used by the Qt6 frontend to set brush / color
|
||||
// through the single FFI entry point until dedicated functions are added.
|
||||
with_engine(handle, |e| {
|
||||
match name.as_str() {
|
||||
"__internal_set_brush" => {
|
||||
let mut tip = e.current_tip.clone();
|
||||
if let Some(sz) = params["size"].as_f64() {
|
||||
tip.size = sz as f32;
|
||||
}
|
||||
if let Some(o) = params["opacity"].as_f64() {
|
||||
tip.opacity = o as f32;
|
||||
}
|
||||
e.set_brush_tip(tip);
|
||||
with_engine(handle, |e| match name.as_str() {
|
||||
"__internal_set_brush" => {
|
||||
let mut tip = e.current_tip.clone();
|
||||
if let Some(sz) = params["size"].as_f64() {
|
||||
tip.size = sz as f32;
|
||||
}
|
||||
"__internal_set_color" => {
|
||||
if let Some(hex) = params["hex"].as_str() {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() >= 6 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
let a = if hex.len() >= 8 {
|
||||
u8::from_str_radix(&hex[6..8], 16).unwrap_or(255)
|
||||
} else {
|
||||
255
|
||||
};
|
||||
e.set_color([r, g, b, a]);
|
||||
}
|
||||
if let Some(o) = params["opacity"].as_f64() {
|
||||
tip.opacity = o as f32;
|
||||
}
|
||||
e.set_brush_tip(tip);
|
||||
}
|
||||
"__internal_set_color" => {
|
||||
if let Some(hex) = params["hex"].as_str() {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() >= 6 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
let a = if hex.len() >= 8 {
|
||||
u8::from_str_radix(&hex[6..8], 16).unwrap_or(255)
|
||||
} else {
|
||||
255
|
||||
};
|
||||
e.set_color([r, g, b, a]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
e.apply_filter(&name, params);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
e.apply_filter(&name, params);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -388,12 +415,16 @@ pub unsafe extern "C" fn hcie_engine_apply_filter(
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_undo(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.undo(); });
|
||||
with_engine(handle, |e| {
|
||||
e.undo();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_redo(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.redo(); });
|
||||
with_engine(handle, |e| {
|
||||
e.redo();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -419,7 +450,9 @@ pub unsafe extern "C" fn hcie_engine_history_len(handle: *mut EngineHandle) -> u
|
||||
/// Jump to a specific history step by index. Pass -1 for original image.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_jump_to_history(handle: *mut EngineHandle, idx: i32) {
|
||||
with_engine(handle, |e| { e.jump_to_history(idx); });
|
||||
with_engine(handle, |e| {
|
||||
e.jump_to_history(idx);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -458,16 +491,16 @@ pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle,
|
||||
with_engine(handle, |e| {
|
||||
use crate::Tool;
|
||||
let tool = match tool_id {
|
||||
0 => Tool::Eyedropper,
|
||||
1 => Tool::Pen,
|
||||
2 => Tool::Brush,
|
||||
3 => Tool::Eraser,
|
||||
4 => Tool::Spray,
|
||||
5 => Tool::FloodFill,
|
||||
6 => Tool::MagicWand,
|
||||
7 => Tool::Select,
|
||||
8 => Tool::Lasso,
|
||||
9 => Tool::PolygonSelect,
|
||||
0 => Tool::Eyedropper,
|
||||
1 => Tool::Pen,
|
||||
2 => Tool::Brush,
|
||||
3 => Tool::Eraser,
|
||||
4 => Tool::Spray,
|
||||
5 => Tool::FloodFill,
|
||||
6 => Tool::MagicWand,
|
||||
7 => Tool::Select,
|
||||
8 => Tool::Lasso,
|
||||
9 => Tool::PolygonSelect,
|
||||
10 => Tool::Move,
|
||||
11 => Tool::Crop,
|
||||
12 => Tool::Text,
|
||||
@@ -482,7 +515,7 @@ pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle,
|
||||
21 => Tool::AiObjectRemoval,
|
||||
22 => Tool::SmartSelect,
|
||||
23 => Tool::VisionSelect,
|
||||
_ => Tool::Brush,
|
||||
_ => Tool::Brush,
|
||||
};
|
||||
e.set_tool(tool);
|
||||
});
|
||||
@@ -499,5 +532,7 @@ pub unsafe extern "C" fn hcie_engine_is_modified(handle: *mut EngineHandle) -> b
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_modified(handle: *mut EngineHandle, modified: bool) {
|
||||
with_engine(handle, |e| { e.set_modified(modified); });
|
||||
with_engine(handle, |e| {
|
||||
e.set_modified(modified);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
//! Mutates `Engine.document`, `Engine.below_cache_dirty`, and (for structural
|
||||
//! changes) `Engine.tile_layers` / `Engine.below_cache`.
|
||||
|
||||
use hcie_protocol::{BlendMode, LayerType};
|
||||
use crate::Engine;
|
||||
use hcie_protocol::{BlendMode, LayerType};
|
||||
|
||||
impl Engine {
|
||||
/// Removes all layers and resets layer-dependent caches without marking
|
||||
@@ -102,7 +102,9 @@ impl Engine {
|
||||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||||
log::trace!(
|
||||
"[set_active_layer] switching from layer idx={} to idx={} (id={})",
|
||||
self.document.active_layer, idx, id
|
||||
self.document.active_layer,
|
||||
idx,
|
||||
id
|
||||
);
|
||||
self.document.set_active_layer(idx);
|
||||
// Active layer change invalidates the below-layer composite cache
|
||||
@@ -130,7 +132,11 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub fn set_layer_visible(&mut self, id: u64, visible: bool) {
|
||||
log::trace!("[set_layer_visible] ===== START id={} visible={} =====", id, visible);
|
||||
log::trace!(
|
||||
"[set_layer_visible] ===== START id={} visible={} =====",
|
||||
id,
|
||||
visible
|
||||
);
|
||||
log::trace!(
|
||||
"[set_layer_visible] BEFORE: active_layer={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}",
|
||||
self.document.active_layer,
|
||||
@@ -163,7 +169,11 @@ impl Engine {
|
||||
);
|
||||
for (i, tl) in self.tile_layers.iter().enumerate() {
|
||||
let tile_count = tl.as_ref().map(|t| t.tile_count()).unwrap_or(0);
|
||||
log::trace!("[set_layer_visible] tile_layers[{}] tile_count={}", i, tile_count);
|
||||
log::trace!(
|
||||
"[set_layer_visible] tile_layers[{}] tile_count={}",
|
||||
i,
|
||||
tile_count
|
||||
);
|
||||
}
|
||||
log::trace!("[set_layer_visible] ===== END id={} =====", id);
|
||||
} else {
|
||||
@@ -191,14 +201,19 @@ impl Engine {
|
||||
/// Group and Mask layers are not editable because they do not own pixel
|
||||
/// buffers. Locked layers are also not editable.
|
||||
pub fn is_layer_editable(&self, id: u64) -> bool {
|
||||
let Some(layer) = self.document.get_layer_by_id(id) else { return false };
|
||||
if layer.locked { return false; }
|
||||
let Some(layer) = self.document.get_layer_by_id(id) else {
|
||||
return false;
|
||||
};
|
||||
if layer.locked {
|
||||
return false;
|
||||
}
|
||||
!matches!(layer.layer_type, LayerType::Group | LayerType::Mask)
|
||||
}
|
||||
|
||||
/// Returns whether the currently active layer can receive pixel edits.
|
||||
pub fn active_layer_is_editable(&self) -> bool {
|
||||
self.document.active_layer()
|
||||
self.document
|
||||
.active_layer()
|
||||
.map(|l| self.is_layer_editable(l.id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -292,10 +307,18 @@ impl Engine {
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool { self.document.can_undo() }
|
||||
pub fn can_redo(&self) -> bool { self.document.can_redo() }
|
||||
pub fn history_len(&self) -> usize { self.document.history_len() }
|
||||
pub fn history_current(&self) -> i32 { self.document.history_current() }
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.document.can_undo()
|
||||
}
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.document.can_redo()
|
||||
}
|
||||
pub fn history_len(&self) -> usize {
|
||||
self.document.history_len()
|
||||
}
|
||||
pub fn history_current(&self) -> i32 {
|
||||
self.document.history_current()
|
||||
}
|
||||
pub fn history_description(&self, idx: usize) -> Option<String> {
|
||||
self.document.history_description(idx)
|
||||
}
|
||||
@@ -354,7 +377,8 @@ impl Engine {
|
||||
|
||||
// Push undo snapshot for the below layer
|
||||
if let Some(idx) = self.document.layer_index_by_id(below_id) {
|
||||
self.document.push_draw_snapshot(idx, before_pixels, None, "Merge Down".to_string());
|
||||
self.document
|
||||
.push_draw_snapshot(idx, before_pixels, None, "Merge Down".to_string());
|
||||
}
|
||||
|
||||
self.document.composite_dirty = true;
|
||||
|
||||
+1471
-416
File diff suppressed because it is too large
Load Diff
@@ -21,10 +21,10 @@
|
||||
//! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the
|
||||
//! dynamic `tiled::composite_tiled_into` / `composite_layers` compositors.
|
||||
|
||||
use hcie_tile::TiledLayer;
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::{composite_layers, tiled};
|
||||
use crate::dynamic_loader::vector::render_vector_shapes;
|
||||
use crate::dynamic_loader::{composite_layers, tiled};
|
||||
use crate::Engine;
|
||||
use hcie_tile::TiledLayer;
|
||||
|
||||
impl Engine {
|
||||
/// **Purpose:**
|
||||
@@ -62,7 +62,13 @@ impl Engine {
|
||||
if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] }
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let opaque_count = l.pixels.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
||||
let opaque_count = l
|
||||
.pixels
|
||||
.iter()
|
||||
.skip(3)
|
||||
.step_by(4)
|
||||
.filter(|&&a| a > 0)
|
||||
.count();
|
||||
let total = l.pixels.len() / 4;
|
||||
log::trace!(
|
||||
"[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)",
|
||||
@@ -100,7 +106,8 @@ impl Engine {
|
||||
}
|
||||
log::trace!(
|
||||
"[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====",
|
||||
has_dirty, dirty
|
||||
has_dirty,
|
||||
dirty
|
||||
);
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
@@ -109,15 +116,17 @@ impl Engine {
|
||||
self.apply_effects_and_sync_tiles();
|
||||
|
||||
let (x0, y0, x1, y1) = match dirty {
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
(x0.min(w), y0.min(h), x1.min(w), y1.min(h))
|
||||
}
|
||||
Some([x0, y0, x1, y1]) => (x0.min(w), y0.min(h), x1.min(w), y1.min(h)),
|
||||
None => (0, 0, w, h),
|
||||
};
|
||||
|
||||
// Now safe to borrow composite_scratch — no more mutable calls to self
|
||||
// that could touch this buffer until we return.
|
||||
if self.composite_scratch.as_ref().map_or(true, |b| b.len() != buf_size) {
|
||||
if self
|
||||
.composite_scratch
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.len() != buf_size)
|
||||
{
|
||||
self.composite_scratch = Some(vec![0u8; buf_size]);
|
||||
}
|
||||
let buf = self.composite_scratch.as_mut().unwrap();
|
||||
@@ -137,7 +146,11 @@ impl Engine {
|
||||
active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let tc = self.tile_layers.get(i).and_then(|t| t.as_ref().map(|tl| tl.tile_count())).unwrap_or(0);
|
||||
let tc = self
|
||||
.tile_layers
|
||||
.get(i)
|
||||
.and_then(|t| t.as_ref().map(|tl| tl.tile_count()))
|
||||
.unwrap_or(0);
|
||||
log::trace!(
|
||||
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
||||
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
||||
@@ -162,8 +175,12 @@ impl Engine {
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[active_idx..],
|
||||
&self.tile_layers[tile_start..],
|
||||
w, h,
|
||||
x0, y0, x1, y1,
|
||||
w,
|
||||
h,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
buf,
|
||||
);
|
||||
log::trace!(
|
||||
@@ -183,13 +200,20 @@ impl Engine {
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers,
|
||||
&self.tile_layers,
|
||||
w, h,
|
||||
x0, y0, x1, y1,
|
||||
w,
|
||||
h,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
buf,
|
||||
);
|
||||
log::trace!(
|
||||
"[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]",
|
||||
x0, y0, x1, y1
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -218,7 +242,10 @@ impl Engine {
|
||||
// Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
|
||||
// have a backup.
|
||||
{
|
||||
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self.document.layers.iter()
|
||||
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
(!l.effects.is_empty() || !l.styles.is_empty())
|
||||
&& !l.pixels.is_empty()
|
||||
@@ -238,48 +265,54 @@ impl Engine {
|
||||
// on top of clean pixels (not on effects-applied pixels). The composite
|
||||
// uses effects_cache for layers with effects, not layer.pixels.
|
||||
for layer in &mut self.document.layers {
|
||||
if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
|
||||
if layer.effects.is_empty() && layer.styles.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if there are any active/enabled effects or styles
|
||||
let has_enabled = layer.effects.iter().any(|e| {
|
||||
match e {
|
||||
hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::Satin { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled,
|
||||
}
|
||||
}) || layer.styles.iter().any(|s| {
|
||||
match s {
|
||||
hcie_protocol::LayerStyle::DropShadow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::Satin { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
|
||||
}
|
||||
let has_enabled = layer.effects.iter().any(|e| match e {
|
||||
hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::Satin { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. }
|
||||
| hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled,
|
||||
}) || layer.styles.iter().any(|s| match s {
|
||||
hcie_protocol::LayerStyle::DropShadow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::Satin { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
|
||||
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
|
||||
});
|
||||
|
||||
if !has_enabled {
|
||||
// No active effects: clean up cached rendering and backup to restore raw performance.
|
||||
*layer.effects_cache.lock().unwrap() = None;
|
||||
self.raw_pixel_backup.remove(&layer.id);
|
||||
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer.dirty = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
|
||||
log::trace!("Applying active effects/styles for layer ID {} because effects are dirty", layer.id);
|
||||
if layer
|
||||
.effects_dirty
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
log::trace!(
|
||||
"Applying active effects/styles for layer ID {} because effects are dirty",
|
||||
layer.id
|
||||
);
|
||||
// DON'T restore raw pixels from backup! Restoring raw pixels from backup overwrites
|
||||
// active stroke drawing and wipes out new strokes. Since we never overwrite layer.pixels
|
||||
// with the effects output, layer.pixels already contains the clean, raw pixels.
|
||||
@@ -287,8 +320,15 @@ impl Engine {
|
||||
.filter(|e| !matches!(e, hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0))
|
||||
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
|
||||
.collect();
|
||||
effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
||||
if effects.is_empty() { continue; }
|
||||
effects.extend(
|
||||
layer
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| hcie_fx::layer_style_to_effect(s)),
|
||||
);
|
||||
if effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let processed = hcie_fx::apply_layer_effects(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
@@ -301,7 +341,9 @@ impl Engine {
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -321,7 +363,9 @@ impl Engine {
|
||||
let db = self.document.dirty_bounds;
|
||||
let mut synced_count = 0usize;
|
||||
for (i, layer) in self.document.layers.iter().enumerate() {
|
||||
if !layer.dirty || layer.pixels.is_empty() { continue; }
|
||||
if !layer.dirty || layer.pixels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some([dx0, dy0, dx1, dy1]) = db {
|
||||
if let Some(ref mut tl) = self.tile_layers[i] {
|
||||
if tl.width() == layer.width && tl.height() == layer.height {
|
||||
@@ -347,7 +391,11 @@ impl Engine {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.tile_layers[i] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||||
self.tile_layers[i] = Some(TiledLayer::from_dense(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
));
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)",
|
||||
i, layer.id, layer.visible
|
||||
@@ -357,7 +405,12 @@ impl Engine {
|
||||
}
|
||||
self.tile_layers.truncate(count);
|
||||
if synced_count > 0 {
|
||||
log::trace!("[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}", synced_count, db, self.tile_layers.len());
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}",
|
||||
synced_count,
|
||||
db,
|
||||
self.tile_layers.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,7 +487,10 @@ pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
||||
// Build tile slices for the compositor
|
||||
let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len());
|
||||
let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer]
|
||||
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (i, l.visible))
|
||||
.collect();
|
||||
|
||||
let mut buf = vec![0u8; buf_size];
|
||||
|
||||
@@ -443,8 +499,12 @@ pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
||||
tiled::composite_tiled_into(
|
||||
&snapshot.layers[..snapshot.active_layer],
|
||||
&snapshot.tile_layers[..tile_slice_len],
|
||||
w, h,
|
||||
0, 0, w, h,
|
||||
w,
|
||||
h,
|
||||
0,
|
||||
0,
|
||||
w,
|
||||
h,
|
||||
&mut buf,
|
||||
);
|
||||
}
|
||||
@@ -454,8 +514,12 @@ pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
||||
tiled::composite_tiled_into(
|
||||
&snapshot.layers[snapshot.active_layer..],
|
||||
&snapshot.tile_layers[tile_start..],
|
||||
w, h,
|
||||
0, 0, w, h,
|
||||
w,
|
||||
h,
|
||||
0,
|
||||
0,
|
||||
w,
|
||||
h,
|
||||
&mut buf,
|
||||
);
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
//! engine's stroke state. Calls into `hcie-draw` and `hcie-brush-engine` via
|
||||
//! the dynamic loader.
|
||||
|
||||
use hcie_protocol::{BrushStyle, BrushTip};
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::{
|
||||
draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke,
|
||||
};
|
||||
use crate::Engine;
|
||||
use hcie_protocol::{BrushStyle, BrushTip};
|
||||
|
||||
impl Engine {
|
||||
/// Interpolate a brush property along the stroke using accumulated distance.
|
||||
@@ -30,8 +30,16 @@ impl Engine {
|
||||
/// `time_enabled` flag is off or the start/end values are equal, the base
|
||||
/// value is returned unchanged. Otherwise the value is lerped from start
|
||||
/// to end and multiplied with the base.
|
||||
fn apply_time_interpolation(base: f32, start: f32, end: f32, time_enabled: bool, t: f32) -> f32 {
|
||||
if !time_enabled { return base; }
|
||||
fn apply_time_interpolation(
|
||||
base: f32,
|
||||
start: f32,
|
||||
end: f32,
|
||||
time_enabled: bool,
|
||||
t: f32,
|
||||
) -> f32 {
|
||||
if !time_enabled {
|
||||
return base;
|
||||
}
|
||||
let lerp = start + (end - start) * t.clamp(0.0, 1.0);
|
||||
base * lerp
|
||||
}
|
||||
@@ -116,7 +124,11 @@ impl Engine {
|
||||
tip.opacity,
|
||||
tip.spacing,
|
||||
self.is_eraser,
|
||||
if tip.style == BrushStyle::Sketch { Some(&mut self.sketch_history) } else { None },
|
||||
if tip.style == BrushStyle::Sketch {
|
||||
Some(&mut self.sketch_history)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tip.spray_particle_size,
|
||||
tip.spray_density,
|
||||
mask_ref,
|
||||
@@ -164,7 +176,9 @@ impl Engine {
|
||||
);
|
||||
layer.dirty = true;
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
let dirty_r = tip.size.max(2.0);
|
||||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||||
@@ -184,7 +198,14 @@ impl Engine {
|
||||
|
||||
/// Pen tool stroke — draws a true anti-aliased line segment.
|
||||
/// Uses begin_stroke/end_stroke for undo.
|
||||
pub fn draw_pen_segment(&mut self, layer_id: u64, x0: f32, y0: f32, x1: f32, y1: f32, pressure: f32,
|
||||
pub fn draw_pen_segment(
|
||||
&mut self,
|
||||
layer_id: u64,
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
pressure: f32,
|
||||
) {
|
||||
let tip = self.current_tip.clone();
|
||||
let color = self.apply_cyclic_color(self.current_color);
|
||||
@@ -192,11 +213,18 @@ impl Engine {
|
||||
let w = self.document.canvas_width as f32;
|
||||
let h = self.document.canvas_height as f32;
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
let px_color = [color[0], color[1], color[2], (color[3] as f32 * tip.opacity * pressure).round() as u8];
|
||||
let px_color = [
|
||||
color[0],
|
||||
color[1],
|
||||
color[2],
|
||||
(color[3] as f32 * tip.opacity * pressure).round() as u8,
|
||||
];
|
||||
draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
|
||||
layer.dirty = true;
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
let dirty_r = tip.size.max(2.0);
|
||||
if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h {
|
||||
@@ -235,7 +263,11 @@ impl Engine {
|
||||
tip.opacity,
|
||||
tip.spacing,
|
||||
self.is_eraser,
|
||||
if tip.style == BrushStyle::Sketch { Some(&mut self.sketch_history) } else { None },
|
||||
if tip.style == BrushStyle::Sketch {
|
||||
Some(&mut self.sketch_history)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tip.spray_particle_size,
|
||||
tip.spray_density,
|
||||
mask_ref,
|
||||
@@ -259,7 +291,9 @@ impl Engine {
|
||||
);
|
||||
}
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
@@ -273,17 +307,19 @@ impl Engine {
|
||||
_ => tip.size.max(2.0),
|
||||
};
|
||||
for &(px, py, _) in points {
|
||||
if px >= 0.0 && py >= 0.0 && px < self.document.canvas_width as f32 && py < self.document.canvas_height as f32 {
|
||||
if px >= 0.0
|
||||
&& py >= 0.0
|
||||
&& px < self.document.canvas_width as f32
|
||||
&& py < self.document.canvas_height as f32
|
||||
{
|
||||
self.document.expand_dirty(px as u32, py as u32, dirty_r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(before) = before_pixels {
|
||||
self.document.push_draw_snapshot(
|
||||
layer_idx, before, None,
|
||||
"Brush Stroke".to_string(),
|
||||
);
|
||||
self.document
|
||||
.push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +338,8 @@ impl Engine {
|
||||
self.document.modified = true;
|
||||
}
|
||||
if let Some(before) = before_pixels {
|
||||
self.document.push_draw_snapshot(layer_idx, before, None, "Line".to_string());
|
||||
self.document
|
||||
.push_draw_snapshot(layer_idx, before, None, "Line".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +358,8 @@ impl Engine {
|
||||
self.document.modified = true;
|
||||
}
|
||||
if let Some(before) = before_pixels {
|
||||
self.document.push_draw_snapshot(layer_idx, before, None, "Rectangle".to_string());
|
||||
self.document
|
||||
.push_draw_snapshot(layer_idx, before, None, "Rectangle".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +381,8 @@ impl Engine {
|
||||
self.document.modified = true;
|
||||
}
|
||||
if let Some(before) = before_pixels {
|
||||
self.document.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
|
||||
self.document
|
||||
.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +401,8 @@ impl Engine {
|
||||
self.document.modified = true;
|
||||
}
|
||||
if let Some(before) = before_pixels {
|
||||
self.document.push_draw_snapshot(layer_idx, before, None, "Ellipse".to_string());
|
||||
self.document
|
||||
.push_draw_snapshot(layer_idx, before, None, "Ellipse".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,12 +445,32 @@ impl Engine {
|
||||
/// Check if the given brush style needs specialized procedural rendering.
|
||||
/// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false.
|
||||
pub(crate) fn is_specialized_style(s: BrushStyle) -> bool {
|
||||
matches!(s, BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Watercolor |
|
||||
BrushStyle::Calligraphy | BrushStyle::Marker | BrushStyle::Glow | BrushStyle::Airbrush |
|
||||
BrushStyle::Spray |
|
||||
BrushStyle::Pencil | BrushStyle::Crayon | BrushStyle::Tree | BrushStyle::Meadow |
|
||||
BrushStyle::Rock | BrushStyle::Clouds | BrushStyle::Dirt | BrushStyle::Star |
|
||||
BrushStyle::Bristle | BrushStyle::Wood | BrushStyle::Sketch | BrushStyle::Hatch |
|
||||
BrushStyle::Square | BrushStyle::WetPaint | BrushStyle::Leaf | BrushStyle::Mixer)
|
||||
matches!(
|
||||
s,
|
||||
BrushStyle::Oil
|
||||
| BrushStyle::Charcoal
|
||||
| BrushStyle::Watercolor
|
||||
| BrushStyle::Calligraphy
|
||||
| BrushStyle::Marker
|
||||
| BrushStyle::Glow
|
||||
| BrushStyle::Airbrush
|
||||
| BrushStyle::Spray
|
||||
| BrushStyle::Pencil
|
||||
| BrushStyle::Crayon
|
||||
| BrushStyle::Tree
|
||||
| BrushStyle::Meadow
|
||||
| BrushStyle::Rock
|
||||
| BrushStyle::Clouds
|
||||
| BrushStyle::Dirt
|
||||
| BrushStyle::Star
|
||||
| BrushStyle::Bristle
|
||||
| BrushStyle::Wood
|
||||
| BrushStyle::Sketch
|
||||
| BrushStyle::Hatch
|
||||
| BrushStyle::Square
|
||||
| BrushStyle::WetPaint
|
||||
| BrushStyle::Leaf
|
||||
| BrushStyle::Mixer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
//! buffers, dirty flags). They depend on `hcie_tile::TiledLayer` and the
|
||||
//! dynamic `tiled::composite_tiled_into` compositor.
|
||||
|
||||
use crate::dynamic_loader::tiled;
|
||||
use crate::Engine;
|
||||
use hcie_protocol::LayerData;
|
||||
use hcie_tile::TiledLayer;
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::tiled;
|
||||
|
||||
/// Wrapper to send a raw pixel pointer to a background thread as a `usize`.
|
||||
///
|
||||
@@ -35,9 +35,13 @@ use crate::dynamic_loader::tiled;
|
||||
struct SendPtr(usize);
|
||||
impl SendPtr {
|
||||
#[inline]
|
||||
fn new(p: *const u8) -> Self { Self(p as usize) }
|
||||
fn new(p: *const u8) -> Self {
|
||||
Self(p as usize)
|
||||
}
|
||||
#[inline]
|
||||
fn as_ptr(&self) -> *const u8 { self.0 as *const u8 }
|
||||
fn as_ptr(&self) -> *const u8 {
|
||||
self.0 as *const u8
|
||||
}
|
||||
}
|
||||
// SAFETY: The usize encodes a pointer to layer.pixels which is stable for the engine lifetime.
|
||||
// The background thread only reads, never writes.
|
||||
@@ -74,7 +78,11 @@ impl Engine {
|
||||
for item in items {
|
||||
// Recycle returned before buffer back into the pool
|
||||
if let Some(buf) = item.return_before {
|
||||
if self.stroke_before_buf.as_ref().map_or(true, |b| b.len() != buf.len()) {
|
||||
if self
|
||||
.stroke_before_buf
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.len() != buf.len())
|
||||
{
|
||||
self.stroke_before_buf = Some(buf);
|
||||
}
|
||||
}
|
||||
@@ -127,8 +135,8 @@ impl Engine {
|
||||
self.last_stroke_pos = Some((x, y, 1.0));
|
||||
self.sketch_history.clear();
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||
let layer_size = (layer.width * layer.height) as usize; // pixel count (for mask)
|
||||
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||
let layer_size = (layer.width * layer.height) as usize; // pixel count (for mask)
|
||||
|
||||
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
||||
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
||||
@@ -211,8 +219,8 @@ impl Engine {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
let layer = &mut self.document.layers[idx];
|
||||
let lw = layer.width;
|
||||
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||
// Restore layer effects from backup so they are re-composited correctly
|
||||
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||
// Restore layer effects from backup so they are re-composited correctly
|
||||
if let Some(backup) = self.stroke_effects_backup.take() {
|
||||
layer.effects = backup;
|
||||
}
|
||||
@@ -221,12 +229,18 @@ impl Engine {
|
||||
}
|
||||
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
|
||||
// Take the pooled before buffer (no allocation)
|
||||
log::debug!("[end_stroke] layer_pixels={}, stroke_before_buf={:?}", layer_pixels, self.stroke_before_buf.as_ref().map(|b| b.len()));
|
||||
log::debug!(
|
||||
"[end_stroke] layer_pixels={}, stroke_before_buf={:?}",
|
||||
layer_pixels,
|
||||
self.stroke_before_buf.as_ref().map(|b| b.len())
|
||||
);
|
||||
let before = match self.stroke_before_buf.take() {
|
||||
Some(buf) if buf.len() == layer_pixels => buf,
|
||||
_ => vec![0u8; layer_pixels],
|
||||
@@ -252,7 +266,8 @@ impl Engine {
|
||||
let t_start = std::time::Instant::now();
|
||||
// SAFETY: after_ptr points to layer.pixels which is valid for the
|
||||
// engine lifetime. No mutations happen between end_stroke() and next begin_stroke().
|
||||
let after_slice = unsafe { std::slice::from_raw_parts(after_ptr.as_ptr(), after_len) };
|
||||
let after_slice =
|
||||
unsafe { std::slice::from_raw_parts(after_ptr.as_ptr(), after_len) };
|
||||
let mut item = PendingHistoryItem {
|
||||
layer_idx: idx,
|
||||
before_pixels: Vec::new(),
|
||||
@@ -291,7 +306,10 @@ impl Engine {
|
||||
item.return_before = Some(before);
|
||||
pending_history.lock().unwrap().push(item);
|
||||
}
|
||||
log::trace!("[end_stroke_async] Background snapshot comparison took {}ms", t_start.elapsed().as_millis());
|
||||
log::trace!(
|
||||
"[end_stroke_async] Background snapshot comparison took {}ms",
|
||||
t_start.elapsed().as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -302,7 +320,10 @@ impl Engine {
|
||||
// Copy into owned memory for PendingHistoryItem.
|
||||
item.after_pixels = after_slice.to_vec();
|
||||
pending_history.lock().unwrap().push(item);
|
||||
log::trace!("[end_stroke_async] Background fallback snapshot comparison took {}ms", t_start.elapsed().as_millis());
|
||||
log::trace!(
|
||||
"[end_stroke_async] Background fallback snapshot comparison took {}ms",
|
||||
t_start.elapsed().as_millis()
|
||||
);
|
||||
});
|
||||
|
||||
// Stroke changed layer.pixels — invalidate effects backup so
|
||||
@@ -364,7 +385,10 @@ impl Engine {
|
||||
let can_reuse = !self.below_cache_dirty
|
||||
&& self.below_cache_active_idx == Some(active_idx)
|
||||
&& active_idx > 0
|
||||
&& self.below_cache.as_ref().map_or(false, |b| b.len() == buf_size);
|
||||
&& self
|
||||
.below_cache
|
||||
.as_ref()
|
||||
.map_or(false, |b| b.len() == buf_size);
|
||||
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache state: active_idx={}, reuse={}, dirty={}, existing_cache={}, existing_active_idx={:?}",
|
||||
@@ -372,7 +396,10 @@ impl Engine {
|
||||
);
|
||||
|
||||
if can_reuse {
|
||||
log::trace!("[begin_stroke] REUSING below_cache for active_idx={}", active_idx);
|
||||
log::trace!(
|
||||
"[begin_stroke] REUSING below_cache for active_idx={}",
|
||||
active_idx
|
||||
);
|
||||
self.below_cache_dirty = false;
|
||||
return;
|
||||
}
|
||||
@@ -384,13 +411,18 @@ impl Engine {
|
||||
}
|
||||
let mut tiles_built = 0usize;
|
||||
for (ti, tl) in self.document.layers.iter().enumerate() {
|
||||
if ti >= active_idx { break; }
|
||||
if ti >= active_idx {
|
||||
break;
|
||||
}
|
||||
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
||||
self.tile_layers[ti] = Some(TiledLayer::from_dense(
|
||||
&tl.pixels, tl.width, tl.height,
|
||||
));
|
||||
self.tile_layers[ti] =
|
||||
Some(TiledLayer::from_dense(&tl.pixels, tl.width, tl.height));
|
||||
tiles_built += 1;
|
||||
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, tl.id);
|
||||
log::trace!(
|
||||
"[begin_stroke] built tile for below layer[{}] id={}",
|
||||
ti,
|
||||
tl.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut cache = match self.below_cache.take() {
|
||||
@@ -402,7 +434,10 @@ impl Engine {
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
||||
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (i, l.visible))
|
||||
.collect();
|
||||
log::trace!(
|
||||
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
||||
active_idx, tile_slice_len, visible_below, tiles_built
|
||||
@@ -410,13 +445,20 @@ impl Engine {
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw, ch, 0, 0, cw, ch,
|
||||
cw,
|
||||
ch,
|
||||
0,
|
||||
0,
|
||||
cw,
|
||||
ch,
|
||||
&mut cache,
|
||||
);
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(), non_zero, active_idx
|
||||
cache.len(),
|
||||
non_zero,
|
||||
active_idx
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
#[test]
|
||||
fn bitmap_brush_stamp_shape() {
|
||||
let mut engine = Engine::new(64, 64);
|
||||
// 4x4 solid white square bitmap stamp
|
||||
let mut pixels = vec![0u8; 16];
|
||||
for i in 0..16 { pixels[i] = 255; }
|
||||
for i in 0..16 {
|
||||
pixels[i] = 255;
|
||||
}
|
||||
let brush = BrushTip {
|
||||
style: BrushStyle::Bitmap,
|
||||
size: 8.0,
|
||||
@@ -25,25 +27,46 @@ fn bitmap_brush_stamp_shape() {
|
||||
|
||||
// Check that the painted area is roughly 4x4, not a circular blob.
|
||||
// Count alpha > 0 pixels and bounding box.
|
||||
let mut min_x = 63; let mut max_x = 0; let mut min_y = 63; let mut max_y = 0;
|
||||
let mut min_x = 63;
|
||||
let mut max_x = 0;
|
||||
let mut min_y = 63;
|
||||
let mut max_y = 0;
|
||||
let mut count = 0;
|
||||
for y in 0..64 {
|
||||
for x in 0..64 {
|
||||
let a = output[(y*64+x)*4+3];
|
||||
let a = output[(y * 64 + x) * 4 + 3];
|
||||
if a > 0 {
|
||||
count += 1;
|
||||
if x < min_x { min_x = x; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if y > max_y { max_y = y; }
|
||||
if x < min_x {
|
||||
min_x = x;
|
||||
}
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y < min_y {
|
||||
min_y = y;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = (max_x - min_x + 1) as i32;
|
||||
let h = (max_y - min_y + 1) as i32;
|
||||
// Should fit inside ~8x8 because size=8 and stamp 4x4 scaled up
|
||||
assert!(w <= 8 && h <= 8, "bitmap dab bounding box {}x{} too large for 4x4 stamp", w, h);
|
||||
assert!(
|
||||
w <= 8 && h <= 8,
|
||||
"bitmap dab bounding box {}x{} too large for 4x4 stamp",
|
||||
w,
|
||||
h
|
||||
);
|
||||
assert!(count >= 8, "bitmap dab should paint some pixels");
|
||||
// Square-ish: aspect ratio not too far from 1 (was very low for circle vs square?)
|
||||
assert!(w.abs_diff(h) <= 2, "bitmap dab bounding box should be roughly square, got {}x{}", w, h);
|
||||
assert!(
|
||||
w.abs_diff(h) <= 2,
|
||||
"bitmap dab bounding box should be roughly square, got {}x{}",
|
||||
w,
|
||||
h
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! public engine API and saves the result as a PNG. Used for manual inspection
|
||||
//! and regression detection.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test leaves_check and inspect target/leaves_check.png"]
|
||||
@@ -44,11 +44,16 @@ fn leaves_brush_visual_check() {
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("leaves_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
let has_color = pixels
|
||||
.chunks_exact(4)
|
||||
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Leaves check produced no colored pixels");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 3. Draw multiple short strokes at different positions.
|
||||
//! 4. Save the composited pixels to `target/meadow_check.png`.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test meadow_check and inspect target/meadow_check.png"]
|
||||
@@ -60,12 +60,17 @@ fn meadow_brush_visual_check() {
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("meadow_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
// Basic sanity: output should contain non-white, non-transparent pixels.
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
let has_color = pixels
|
||||
.chunks_exact(4)
|
||||
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Meadow check produced no colored pixels");
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! - Allocates ~400MB of pixel/tile/mask buffers.
|
||||
//! - Uses `std::time::Instant` for measurement; results depend on the host CPU.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
@@ -112,18 +112,29 @@ fn benchmark_4k_stroke_on_multilayer_document() {
|
||||
let total_us = total_start.elapsed().as_micros() as f64;
|
||||
|
||||
let avg_segment_us = segment_times.iter().sum::<f64>() / segment_times.len().max(1) as f64;
|
||||
let avg_composite_us = composite_times.iter().sum::<f64>() / composite_times.len().max(1) as f64;
|
||||
let avg_composite_us =
|
||||
composite_times.iter().sum::<f64>() / composite_times.len().max(1) as f64;
|
||||
let max_segment_us = segment_times.iter().copied().fold(0.0, f64::max);
|
||||
let max_composite_us = composite_times.iter().copied().fold(0.0, f64::max);
|
||||
|
||||
println!("\n=== 4K Multi-Layer Stroke Benchmark ===");
|
||||
println!("Canvas: {}x{}, Layers: {}, Segments: {}", W, H, LAYERS, SEGMENTS);
|
||||
println!(
|
||||
"Canvas: {}x{}, Layers: {}, Segments: {}",
|
||||
W, H, LAYERS, SEGMENTS
|
||||
);
|
||||
println!("Average stroke_to: {:>8.1} us", avg_segment_us);
|
||||
println!("Max stroke_to: {:>8.1} us", max_segment_us);
|
||||
println!("Average composite: {:>8.1} us", avg_composite_us);
|
||||
println!("Max composite: {:>8.1} us", max_composite_us);
|
||||
println!("Total wall time: {:>8.1} us ({:.2} s)", total_us, total_us / 1_000_000.0);
|
||||
println!("Segments per second: {:>8.1}", SEGMENTS as f64 / (total_us / 1_000_000.0));
|
||||
println!(
|
||||
"Total wall time: {:>8.1} us ({:.2} s)",
|
||||
total_us,
|
||||
total_us / 1_000_000.0
|
||||
);
|
||||
println!(
|
||||
"Segments per second: {:>8.1}",
|
||||
SEGMENTS as f64 / (total_us / 1_000_000.0)
|
||||
);
|
||||
|
||||
// Soft sanity check: the engine must still report a valid top layer and the
|
||||
// document must be marked modified after painting.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! using the public engine API and saves the result as a PNG. Used for manual
|
||||
//! inspection and regression detection.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test stamp_floor_check and inspect target/stamp_floor_check.png"]
|
||||
@@ -46,11 +46,16 @@ fn stamp_floor_brush_visual_check() {
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("stamp_floor_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
let has_color = pixels
|
||||
.chunks_exact(4)
|
||||
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Stamp Floor check produced no colored pixels");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! - undo_after_rect_golden: validates history snapshot and restoration
|
||||
|
||||
use hcie_engine_api::Engine;
|
||||
use hcie_engine_api::{BrushTip, BrushStyle, VectorShape};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, VectorShape};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Compute SHA-256 hash of pixel buffer for golden comparison.
|
||||
@@ -46,7 +46,10 @@ fn white_canvas_empty_document() {
|
||||
for i in (0..pixels.len()).step_by(4) {
|
||||
assert_eq!(pixels[i + 3], 0, "Alpha channel should be 0 (transparent)");
|
||||
}
|
||||
assert_eq!(hash_pixels(&pixels), "5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1");
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Green rectangle draw and composite.
|
||||
@@ -64,7 +67,10 @@ fn green_rect_draw_and_composite() {
|
||||
assert_eq!(pixels[center_idx + 1], 255);
|
||||
assert_eq!(pixels[center_idx + 2], 0);
|
||||
assert_eq!(pixels[center_idx + 3], 255);
|
||||
assert_eq!(hash_pixels(&pixels), "01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a");
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Red rectangle over green rectangle.
|
||||
@@ -91,7 +97,10 @@ fn red_rect_over_green_rect() {
|
||||
assert_eq!(pixels[center_idx], 255);
|
||||
assert_eq!(pixels[center_idx + 1], 0);
|
||||
assert_eq!(pixels[center_idx + 2], 0);
|
||||
assert_eq!(hash_pixels(&pixels), "c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750");
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Vector shape rendering.
|
||||
@@ -100,7 +109,10 @@ fn red_rect_over_green_rect() {
|
||||
fn vector_rect_golden() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
let shape = VectorShape::Rect {
|
||||
x1: 0.0, y1: 0.0, x2: 16.0, y2: 16.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
x2: 16.0,
|
||||
y2: 16.0,
|
||||
stroke: 1.0,
|
||||
color: [255, 0, 0, 255],
|
||||
fill_color: [255, 0, 0, 255],
|
||||
@@ -116,9 +128,14 @@ fn vector_rect_golden() {
|
||||
assert_eq!(pixels.len(), 16 * 16 * 4);
|
||||
|
||||
// Verify at least one red pixel (vector rect was drawn)
|
||||
let has_red = pixels.chunks(4).any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
|
||||
let has_red = pixels
|
||||
.chunks(4)
|
||||
.any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
|
||||
assert!(has_red, "Vector rect should contain red pixels");
|
||||
assert_eq!(hash_pixels(&pixels), "71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d");
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Brush stroke rendering.
|
||||
@@ -144,8 +161,14 @@ fn brush_stroke_golden() {
|
||||
|
||||
// Brush stroke should produce non-white pixels
|
||||
let has_non_white = pixels.iter().any(|&p| p < 255);
|
||||
assert!(has_non_white, "Brush stroke should produce non-white pixels");
|
||||
assert_eq!(hash_pixels(&pixels), "e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087");
|
||||
assert!(
|
||||
has_non_white,
|
||||
"Brush stroke should produce non-white pixels"
|
||||
);
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Invert filter.
|
||||
@@ -164,7 +187,10 @@ fn invert_filter_golden() {
|
||||
assert_eq!(pixels[idx], 155); // 255 - 100
|
||||
assert_eq!(pixels[idx + 1], 105); // 255 - 150
|
||||
assert_eq!(pixels[idx + 2], 55); // 255 - 200
|
||||
assert_eq!(hash_pixels(&pixels), "35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f");
|
||||
assert_eq!(
|
||||
hash_pixels(&pixels),
|
||||
"35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: Undo after rectangle.
|
||||
@@ -196,7 +222,10 @@ fn vector_fill_toggle_and_delete() {
|
||||
|
||||
// Create a rect with fill=false
|
||||
let shape = VectorShape::Rect {
|
||||
x1: 2.0, y1: 2.0, x2: 14.0, y2: 14.0,
|
||||
x1: 2.0,
|
||||
y1: 2.0,
|
||||
x2: 14.0,
|
||||
y2: 14.0,
|
||||
stroke: 1.0,
|
||||
color: [255, 0, 0, 255],
|
||||
fill_color: [0, 0, 255, 255],
|
||||
@@ -211,25 +240,39 @@ fn vector_fill_toggle_and_delete() {
|
||||
|
||||
// Verify fill is initially false
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(false), "fill should be false initially");
|
||||
assert_eq!(
|
||||
shapes[0].fill(),
|
||||
Some(false),
|
||||
"fill should be false initially"
|
||||
);
|
||||
|
||||
// Toggle fill ON
|
||||
engine.set_vector_shape_fill(layer_id, 0, true);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(true), "fill should be true after toggle");
|
||||
assert_eq!(
|
||||
shapes[0].fill(),
|
||||
Some(true),
|
||||
"fill should be true after toggle"
|
||||
);
|
||||
|
||||
// Verify composite pixels contain blue fill
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let has_blue = pixels.chunks(4).any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
|
||||
let has_blue = pixels
|
||||
.chunks(4)
|
||||
.any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
|
||||
assert!(has_blue, "should have blue fill pixels after enabling fill");
|
||||
|
||||
// Toggle fill OFF
|
||||
engine.set_vector_shape_fill(layer_id, 0, false);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(false), "fill should be false after toggling off");
|
||||
assert_eq!(
|
||||
shapes[0].fill(),
|
||||
Some(false),
|
||||
"fill should be false after toggling off"
|
||||
);
|
||||
|
||||
// Delete the shape
|
||||
engine.delete_vector_shape(layer_id, 0);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert!(shapes.is_empty(), "shapes should be empty after delete");
|
||||
}
|
||||
}
|
||||
|
||||
+221
-53
@@ -1,18 +1,17 @@
|
||||
use crate::color_overlay::generate_color_overlay;
|
||||
use crate::drop_shadow::generate_shadow;
|
||||
use crate::emboss::generate_bevel_emboss;
|
||||
use crate::gradient_overlay::generate_gradient_overlay;
|
||||
use crate::helpers::{composite_effect, composite_inside_effect, extract_alpha};
|
||||
use crate::inner_shadow::generate_inner_shadow;
|
||||
use crate::outer_glow::generate_glow;
|
||||
use crate::satin::generate_satin;
|
||||
use crate::stroke::generate_stroke;
|
||||
/// Effect application orchestrator.
|
||||
///
|
||||
/// Applies all enabled layer effects to a layer's pixel buffer in standard
|
||||
/// Photoshop order. Delegates to individual effect modules for rendering.
|
||||
|
||||
use crate::types::LayerEffect;
|
||||
use crate::emboss::generate_bevel_emboss;
|
||||
use crate::outer_glow::generate_glow;
|
||||
use crate::color_overlay::generate_color_overlay;
|
||||
use crate::satin::generate_satin;
|
||||
use crate::gradient_overlay::generate_gradient_overlay;
|
||||
use crate::stroke::generate_stroke;
|
||||
use crate::drop_shadow::generate_shadow;
|
||||
use crate::inner_shadow::generate_inner_shadow;
|
||||
use crate::helpers::{extract_alpha, composite_effect, composite_inside_effect};
|
||||
use hcie_blend::blend_pixels;
|
||||
|
||||
/// Apply all enabled layer effects to a layer's pixel buffer in standard Photoshop order.
|
||||
@@ -34,7 +33,9 @@ pub fn apply_layer_effects(
|
||||
} else {
|
||||
let mut out = pixels.to_vec();
|
||||
for i in 0..px_count {
|
||||
out[i * 4 + 3] = (out[i * 4 + 3] as f32 * fill_opacity).round().clamp(0.0, 255.0) as u8;
|
||||
out[i * 4 + 3] = (out[i * 4 + 3] as f32 * fill_opacity)
|
||||
.round()
|
||||
.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -55,31 +56,62 @@ pub fn apply_layer_effects(
|
||||
inside.copy_from_slice(pixels);
|
||||
if fill_opacity < 1.0 {
|
||||
for i in 0..px_count {
|
||||
inside[i * 4 + 3] = (inside[i * 4 + 3] as f32 * fill_opacity).round().clamp(0.0, 255.0) as u8;
|
||||
inside[i * 4 + 3] = (inside[i * 4 + 3] as f32 * fill_opacity)
|
||||
.round()
|
||||
.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
// Inside effects — Photoshop spec order:
|
||||
// a. Bevel & Emboss
|
||||
if let Some(LayerEffect::BevelEmboss {
|
||||
enabled: true, style, technique, depth, direction, size, soften,
|
||||
angle, altitude, highlight_blend, highlight_color, highlight_opacity,
|
||||
shadow_blend, shadow_color, shadow_opacity, contour, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::BevelEmboss { .. })) {
|
||||
enabled: true,
|
||||
style,
|
||||
technique,
|
||||
depth,
|
||||
direction,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
highlight_blend,
|
||||
highlight_color,
|
||||
highlight_opacity,
|
||||
shadow_blend,
|
||||
shadow_color,
|
||||
shadow_opacity,
|
||||
contour,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::BevelEmboss { .. }))
|
||||
{
|
||||
// Emboss is rendered on top of the original layer fill; do not replace
|
||||
// it with a hard-coded grey. Photoshop keeps the original pixel color
|
||||
// and applies highlight/shadow blends over it.
|
||||
let (highlight, shadow) = generate_bevel_emboss(
|
||||
&alpha, canvas_w, canvas_h,
|
||||
*depth, *size, *soften, *angle, *altitude,
|
||||
*direction, *technique, *style,
|
||||
*highlight_color, *shadow_color,
|
||||
&alpha,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
*depth,
|
||||
*size,
|
||||
*soften,
|
||||
*angle,
|
||||
*altitude,
|
||||
*direction,
|
||||
*technique,
|
||||
*style,
|
||||
*highlight_color,
|
||||
*shadow_color,
|
||||
contour,
|
||||
);
|
||||
|
||||
// For Emboss and OuterBevel, the effect extends outside the shape.
|
||||
// Inside pixels go to `inside`, outside pixels go to `behind`.
|
||||
let has_outer = matches!(*style, crate::types::BevelStyle::Emboss | crate::types::BevelStyle::OuterBevel);
|
||||
let has_outer = matches!(
|
||||
*style,
|
||||
crate::types::BevelStyle::Emboss | crate::types::BevelStyle::OuterBevel
|
||||
);
|
||||
if has_outer {
|
||||
// Split highlight/shadow into inside and outside portions
|
||||
let mut hl_inside = vec![0u8; px_count * 4];
|
||||
@@ -95,8 +127,20 @@ pub fn apply_layer_effects(
|
||||
sh_outside[i * 4..i * 4 + 4].copy_from_slice(&shadow[i * 4..i * 4 + 4]);
|
||||
}
|
||||
}
|
||||
composite_effect(&mut inside, &hl_inside, *highlight_blend, *highlight_opacity, px_count);
|
||||
composite_effect(&mut inside, &sh_inside, *shadow_blend, *shadow_opacity, px_count);
|
||||
composite_effect(
|
||||
&mut inside,
|
||||
&hl_inside,
|
||||
*highlight_blend,
|
||||
*highlight_opacity,
|
||||
px_count,
|
||||
);
|
||||
composite_effect(
|
||||
&mut inside,
|
||||
&sh_inside,
|
||||
*shadow_blend,
|
||||
*shadow_opacity,
|
||||
px_count,
|
||||
);
|
||||
// Store outside portions to apply to `behind` later
|
||||
bevel_hl_outside = Some(hl_outside);
|
||||
bevel_sh_outside = Some(sh_outside);
|
||||
@@ -105,65 +149,146 @@ pub fn apply_layer_effects(
|
||||
bevel_hl_op = *highlight_opacity;
|
||||
bevel_sh_op = *shadow_opacity;
|
||||
} else {
|
||||
composite_effect(&mut inside, &highlight, *highlight_blend, *highlight_opacity, px_count);
|
||||
composite_effect(&mut inside, &shadow, *shadow_blend, *shadow_opacity, px_count);
|
||||
composite_effect(
|
||||
&mut inside,
|
||||
&highlight,
|
||||
*highlight_blend,
|
||||
*highlight_opacity,
|
||||
px_count,
|
||||
);
|
||||
composite_effect(
|
||||
&mut inside,
|
||||
&shadow,
|
||||
*shadow_blend,
|
||||
*shadow_opacity,
|
||||
px_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// b. Satin
|
||||
if let Some(LayerEffect::Satin {
|
||||
enabled: true, blend_mode, color, opacity, angle, distance, size, invert, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::Satin { .. })) {
|
||||
let satin = generate_satin(&alpha, canvas_w, canvas_h, *angle, *distance, *size, *color, *invert);
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
size,
|
||||
invert,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::Satin { .. }))
|
||||
{
|
||||
let satin = generate_satin(
|
||||
&alpha, canvas_w, canvas_h, *angle, *distance, *size, *color, *invert,
|
||||
);
|
||||
composite_effect(&mut inside, &satin, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// c. Color Overlay
|
||||
if let Some(LayerEffect::ColorOverlay {
|
||||
enabled: true, blend_mode, color, opacity, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::ColorOverlay { .. })) {
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::ColorOverlay { .. }))
|
||||
{
|
||||
let overlay = generate_color_overlay(&alpha, canvas_w, canvas_h, *color);
|
||||
composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// d. Gradient Overlay
|
||||
if let Some(LayerEffect::GradientOverlay {
|
||||
enabled: true, blend_mode, opacity, angle, scale, gradient, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::GradientOverlay { .. })) {
|
||||
let overlay = generate_gradient_overlay(&alpha, canvas_w, canvas_h, *angle, *scale, gradient);
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
opacity,
|
||||
angle,
|
||||
scale,
|
||||
gradient,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::GradientOverlay { .. }))
|
||||
{
|
||||
let overlay =
|
||||
generate_gradient_overlay(&alpha, canvas_w, canvas_h, *angle, *scale, gradient);
|
||||
composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// e. Pattern Overlay
|
||||
if let Some(LayerEffect::PatternOverlay {
|
||||
enabled: true, blend_mode, opacity, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::PatternOverlay { .. })) {
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
opacity,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::PatternOverlay { .. }))
|
||||
{
|
||||
let overlay = generate_color_overlay(&alpha, canvas_w, canvas_h, [128, 128, 128, 255]);
|
||||
composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// f. Inner Glow
|
||||
if let Some(LayerEffect::InnerGlow {
|
||||
enabled: true, blend_mode, color, opacity, choke, size, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::InnerGlow { .. })) {
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
choke,
|
||||
size,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::InnerGlow { .. }))
|
||||
{
|
||||
let glow = generate_glow(&alpha, canvas_w, canvas_h, *choke, *size, *color, true);
|
||||
composite_effect(&mut inside, &glow, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// g. Inner Shadow
|
||||
if let Some(LayerEffect::InnerShadow {
|
||||
enabled: true, blend_mode, color, opacity, angle, distance, choke, size, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::InnerShadow { .. })) {
|
||||
let shadow = generate_inner_shadow(&alpha, canvas_w, canvas_h, *angle, *distance, *choke, *size, *color);
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
choke,
|
||||
size,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::InnerShadow { .. }))
|
||||
{
|
||||
let shadow = generate_inner_shadow(
|
||||
&alpha, canvas_w, canvas_h, *angle, *distance, *choke, *size, *color,
|
||||
);
|
||||
composite_effect(&mut inside, &shadow, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// h. Stroke (Inside and Center — Outside handled in behind phase)
|
||||
if let Some(LayerEffect::Stroke {
|
||||
enabled: true, position, blend_mode, opacity, size, color, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::Stroke { .. })) {
|
||||
enabled: true,
|
||||
position,
|
||||
blend_mode,
|
||||
opacity,
|
||||
size,
|
||||
color,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::Stroke { .. }))
|
||||
{
|
||||
if !matches!(position, crate::types::StrokePosition::Outside) {
|
||||
let stroke = generate_stroke(&alpha, canvas_w, canvas_h, *size, *position, *color, *opacity);
|
||||
let stroke = generate_stroke(
|
||||
&alpha, canvas_w, canvas_h, *size, *position, *color, *opacity,
|
||||
);
|
||||
composite_inside_effect(&mut inside, &stroke, *blend_mode, 1.0, px_count);
|
||||
}
|
||||
}
|
||||
@@ -174,26 +299,59 @@ pub fn apply_layer_effects(
|
||||
// Behind effects — Photoshop spec order:
|
||||
// a. Drop Shadow
|
||||
if let Some(LayerEffect::DropShadow {
|
||||
enabled: true, blend_mode, color, opacity, angle, distance, spread, size, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::DropShadow { .. })) {
|
||||
let shadow = generate_shadow(&alpha, canvas_w, canvas_h, *angle, *distance, *spread, *size, *color, false);
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::DropShadow { .. }))
|
||||
{
|
||||
let shadow = generate_shadow(
|
||||
&alpha, canvas_w, canvas_h, *angle, *distance, *spread, *size, *color, false,
|
||||
);
|
||||
composite_effect(&mut behind, &shadow, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// b. Outer Glow
|
||||
if let Some(LayerEffect::OuterGlow {
|
||||
enabled: true, blend_mode, color, opacity, spread, size, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::OuterGlow { .. })) {
|
||||
enabled: true,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
spread,
|
||||
size,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::OuterGlow { .. }))
|
||||
{
|
||||
let glow = generate_glow(&alpha, canvas_w, canvas_h, *spread, *size, *color, false);
|
||||
composite_effect(&mut behind, &glow, *blend_mode, *opacity, px_count);
|
||||
}
|
||||
|
||||
// c. Outside Stroke
|
||||
if let Some(LayerEffect::Stroke {
|
||||
enabled: true, position, blend_mode, opacity, size, color, ..
|
||||
}) = effects.iter().find(|e| matches!(e, LayerEffect::Stroke { .. })) {
|
||||
enabled: true,
|
||||
position,
|
||||
blend_mode,
|
||||
opacity,
|
||||
size,
|
||||
color,
|
||||
..
|
||||
}) = effects
|
||||
.iter()
|
||||
.find(|e| matches!(e, LayerEffect::Stroke { .. }))
|
||||
{
|
||||
if matches!(position, crate::types::StrokePosition::Outside) {
|
||||
let stroke = generate_stroke(&alpha, canvas_w, canvas_h, *size, *position, *color, *opacity);
|
||||
let stroke = generate_stroke(
|
||||
&alpha, canvas_w, canvas_h, *size, *position, *color, *opacity,
|
||||
);
|
||||
composite_effect(&mut behind, &stroke, *blend_mode, 1.0, px_count);
|
||||
}
|
||||
}
|
||||
@@ -209,8 +367,18 @@ pub fn apply_layer_effects(
|
||||
|
||||
// 3. Blend inside ON TOP of behind using Normal blend
|
||||
for i in 0..px_count {
|
||||
let b = [behind[i * 4], behind[i * 4 + 1], behind[i * 4 + 2], behind[i * 4 + 3]];
|
||||
let ins = [inside[i * 4], inside[i * 4 + 1], inside[i * 4 + 2], inside[i * 4 + 3]];
|
||||
let b = [
|
||||
behind[i * 4],
|
||||
behind[i * 4 + 1],
|
||||
behind[i * 4 + 2],
|
||||
behind[i * 4 + 3],
|
||||
];
|
||||
let ins = [
|
||||
inside[i * 4],
|
||||
inside[i * 4 + 1],
|
||||
inside[i * 4 + 2],
|
||||
inside[i * 4 + 3],
|
||||
];
|
||||
if ins[3] == 0 {
|
||||
behind[i * 4..i * 4 + 4].copy_from_slice(&b);
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
pub fn generate_color_overlay(
|
||||
alpha: &[u8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
color: [u8; 4],
|
||||
) -> Vec<u8> {
|
||||
pub fn generate_color_overlay(alpha: &[u8], w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
|
||||
let px = (w * h) as usize;
|
||||
let mut buf = vec![0u8; px * 4];
|
||||
|
||||
@@ -18,4 +13,4 @@ pub fn generate_color_overlay(
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
///
|
||||
/// Renders a drop shadow by offsetting the layer alpha, applying spread,
|
||||
/// blurring, and filling with the shadow color. Supports knock-out mode.
|
||||
|
||||
use crate::helpers::{apply_spread, box_blur_f32};
|
||||
|
||||
/// Generate a drop shadow buffer.
|
||||
@@ -56,7 +55,9 @@ pub(crate) fn generate_shadow(
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let a = alpha[(y as u32 * w + x as u32) as usize];
|
||||
if a == 0 { continue; }
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let dx = x + dx_raw;
|
||||
let dy = y + dy_raw;
|
||||
if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 {
|
||||
|
||||
+81
-25
@@ -1,5 +1,5 @@
|
||||
use crate::types;
|
||||
use crate::helpers::box_blur_f32;
|
||||
use crate::types;
|
||||
|
||||
/// Look up a value in a contour curve using linear interpolation.
|
||||
/// input is in [0.0, 1.0], output is remapped through the curve.
|
||||
@@ -96,7 +96,10 @@ pub fn generate_bevel_emboss(
|
||||
// the outer bevel band can inherit the alpha of the nearest content pixel.
|
||||
// This is critical: without it, alpha[i]==0 outside the shape produces
|
||||
// zero-strength highlight/shadow, making Emboss/OuterBevel invisible.
|
||||
let outside_src = if matches!(style, types::BevelStyle::Emboss | types::BevelStyle::OuterBevel) {
|
||||
let outside_src = if matches!(
|
||||
style,
|
||||
types::BevelStyle::Emboss | types::BevelStyle::OuterBevel
|
||||
) {
|
||||
edt_outside_src(alpha, iw, ih)
|
||||
} else {
|
||||
vec![0usize; px]
|
||||
@@ -112,10 +115,18 @@ pub fn generate_bevel_emboss(
|
||||
if alpha[i] > 0 {
|
||||
let x = i % iw;
|
||||
let y = i / iw;
|
||||
if x < min_x { min_x = x; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if y > max_y { max_y = y; }
|
||||
if x < min_x {
|
||||
min_x = x;
|
||||
}
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y < min_y {
|
||||
min_y = y;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
((min_x + max_x) as f32 / 2.0, (min_y + max_y) as f32 / 2.0)
|
||||
@@ -180,7 +191,11 @@ pub fn generate_bevel_emboss(
|
||||
// Apply soften box-blur on the height field.
|
||||
// Even at soften=0, apply minimal 2px blur for smoother gradient estimation at edges.
|
||||
// Emboss benefits from more blur to reduce wrinkled edges on large shapes.
|
||||
let base_blur = if matches!(style, types::BevelStyle::Emboss) { 2 } else { 1 };
|
||||
let base_blur = if matches!(style, types::BevelStyle::Emboss) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let soften_radius = (soften.max(0.0).round() as i32).max(base_blur);
|
||||
height = box_blur_f32(&height, w, h, soften_radius);
|
||||
|
||||
@@ -200,20 +215,32 @@ pub fn generate_bevel_emboss(
|
||||
types::BevelStyle::Emboss => true,
|
||||
_ => alpha[i] > 0,
|
||||
};
|
||||
if !show { continue; }
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_inner = alpha[i] > 0;
|
||||
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() };
|
||||
let dist_abs = if is_inner {
|
||||
dist_inside_sq[i].sqrt()
|
||||
} else {
|
||||
dist_outside_sq[i].sqrt()
|
||||
};
|
||||
|
||||
if !is_inner && dist_abs > radius { continue; }
|
||||
if !is_inner && dist_abs > radius {
|
||||
continue;
|
||||
}
|
||||
|
||||
let technique_show = match technique {
|
||||
types::Technique::Smooth => true,
|
||||
_ => dist_abs <= radius,
|
||||
};
|
||||
if !technique_show { continue; }
|
||||
if !technique_show {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_inner && dist_abs > radius { continue; }
|
||||
if is_inner && dist_abs > radius {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x0 = if x > 0 { x - 1 } else { x };
|
||||
let x1 = if x < iw - 1 { x + 1 } else { x };
|
||||
@@ -250,7 +277,11 @@ pub fn generate_bevel_emboss(
|
||||
// Inner/Outer bevel need blur for smooth corners.
|
||||
// MAE-optimized against Photoshop ground truth (288 emboss samples, 256×256).
|
||||
// BEGIN_TUNING_ZONE
|
||||
let lighting_blur = if matches!(style, crate::types::BevelStyle::Emboss) { 0 } else { 5 };
|
||||
let lighting_blur = if matches!(style, crate::types::BevelStyle::Emboss) {
|
||||
0
|
||||
} else {
|
||||
5
|
||||
};
|
||||
lighting = box_blur_f32(&lighting, w, h, lighting_blur);
|
||||
|
||||
for i in 0..px {
|
||||
@@ -261,20 +292,31 @@ pub fn generate_bevel_emboss(
|
||||
crate::types::BevelStyle::Emboss => true,
|
||||
_ => is_inner,
|
||||
};
|
||||
if !show { continue; }
|
||||
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() };
|
||||
if !is_inner && dist_abs > radius { continue; }
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
let dist_abs = if is_inner {
|
||||
dist_inside_sq[i].sqrt()
|
||||
} else {
|
||||
dist_outside_sq[i].sqrt()
|
||||
};
|
||||
if !is_inner && dist_abs > radius {
|
||||
continue;
|
||||
}
|
||||
let technique_show = match technique {
|
||||
crate::types::Technique::Smooth => true,
|
||||
_ => dist_abs <= radius,
|
||||
};
|
||||
if !technique_show { continue; }
|
||||
if !technique_show {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ndl = lighting[i];
|
||||
// Emboss has tilt so the interior is never truly flat — skip the
|
||||
// is_flat shortcut for emboss to allow tilt-based highlight/shadow
|
||||
// across the entire shape interior.
|
||||
let is_flat = is_inner && dist_abs > radius && !matches!(style, crate::types::BevelStyle::Emboss);
|
||||
let is_flat =
|
||||
is_inner && dist_abs > radius && !matches!(style, crate::types::BevelStyle::Emboss);
|
||||
if is_flat {
|
||||
continue;
|
||||
}
|
||||
@@ -417,7 +459,9 @@ fn edt_2d(dt: &mut [f32], w: usize, h: usize) {
|
||||
/// 1D EDT (Felzenszwalb & Huttenlocher).
|
||||
fn edt_1d(f: &mut [f32]) {
|
||||
let n = f.len();
|
||||
if n == 0 { return; }
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut d = vec![0.0f32; n];
|
||||
let mut v = vec![0usize; n];
|
||||
@@ -437,7 +481,10 @@ fn edt_1d(f: &mut [f32]) {
|
||||
let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
|
||||
while s <= z[k] {
|
||||
k = k.wrapping_sub(1);
|
||||
if k == usize::MAX { k = 0; break; }
|
||||
if k == usize::MAX {
|
||||
k = 0;
|
||||
break;
|
||||
}
|
||||
let vk2 = v[k];
|
||||
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
|
||||
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32);
|
||||
@@ -451,7 +498,9 @@ fn edt_1d(f: &mut [f32]) {
|
||||
k = 0;
|
||||
for q in 0..n {
|
||||
let qf = q as f32;
|
||||
while z[k + 1] < qf { k += 1; }
|
||||
while z[k + 1] < qf {
|
||||
k += 1;
|
||||
}
|
||||
let vk = v[k];
|
||||
let diff = qf - vk as f32;
|
||||
d[q] = diff * diff + f[vk];
|
||||
@@ -494,7 +543,9 @@ fn edt_2d_src(dt: &mut [f32], src_idx: &mut [usize], w: usize, h: usize) {
|
||||
/// 1D EDT with source index tracking (Felzenszwalb & Huttenlocher).
|
||||
fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
let n = f.len();
|
||||
if n == 0 { return; }
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut d = vec![0.0f32; n];
|
||||
let mut d_src = vec![0usize; n];
|
||||
@@ -515,7 +566,10 @@ fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
|
||||
while s <= z[k] {
|
||||
k = k.wrapping_sub(1);
|
||||
if k == usize::MAX { k = 0; break; }
|
||||
if k == usize::MAX {
|
||||
k = 0;
|
||||
break;
|
||||
}
|
||||
let vk2 = v[k];
|
||||
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
|
||||
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32);
|
||||
@@ -529,7 +583,9 @@ fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
k = 0;
|
||||
for q in 0..n {
|
||||
let qf = q as f32;
|
||||
while z[k + 1] < qf { k += 1; }
|
||||
while z[k + 1] < qf {
|
||||
k += 1;
|
||||
}
|
||||
let vk = v[k];
|
||||
let diff = qf - vk as f32;
|
||||
d[q] = diff * diff + f[vk];
|
||||
@@ -538,4 +594,4 @@ fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
|
||||
f.copy_from_slice(&d);
|
||||
src_idx.copy_from_slice(&d_src);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ pub fn generate_gradient_overlay(
|
||||
for x in 0..w as usize {
|
||||
let i = y * w as usize + x;
|
||||
let a = alpha[i];
|
||||
if a == 0 { continue; }
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fx = x as f32 - cx;
|
||||
let fy = y as f32 - cy;
|
||||
@@ -89,4 +91,4 @@ fn sample_gradient(t: f32, gradient: &crate::types::GradientDef) -> [u8; 4] {
|
||||
}
|
||||
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -40,8 +40,15 @@ pub(crate) fn composite_effect(
|
||||
) {
|
||||
for i in 0..px_count {
|
||||
let dst_px = [dst[i * 4], dst[i * 4 + 1], dst[i * 4 + 2], dst[i * 4 + 3]];
|
||||
let src_px = [effect_buf[i * 4], effect_buf[i * 4 + 1], effect_buf[i * 4 + 2], effect_buf[i * 4 + 3]];
|
||||
if src_px[3] == 0 { continue; }
|
||||
let src_px = [
|
||||
effect_buf[i * 4],
|
||||
effect_buf[i * 4 + 1],
|
||||
effect_buf[i * 4 + 2],
|
||||
effect_buf[i * 4 + 3],
|
||||
];
|
||||
if src_px[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let out = hcie_blend::blend_pixels(dst_px, src_px, blend_mode, opacity);
|
||||
dst[i * 4..i * 4 + 4].copy_from_slice(&out);
|
||||
}
|
||||
@@ -68,11 +75,18 @@ pub(crate) fn composite_inside_effect(
|
||||
) {
|
||||
for i in 0..px_count {
|
||||
let dst_px = [dst[i * 4], dst[i * 4 + 1], dst[i * 4 + 2], dst[i * 4 + 3]];
|
||||
let src_px = [effect_buf[i * 4], effect_buf[i * 4 + 1], effect_buf[i * 4 + 2], effect_buf[i * 4 + 3]];
|
||||
if src_px[3] == 0 || dst_px[3] == 0 { continue; }
|
||||
|
||||
let src_px = [
|
||||
effect_buf[i * 4],
|
||||
effect_buf[i * 4 + 1],
|
||||
effect_buf[i * 4 + 2],
|
||||
effect_buf[i * 4 + 3],
|
||||
];
|
||||
if src_px[3] == 0 || dst_px[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eff_sa = (src_px[3] as f32 / 255.0) * opacity;
|
||||
|
||||
|
||||
if blend_mode == hcie_blend::BlendMode::Normal {
|
||||
if eff_sa >= 1.0 {
|
||||
dst[i * 4] = src_px[0];
|
||||
@@ -110,7 +124,9 @@ pub(crate) fn apply_spread(alpha: &mut [u8], spread: f32) {
|
||||
}
|
||||
if spread >= 100.0 {
|
||||
for a in alpha.iter_mut() {
|
||||
if *a > 0 { *a = 255; }
|
||||
if *a > 0 {
|
||||
*a = 255;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
/// Renders an inner shadow by offsetting the inverted layer alpha,
|
||||
/// blurring, and masking with the original alpha. MAE-optimized
|
||||
/// parameters for Photoshop-compatible output.
|
||||
|
||||
use crate::helpers::{apply_spread, box_blur_f32};
|
||||
|
||||
/// Generate an inner shadow buffer.
|
||||
@@ -54,7 +53,7 @@ pub(crate) fn generate_inner_shadow(
|
||||
for x in 0..w as i32 {
|
||||
let orig_i = (y as u32 * w + x as u32) as usize;
|
||||
let val = 255 - alpha[orig_i];
|
||||
|
||||
|
||||
let dx = x + dx_raw;
|
||||
let dy = y + dy_raw;
|
||||
if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 {
|
||||
|
||||
+14
-11
@@ -25,22 +25,25 @@
|
||||
//! ## Dependencies
|
||||
//! - `hcie-blend` — Blend mode math for compositing effects with layers
|
||||
|
||||
pub mod types;
|
||||
pub mod parser;
|
||||
pub mod helpers;
|
||||
pub mod apply_effects;
|
||||
pub mod drop_shadow;
|
||||
pub mod inner_shadow;
|
||||
pub mod emboss;
|
||||
pub mod outer_glow;
|
||||
pub mod color_overlay;
|
||||
pub mod satin;
|
||||
pub mod drop_shadow;
|
||||
pub mod emboss;
|
||||
pub mod gradient_overlay;
|
||||
pub mod helpers;
|
||||
pub mod inner_shadow;
|
||||
pub mod outer_glow;
|
||||
pub mod parser;
|
||||
pub mod satin;
|
||||
pub mod stroke;
|
||||
pub mod tuned;
|
||||
pub mod types;
|
||||
|
||||
pub use types::LayerEffect;
|
||||
pub use types::{blend_mode_to_string, blend_mode_from_string, protocol_to_hcie_fx_effect, hcie_fx_effect_to_protocol, layer_style_to_effect};
|
||||
pub use parser::{parse_lfx2, parse_lrFX, parse_asl, parse_asl_styles};
|
||||
pub use apply_effects::apply_layer_effects;
|
||||
pub use helpers::box_blur_f32;
|
||||
pub use parser::{parse_asl, parse_asl_styles, parse_lfx2, parse_lrFX};
|
||||
pub use types::LayerEffect;
|
||||
pub use types::{
|
||||
blend_mode_from_string, blend_mode_to_string, hcie_fx_effect_to_protocol,
|
||||
layer_style_to_effect, protocol_to_hcie_fx_effect,
|
||||
};
|
||||
|
||||
@@ -73,4 +73,4 @@ pub fn generate_glow(
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
+534
-168
File diff suppressed because it is too large
Load Diff
@@ -74,4 +74,4 @@ pub fn generate_satin(
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -29,9 +29,13 @@ pub fn generate_stroke(
|
||||
crate::types::StrokePosition::Inside => {
|
||||
let dist_sq = crate::tuned::edt_inside(alpha, iw, ih);
|
||||
for i in 0..px {
|
||||
if alpha[i] == 0 { continue; }
|
||||
if alpha[i] == 0 {
|
||||
continue;
|
||||
}
|
||||
let d = dist_sq[i].sqrt();
|
||||
if d >= radius { continue; }
|
||||
if d >= radius {
|
||||
continue;
|
||||
}
|
||||
let outer_dist = radius - d;
|
||||
let coverage = (outer_dist / aa).clamp(0.0, 1.0);
|
||||
stroke_alpha[i] = coverage * alpha[i] as f32;
|
||||
@@ -42,7 +46,9 @@ pub fn generate_stroke(
|
||||
let nearest = crate::tuned::edt_outside_src(alpha, iw, ih);
|
||||
for i in 0..px {
|
||||
let d = dist_sq[i].sqrt();
|
||||
if d == 0.0 || d >= radius { continue; }
|
||||
if d == 0.0 || d >= radius {
|
||||
continue;
|
||||
}
|
||||
let x_i = (i % iw) as f32;
|
||||
let y_i = (i / iw) as f32;
|
||||
let x_n = (nearest[i] % iw) as f32;
|
||||
@@ -76,8 +82,10 @@ pub fn generate_stroke(
|
||||
} else {
|
||||
(dist_out_sq[i].sqrt(), false)
|
||||
};
|
||||
if d == 0.0 || d >= half_r { continue; }
|
||||
|
||||
if d == 0.0 || d >= half_r {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_a = if is_inside {
|
||||
alpha[i] as f32
|
||||
} else {
|
||||
@@ -123,4 +131,4 @@ pub fn generate_stroke(
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
+109
-35
@@ -36,7 +36,10 @@ pub fn generate_bevel_tuned(
|
||||
|
||||
// For Emboss and OuterBevel, track the nearest opaque source pixel so
|
||||
// the outer bevel band can inherit the alpha of the nearest content pixel.
|
||||
let outside_src = if matches!(*style, types::BevelStyle::Emboss | types::BevelStyle::OuterBevel) {
|
||||
let outside_src = if matches!(
|
||||
*style,
|
||||
types::BevelStyle::Emboss | types::BevelStyle::OuterBevel
|
||||
) {
|
||||
edt_outside_src(alpha, iw, ih)
|
||||
} else {
|
||||
vec![0usize; px]
|
||||
@@ -52,10 +55,18 @@ pub fn generate_bevel_tuned(
|
||||
if alpha[i] > 0 {
|
||||
let x = i % iw;
|
||||
let y = i / iw;
|
||||
if x < min_x { min_x = x; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if y > max_y { max_y = y; }
|
||||
if x < min_x {
|
||||
min_x = x;
|
||||
}
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y < min_y {
|
||||
min_y = y;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
((min_x + max_x) as f32 / 2.0, (min_y + max_y) as f32 / 2.0)
|
||||
@@ -134,11 +145,19 @@ pub fn generate_bevel_tuned(
|
||||
types::BevelStyle::Emboss => true,
|
||||
_ => alpha[i] > 0,
|
||||
};
|
||||
if !show { continue; }
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
let is_inner = alpha[i] > 0;
|
||||
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() };
|
||||
if !is_inner && dist_abs > radius { continue; }
|
||||
let x0 = if x > 0 { x - 1 } else { x };
|
||||
let dist_abs = if is_inner {
|
||||
dist_inside_sq[i].sqrt()
|
||||
} else {
|
||||
dist_outside_sq[i].sqrt()
|
||||
};
|
||||
if !is_inner && dist_abs > radius {
|
||||
continue;
|
||||
}
|
||||
let x0 = if x > 0 { x - 1 } else { x };
|
||||
let x1 = if x < iw - 1 { x + 1 } else { x };
|
||||
let y0 = if y > 0 { y - 1 } else { y };
|
||||
let y1 = if y < ih - 1 { y + 1 } else { y };
|
||||
@@ -177,9 +196,17 @@ pub fn generate_bevel_tuned(
|
||||
types::BevelStyle::Emboss => true,
|
||||
_ => is_inner,
|
||||
};
|
||||
if !show { continue; }
|
||||
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() };
|
||||
if !is_inner && dist_abs > radius { continue; }
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
let dist_abs = if is_inner {
|
||||
dist_inside_sq[i].sqrt()
|
||||
} else {
|
||||
dist_outside_sq[i].sqrt()
|
||||
};
|
||||
if !is_inner && dist_abs > radius {
|
||||
continue;
|
||||
}
|
||||
let ndl = lighting[i];
|
||||
// Emboss has tilt so the interior is never truly flat — skip the
|
||||
// is_flat shortcut for emboss to allow tilt-based highlight/shadow
|
||||
@@ -325,7 +352,9 @@ pub fn generate_shadow_tuned(
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let a = alpha[(y as u32 * w + x as u32) as usize];
|
||||
if a == 0 { continue; }
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let dx = x + dx_raw;
|
||||
let dy = y + dy_raw;
|
||||
if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 {
|
||||
@@ -587,9 +616,13 @@ pub fn generate_stroke_tuned(
|
||||
types::StrokePosition::Inside => {
|
||||
let dist_sq = edt_inside(alpha, iw, ih);
|
||||
for i in 0..px {
|
||||
if alpha[i] == 0 { continue; }
|
||||
if alpha[i] == 0 {
|
||||
continue;
|
||||
}
|
||||
let d = dist_sq[i].sqrt();
|
||||
if d >= radius { continue; }
|
||||
if d >= radius {
|
||||
continue;
|
||||
}
|
||||
let outer_dist = radius - d; // distance to outer stroke edge
|
||||
let coverage = (outer_dist / aa).clamp(0.0, 1.0);
|
||||
stroke_alpha[i] = coverage * alpha[i] as f32;
|
||||
@@ -600,7 +633,9 @@ pub fn generate_stroke_tuned(
|
||||
let nearest = edt_outside_src(alpha, iw, ih);
|
||||
for i in 0..px {
|
||||
let d = dist_sq[i].sqrt();
|
||||
if d == 0.0 || d >= radius { continue; }
|
||||
if d == 0.0 || d >= radius {
|
||||
continue;
|
||||
}
|
||||
let src_a = alpha[nearest[i]] as f32;
|
||||
let outer_dist = radius - d; // distance to outer stroke edge
|
||||
let coverage = (outer_dist / aa).clamp(0.0, 1.0);
|
||||
@@ -618,7 +653,9 @@ pub fn generate_stroke_tuned(
|
||||
} else {
|
||||
(dist_out_sq[i].sqrt(), alpha[nearest[i]] as f32)
|
||||
};
|
||||
if d == 0.0 || d >= half_r { continue; }
|
||||
if d == 0.0 || d >= half_r {
|
||||
continue;
|
||||
}
|
||||
let outer_dist = half_r - d; // distance to outer stroke edge
|
||||
let coverage = (outer_dist / aa).clamp(0.0, 1.0);
|
||||
stroke_alpha[i] = coverage * base_a;
|
||||
@@ -653,7 +690,9 @@ pub fn edt_inside(alpha: &[u8], w: usize, h: usize) -> Vec<f32> {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if alpha[i] == 0 { dt[i] = 0.0; }
|
||||
if alpha[i] == 0 {
|
||||
dt[i] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
edt_2d(&mut dt, w, h);
|
||||
@@ -670,7 +709,9 @@ pub fn edt_outside(alpha: &[u8], w: usize, h: usize) -> Vec<f32> {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if alpha[i] > 0 { dt[i] = 0.0; }
|
||||
if alpha[i] > 0 {
|
||||
dt[i] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
edt_2d(&mut dt, w, h);
|
||||
@@ -696,7 +737,9 @@ pub fn edt_outside_src(alpha: &[u8], w: usize, h: usize) -> Vec<usize> {
|
||||
|
||||
fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
let n = f.len();
|
||||
if n == 0 { return; }
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let mut d = vec![0.0f32; n];
|
||||
let mut d_src = vec![0usize; n];
|
||||
let mut v = vec![0usize; n];
|
||||
@@ -713,7 +756,10 @@ fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
|
||||
while s <= z[k] {
|
||||
k = k.wrapping_sub(1);
|
||||
if k == usize::MAX { k = 0; break; }
|
||||
if k == usize::MAX {
|
||||
k = 0;
|
||||
break;
|
||||
}
|
||||
let vk2 = v[k];
|
||||
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
|
||||
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32);
|
||||
@@ -726,7 +772,9 @@ fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
|
||||
k = 0;
|
||||
for q in 0..n {
|
||||
let qf = q as f32;
|
||||
while z[k + 1] < qf { k += 1; }
|
||||
while z[k + 1] < qf {
|
||||
k += 1;
|
||||
}
|
||||
let vk = v[k];
|
||||
let diff = qf - vk as f32;
|
||||
d[q] = diff * diff + f[vk];
|
||||
@@ -740,39 +788,60 @@ fn edt_2d_src(dt: &mut [f32], src_idx: &mut [usize], w: usize, h: usize) {
|
||||
let mut col = vec![0.0f32; h];
|
||||
let mut col_src = vec![0usize; h];
|
||||
for x in 0..w {
|
||||
for y in 0..h { col[y] = dt[y * w + x]; col_src[y] = src_idx[y * w + x]; }
|
||||
for y in 0..h {
|
||||
col[y] = dt[y * w + x];
|
||||
col_src[y] = src_idx[y * w + x];
|
||||
}
|
||||
edt_1d_src(&mut col, &mut col_src);
|
||||
for y in 0..h { dt[y * w + x] = col[y]; src_idx[y * w + x] = col_src[y]; }
|
||||
for y in 0..h {
|
||||
dt[y * w + x] = col[y];
|
||||
src_idx[y * w + x] = col_src[y];
|
||||
}
|
||||
}
|
||||
let mut row = vec![0.0f32; w];
|
||||
let mut row_src = vec![0usize; w];
|
||||
for y in 0..h {
|
||||
for x in 0..w { row[x] = dt[y * w + x]; row_src[x] = src_idx[y * w + x]; }
|
||||
for x in 0..w {
|
||||
row[x] = dt[y * w + x];
|
||||
row_src[x] = src_idx[y * w + x];
|
||||
}
|
||||
edt_1d_src(&mut row, &mut row_src);
|
||||
for x in 0..w { dt[y * w + x] = row[x]; src_idx[y * w + x] = row_src[x]; }
|
||||
for x in 0..w {
|
||||
dt[y * w + x] = row[x];
|
||||
src_idx[y * w + x] = row_src[x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn edt_2d(dt: &mut [f32], w: usize, h: usize) {
|
||||
let mut col = vec![0.0f32; h];
|
||||
for x in 0..w {
|
||||
for y in 0..h { col[y] = dt[y * w + x]; }
|
||||
for y in 0..h {
|
||||
col[y] = dt[y * w + x];
|
||||
}
|
||||
edt_1d(&mut col);
|
||||
for y in 0..h { dt[y * w + x] = col[y]; }
|
||||
for y in 0..h {
|
||||
dt[y * w + x] = col[y];
|
||||
}
|
||||
}
|
||||
|
||||
let mut row = vec![0.0f32; w];
|
||||
for y in 0..h {
|
||||
for x in 0..w { row[x] = dt[y * w + x]; }
|
||||
for x in 0..w {
|
||||
row[x] = dt[y * w + x];
|
||||
}
|
||||
edt_1d(&mut row);
|
||||
for x in 0..w { dt[y * w + x] = row[x]; }
|
||||
for x in 0..w {
|
||||
dt[y * w + x] = row[x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edt_1d(f: &mut [f32]) {
|
||||
let n = f.len();
|
||||
if n == 0 { return; }
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let mut d = vec![0.0f32; n];
|
||||
let mut v = vec![0usize; n];
|
||||
let mut z = vec![0.0f32; n + 1];
|
||||
@@ -788,7 +857,10 @@ fn edt_1d(f: &mut [f32]) {
|
||||
let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
|
||||
while s <= z[k] {
|
||||
k = k.wrapping_sub(1);
|
||||
if k == usize::MAX { k = 0; break; }
|
||||
if k == usize::MAX {
|
||||
k = 0;
|
||||
break;
|
||||
}
|
||||
let vk2 = v[k];
|
||||
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
|
||||
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32);
|
||||
@@ -801,10 +873,12 @@ fn edt_1d(f: &mut [f32]) {
|
||||
k = 0;
|
||||
for q in 0..n {
|
||||
let qf = q as f32;
|
||||
while z[k + 1] < qf { k += 1; }
|
||||
while z[k + 1] < qf {
|
||||
k += 1;
|
||||
}
|
||||
let vk = v[k];
|
||||
let diff = qf - vk as f32;
|
||||
d[q] = diff * diff + f[vk];
|
||||
}
|
||||
f.copy_from_slice(&d);
|
||||
}
|
||||
}
|
||||
|
||||
+664
-194
@@ -2,19 +2,40 @@ use hcie_blend::BlendMode;
|
||||
use hcie_protocol::effects;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BevelStyle { InnerBevel, OuterBevel, Emboss, PillowEmboss, StrokeEmboss }
|
||||
pub enum BevelStyle {
|
||||
InnerBevel,
|
||||
OuterBevel,
|
||||
Emboss,
|
||||
PillowEmboss,
|
||||
StrokeEmboss,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Technique { Smooth, ChiselHard, ChiselSoft }
|
||||
pub enum Technique {
|
||||
Smooth,
|
||||
ChiselHard,
|
||||
ChiselSoft,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction { Up, Down }
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StrokePosition { Inside, Center, Outside }
|
||||
pub enum StrokePosition {
|
||||
Inside,
|
||||
Center,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StrokeFillType { Color, Gradient, Pattern }
|
||||
pub enum StrokeFillType {
|
||||
Color,
|
||||
Gradient,
|
||||
Pattern,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContourCurve {
|
||||
@@ -25,20 +46,74 @@ pub struct ContourCurve {
|
||||
pub fn contour_name_to_curve(name: &str) -> Option<ContourCurve> {
|
||||
match name {
|
||||
"Linear" => None, // Linear is the default (no remapping needed)
|
||||
"Cone" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }),
|
||||
"Cone-Inverted" => Some(ContourCurve { points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)] }),
|
||||
"Gaussian" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 0.85), (1.0, 1.0)] }),
|
||||
"Half Round" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.25, 0.75), (0.5, 1.0), (0.75, 0.75), (1.0, 0.0)] }),
|
||||
"Round" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }),
|
||||
"Ring" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.25, 1.0), (0.5, 0.0), (0.75, 1.0), (1.0, 0.0)] }),
|
||||
"Ring-Double" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.125, 1.0), (0.25, 0.0), (0.375, 1.0), (0.5, 0.0), (0.625, 1.0), (0.75, 0.0), (0.875, 1.0), (1.0, 0.0)] }),
|
||||
"Sawtooth" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 1.0), (0.5, 0.0), (1.0, 1.0)] }),
|
||||
"Square" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }),
|
||||
"Valley" => Some(ContourCurve { points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)] }),
|
||||
"Shallow-Slope" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 0.25), (1.0, 1.0)] }),
|
||||
"Wave" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.25, 1.0), (0.5, 0.5), (0.75, 1.0), (1.0, 0.0)] }),
|
||||
"Cove" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.25, 0.5), (0.5, 1.0), (0.75, 0.5), (1.0, 0.0)] }),
|
||||
"Washboard" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.125, 1.0), (0.25, 0.0), (0.375, 1.0), (0.5, 0.0), (0.625, 1.0), (0.75, 0.0), (0.875, 1.0), (1.0, 0.0)] }),
|
||||
"Cone" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)],
|
||||
}),
|
||||
"Cone-Inverted" => Some(ContourCurve {
|
||||
points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)],
|
||||
}),
|
||||
"Gaussian" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 0.85), (1.0, 1.0)],
|
||||
}),
|
||||
"Half Round" => Some(ContourCurve {
|
||||
points: vec![
|
||||
(0.0, 0.0),
|
||||
(0.25, 0.75),
|
||||
(0.5, 1.0),
|
||||
(0.75, 0.75),
|
||||
(1.0, 0.0),
|
||||
],
|
||||
}),
|
||||
"Round" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)],
|
||||
}),
|
||||
"Ring" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.25, 1.0), (0.5, 0.0), (0.75, 1.0), (1.0, 0.0)],
|
||||
}),
|
||||
"Ring-Double" => Some(ContourCurve {
|
||||
points: vec![
|
||||
(0.0, 0.0),
|
||||
(0.125, 1.0),
|
||||
(0.25, 0.0),
|
||||
(0.375, 1.0),
|
||||
(0.5, 0.0),
|
||||
(0.625, 1.0),
|
||||
(0.75, 0.0),
|
||||
(0.875, 1.0),
|
||||
(1.0, 0.0),
|
||||
],
|
||||
}),
|
||||
"Sawtooth" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 1.0), (0.5, 0.0), (1.0, 1.0)],
|
||||
}),
|
||||
"Square" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)],
|
||||
}),
|
||||
"Valley" => Some(ContourCurve {
|
||||
points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)],
|
||||
}),
|
||||
"Shallow-Slope" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.5, 0.25), (1.0, 1.0)],
|
||||
}),
|
||||
"Wave" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.25, 1.0), (0.5, 0.5), (0.75, 1.0), (1.0, 0.0)],
|
||||
}),
|
||||
"Cove" => Some(ContourCurve {
|
||||
points: vec![(0.0, 0.0), (0.25, 0.5), (0.5, 1.0), (0.75, 0.5), (1.0, 0.0)],
|
||||
}),
|
||||
"Washboard" => Some(ContourCurve {
|
||||
points: vec![
|
||||
(0.0, 0.0),
|
||||
(0.125, 1.0),
|
||||
(0.25, 0.0),
|
||||
(0.375, 1.0),
|
||||
(0.5, 0.0),
|
||||
(0.625, 1.0),
|
||||
(0.75, 0.0),
|
||||
(0.875, 1.0),
|
||||
(1.0, 0.0),
|
||||
],
|
||||
}),
|
||||
_ => None, // Default to linear
|
||||
}
|
||||
}
|
||||
@@ -223,7 +298,8 @@ pub fn blend_mode_to_string(mode: &BlendMode) -> String {
|
||||
BlendMode::Color => "color",
|
||||
BlendMode::PassThrough => "passthrough",
|
||||
BlendMode::Luminosity => "luminosity",
|
||||
}.to_string()
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn blend_mode_from_string(s: &str) -> BlendMode {
|
||||
@@ -265,88 +341,244 @@ pub fn blend_mode_from_string(s: &str) -> BlendMode {
|
||||
/// Used by engine-api before calling apply_layer_effects().
|
||||
pub fn protocol_to_hcie_fx_effect(fx: &effects::LayerEffect) -> LayerEffect {
|
||||
match fx {
|
||||
effects::LayerEffect::DropShadow { enabled, blend_mode, color, opacity, angle, distance, spread, size, noise, contour } => {
|
||||
LayerEffect::DropShadow {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::InnerShadow { enabled, blend_mode, color, opacity, angle, distance, choke, size, noise, contour } => {
|
||||
LayerEffect::InnerShadow {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, choke: *choke, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::OuterGlow { enabled, blend_mode, color, opacity, spread, size, noise, contour } => {
|
||||
LayerEffect::OuterGlow {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color,
|
||||
opacity: *opacity, spread: *spread, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::InnerGlow { enabled, blend_mode, color, opacity, choke, size, noise, contour, source } => {
|
||||
LayerEffect::InnerGlow {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color,
|
||||
opacity: *opacity, choke: *choke, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
source: *source,
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::BevelEmboss { enabled, style, technique, depth, direction, size, soften, angle, altitude, highlight_blend, highlight_color, highlight_opacity, shadow_blend, shadow_color, shadow_opacity, contour } => {
|
||||
LayerEffect::BevelEmboss {
|
||||
enabled: *enabled,
|
||||
style: match style { effects::BevelStyle::InnerBevel => BevelStyle::InnerBevel, effects::BevelStyle::OuterBevel => BevelStyle::OuterBevel, effects::BevelStyle::Emboss => BevelStyle::Emboss, effects::BevelStyle::PillowEmboss => BevelStyle::PillowEmboss, effects::BevelStyle::StrokeEmboss => BevelStyle::StrokeEmboss },
|
||||
technique: match technique { effects::Technique::Smooth => Technique::Smooth, effects::Technique::ChiselHard => Technique::ChiselHard, effects::Technique::ChiselSoft => Technique::ChiselSoft },
|
||||
depth: *depth,
|
||||
direction: match direction { effects::Direction::Up => Direction::Up, effects::Direction::Down => Direction::Down },
|
||||
size: *size, soften: *soften, angle: *angle, altitude: *altitude,
|
||||
highlight_blend: blend_mode_from_string(highlight_blend), highlight_color: *highlight_color, highlight_opacity: *highlight_opacity,
|
||||
shadow_blend: blend_mode_from_string(shadow_blend), shadow_color: *shadow_color, shadow_opacity: *shadow_opacity,
|
||||
contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::Satin { enabled, blend_mode, color, opacity, angle, distance, size, invert, contour } => {
|
||||
LayerEffect::Satin {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, size: *size, invert: *invert,
|
||||
contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::ColorOverlay { enabled, blend_mode, color, opacity } => {
|
||||
LayerEffect::ColorOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, opacity: *opacity,
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::GradientOverlay { enabled, blend_mode, opacity, angle, scale, gradient } => {
|
||||
LayerEffect::GradientOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity,
|
||||
angle: *angle, scale: *scale,
|
||||
gradient: GradientDef {
|
||||
color_stops: gradient.color_stops.clone(),
|
||||
transparency_stops: gradient.transparency_stops.clone(),
|
||||
midpoint: gradient.midpoint,
|
||||
angle: gradient.angle,
|
||||
scale: gradient.scale,
|
||||
gradient_type: gradient.gradient_type,
|
||||
},
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::PatternOverlay { enabled, blend_mode, opacity, scale, pattern_id } => {
|
||||
LayerEffect::PatternOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity,
|
||||
scale: *scale, pattern_id: pattern_id.clone(),
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::Stroke { enabled, position, fill_type, blend_mode, opacity, size, color } => {
|
||||
LayerEffect::Stroke {
|
||||
enabled: *enabled,
|
||||
position: match position { effects::StrokePosition::Inside => StrokePosition::Inside, effects::StrokePosition::Center => StrokePosition::Center, effects::StrokePosition::Outside => StrokePosition::Outside },
|
||||
fill_type: match fill_type { effects::StrokeFillType::Color => StrokeFillType::Color, effects::StrokeFillType::Gradient => StrokeFillType::Gradient, effects::StrokeFillType::Pattern => StrokeFillType::Pattern },
|
||||
blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity, size: *size, color: *color,
|
||||
}
|
||||
}
|
||||
effects::LayerEffect::DropShadow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => LayerEffect::DropShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
spread: *spread,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
effects::LayerEffect::InnerShadow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
choke,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => LayerEffect::InnerShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
choke: *choke,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
effects::LayerEffect::OuterGlow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
spread,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => LayerEffect::OuterGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
spread: *spread,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
effects::LayerEffect::InnerGlow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
choke,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
source,
|
||||
} => LayerEffect::InnerGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
choke: *choke,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
source: *source,
|
||||
},
|
||||
effects::LayerEffect::BevelEmboss {
|
||||
enabled,
|
||||
style,
|
||||
technique,
|
||||
depth,
|
||||
direction,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
highlight_blend,
|
||||
highlight_color,
|
||||
highlight_opacity,
|
||||
shadow_blend,
|
||||
shadow_color,
|
||||
shadow_opacity,
|
||||
contour,
|
||||
} => LayerEffect::BevelEmboss {
|
||||
enabled: *enabled,
|
||||
style: match style {
|
||||
effects::BevelStyle::InnerBevel => BevelStyle::InnerBevel,
|
||||
effects::BevelStyle::OuterBevel => BevelStyle::OuterBevel,
|
||||
effects::BevelStyle::Emboss => BevelStyle::Emboss,
|
||||
effects::BevelStyle::PillowEmboss => BevelStyle::PillowEmboss,
|
||||
effects::BevelStyle::StrokeEmboss => BevelStyle::StrokeEmboss,
|
||||
},
|
||||
technique: match technique {
|
||||
effects::Technique::Smooth => Technique::Smooth,
|
||||
effects::Technique::ChiselHard => Technique::ChiselHard,
|
||||
effects::Technique::ChiselSoft => Technique::ChiselSoft,
|
||||
},
|
||||
depth: *depth,
|
||||
direction: match direction {
|
||||
effects::Direction::Up => Direction::Up,
|
||||
effects::Direction::Down => Direction::Down,
|
||||
},
|
||||
size: *size,
|
||||
soften: *soften,
|
||||
angle: *angle,
|
||||
altitude: *altitude,
|
||||
highlight_blend: blend_mode_from_string(highlight_blend),
|
||||
highlight_color: *highlight_color,
|
||||
highlight_opacity: *highlight_opacity,
|
||||
shadow_blend: blend_mode_from_string(shadow_blend),
|
||||
shadow_color: *shadow_color,
|
||||
shadow_opacity: *shadow_opacity,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
effects::LayerEffect::Satin {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
size,
|
||||
invert,
|
||||
contour,
|
||||
} => LayerEffect::Satin {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
size: *size,
|
||||
invert: *invert,
|
||||
contour: contour.as_ref().map(|c| ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
effects::LayerEffect::ColorOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
} => LayerEffect::ColorOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
},
|
||||
effects::LayerEffect::GradientOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
opacity,
|
||||
angle,
|
||||
scale,
|
||||
gradient,
|
||||
} => LayerEffect::GradientOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
scale: *scale,
|
||||
gradient: GradientDef {
|
||||
color_stops: gradient.color_stops.clone(),
|
||||
transparency_stops: gradient.transparency_stops.clone(),
|
||||
midpoint: gradient.midpoint,
|
||||
angle: gradient.angle,
|
||||
scale: gradient.scale,
|
||||
gradient_type: gradient.gradient_type,
|
||||
},
|
||||
},
|
||||
effects::LayerEffect::PatternOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
opacity,
|
||||
scale,
|
||||
pattern_id,
|
||||
} => LayerEffect::PatternOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
scale: *scale,
|
||||
pattern_id: pattern_id.clone(),
|
||||
},
|
||||
effects::LayerEffect::Stroke {
|
||||
enabled,
|
||||
position,
|
||||
fill_type,
|
||||
blend_mode,
|
||||
opacity,
|
||||
size,
|
||||
color,
|
||||
} => LayerEffect::Stroke {
|
||||
enabled: *enabled,
|
||||
position: match position {
|
||||
effects::StrokePosition::Inside => StrokePosition::Inside,
|
||||
effects::StrokePosition::Center => StrokePosition::Center,
|
||||
effects::StrokePosition::Outside => StrokePosition::Outside,
|
||||
},
|
||||
fill_type: match fill_type {
|
||||
effects::StrokeFillType::Color => StrokeFillType::Color,
|
||||
effects::StrokeFillType::Gradient => StrokeFillType::Gradient,
|
||||
effects::StrokeFillType::Pattern => StrokeFillType::Pattern,
|
||||
},
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
size: *size,
|
||||
color: *color,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,88 +586,244 @@ pub fn protocol_to_hcie_fx_effect(fx: &effects::LayerEffect) -> LayerEffect {
|
||||
/// to protocol-layer effect (serialization format with String blend_mode).
|
||||
pub fn hcie_fx_effect_to_protocol(fx: &LayerEffect) -> effects::LayerEffect {
|
||||
match fx {
|
||||
LayerEffect::DropShadow { enabled, blend_mode, color, opacity, angle, distance, spread, size, noise, contour } => {
|
||||
effects::LayerEffect::DropShadow {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
LayerEffect::InnerShadow { enabled, blend_mode, color, opacity, angle, distance, choke, size, noise, contour } => {
|
||||
effects::LayerEffect::InnerShadow {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, choke: *choke, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
LayerEffect::OuterGlow { enabled, blend_mode, color, opacity, spread, size, noise, contour } => {
|
||||
effects::LayerEffect::OuterGlow {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color,
|
||||
opacity: *opacity, spread: *spread, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
LayerEffect::InnerGlow { enabled, blend_mode, color, opacity, choke, size, noise, contour, source } => {
|
||||
effects::LayerEffect::InnerGlow {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color,
|
||||
opacity: *opacity, choke: *choke, size: *size,
|
||||
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
source: *source,
|
||||
}
|
||||
}
|
||||
LayerEffect::BevelEmboss { enabled, style, technique, depth, direction, size, soften, angle, altitude, highlight_blend, highlight_color, highlight_opacity, shadow_blend, shadow_color, shadow_opacity, contour } => {
|
||||
effects::LayerEffect::BevelEmboss {
|
||||
enabled: *enabled,
|
||||
style: match style { BevelStyle::InnerBevel => effects::BevelStyle::InnerBevel, BevelStyle::OuterBevel => effects::BevelStyle::OuterBevel, BevelStyle::Emboss => effects::BevelStyle::Emboss, BevelStyle::PillowEmboss => effects::BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss => effects::BevelStyle::StrokeEmboss },
|
||||
technique: match technique { Technique::Smooth => effects::Technique::Smooth, Technique::ChiselHard => effects::Technique::ChiselHard, Technique::ChiselSoft => effects::Technique::ChiselSoft },
|
||||
depth: *depth,
|
||||
direction: match direction { Direction::Up => effects::Direction::Up, Direction::Down => effects::Direction::Down },
|
||||
size: *size, soften: *soften, angle: *angle, altitude: *altitude,
|
||||
highlight_blend: blend_mode_to_string(highlight_blend), highlight_color: *highlight_color, highlight_opacity: *highlight_opacity,
|
||||
shadow_blend: blend_mode_to_string(shadow_blend), shadow_color: *shadow_color, shadow_opacity: *shadow_opacity,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
LayerEffect::Satin { enabled, blend_mode, color, opacity, angle, distance, size, invert, contour } => {
|
||||
effects::LayerEffect::Satin {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color,
|
||||
opacity: *opacity, angle: *angle, distance: *distance, size: *size, invert: *invert,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }),
|
||||
}
|
||||
}
|
||||
LayerEffect::ColorOverlay { enabled, blend_mode, color, opacity } => {
|
||||
effects::LayerEffect::ColorOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, opacity: *opacity,
|
||||
}
|
||||
}
|
||||
LayerEffect::GradientOverlay { enabled, blend_mode, opacity, angle, scale, gradient } => {
|
||||
effects::LayerEffect::GradientOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity,
|
||||
angle: *angle, scale: *scale,
|
||||
gradient: effects::GradientDef {
|
||||
color_stops: gradient.color_stops.clone(),
|
||||
transparency_stops: gradient.transparency_stops.clone(),
|
||||
midpoint: gradient.midpoint,
|
||||
angle: gradient.angle,
|
||||
scale: gradient.scale,
|
||||
gradient_type: gradient.gradient_type,
|
||||
},
|
||||
}
|
||||
}
|
||||
LayerEffect::PatternOverlay { enabled, blend_mode, opacity, scale, pattern_id } => {
|
||||
effects::LayerEffect::PatternOverlay {
|
||||
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity,
|
||||
scale: *scale, pattern_id: pattern_id.clone(),
|
||||
}
|
||||
}
|
||||
LayerEffect::Stroke { enabled, position, fill_type, blend_mode, opacity, size, color } => {
|
||||
effects::LayerEffect::Stroke {
|
||||
enabled: *enabled,
|
||||
position: match position { StrokePosition::Inside => effects::StrokePosition::Inside, StrokePosition::Center => effects::StrokePosition::Center, StrokePosition::Outside => effects::StrokePosition::Outside },
|
||||
fill_type: match fill_type { StrokeFillType::Color => effects::StrokeFillType::Color, StrokeFillType::Gradient => effects::StrokeFillType::Gradient, StrokeFillType::Pattern => effects::StrokeFillType::Pattern },
|
||||
blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity, size: *size, color: *color,
|
||||
}
|
||||
}
|
||||
LayerEffect::DropShadow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => effects::LayerEffect::DropShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
spread: *spread,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
LayerEffect::InnerShadow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
choke,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => effects::LayerEffect::InnerShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
choke: *choke,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
LayerEffect::OuterGlow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
spread,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
} => effects::LayerEffect::OuterGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
spread: *spread,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
LayerEffect::InnerGlow {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
choke,
|
||||
size,
|
||||
noise,
|
||||
contour,
|
||||
source,
|
||||
} => effects::LayerEffect::InnerGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
choke: *choke,
|
||||
size: *size,
|
||||
noise: *noise,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
source: *source,
|
||||
},
|
||||
LayerEffect::BevelEmboss {
|
||||
enabled,
|
||||
style,
|
||||
technique,
|
||||
depth,
|
||||
direction,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
highlight_blend,
|
||||
highlight_color,
|
||||
highlight_opacity,
|
||||
shadow_blend,
|
||||
shadow_color,
|
||||
shadow_opacity,
|
||||
contour,
|
||||
} => effects::LayerEffect::BevelEmboss {
|
||||
enabled: *enabled,
|
||||
style: match style {
|
||||
BevelStyle::InnerBevel => effects::BevelStyle::InnerBevel,
|
||||
BevelStyle::OuterBevel => effects::BevelStyle::OuterBevel,
|
||||
BevelStyle::Emboss => effects::BevelStyle::Emboss,
|
||||
BevelStyle::PillowEmboss => effects::BevelStyle::PillowEmboss,
|
||||
BevelStyle::StrokeEmboss => effects::BevelStyle::StrokeEmboss,
|
||||
},
|
||||
technique: match technique {
|
||||
Technique::Smooth => effects::Technique::Smooth,
|
||||
Technique::ChiselHard => effects::Technique::ChiselHard,
|
||||
Technique::ChiselSoft => effects::Technique::ChiselSoft,
|
||||
},
|
||||
depth: *depth,
|
||||
direction: match direction {
|
||||
Direction::Up => effects::Direction::Up,
|
||||
Direction::Down => effects::Direction::Down,
|
||||
},
|
||||
size: *size,
|
||||
soften: *soften,
|
||||
angle: *angle,
|
||||
altitude: *altitude,
|
||||
highlight_blend: blend_mode_to_string(highlight_blend),
|
||||
highlight_color: *highlight_color,
|
||||
highlight_opacity: *highlight_opacity,
|
||||
shadow_blend: blend_mode_to_string(shadow_blend),
|
||||
shadow_color: *shadow_color,
|
||||
shadow_opacity: *shadow_opacity,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
LayerEffect::Satin {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
size,
|
||||
invert,
|
||||
contour,
|
||||
} => effects::LayerEffect::Satin {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
distance: *distance,
|
||||
size: *size,
|
||||
invert: *invert,
|
||||
contour: contour.as_ref().map(|c| effects::ContourCurve {
|
||||
points: c.points.clone(),
|
||||
}),
|
||||
},
|
||||
LayerEffect::ColorOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
color,
|
||||
opacity,
|
||||
} => effects::LayerEffect::ColorOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
},
|
||||
LayerEffect::GradientOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
opacity,
|
||||
angle,
|
||||
scale,
|
||||
gradient,
|
||||
} => effects::LayerEffect::GradientOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
angle: *angle,
|
||||
scale: *scale,
|
||||
gradient: effects::GradientDef {
|
||||
color_stops: gradient.color_stops.clone(),
|
||||
transparency_stops: gradient.transparency_stops.clone(),
|
||||
midpoint: gradient.midpoint,
|
||||
angle: gradient.angle,
|
||||
scale: gradient.scale,
|
||||
gradient_type: gradient.gradient_type,
|
||||
},
|
||||
},
|
||||
LayerEffect::PatternOverlay {
|
||||
enabled,
|
||||
blend_mode,
|
||||
opacity,
|
||||
scale,
|
||||
pattern_id,
|
||||
} => effects::LayerEffect::PatternOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
scale: *scale,
|
||||
pattern_id: pattern_id.clone(),
|
||||
},
|
||||
LayerEffect::Stroke {
|
||||
enabled,
|
||||
position,
|
||||
fill_type,
|
||||
blend_mode,
|
||||
opacity,
|
||||
size,
|
||||
color,
|
||||
} => effects::LayerEffect::Stroke {
|
||||
enabled: *enabled,
|
||||
position: match position {
|
||||
StrokePosition::Inside => effects::StrokePosition::Inside,
|
||||
StrokePosition::Center => effects::StrokePosition::Center,
|
||||
StrokePosition::Outside => effects::StrokePosition::Outside,
|
||||
},
|
||||
fill_type: match fill_type {
|
||||
StrokeFillType::Color => effects::StrokeFillType::Color,
|
||||
StrokeFillType::Gradient => effects::StrokeFillType::Gradient,
|
||||
StrokeFillType::Pattern => effects::StrokeFillType::Pattern,
|
||||
},
|
||||
blend_mode: blend_mode_to_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
size: *size,
|
||||
color: *color,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +833,16 @@ pub fn hcie_fx_effect_to_protocol(fx: &LayerEffect) -> effects::LayerEffect {
|
||||
pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerEffect> {
|
||||
use hcie_protocol::LayerStyle;
|
||||
match style {
|
||||
LayerStyle::DropShadow { enabled, opacity, angle, distance, spread, size, color, blend_mode } => Some(LayerEffect::DropShadow {
|
||||
LayerStyle::DropShadow {
|
||||
enabled,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::DropShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
@@ -457,7 +854,16 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
noise: 0.0,
|
||||
contour: None,
|
||||
}),
|
||||
LayerStyle::InnerShadow { enabled, opacity, angle, distance, spread, size, color, blend_mode } => Some(LayerEffect::InnerShadow {
|
||||
LayerStyle::InnerShadow {
|
||||
enabled,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::InnerShadow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
@@ -469,7 +875,14 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
noise: 0.0,
|
||||
contour: None,
|
||||
}),
|
||||
LayerStyle::OuterGlow { enabled, opacity, spread, size, color, blend_mode } => Some(LayerEffect::OuterGlow {
|
||||
LayerStyle::OuterGlow {
|
||||
enabled,
|
||||
opacity,
|
||||
spread,
|
||||
size,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::OuterGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
@@ -479,7 +892,14 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
noise: 0.0,
|
||||
contour: None,
|
||||
}),
|
||||
LayerStyle::InnerGlow { enabled, opacity, spread, size, color, blend_mode } => Some(LayerEffect::InnerGlow {
|
||||
LayerStyle::InnerGlow {
|
||||
enabled,
|
||||
opacity,
|
||||
spread,
|
||||
size,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::InnerGlow {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
@@ -490,7 +910,24 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
contour: None,
|
||||
source: 0,
|
||||
}),
|
||||
LayerStyle::BevelEmboss { enabled, depth, size, angle, altitude, highlight_opacity, shadow_opacity, direction, style, technique, soften, highlight_blend_mode, highlight_color, shadow_blend_mode, shadow_color, contour } => Some(LayerEffect::BevelEmboss {
|
||||
LayerStyle::BevelEmboss {
|
||||
enabled,
|
||||
depth,
|
||||
size,
|
||||
angle,
|
||||
altitude,
|
||||
highlight_opacity,
|
||||
shadow_opacity,
|
||||
direction,
|
||||
style,
|
||||
technique,
|
||||
soften,
|
||||
highlight_blend_mode,
|
||||
highlight_color,
|
||||
shadow_blend_mode,
|
||||
shadow_color,
|
||||
contour,
|
||||
} => Some(LayerEffect::BevelEmboss {
|
||||
enabled: *enabled,
|
||||
style: match style.as_str() {
|
||||
"InnerBevel" => BevelStyle::InnerBevel,
|
||||
@@ -524,7 +961,15 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
shadow_opacity: *shadow_opacity,
|
||||
contour: contour_name_to_curve(contour),
|
||||
}),
|
||||
LayerStyle::Satin { enabled, opacity, angle, distance, size, color, invert } => Some(LayerEffect::Satin {
|
||||
LayerStyle::Satin {
|
||||
enabled,
|
||||
opacity,
|
||||
angle,
|
||||
distance,
|
||||
size,
|
||||
color,
|
||||
invert,
|
||||
} => Some(LayerEffect::Satin {
|
||||
enabled: *enabled,
|
||||
blend_mode: BlendMode::Multiply,
|
||||
color: *color,
|
||||
@@ -535,13 +980,25 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
invert: *invert,
|
||||
contour: None,
|
||||
}),
|
||||
LayerStyle::ColorOverlay { enabled, opacity, color, blend_mode } => Some(LayerEffect::ColorOverlay {
|
||||
LayerStyle::ColorOverlay {
|
||||
enabled,
|
||||
opacity,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::ColorOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
color: *color,
|
||||
opacity: *opacity,
|
||||
}),
|
||||
LayerStyle::GradientOverlay { enabled, opacity, blend_mode, angle, scale, gradient_type: _ } => Some(LayerEffect::GradientOverlay {
|
||||
LayerStyle::GradientOverlay {
|
||||
enabled,
|
||||
opacity,
|
||||
blend_mode,
|
||||
angle,
|
||||
scale,
|
||||
gradient_type: _,
|
||||
} => Some(LayerEffect::GradientOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
@@ -556,14 +1013,27 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
|
||||
gradient_type: 0,
|
||||
},
|
||||
}),
|
||||
LayerStyle::PatternOverlay { enabled, opacity, blend_mode, scale, pattern_name } => Some(LayerEffect::PatternOverlay {
|
||||
LayerStyle::PatternOverlay {
|
||||
enabled,
|
||||
opacity,
|
||||
blend_mode,
|
||||
scale,
|
||||
pattern_name,
|
||||
} => Some(LayerEffect::PatternOverlay {
|
||||
enabled: *enabled,
|
||||
blend_mode: blend_mode_from_string(blend_mode),
|
||||
opacity: *opacity,
|
||||
scale: *scale,
|
||||
pattern_id: pattern_name.clone(),
|
||||
}),
|
||||
LayerStyle::Stroke { enabled, size, position, opacity, color, blend_mode } => Some(LayerEffect::Stroke {
|
||||
LayerStyle::Stroke {
|
||||
enabled,
|
||||
size,
|
||||
position,
|
||||
opacity,
|
||||
color,
|
||||
blend_mode,
|
||||
} => Some(LayerEffect::Stroke {
|
||||
enabled: *enabled,
|
||||
position: match position.as_str() {
|
||||
"Inside" => StrokePosition::Inside,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
|
||||
use crate::dialogs;
|
||||
use crate::dock::manager::DockDragState;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use crate::i18n;
|
||||
use crate::io::tablet::TabletState;
|
||||
@@ -117,6 +118,8 @@ pub enum ActiveDialog {
|
||||
pub struct HcieIcedApp {
|
||||
/// All open documents.
|
||||
pub documents: Vec<IcedDocument>,
|
||||
/// Whether the center document host shows the post-close welcome surface.
|
||||
pub show_welcome: bool,
|
||||
/// Index of the currently active document.
|
||||
pub active_doc: usize,
|
||||
/// Current tool state.
|
||||
@@ -284,6 +287,8 @@ pub struct HcieIcedApp {
|
||||
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
|
||||
/// Last drag position during vector shape editing (canvas coordinates).
|
||||
pub vector_drag_last: Option<(f32, f32)>,
|
||||
/// Timestamp of the last expensive vector rerasterization during a drag.
|
||||
pub vector_drag_last_render: Option<std::time::Instant>,
|
||||
/// Immutable basis for the active vector drag.
|
||||
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
|
||||
/// Boolean operation shape A selector index (geometry panel).
|
||||
@@ -838,9 +843,39 @@ pub enum Message {
|
||||
// ── Dock Layout ─────────────────────────────────────
|
||||
PaneClicked(iced::widget::pane_grid::Pane),
|
||||
PaneDragged(iced::widget::pane_grid::DragEvent),
|
||||
/// Begins a reliable custom drag from the visible dock title content.
|
||||
DockHeaderDragStart(iced::widget::pane_grid::Pane),
|
||||
PaneResized(iced::widget::pane_grid::ResizeEvent),
|
||||
PaneClose(iced::widget::pane_grid::Pane),
|
||||
PaneMaximize(iced::widget::pane_grid::Pane),
|
||||
/// Moves a tool pane to the fixed right auto-hide rail.
|
||||
PaneAutoHide(iced::widget::pane_grid::Pane),
|
||||
/// Moves a docked tool into an in-viewport floating card.
|
||||
PaneFloat(iced::widget::pane_grid::Pane),
|
||||
/// Begins dragging a floating card header.
|
||||
FloatingDragStart(PaneType),
|
||||
/// Raises a clicked floating card without beginning a drag.
|
||||
FloatingFocus(PaneType),
|
||||
/// Redocks a floating tool beside Canvas.
|
||||
FloatingRedock(PaneType),
|
||||
/// Minimizes a floating tool to the fixed right auto-hide rail.
|
||||
FloatingAutoHide(PaneType),
|
||||
/// Closes a floating tool placement.
|
||||
FloatingClose(PaneType),
|
||||
/// Shared full-window logical cursor feed for dock and dialog drags.
|
||||
DockPointerMoved(f32, f32),
|
||||
/// Ends active full-window floating and dialog drags.
|
||||
DockPointerReleased,
|
||||
/// Clears click-only or outside-release dock state after PaneGrid handles the release event.
|
||||
DockReleaseCleanup,
|
||||
/// Tracks the current logical viewport for geometry and clamping.
|
||||
DockViewportChanged(iced::Size),
|
||||
/// Opens one temporary auto-hide overlay.
|
||||
AutoHideActivate(PaneType),
|
||||
/// Closes the temporary overlay without changing placement.
|
||||
AutoHideDismiss,
|
||||
/// Pins an auto-hidden panel back into PaneGrid.
|
||||
AutoHideRestore(PaneType),
|
||||
|
||||
// ── File I/O ────────────────────────────────────────
|
||||
NewDocument(u32, u32),
|
||||
@@ -1178,10 +1213,11 @@ impl HcieIcedApp {
|
||||
};
|
||||
|
||||
// Load saved settings
|
||||
let mut settings = crate::settings::AppSettings::load();
|
||||
let settings = crate::settings::AppSettings::load();
|
||||
|
||||
let mut app = Self {
|
||||
documents: vec![doc],
|
||||
show_welcome: false,
|
||||
active_doc: 0,
|
||||
tool_state: ToolState::default(),
|
||||
fg_color: [220, 50, 50, 255],
|
||||
@@ -1225,7 +1261,7 @@ impl HcieIcedApp {
|
||||
layer_style_baseline: None,
|
||||
pending_file_operation: None,
|
||||
pending_document_close: None,
|
||||
dock: DockState::new(),
|
||||
dock: settings.panel_layout.restore_dock(),
|
||||
tablet_state: Arc::new(Mutex::new(TabletState::new())),
|
||||
initialized: false,
|
||||
theme_state: ThemeState::new(settings.theme_preset),
|
||||
@@ -1274,6 +1310,7 @@ impl HcieIcedApp {
|
||||
colors_dirty: false,
|
||||
vector_shapes_snapshot: None,
|
||||
vector_drag_last: None,
|
||||
vector_drag_last_render: None,
|
||||
vector_drag_session: None,
|
||||
bool_shape_a: None,
|
||||
bool_shape_b: None,
|
||||
@@ -1288,6 +1325,38 @@ impl HcieIcedApp {
|
||||
{
|
||||
app.dock.reopen_pane(panel);
|
||||
}
|
||||
if let Some(panel) = app
|
||||
.screenshot_request
|
||||
.as_ref()
|
||||
.and_then(|request| request.floating_panel.as_deref())
|
||||
.and_then(crate::dock::state::PaneType::from_label)
|
||||
{
|
||||
app.dock.reopen_pane(panel);
|
||||
if let Some(pane) = app.dock.pane_for_type(panel) {
|
||||
app.dock.float_pane(pane, (settings.window.width, settings.window.height));
|
||||
}
|
||||
}
|
||||
if let Some(panel) = app
|
||||
.screenshot_request
|
||||
.as_ref()
|
||||
.and_then(|request| request.auto_hide_panel.as_deref())
|
||||
.and_then(crate::dock::state::PaneType::from_label)
|
||||
{
|
||||
app.dock.reopen_pane(panel);
|
||||
if let Some(pane) = app.dock.pane_for_type(panel) {
|
||||
app.dock.auto_hide_pane(
|
||||
pane,
|
||||
Some(crate::dock::manager::DockEdge::Right),
|
||||
);
|
||||
}
|
||||
}
|
||||
if app
|
||||
.screenshot_request
|
||||
.as_ref()
|
||||
.is_some_and(|request| request.welcome)
|
||||
{
|
||||
app.show_welcome = true;
|
||||
}
|
||||
|
||||
// Apply saved settings to tool state
|
||||
settings.apply_to_tool_state(&mut app.tool_state);
|
||||
@@ -1555,13 +1624,26 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes one non-final document tab and keeps the active index valid.
|
||||
/// Removes one document tab and keeps the editor shell alive for the final tab.
|
||||
///
|
||||
/// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow:
|
||||
/// indices before the active tab shift it left; closing the active tab selects the tab now at
|
||||
/// that position or the preceding final tab. Side Effects: Drops the document and edit state.
|
||||
fn close_document_tab(&mut self, document_index: usize) {
|
||||
if self.documents.len() <= 1 || document_index >= self.documents.len() {
|
||||
if document_index >= self.documents.len() {
|
||||
return;
|
||||
}
|
||||
if self.documents.len() == 1 {
|
||||
let _ = self.update(Message::NewDocument(800, 600));
|
||||
self.documents.remove(document_index);
|
||||
self.active_doc = 0;
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
self.show_welcome = true;
|
||||
self.pending_document_close = None;
|
||||
self.vector_shapes_snapshot = None;
|
||||
self.vector_drag_session = None;
|
||||
self.vector_drag_last = None;
|
||||
self.vector_drag_last_render = None;
|
||||
return;
|
||||
}
|
||||
self.documents.remove(document_index);
|
||||
@@ -1745,6 +1827,9 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
Message::OverlayOutsideClick => {
|
||||
if self.dock.active_auto_hide.take().is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
if self.sub_tools_open {
|
||||
self.sub_tools_open = false;
|
||||
} else {
|
||||
@@ -3595,7 +3680,89 @@ impl HcieIcedApp {
|
||||
// The actual offset computation uses delta from the last position.
|
||||
self.layer_style_dragging = true;
|
||||
}
|
||||
Message::LayerStyleDialogDragMove(x, y) => {
|
||||
Message::LayerStyleDialogDragMove(x, y) | Message::DockPointerMoved(x, y) => {
|
||||
self.last_cursor_pos = Some((x, y));
|
||||
let pointer = iced::Point::new(x, y);
|
||||
if self.dock.drag.is_none() {
|
||||
if let Some(header) = self.dock.header_drag {
|
||||
if pointer.distance(header.origin) >= 4.0 {
|
||||
self.dock.drag = Some(DockDragState {
|
||||
source_pane: header.source_pane,
|
||||
source_type: header.source_type,
|
||||
pointer: Some(pointer),
|
||||
candidate_target: None,
|
||||
accepted_preview: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dock.drag.is_some() {
|
||||
let geometry = crate::dock::preview::DockGeometry {
|
||||
viewport: iced::Size::new(
|
||||
self.settings.window.width,
|
||||
self.settings.window.height,
|
||||
),
|
||||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||||
};
|
||||
let regions = self.dock.pane_grid.layout().pane_regions(
|
||||
crate::dock::preview::PANE_SPACING,
|
||||
geometry.grid_bounds().size(),
|
||||
);
|
||||
let source_type = self.dock.drag.map(|drag| drag.source_type);
|
||||
let approved = crate::dock::preview::approved_target(
|
||||
pointer, geometry, ®ions,
|
||||
)
|
||||
.filter(|target| {
|
||||
source_type.is_some_and(|source| {
|
||||
crate::dock::manager::DockDropPolicy::accepts(source, target.zone)
|
||||
})
|
||||
});
|
||||
if let Some(drag) = &mut self.dock.drag {
|
||||
drag.pointer = Some(pointer);
|
||||
drag.candidate_target = approved.map(|target| target.target);
|
||||
drag.accepted_preview = approved.map(|target| target.preview);
|
||||
}
|
||||
}
|
||||
if let Some(floating_drag) = self.dock.floating_drag {
|
||||
let geometry = crate::dock::preview::DockGeometry {
|
||||
viewport: iced::Size::new(
|
||||
self.settings.window.width,
|
||||
self.settings.window.height,
|
||||
),
|
||||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||||
};
|
||||
let regions = self.dock.pane_grid.layout().pane_regions(
|
||||
crate::dock::preview::PANE_SPACING,
|
||||
geometry.grid_bounds().size(),
|
||||
);
|
||||
self.dock.floating_target = crate::dock::preview::approved_target(
|
||||
pointer, geometry, ®ions,
|
||||
)
|
||||
.filter(|target| {
|
||||
crate::dock::manager::DockDropPolicy::accepts(
|
||||
floating_drag.panel,
|
||||
target.zone,
|
||||
)
|
||||
});
|
||||
if let Some(current) = self
|
||||
.dock
|
||||
.floating
|
||||
.iter()
|
||||
.find(|item| item.panel == floating_drag.panel)
|
||||
.map(|item| item.rect)
|
||||
{
|
||||
let rect = crate::dock::manager::DockRect {
|
||||
x: x - floating_drag.pointer_offset.x,
|
||||
y: y - floating_drag.pointer_offset.y,
|
||||
..current
|
||||
};
|
||||
self.dock.move_floating(
|
||||
floating_drag.panel,
|
||||
rect,
|
||||
(self.settings.window.width, self.settings.window.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.layer_style_dragging {
|
||||
if let Some((lx, ly)) = self.layer_style_drag_start {
|
||||
// Compute delta from last position and add to offset
|
||||
@@ -3607,9 +3774,41 @@ impl HcieIcedApp {
|
||||
self.layer_style_drag_start = Some((x, y));
|
||||
}
|
||||
}
|
||||
Message::LayerStyleDialogDragEnd => {
|
||||
Message::LayerStyleDialogDragEnd | Message::DockPointerReleased => {
|
||||
self.layer_style_dragging = false;
|
||||
self.layer_style_drag_start = None;
|
||||
let mut persist_dock = self.dock.resize_persistence_dirty;
|
||||
self.dock.resize_persistence_dirty = false;
|
||||
if let Some(header) = self.dock.header_drag.take() {
|
||||
if let Some(drag) = self
|
||||
.dock
|
||||
.drag
|
||||
.take()
|
||||
.filter(|drag| drag.source_pane == header.source_pane)
|
||||
{
|
||||
if let Some(target) = drag.candidate_target {
|
||||
if self.dock.apply_drop(drag.source_pane, target) {
|
||||
persist_dock = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(drag) = self.dock.floating_drag.take() {
|
||||
if let Some(target) = self.dock.floating_target.take() {
|
||||
self.dock.redock_floating_at(drag.panel, target.target);
|
||||
}
|
||||
persist_dock = true;
|
||||
}
|
||||
if persist_dock {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
|
||||
}
|
||||
Message::DockReleaseCleanup => {
|
||||
self.dock.cancel_transient_drags();
|
||||
}
|
||||
|
||||
// ── Brushes ───────────────────────────────────
|
||||
@@ -4879,28 +5078,68 @@ impl HcieIcedApp {
|
||||
);
|
||||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||||
let angle = shape.angle();
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
engine.set_vector_shape_bounds(
|
||||
session.layer_id,
|
||||
session.shape_index,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
);
|
||||
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
|
||||
if let Some(shapes) = self.documents[self.active_doc]
|
||||
.engine
|
||||
.get_layer_shapes_direct(session.layer_id)
|
||||
{
|
||||
if let Some(current) = shapes.get_mut(session.shape_index) {
|
||||
*current = shape;
|
||||
}
|
||||
}
|
||||
self.documents[self.active_doc].modified = true;
|
||||
self.vector_drag_last = Some((x, y));
|
||||
self.refresh_composite_if_needed();
|
||||
let should_render = self
|
||||
.vector_drag_last_render
|
||||
.is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16));
|
||||
if should_render {
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
engine.set_vector_shape_bounds(
|
||||
session.layer_id,
|
||||
session.shape_index,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
);
|
||||
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
|
||||
self.vector_drag_last_render = Some(std::time::Instant::now());
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::VectorSelectDragEnd => {
|
||||
if self.vector_drag_last.is_some() {
|
||||
if let Some(session) = self.vector_drag_session.as_ref() {
|
||||
if let Some(shape) = self.documents[self.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.and_then(|shapes| shapes.get(session.shape_index).cloned())
|
||||
{
|
||||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||||
let angle = shape.angle();
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
engine.set_vector_shape_bounds(
|
||||
session.layer_id,
|
||||
session.shape_index,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
);
|
||||
engine.set_vector_shape_angle(
|
||||
session.layer_id,
|
||||
session.shape_index,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
|
||||
self.vector_drag_last = None;
|
||||
self.vector_drag_last_render = None;
|
||||
self.vector_drag_session = None;
|
||||
self.documents[self.active_doc].vector_edit_handle =
|
||||
hcie_engine_api::VectorEditHandle::None;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
Message::VectorShapeDelete => {
|
||||
@@ -5973,6 +6212,7 @@ impl HcieIcedApp {
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.show_welcome = false;
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.documents[self.active_doc].name = path
|
||||
.file_name()
|
||||
@@ -6010,28 +6250,193 @@ impl HcieIcedApp {
|
||||
|
||||
// ── Dock Layout ───────────────────────────────
|
||||
Message::PaneClicked(pane) => {
|
||||
self.dock.active_auto_hide = None;
|
||||
self.dock.focus(pane);
|
||||
}
|
||||
Message::DockHeaderDragStart(pane) => {
|
||||
if let Some(source_type) = self
|
||||
.dock
|
||||
.pane_type(pane)
|
||||
.filter(|panel| *panel != PaneType::Canvas)
|
||||
{
|
||||
let origin = self
|
||||
.last_cursor_pos
|
||||
.map(|(x, y)| iced::Point::new(x, y))
|
||||
.unwrap_or_default();
|
||||
self.dock.header_drag = Some(
|
||||
crate::dock::manager::DockHeaderDragState {
|
||||
source_pane: pane,
|
||||
source_type,
|
||||
origin,
|
||||
},
|
||||
);
|
||||
self.dock.focus(pane);
|
||||
}
|
||||
}
|
||||
Message::PaneDragged(drag_event) => {
|
||||
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event {
|
||||
self.dock.pane_grid.drop(pane, target);
|
||||
use iced::widget::pane_grid::DragEvent;
|
||||
match drag_event {
|
||||
DragEvent::Picked { pane } => {
|
||||
if let Some(source_type) = self
|
||||
.dock
|
||||
.pane_type(pane)
|
||||
.filter(|panel| *panel != PaneType::Canvas)
|
||||
{
|
||||
self.dock.drag = Some(DockDragState {
|
||||
source_pane: pane,
|
||||
source_type,
|
||||
pointer: self.last_cursor_pos.map(|(x, y)| iced::Point::new(x, y)),
|
||||
candidate_target: None,
|
||||
accepted_preview: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
DragEvent::Dropped { pane, target } => {
|
||||
let approved = self.dock.drag.as_ref().is_some_and(|drag| {
|
||||
drag.source_pane == pane
|
||||
&& drag.candidate_target.is_some_and(|candidate| {
|
||||
crate::dock::preview::target_matches(candidate, target)
|
||||
&& crate::dock::manager::DockDropPolicy::accepts(
|
||||
drag.source_type,
|
||||
crate::dock::manager::DockDropZone::from_target(target),
|
||||
)
|
||||
})
|
||||
});
|
||||
self.dock.drag = None;
|
||||
if approved && self.dock.apply_drop(pane, target) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
DragEvent::Canceled { .. } => self.dock.drag = None,
|
||||
}
|
||||
}
|
||||
Message::PaneResized(resize_event) => {
|
||||
self.dock
|
||||
.pane_grid
|
||||
.resize(resize_event.split, resize_event.ratio);
|
||||
let grid_size = self.dock.last_grid_size.unwrap_or_else(|| {
|
||||
crate::dock::preview::DockGeometry {
|
||||
viewport: iced::Size::new(
|
||||
self.settings.window.width,
|
||||
self.settings.window.height,
|
||||
),
|
||||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||||
}
|
||||
.grid_bounds()
|
||||
.size()
|
||||
});
|
||||
let ratio = crate::dock::sizing::constrain_user_resize_ratio(
|
||||
&self.dock.pane_grid,
|
||||
resize_event.split,
|
||||
resize_event.ratio,
|
||||
grid_size,
|
||||
);
|
||||
self.dock.pane_grid.resize(resize_event.split, ratio);
|
||||
self.dock.resize_persistence_dirty = true;
|
||||
}
|
||||
Message::PaneClose(pane) => {
|
||||
// Don't close canvas panes
|
||||
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
|
||||
return Task::none();
|
||||
}
|
||||
self.dock.close_pane(pane);
|
||||
if self.dock.close_pane(pane) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::PaneMaximize(pane) => {
|
||||
self.dock.toggle_maximize(pane);
|
||||
}
|
||||
Message::PaneAutoHide(pane) => {
|
||||
if self
|
||||
.dock
|
||||
.auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right))
|
||||
{
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::PaneFloat(pane) => {
|
||||
let viewport = (self.settings.window.width, self.settings.window.height);
|
||||
if self.dock.float_pane(pane, viewport) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::FloatingFocus(panel) => {
|
||||
self.dock.focus_floating(panel);
|
||||
}
|
||||
Message::FloatingDragStart(panel) => {
|
||||
if let Some(rect) = self.dock.focus_floating(panel) {
|
||||
let pointer = self.last_cursor_pos.unwrap_or((rect.x, rect.y));
|
||||
self.dock.floating_drag = Some(crate::dock::floating::FloatingDragState {
|
||||
panel,
|
||||
pointer_offset: iced::Point::new(pointer.0 - rect.x, pointer.1 - rect.y),
|
||||
});
|
||||
self.dock.floating_target = None;
|
||||
}
|
||||
}
|
||||
Message::FloatingRedock(panel) => {
|
||||
if self.dock.redock_floating(panel) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::FloatingClose(panel) => {
|
||||
if self.dock.close_floating(panel) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::FloatingAutoHide(panel) => {
|
||||
if self
|
||||
.dock
|
||||
.auto_hide_floating(panel, crate::dock::manager::DockEdge::Right)
|
||||
{
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
Message::DockViewportChanged(size) => {
|
||||
self.settings.window.width = size.width;
|
||||
self.settings.window.height = size.height;
|
||||
self.dock.clamp_floating((size.width, size.height));
|
||||
let grid_size = crate::dock::preview::DockGeometry {
|
||||
viewport: size,
|
||||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||||
}
|
||||
.grid_bounds()
|
||||
.size();
|
||||
self.dock.set_grid_size(grid_size);
|
||||
}
|
||||
Message::AutoHideActivate(panel) => {
|
||||
self.dock.active_auto_hide = if self.dock.active_auto_hide == Some(panel) {
|
||||
None
|
||||
} else {
|
||||
Some(panel)
|
||||
};
|
||||
}
|
||||
Message::AutoHideDismiss => self.dock.active_auto_hide = None,
|
||||
Message::AutoHideRestore(panel) => {
|
||||
if self.dock.restore_auto_hidden(panel) {
|
||||
self.settings
|
||||
.panel_layout
|
||||
.persist_current_placement(&self.dock);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dock Profiles ─────────────────────────────
|
||||
Message::DockProfileSave(name) => {
|
||||
@@ -6164,6 +6569,7 @@ impl HcieIcedApp {
|
||||
|
||||
// ── File I/O ─────────────────────────────────
|
||||
Message::NewDocument(w, h) => {
|
||||
self.show_welcome = false;
|
||||
let mut engine = Engine::new(w, h);
|
||||
engine.pre_tile_all_layers();
|
||||
let composite_raw = engine.get_composite_pixels();
|
||||
@@ -6236,8 +6642,9 @@ impl HcieIcedApp {
|
||||
.is_some_and(|document| document.modified);
|
||||
match document_close_disposition(self.documents.len(), idx, modified) {
|
||||
DocumentCloseDisposition::Invalid => return Task::none(),
|
||||
DocumentCloseDisposition::Window => return self.update(Message::WindowClose),
|
||||
DocumentCloseDisposition::Confirm | DocumentCloseDisposition::Close => {
|
||||
DocumentCloseDisposition::Welcome
|
||||
| DocumentCloseDisposition::Confirm
|
||||
| DocumentCloseDisposition::Close => {
|
||||
if self.preview_baseline.is_some() {
|
||||
self.restore_preview();
|
||||
self.filter_preview_active = false;
|
||||
@@ -6544,6 +6951,9 @@ impl HcieIcedApp {
|
||||
.map_or(0, |duration| duration.as_secs());
|
||||
self.screenshot_request = Some(crate::cli::ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
output: std::path::PathBuf::from(format!(
|
||||
"target/screenshots/iced_{timestamp}.png"
|
||||
)),
|
||||
@@ -6715,6 +7125,16 @@ impl HcieIcedApp {
|
||||
self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded;
|
||||
self.settings.panel_layout.sidebar_width =
|
||||
if self.sidebar_expanded { 72.0 } else { 36.0 };
|
||||
let grid_size = crate::dock::preview::DockGeometry {
|
||||
viewport: iced::Size::new(
|
||||
self.settings.window.width,
|
||||
self.settings.window.height,
|
||||
),
|
||||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||||
}
|
||||
.grid_bounds()
|
||||
.size();
|
||||
self.dock.set_grid_size(grid_size);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
|
||||
@@ -6732,10 +7152,16 @@ impl HcieIcedApp {
|
||||
ts.reset_pressure();
|
||||
}
|
||||
}
|
||||
if !pressed {
|
||||
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contextual key actions ───────────────────────
|
||||
Message::EscapePressed => {
|
||||
if self.dock.active_auto_hide.take().is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
// Mirrors egui's Escape handling. Priority: viewer > crop >
|
||||
// transform > vector selection > selection > dialog > viewer fallback.
|
||||
match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) {
|
||||
@@ -6971,10 +7397,19 @@ impl HcieIcedApp {
|
||||
let has_dock_profile_dialog = self.show_dock_profile_dialog;
|
||||
let has_context_menu = self.canvas_context_menu.is_some();
|
||||
let has_color_picker = self.color_picker_target.is_some();
|
||||
let has_floating = !self.dock.floating.is_empty();
|
||||
|
||||
if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu || has_color_picker
|
||||
if has_dialog
|
||||
|| has_menu
|
||||
|| has_dock_profile_dialog
|
||||
|| has_context_menu
|
||||
|| has_color_picker
|
||||
|| has_floating
|
||||
{
|
||||
let mut stack = iced::widget::Stack::new().push(content);
|
||||
for item in &self.dock.floating {
|
||||
stack = stack.push(crate::dock::floating::card(item, self, colors));
|
||||
}
|
||||
if has_dialog {
|
||||
stack = stack.push(dialog_overlay);
|
||||
}
|
||||
@@ -7266,13 +7701,17 @@ impl HcieIcedApp {
|
||||
== iced::event::Status::Ignored)
|
||||
.then_some(Message::OverlayOutsideClick),
|
||||
iced::mouse::Event::CursorMoved { position } => {
|
||||
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
|
||||
Some(Message::DockPointerMoved(position.x, position.y))
|
||||
}
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
||||
Some(Message::LayerStyleDialogDragEnd)
|
||||
Some(Message::DockPointerReleased)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
iced::Event::Window(iced::window::Event::Opened { size, .. })
|
||||
| iced::Event::Window(iced::window::Event::Resized(size)) => {
|
||||
Some(Message::DockViewportChanged(size))
|
||||
}
|
||||
iced::Event::Touch(touch_event) => {
|
||||
// iced 0.13 touch events do not carry a force/pressure field,
|
||||
// so we can only forward position. A touch press/move marks
|
||||
@@ -7360,7 +7799,7 @@ fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option<Overlay
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DocumentCloseDisposition {
|
||||
Invalid,
|
||||
Window,
|
||||
Welcome,
|
||||
Confirm,
|
||||
Close,
|
||||
}
|
||||
@@ -7368,7 +7807,7 @@ enum DocumentCloseDisposition {
|
||||
/// Resolves tab-close behavior without mutating application state.
|
||||
///
|
||||
/// Arguments: open document count, requested index, and modified state. Returns: whether to ignore,
|
||||
/// delegate to window close, confirm, or remove immediately. Side Effects: None.
|
||||
/// show the welcome state, confirm, or remove immediately. Side Effects: None.
|
||||
fn document_close_disposition(
|
||||
document_count: usize,
|
||||
document_index: usize,
|
||||
@@ -7376,10 +7815,10 @@ fn document_close_disposition(
|
||||
) -> DocumentCloseDisposition {
|
||||
if document_index >= document_count {
|
||||
DocumentCloseDisposition::Invalid
|
||||
} else if document_count == 1 {
|
||||
DocumentCloseDisposition::Window
|
||||
} else if modified {
|
||||
DocumentCloseDisposition::Confirm
|
||||
} else if document_count == 1 {
|
||||
DocumentCloseDisposition::Welcome
|
||||
} else {
|
||||
DocumentCloseDisposition::Close
|
||||
}
|
||||
@@ -7440,7 +7879,7 @@ mod cycle_one_ux_tests {
|
||||
);
|
||||
assert_eq!(
|
||||
document_close_disposition(1, 0, false),
|
||||
DocumentCloseDisposition::Window
|
||||
DocumentCloseDisposition::Welcome
|
||||
);
|
||||
assert_eq!(
|
||||
document_close_disposition(2, 2, false),
|
||||
|
||||
@@ -13,6 +13,12 @@ use std::path::PathBuf;
|
||||
pub struct ScreenshotRequest {
|
||||
/// Optional exact dock-pane label. `None` captures the complete viewport.
|
||||
pub panel: Option<String>,
|
||||
/// Optional tool panel floated before an automated full-viewport capture.
|
||||
pub floating_panel: Option<String>,
|
||||
/// Optional tool panel moved to the auto-hide rail before capture.
|
||||
pub auto_hide_panel: Option<String>,
|
||||
/// Whether the post-close welcome surface is activated before capture.
|
||||
pub welcome: bool,
|
||||
/// PNG output path supplied by the caller.
|
||||
pub output: PathBuf,
|
||||
}
|
||||
@@ -64,17 +70,64 @@ where
|
||||
let output = required_operand(&tokens, index + 1, "--screenshot")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-floating" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let panel = required_operand(&tokens, index + 1, "--screenshot-floating")?;
|
||||
let output = required_operand(&tokens, index + 2, "--screenshot-floating")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: Some(panel.to_owned()),
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-panel" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?;
|
||||
let output = required_operand(&tokens, index + 2, "--screenshot-panel")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: Some(panel.to_owned()),
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-welcome" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let output = required_operand(&tokens, index + 1, "--screenshot-welcome")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: true,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-auto-hide" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let panel = required_operand(&tokens, index + 1, "--screenshot-auto-hide")?;
|
||||
let output = required_operand(&tokens, index + 2, "--screenshot-auto-hide")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: Some(panel.to_owned()),
|
||||
welcome: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
@@ -120,7 +173,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
|
||||
|
||||
/// Returns the command-line help shown by the binary.
|
||||
pub fn help_text() -> &'static str {
|
||||
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n\nArguments:\n FILE Image file to open on startup"
|
||||
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n\nArguments:\n FILE Image file to open on startup"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -136,6 +189,9 @@ mod tests {
|
||||
args.screenshot,
|
||||
Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
output: PathBuf::from("full.png"),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -271,7 +271,10 @@ pub fn popup_view(
|
||||
|
||||
let body: Element<'static, Message> = match tab {
|
||||
0 => rgb_popup_section(color),
|
||||
1 => Canvas::new(ColorWheel { color })
|
||||
1 => Canvas::new(ColorWheel {
|
||||
color,
|
||||
output: WheelOutput::Popup,
|
||||
})
|
||||
.width(Length::Fixed(220.0))
|
||||
.height(Length::Fixed(220.0))
|
||||
.into(),
|
||||
@@ -425,6 +428,16 @@ fn popup_slider_row<'a>(
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ColorWheel {
|
||||
color: [u8; 4],
|
||||
output: WheelOutput,
|
||||
}
|
||||
|
||||
/// Selects the application message emitted by a shared interactive wheel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WheelOutput {
|
||||
/// Updates the docked foreground color.
|
||||
Foreground,
|
||||
/// Updates the currently targeted popup color.
|
||||
Popup,
|
||||
}
|
||||
|
||||
/// Tracks whether a wheel drag began over an editable region.
|
||||
@@ -434,6 +447,14 @@ struct ColorWheelState {
|
||||
}
|
||||
|
||||
impl ColorWheel {
|
||||
/// Wraps a selected color in the message expected by this wheel placement.
|
||||
fn message(self, color: [u8; 4]) -> Message {
|
||||
match self.output {
|
||||
WheelOutput::Foreground => Message::FgColorChanged(color),
|
||||
WheelOutput::Popup => Message::PopupColorChanged(color),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a local wheel position to HSL, preserving the current component when
|
||||
/// the pointer lies outside both the ring and center square.
|
||||
fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> {
|
||||
@@ -565,7 +586,7 @@ impl canvas::Program<Message> for ColorWheel {
|
||||
state.dragging = true;
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some(Message::PopupColorChanged(color)),
|
||||
Some(self.message(color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -573,7 +594,7 @@ impl canvas::Program<Message> for ColorWheel {
|
||||
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some(Message::PopupColorChanged(color)),
|
||||
Some(self.message(color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -729,7 +750,7 @@ pub fn view<'a>(
|
||||
// ── 4. Tab content ──
|
||||
let tab_content: Element<'_, Message> = match color_tab {
|
||||
0 => build_wheel_view(h, s, l, fg_color),
|
||||
1 => build_slider_view(h, s, l),
|
||||
1 => build_slider_view(h, s, l, fg_color[3]),
|
||||
_ => build_grid_view(fg_color),
|
||||
};
|
||||
|
||||
@@ -775,6 +796,7 @@ pub fn view<'a>(
|
||||
});
|
||||
let clickable = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::FgColorChanged(c))
|
||||
.on_right_press(Message::BgColorChanged(c))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
recent_items.push(clickable.into());
|
||||
}
|
||||
@@ -784,7 +806,7 @@ pub fn view<'a>(
|
||||
row(recent_items).spacing(2).into()
|
||||
};
|
||||
|
||||
column![
|
||||
let content = column![
|
||||
text("Color").size(11).font(iced::Font::MONOSPACE),
|
||||
swatch_row,
|
||||
hex_row,
|
||||
@@ -796,118 +818,21 @@ pub fn view<'a>(
|
||||
recent_row,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink)
|
||||
.spacing(3)
|
||||
.padding(Padding::from([4, 4]))
|
||||
.into()
|
||||
.padding(Padding::from([4, 4]));
|
||||
|
||||
scrollable(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the HSL color wheel view (tab W).
|
||||
/// Builds a responsive Canvas-based HSL wheel for the docked panel.
|
||||
///
|
||||
/// Uses a hue ring made of colored cells and an SL picker grid.
|
||||
fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
// Hue ring: 36 cells, each 10 degrees
|
||||
let cell_size = 10;
|
||||
let mut hue_ring = row![].spacing(0);
|
||||
for i in 0..36 {
|
||||
let hue = i as f32 * 10.0;
|
||||
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5);
|
||||
let cell_color =
|
||||
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let is_current_h = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0;
|
||||
let is_selected = is_current_h && (s - 1.0).abs() < 0.1 && (l - 0.5).abs() < 0.1;
|
||||
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_selected { "\u{2022}" } else { "" })
|
||||
.size(8)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(dot_color),
|
||||
});
|
||||
|
||||
let cell = container(dot)
|
||||
.width(cell_size)
|
||||
.height(cell_size)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(cell_color)),
|
||||
border: if is_current_h {
|
||||
iced::Border::default().color(iced::Color::WHITE).width(1)
|
||||
} else {
|
||||
iced::Border::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let h_val = hue;
|
||||
let s_val = s;
|
||||
let l_val = l;
|
||||
let clickable = iced::widget::mouse_area(cell)
|
||||
.on_press(Message::FgColorChanged({
|
||||
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
|
||||
[r, g, b, 255]
|
||||
}))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
hue_ring = hue_ring.push(clickable);
|
||||
}
|
||||
|
||||
// SL picker grid: 12 columns (saturation) x 12 rows (lightness)
|
||||
let sl_size = 10;
|
||||
let mut sl_grid = column![].spacing(0);
|
||||
for row_idx in 0..12 {
|
||||
let lightness = row_idx as f32 / 11.0;
|
||||
let mut sl_row = row![].spacing(0);
|
||||
for col_idx in 0..12 {
|
||||
let sat = col_idx as f32 / 11.0;
|
||||
let (r, g, b) = hsl_to_rgb(h, sat, lightness);
|
||||
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05;
|
||||
let cell_color =
|
||||
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_current { "\u{2022}" } else { "" })
|
||||
.size(8)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(dot_color),
|
||||
});
|
||||
|
||||
let cell = container(dot)
|
||||
.width(sl_size)
|
||||
.height(sl_size)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(cell_color)),
|
||||
border: if is_current {
|
||||
iced::Border::default().color(iced::Color::WHITE).width(1)
|
||||
} else {
|
||||
iced::Border::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let h_val = h;
|
||||
let s_val = sat;
|
||||
let l_val = lightness;
|
||||
let clickable = iced::widget::mouse_area(cell)
|
||||
.on_press(Message::FgColorChanged({
|
||||
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
|
||||
[r, g, b, 255]
|
||||
}))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
sl_row = sl_row.push(clickable);
|
||||
}
|
||||
sl_grid = sl_grid.push(sl_row);
|
||||
}
|
||||
|
||||
// HSL readout
|
||||
/// The wheel fills narrow pane widths while retaining intrinsic vertical height, avoiding the
|
||||
/// former fixed 360-pixel hue strip and allowing the outer panel scrollable to handle short panes.
|
||||
fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
let readout = text(format!(
|
||||
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
|
||||
h,
|
||||
@@ -917,32 +842,33 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
|
||||
.size(9)
|
||||
.font(iced::Font::MONOSPACE);
|
||||
|
||||
column![
|
||||
text("Hue").size(9),
|
||||
hue_ring,
|
||||
text("Saturation / Lightness").size(9),
|
||||
sl_grid,
|
||||
readout,
|
||||
]
|
||||
let wheel = Canvas::new(ColorWheel {
|
||||
color: *fg_color,
|
||||
output: WheelOutput::Foreground,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(220.0));
|
||||
|
||||
column![wheel, readout]
|
||||
.spacing(3)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the HSL sliders view (tab H).
|
||||
fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
|
||||
fn build_slider_view<'a>(h: f32, s: f32, l: f32, alpha: u8) -> Element<'a, Message> {
|
||||
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(v, s, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
Message::FgColorChanged([r, g, b, alpha])
|
||||
})
|
||||
.step(1.0);
|
||||
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
Message::FgColorChanged([r, g, b, alpha])
|
||||
})
|
||||
.step(1.0);
|
||||
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
Message::FgColorChanged([r, g, b, alpha])
|
||||
})
|
||||
.step(1.0);
|
||||
|
||||
@@ -1013,7 +939,8 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
..Default::default()
|
||||
});
|
||||
let clickable = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
|
||||
.on_press(Message::FgColorChanged([c[0], c[1], c[2], fg_color[3]]))
|
||||
.on_right_press(Message::BgColorChanged([c[0], c[1], c[2], fg_color[3]]))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
row_widgets = row_widgets.push(clickable);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Auto-hide rail and temporary overlay presentation.
|
||||
//!
|
||||
//! Rail presses activate one opaque overlay. Panel content is supplied by `dock::view::panel_body`,
|
||||
//! so auto-hidden and docked panels use the same implementation.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::dock::state::PaneType;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, row, text, tooltip};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Renders the fixed right auto-hide rail outside PaneGrid.
|
||||
pub fn right_rail<'a>(
|
||||
panels: impl Iterator<Item = PaneType>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let mut tabs = column![].spacing(3).padding([4, 2]).width(36);
|
||||
for panel in panels {
|
||||
let tab = button(text(panel.icon_glyph()).size(16))
|
||||
.on_press(Message::AutoHideActivate(panel))
|
||||
.width(32)
|
||||
.height(32)
|
||||
.padding(6)
|
||||
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status));
|
||||
tabs = tabs.push(tooltip(
|
||||
tab,
|
||||
text(panel.label()).size(11),
|
||||
tooltip::Position::Left,
|
||||
));
|
||||
}
|
||||
container(tabs)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Builds an opaque temporary auto-hide overlay aligned to the right edge.
|
||||
pub fn overlay<'a>(
|
||||
panel: PaneType,
|
||||
body: Element<'a, Message>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let header = row![
|
||||
text(panel.icon_glyph()).size(14).color(colors.accent),
|
||||
text(panel.label()).size(12).color(colors.text_primary),
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
tooltip(
|
||||
button(text("●").size(10))
|
||||
.on_press(Message::AutoHideRestore(panel))
|
||||
.padding([3, 6])
|
||||
.style(
|
||||
move |_theme, status| crate::dock::view::chrome_button_style(colors, status)
|
||||
),
|
||||
text("Pin panel open").size(11),
|
||||
tooltip::Position::Left
|
||||
),
|
||||
button(text("×").size(14))
|
||||
.on_press(Message::AutoHideDismiss)
|
||||
.padding([2, 7])
|
||||
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status)),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.height(28)
|
||||
.padding([0, 6]);
|
||||
let card = container(column![header, body].height(Length::Fill))
|
||||
.width(320)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45),
|
||||
offset: iced::Vector::new(-6.0, 4.0),
|
||||
blur_radius: 16.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
container(card)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(iced::alignment::Horizontal::Right)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! In-viewport floating tool panel geometry and card rendering.
|
||||
//!
|
||||
//! Cards reuse dock panel bodies and are positioned with ordinary padding and `Space` widgets.
|
||||
//! Only visible card bounds receive events; surrounding viewport space remains inert.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::dock::manager::DockRect;
|
||||
use crate::dock::persistence::PersistedFloatingPanel;
|
||||
use crate::dock::state::PaneType;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, mouse_area, row, text, tooltip, Space};
|
||||
use iced::{Element, Length, Point};
|
||||
|
||||
pub const DEFAULT_WIDTH: f32 = 320.0;
|
||||
pub const DEFAULT_HEIGHT: f32 = 420.0;
|
||||
|
||||
/// Active header drag retaining the pointer-to-card offset.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FloatingDragState {
|
||||
pub panel: PaneType,
|
||||
pub pointer_offset: Point,
|
||||
}
|
||||
|
||||
/// Creates a recoverable default rectangle inside the current viewport.
|
||||
pub fn default_rect(viewport: (f32, f32), ordinal: usize) -> DockRect {
|
||||
DockRect {
|
||||
x: 120.0 + ordinal as f32 * 24.0,
|
||||
y: 96.0 + ordinal as f32 * 24.0,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
}
|
||||
.clamped(viewport)
|
||||
}
|
||||
|
||||
/// Positions one opaque floating card in full-viewport logical coordinates.
|
||||
pub fn card<'a>(
|
||||
item: &PersistedFloatingPanel,
|
||||
app: &'a crate::app::HcieIcedApp,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let panel = item.panel;
|
||||
let title = mouse_area(
|
||||
row![
|
||||
text(panel.icon_glyph()).size(14).color(colors.accent),
|
||||
text(panel.label()).size(12).color(colors.text_primary),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.padding([0, 8])
|
||||
.height(30),
|
||||
)
|
||||
.on_press(Message::FloatingDragStart(panel));
|
||||
let control = |label, message| {
|
||||
button(text(label).size(12))
|
||||
.on_press(message)
|
||||
.padding([3, 7])
|
||||
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status))
|
||||
};
|
||||
let header = row![
|
||||
title,
|
||||
tooltip(
|
||||
control("−", Message::FloatingAutoHide(panel)),
|
||||
text("Minimize to side rail").size(11),
|
||||
tooltip::Position::Bottom
|
||||
),
|
||||
tooltip(
|
||||
control("●", Message::FloatingRedock(panel)),
|
||||
text("Redock panel").size(11),
|
||||
tooltip::Position::Bottom
|
||||
),
|
||||
tooltip(
|
||||
control("×", Message::FloatingClose(panel)),
|
||||
text("Close panel").size(11),
|
||||
tooltip::Position::Bottom
|
||||
),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.height(30);
|
||||
let body = crate::dock::view::panel_body(panel, app, colors);
|
||||
let card = container(column![header, body].height(Length::Fill))
|
||||
.width(item.rect.width)
|
||||
.height(item.rect.height)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5),
|
||||
offset: iced::Vector::new(5.0, 7.0),
|
||||
blur_radius: 18.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let card = mouse_area(card).on_press(Message::FloatingFocus(panel));
|
||||
column![
|
||||
Space::with_height(item.rect.y.max(0.0)),
|
||||
row![
|
||||
Space::with_width(item.rect.x.max(0.0)),
|
||||
card,
|
||||
Space::with_width(Length::Fill)
|
||||
],
|
||||
Space::with_height(Length::Fill),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_rect_clamps_to_small_viewports() {
|
||||
let rect = default_rect((240.0, 180.0), 4);
|
||||
assert!(rect.x <= 192.0 && rect.y <= 152.0);
|
||||
assert_eq!((rect.width, rect.height), (240.0, 180.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Policy and runtime types for the VS-style dock manager.
|
||||
//!
|
||||
//! Raw `PaneGrid` targets are classified and validated before `DockState` mutates Iced state.
|
||||
|
||||
use crate::dock::state::PaneType;
|
||||
use iced::widget::pane_grid::{Edge, Pane, Region, Target};
|
||||
use iced::{Point, Rectangle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Semantic ownership of content participating in the application layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DockRole {
|
||||
/// The unique center document host.
|
||||
Document,
|
||||
/// A movable utility panel.
|
||||
Tool,
|
||||
/// Shell UI that never enters the dock manager.
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// Stable edge names used by auto-hide and persisted placements.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DockEdge {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl DockEdge {
|
||||
/// Converts an Iced edge to its stable dock equivalent.
|
||||
pub fn from_iced(edge: Edge) -> Self {
|
||||
match edge {
|
||||
Edge::Left => Self::Left,
|
||||
Edge::Right => Self::Right,
|
||||
Edge::Top => Self::Top,
|
||||
Edge::Bottom => Self::Bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical rectangle persisted for floating panels without runtime window IDs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DockRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl DockRect {
|
||||
/// Clamps dimensions and enough header area into the supplied viewport.
|
||||
pub fn clamped(self, viewport: (f32, f32)) -> Self {
|
||||
Self {
|
||||
x: self.x.clamp(0.0, (viewport.0 - 48.0).max(0.0)),
|
||||
y: self.y.clamp(0.0, (viewport.1 - 28.0).max(0.0)),
|
||||
width: self.width.clamp(180.0, viewport.0.max(180.0)),
|
||||
height: self.height.clamp(120.0, viewport.1.max(120.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable placement of a document or tool panel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
|
||||
pub enum DockPlacement {
|
||||
Docked,
|
||||
AutoHidden(DockEdge),
|
||||
Floating(DockRect),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Policy-level class of an Iced drop target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DockDropZone {
|
||||
OuterEdge(DockEdge),
|
||||
PaneEdge(DockEdge),
|
||||
Center,
|
||||
}
|
||||
|
||||
impl DockDropZone {
|
||||
/// Classifies a raw Iced target without retaining runtime pane identifiers.
|
||||
pub fn from_target(target: Target) -> Self {
|
||||
match target {
|
||||
Target::Edge(edge) => Self::OuterEdge(DockEdge::from_iced(edge)),
|
||||
Target::Pane(_, Region::Edge(edge)) => Self::PaneEdge(DockEdge::from_iced(edge)),
|
||||
Target::Pane(_, Region::Center) => Self::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current interaction state for one PaneGrid drag.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DockDragState {
|
||||
pub source_pane: Pane,
|
||||
pub source_type: PaneType,
|
||||
pub pointer: Option<Point>,
|
||||
pub candidate_target: Option<Target>,
|
||||
pub accepted_preview: Option<Rectangle>,
|
||||
}
|
||||
|
||||
/// Pointer-down state for the visible dock title before its drag deadband is crossed.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DockHeaderDragState {
|
||||
/// Runtime pane that owns the pressed title.
|
||||
pub source_pane: Pane,
|
||||
/// Stable panel payload used by drop policy.
|
||||
pub source_type: PaneType,
|
||||
/// Full-window logical pointer position at press time.
|
||||
pub origin: Point,
|
||||
}
|
||||
|
||||
/// Stateless gate for all dock placement mutations.
|
||||
pub struct DockDropPolicy;
|
||||
|
||||
impl DockDropPolicy {
|
||||
/// Returns the semantic role of a pane type.
|
||||
pub fn role(panel: PaneType) -> DockRole {
|
||||
if panel == PaneType::Canvas {
|
||||
DockRole::Document
|
||||
} else {
|
||||
DockRole::Tool
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true only for tool-to-edge drops.
|
||||
pub fn accepts(source: PaneType, zone: DockDropZone) -> bool {
|
||||
Self::role(source) == DockRole::Tool
|
||||
&& matches!(zone, DockDropZone::OuterEdge(_) | DockDropZone::PaneEdge(_))
|
||||
}
|
||||
|
||||
/// Validates actions that remove content from the grid.
|
||||
pub fn accepts_placement(source: PaneType, placement: DockPlacement) -> bool {
|
||||
Self::role(source) == DockRole::Tool && !matches!(placement, DockPlacement::Docked)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn policy_matrix_allows_only_tool_edges() {
|
||||
let zones = [
|
||||
DockDropZone::OuterEdge(DockEdge::Left),
|
||||
DockDropZone::PaneEdge(DockEdge::Right),
|
||||
DockDropZone::Center,
|
||||
];
|
||||
for panel in PaneType::ALL {
|
||||
for zone in zones {
|
||||
assert_eq!(
|
||||
DockDropPolicy::accepts(panel, zone),
|
||||
panel != PaneType::Canvas && zone != DockDropZone::Center
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_cannot_close_auto_hide_or_float() {
|
||||
for placement in [
|
||||
DockPlacement::Closed,
|
||||
DockPlacement::AutoHidden(DockEdge::Right),
|
||||
DockPlacement::Floating(DockRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 320.0,
|
||||
height: 400.0,
|
||||
}),
|
||||
] {
|
||||
assert!(!DockDropPolicy::accepts_placement(
|
||||
PaneType::Canvas,
|
||||
placement
|
||||
));
|
||||
assert!(DockDropPolicy::accepts_placement(
|
||||
PaneType::Layers,
|
||||
placement
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,12 @@
|
||||
//! Provides a resizable, draggable pane grid where each pane
|
||||
//! can host a different panel (layers, history, brushes, etc.).
|
||||
|
||||
pub mod auto_hide;
|
||||
pub mod floating;
|
||||
pub mod manager;
|
||||
pub mod persistence;
|
||||
pub mod preview;
|
||||
pub mod sizing;
|
||||
pub mod state;
|
||||
pub mod view;
|
||||
pub mod welcome;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Stable serialization for dock layouts without runtime `Pane` identifiers.
|
||||
//!
|
||||
//! Live Iced nodes are mapped to panel payloads; restore removes duplicates, clamps ratios, and
|
||||
//! injects the unique Canvas when malformed or legacy data omits it.
|
||||
|
||||
use crate::dock::manager::{DockEdge, DockRect};
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use iced::widget::pane_grid::{Axis, Configuration, Node};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Stable split axis independent of Iced serialization.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DockAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Serializable dock tree whose leaves contain stable panel types.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum DockLayoutNode {
|
||||
Split {
|
||||
axis: DockAxis,
|
||||
ratio: f32,
|
||||
first: Box<DockLayoutNode>,
|
||||
second: Box<DockLayoutNode>,
|
||||
},
|
||||
Panel {
|
||||
panel: PaneType,
|
||||
},
|
||||
}
|
||||
|
||||
/// Persisted panel and rail edge for one auto-hidden tool.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersistedAutoHiddenPanel {
|
||||
pub panel: PaneType,
|
||||
pub edge: DockEdge,
|
||||
}
|
||||
|
||||
/// Persisted panel and logical viewport rectangle for one floating tool.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersistedFloatingPanel {
|
||||
pub panel: PaneType,
|
||||
pub rect: DockRect,
|
||||
}
|
||||
|
||||
/// Complete stable dock payload stored under `PanelLayout`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersistedDockLayout {
|
||||
#[serde(default)]
|
||||
pub tree: Option<DockLayoutNode>,
|
||||
#[serde(default)]
|
||||
pub auto_hidden: Vec<PersistedAutoHiddenPanel>,
|
||||
#[serde(default)]
|
||||
pub floating: Vec<PersistedFloatingPanel>,
|
||||
#[serde(default)]
|
||||
pub focused: Option<PaneType>,
|
||||
}
|
||||
|
||||
impl PersistedDockLayout {
|
||||
/// Captures topology and detached placements from a runtime dock.
|
||||
pub fn capture(dock: &DockState) -> Self {
|
||||
Self {
|
||||
tree: snapshot_node(dock.pane_grid.layout(), &dock.pane_grid),
|
||||
auto_hidden: dock.persisted_auto_hidden(),
|
||||
floating: dock.floating.clone(),
|
||||
focused: dock.focused_type(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores a sanitized runtime dock and guarantees a unique Canvas.
|
||||
pub fn restore(&self) -> DockState {
|
||||
DockState::from_persisted(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively replaces runtime leaves with stable panel payloads.
|
||||
fn snapshot_node(
|
||||
node: &Node,
|
||||
state: &iced::widget::pane_grid::State<PaneType>,
|
||||
) -> Option<DockLayoutNode> {
|
||||
match node {
|
||||
Node::Pane(pane) => state
|
||||
.get(*pane)
|
||||
.copied()
|
||||
.map(|panel| DockLayoutNode::Panel { panel }),
|
||||
Node::Split {
|
||||
axis, ratio, a, b, ..
|
||||
} => Some(DockLayoutNode::Split {
|
||||
axis: match axis {
|
||||
Axis::Horizontal => DockAxis::Horizontal,
|
||||
Axis::Vertical => DockAxis::Vertical,
|
||||
},
|
||||
ratio: sanitized_ratio(*ratio),
|
||||
first: Box::new(snapshot_node(a, state)?),
|
||||
second: Box::new(snapshot_node(b, state)?),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a persisted tree to Iced configuration while removing duplicate leaves.
|
||||
pub(crate) fn sanitized_configuration(tree: Option<&DockLayoutNode>) -> Configuration<PaneType> {
|
||||
fn sanitize(
|
||||
node: &DockLayoutNode,
|
||||
seen: &mut HashSet<PaneType>,
|
||||
) -> Option<Configuration<PaneType>> {
|
||||
match node {
|
||||
DockLayoutNode::Panel { panel } => {
|
||||
seen.insert(*panel).then_some(Configuration::Pane(*panel))
|
||||
}
|
||||
DockLayoutNode::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first,
|
||||
second,
|
||||
} => {
|
||||
let first = sanitize(first, seen);
|
||||
let second = sanitize(second, seen);
|
||||
match (first, second) {
|
||||
(Some(a), Some(b)) => Some(Configuration::Split {
|
||||
axis: match axis {
|
||||
DockAxis::Horizontal => Axis::Horizontal,
|
||||
DockAxis::Vertical => Axis::Vertical,
|
||||
},
|
||||
ratio: sanitized_ratio(*ratio),
|
||||
a: Box::new(a),
|
||||
b: Box::new(b),
|
||||
}),
|
||||
(Some(node), None) | (None, Some(node)) => Some(node),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let sanitized = tree.and_then(|node| sanitize(node, &mut seen));
|
||||
match (sanitized, seen.contains(&PaneType::Canvas)) {
|
||||
(Some(config), true) => config,
|
||||
(Some(config), false) => Configuration::Split {
|
||||
axis: Axis::Vertical,
|
||||
ratio: 0.75,
|
||||
a: Box::new(Configuration::Pane(PaneType::Canvas)),
|
||||
b: Box::new(config),
|
||||
},
|
||||
(None, _) => Configuration::Pane(PaneType::Canvas),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates persisted split ratios without imposing a legacy blanket layout clamp.
|
||||
///
|
||||
/// **Arguments:** `ratio` is a serialized or live split fraction. **Returns:** A finite ratio in
|
||||
/// `0.01..=0.99`, using an even split for NaN or infinity. **Logic & Workflow:** Content policy
|
||||
/// performs pixel-aware clamping once the viewport is known; persistence only prevents malformed
|
||||
/// geometry. **Side Effects / Dependencies:** None.
|
||||
fn sanitized_ratio(ratio: f32) -> f32 {
|
||||
if ratio.is_finite() {
|
||||
ratio.clamp(0.01, 0.99)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn persisted_layout_round_trips_without_runtime_ids() {
|
||||
let dock = DockState::new();
|
||||
let json = serde_json::to_string(&PersistedDockLayout::capture(&dock)).unwrap();
|
||||
assert!(!json.contains("Pane("));
|
||||
let restored: PersistedDockLayout = serde_json::from_str(&json).unwrap();
|
||||
assert!(restored.restore().invariants_hold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tree_deduplicates_and_injects_canvas() {
|
||||
let tree = DockLayoutNode::Split {
|
||||
axis: DockAxis::Vertical,
|
||||
ratio: 4.0,
|
||||
first: Box::new(DockLayoutNode::Panel {
|
||||
panel: PaneType::Layers,
|
||||
}),
|
||||
second: Box::new(DockLayoutNode::Panel {
|
||||
panel: PaneType::Layers,
|
||||
}),
|
||||
};
|
||||
let restored = PersistedDockLayout {
|
||||
tree: Some(tree),
|
||||
..Default::default()
|
||||
}
|
||||
.restore();
|
||||
assert!(restored.invariants_hold());
|
||||
assert!(restored.is_pane_open(PaneType::Layers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_new_fields_are_serde_compatible() {
|
||||
let layout: PersistedDockLayout = serde_json::from_str(r#"{"tree":null}"#).unwrap();
|
||||
assert!(layout.auto_hidden.is_empty());
|
||||
assert!(layout.floating.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_sanitization_accepts_extreme_finite_values_only() {
|
||||
assert_eq!(sanitized_ratio(0.02), 0.02);
|
||||
assert_eq!(sanitized_ratio(0.999), 0.99);
|
||||
assert_eq!(sanitized_ratio(f32::NAN), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod floating_roundtrip_tests {
|
||||
use super::*;
|
||||
|
||||
/// Proves floating logical rectangles survive serialization, clamp, and unique redocking.
|
||||
#[test]
|
||||
fn floating_round_trip_clamps_and_redocks_uniquely() {
|
||||
let mut dock = DockState::new();
|
||||
let layers = dock.pane_for_type(PaneType::Layers).unwrap();
|
||||
assert!(dock.float_pane(layers, (1280.0, 800.0)));
|
||||
let json = serde_json::to_string(&PersistedDockLayout::capture(&dock)).unwrap();
|
||||
let layout: PersistedDockLayout = serde_json::from_str(&json).unwrap();
|
||||
let mut restored = layout.restore();
|
||||
restored.clamp_floating((220.0, 160.0));
|
||||
let rect = restored.floating[0].rect;
|
||||
assert!(rect.x <= 172.0 && rect.y <= 132.0);
|
||||
assert!(restored.redock_floating(PaneType::Layers));
|
||||
assert!(restored.floating.is_empty() && restored.invariants_hold());
|
||||
assert_eq!(
|
||||
restored
|
||||
.pane_grid
|
||||
.iter()
|
||||
.filter(|(_, panel)| **panel == PaneType::Layers)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Pure geometry for policy-approved dock drop previews.
|
||||
//!
|
||||
//! Global logical pointer coordinates are converted into PaneGrid-local edge targets. Boundaries
|
||||
//! and edge priority mirror Iced's own hit testing; pane centers never produce mutations.
|
||||
|
||||
use crate::dock::manager::{DockDropZone, DockEdge};
|
||||
use iced::widget::pane_grid::{Edge, Pane, Region, Target};
|
||||
use iced::{Point, Rectangle, Size};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const TITLE_HEIGHT: f32 = 28.0;
|
||||
pub const TOOLBAR_HEIGHT: f32 = 28.0;
|
||||
pub const STATUS_HEIGHT: f32 = 20.0;
|
||||
pub const TOOLBOX_GAP: f32 = 2.0;
|
||||
pub const AUTO_HIDE_RAIL_WIDTH: f32 = 36.0;
|
||||
pub const PANE_SPACING: f32 = 4.0;
|
||||
const OUTER_THICKNESS_RATIO: f32 = 25.0;
|
||||
|
||||
/// Full shell metrics used to locate PaneGrid independently of renderer scale.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DockGeometry {
|
||||
pub viewport: Size,
|
||||
pub toolbox_width: f32,
|
||||
}
|
||||
|
||||
impl DockGeometry {
|
||||
/// Returns PaneGrid's global logical rectangle after fixed shell regions are removed.
|
||||
pub fn grid_bounds(self) -> Rectangle {
|
||||
let x = self.toolbox_width + TOOLBOX_GAP;
|
||||
let y = TITLE_HEIGHT + TOOLBAR_HEIGHT;
|
||||
Rectangle {
|
||||
x,
|
||||
y,
|
||||
width: (self.viewport.width - x - AUTO_HIDE_RAIL_WIDTH).max(0.0),
|
||||
height: (self.viewport.height - y - STATUS_HEIGHT).max(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approved raw target and PaneGrid-local visual geometry.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ApprovedTarget {
|
||||
pub target: Target,
|
||||
pub zone: DockDropZone,
|
||||
pub preview: Rectangle,
|
||||
}
|
||||
|
||||
/// Resolves one global logical pointer to an approved edge-only target.
|
||||
pub fn approved_target(
|
||||
pointer: Point,
|
||||
geometry: DockGeometry,
|
||||
pane_regions: &BTreeMap<Pane, Rectangle>,
|
||||
) -> Option<ApprovedTarget> {
|
||||
let grid = geometry.grid_bounds();
|
||||
if !contains(grid, pointer) || grid.width <= 0.0 || grid.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let local = Point::new(pointer.x - grid.x, pointer.y - grid.y);
|
||||
let grid_local = Rectangle::new(Point::ORIGIN, grid.size());
|
||||
if let Some(edge) = outer_edge(local, grid_local) {
|
||||
return Some(approved(Target::Edge(to_iced(edge)), edge, grid_local));
|
||||
}
|
||||
pane_regions.iter().find_map(|(pane, rect)| {
|
||||
contains(*rect, local)
|
||||
.then(|| pane_edge(local, *rect))
|
||||
.flatten()
|
||||
.map(|edge| {
|
||||
approved(
|
||||
Target::Pane(*pane, Region::Edge(to_iced(edge))),
|
||||
edge,
|
||||
*rect,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks exact raw-target consistency without requiring `Target: PartialEq`.
|
||||
pub fn target_matches(approved: Target, raw: Target) -> bool {
|
||||
match (approved, raw) {
|
||||
(Target::Edge(a), Target::Edge(b)) => edge_eq(a, b),
|
||||
(Target::Pane(ap, Region::Edge(a)), Target::Pane(bp, Region::Edge(b))) => {
|
||||
ap == bp && edge_eq(a, b)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn approved(target: Target, edge: DockEdge, bounds: Rectangle) -> ApprovedTarget {
|
||||
ApprovedTarget {
|
||||
target,
|
||||
zone: match target {
|
||||
Target::Edge(_) => DockDropZone::OuterEdge(edge),
|
||||
_ => DockDropZone::PaneEdge(edge),
|
||||
},
|
||||
preview: edge_rect(bounds, edge),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_edge(point: Point, bounds: Rectangle) -> Option<DockEdge> {
|
||||
let thickness = bounds.width.min(bounds.height) / OUTER_THICKNESS_RATIO;
|
||||
if point.x > bounds.x && point.x < bounds.x + thickness {
|
||||
Some(DockEdge::Left)
|
||||
} else if point.x > bounds.x + bounds.width - thickness
|
||||
&& point.x < bounds.x + bounds.width
|
||||
{
|
||||
Some(DockEdge::Right)
|
||||
} else if point.y > bounds.y && point.y < bounds.y + thickness {
|
||||
Some(DockEdge::Top)
|
||||
} else if point.y > bounds.y + bounds.height - thickness
|
||||
&& point.y < bounds.y + bounds.height
|
||||
{
|
||||
Some(DockEdge::Bottom)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_edge(point: Point, bounds: Rectangle) -> Option<DockEdge> {
|
||||
if point.x < bounds.x + bounds.width / 3.0 {
|
||||
Some(DockEdge::Left)
|
||||
} else if point.x > bounds.x + 2.0 * bounds.width / 3.0 {
|
||||
Some(DockEdge::Right)
|
||||
} else if point.y < bounds.y + bounds.height / 3.0 {
|
||||
Some(DockEdge::Top)
|
||||
} else if point.y > bounds.y + 2.0 * bounds.height / 3.0 {
|
||||
Some(DockEdge::Bottom)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_rect(bounds: Rectangle, edge: DockEdge) -> Rectangle {
|
||||
match edge {
|
||||
DockEdge::Left => Rectangle {
|
||||
width: bounds.width * 0.5,
|
||||
..bounds
|
||||
},
|
||||
DockEdge::Right => Rectangle {
|
||||
x: bounds.x + bounds.width * 0.5,
|
||||
width: bounds.width * 0.5,
|
||||
..bounds
|
||||
},
|
||||
DockEdge::Top => Rectangle {
|
||||
height: bounds.height * 0.5,
|
||||
..bounds
|
||||
},
|
||||
DockEdge::Bottom => Rectangle {
|
||||
y: bounds.y + bounds.height * 0.5,
|
||||
height: bounds.height * 0.5,
|
||||
..bounds
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(bounds: Rectangle, point: Point) -> bool {
|
||||
point.x >= bounds.x
|
||||
&& point.y >= bounds.y
|
||||
&& point.x <= bounds.x + bounds.width
|
||||
&& point.y <= bounds.y + bounds.height
|
||||
}
|
||||
|
||||
fn to_iced(edge: DockEdge) -> Edge {
|
||||
match edge {
|
||||
DockEdge::Left => Edge::Left,
|
||||
DockEdge::Right => Edge::Right,
|
||||
DockEdge::Top => Edge::Top,
|
||||
DockEdge::Bottom => Edge::Bottom,
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_eq(a: Edge, b: Edge) -> bool {
|
||||
matches!(
|
||||
(a, b),
|
||||
(Edge::Left, Edge::Left)
|
||||
| (Edge::Right, Edge::Right)
|
||||
| (Edge::Top, Edge::Top)
|
||||
| (Edge::Bottom, Edge::Bottom)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::widget::pane_grid::State;
|
||||
|
||||
fn setup() -> (DockGeometry, BTreeMap<Pane, Rectangle>) {
|
||||
let (state, _) = State::new(());
|
||||
let pane = *state.iter().next().unwrap().0;
|
||||
let geometry = DockGeometry {
|
||||
viewport: Size::new(800.0, 600.0),
|
||||
toolbox_width: 36.0,
|
||||
};
|
||||
let mut regions = BTreeMap::new();
|
||||
regions.insert(
|
||||
pane,
|
||||
Rectangle::new(Point::ORIGIN, geometry.grid_bounds().size()),
|
||||
);
|
||||
(geometry, regions)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centers_are_never_approved() {
|
||||
let (geometry, regions) = setup();
|
||||
let grid = geometry.grid_bounds();
|
||||
assert!(approved_target(
|
||||
Point::new(grid.center_x(), grid.center_y()),
|
||||
geometry,
|
||||
®ions
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_and_pane_edges_are_approved() {
|
||||
let (geometry, regions) = setup();
|
||||
let grid = geometry.grid_bounds();
|
||||
let outer = approved_target(
|
||||
Point::new(grid.x + 2.0, grid.center_y()),
|
||||
geometry,
|
||||
®ions,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outer.zone, DockDropZone::OuterEdge(DockEdge::Left));
|
||||
let pane = approved_target(
|
||||
Point::new(grid.center_x(), grid.y + grid.height * 0.2),
|
||||
geometry,
|
||||
®ions,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pane.zone, DockDropZone::PaneEdge(DockEdge::Top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_viewport_clamps_to_empty_bounds() {
|
||||
let geometry = DockGeometry {
|
||||
viewport: Size::new(60.0, 50.0),
|
||||
toolbox_width: 72.0,
|
||||
};
|
||||
assert_eq!(geometry.grid_bounds().size(), Size::new(0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_classification_matches_iced_thirds_and_priority() {
|
||||
let bounds = Rectangle::new(Point::new(10.0, 20.0), Size::new(300.0, 180.0));
|
||||
assert_eq!(
|
||||
pane_edge(Point::new(109.9, 21.0), bounds),
|
||||
Some(DockEdge::Left)
|
||||
);
|
||||
assert_eq!(pane_edge(Point::new(110.0, 110.0), bounds), None);
|
||||
assert_eq!(
|
||||
pane_edge(Point::new(210.0, 79.9), bounds),
|
||||
Some(DockEdge::Top)
|
||||
);
|
||||
assert_eq!(
|
||||
pane_edge(Point::new(11.0, 21.0), bounds),
|
||||
Some(DockEdge::Left)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_edge_uses_iced_dynamic_thickness_and_priority() {
|
||||
let bounds = Rectangle::new(Point::ORIGIN, Size::new(500.0, 250.0));
|
||||
assert_eq!(outer_edge(Point::new(9.9, 1.0), bounds), Some(DockEdge::Left));
|
||||
assert_eq!(outer_edge(Point::new(10.0, 125.0), bounds), None);
|
||||
assert_eq!(outer_edge(Point::new(250.0, 9.9), bounds), Some(DockEdge::Top));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Content-aware sizing policy and constraint solver for the Iced dock tree.
|
||||
//!
|
||||
//! **Purpose:** Keeps utility panels legible while assigning adaptable space to the canvas and
|
||||
//! other flexible content. **Logic & Workflow:** Leaf policies are aggregated through the
|
||||
//! `PaneGrid` tree, legal pixel intervals are converted to split ratios, and ratio updates are
|
||||
//! collected before mutating Iced state. **Side Effects / Dependencies:** Public rebalance
|
||||
//! functions mutate only `pane_grid::State` split ratios and perform no I/O.
|
||||
|
||||
use crate::dock::state::PaneType;
|
||||
use iced::widget::pane_grid::{Axis, Node, Split, State};
|
||||
use iced::Size;
|
||||
|
||||
/// Space between adjacent dock panes, matching `dock::view`.
|
||||
pub const PANE_SPACING: f32 = 4.0;
|
||||
|
||||
/// Minimum, preferred, and maximum extent for one axis of a panel or subtree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PaneAxisPolicy {
|
||||
/// Smallest usable logical-pixel extent.
|
||||
pub min: f32,
|
||||
/// Comfortable logical-pixel extent before surplus growth.
|
||||
pub preferred: f32,
|
||||
/// Largest useful extent, or `f32::INFINITY` for flexible content.
|
||||
pub max: f32,
|
||||
/// Relative priority for receiving constrained and surplus space.
|
||||
pub grow_priority: u16,
|
||||
}
|
||||
|
||||
/// Independent horizontal and vertical sizing policies for one panel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PaneSizePolicy {
|
||||
/// Horizontal sizing policy.
|
||||
pub width: PaneAxisPolicy,
|
||||
/// Vertical sizing policy.
|
||||
pub height: PaneAxisPolicy,
|
||||
}
|
||||
|
||||
/// Reason for solving the tree, used to preserve the correct existing dimensions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RebalanceMode {
|
||||
/// Computes content-aware ratios for the built-in layout once its viewport is known.
|
||||
InitialDefault,
|
||||
/// Corrects new Iced 50/50 splits while retaining other valid ratios.
|
||||
StructureChanged,
|
||||
/// Preserves lower-growth utility pixels and sends viewport deltas to flexible content.
|
||||
WindowResize,
|
||||
/// Preserves existing ratios except where pixel constraints require a clamp.
|
||||
UserResize,
|
||||
}
|
||||
|
||||
/// Returns the content policy for a dock panel.
|
||||
///
|
||||
/// **Arguments:** `panel` identifies the leaf being measured. **Returns:** Width and height
|
||||
/// constraints in logical pixels. **Logic & Workflow:** Canvas is unbounded and receives the
|
||||
/// highest priority; Color Palette is deliberately bounded and low-growth; editors remain
|
||||
/// flexible; list-like tools use compact but readable defaults. **Side Effects / Dependencies:**
|
||||
/// None.
|
||||
pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
|
||||
let axis = |min, preferred, max, grow_priority| PaneAxisPolicy {
|
||||
min,
|
||||
preferred,
|
||||
max,
|
||||
grow_priority,
|
||||
};
|
||||
match panel {
|
||||
PaneType::Canvas => PaneSizePolicy {
|
||||
width: axis(320.0, 800.0, f32::INFINITY, 100),
|
||||
height: axis(240.0, 600.0, f32::INFINITY, 100),
|
||||
},
|
||||
PaneType::ColorPicker => PaneSizePolicy {
|
||||
width: axis(180.0, 280.0, 380.0, 1),
|
||||
height: axis(180.0, 330.0, 380.0, 1),
|
||||
},
|
||||
PaneType::Script | PaneType::AiChat | PaneType::AiScript => PaneSizePolicy {
|
||||
width: axis(220.0, 360.0, f32::INFINITY, 60),
|
||||
height: axis(180.0, 420.0, f32::INFINITY, 60),
|
||||
},
|
||||
PaneType::Layers | PaneType::History | PaneType::LayerDetails => PaneSizePolicy {
|
||||
width: axis(160.0, 300.0, f32::INFINITY, 35),
|
||||
height: axis(120.0, 300.0, f32::INFINITY, 35),
|
||||
},
|
||||
PaneType::Brushes | PaneType::Filters => PaneSizePolicy {
|
||||
width: axis(160.0, 300.0, f32::INFINITY, 30),
|
||||
height: axis(120.0, 280.0, f32::INFINITY, 30),
|
||||
},
|
||||
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
|
||||
width: axis(160.0, 280.0, f32::INFINITY, 25),
|
||||
height: axis(120.0, 280.0, f32::INFINITY, 25),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregates one axis of a subtree into a single policy.
|
||||
///
|
||||
/// **Arguments:** `node` is the subtree root, `state` resolves runtime pane IDs to stable panel
|
||||
/// types, and `axis` selects width or height. **Returns:** Combined minimum, preferred, maximum,
|
||||
/// and highest growth priority. **Logic & Workflow:** Children split on the queried axis add their
|
||||
/// extents and spacing; children split across it share an extent, so minima/preferences use the
|
||||
/// larger child and maxima use the tighter child. **Side Effects / Dependencies:** None.
|
||||
pub fn aggregate_subtree_policy(
|
||||
node: &Node,
|
||||
state: &State<PaneType>,
|
||||
axis: Axis,
|
||||
) -> PaneAxisPolicy {
|
||||
match node {
|
||||
Node::Pane(pane) => state
|
||||
.get(*pane)
|
||||
.map(|panel| policy_axis(panel_size_policy(*panel), axis))
|
||||
.unwrap_or(PaneAxisPolicy {
|
||||
min: 0.0,
|
||||
preferred: 0.0,
|
||||
max: f32::INFINITY,
|
||||
grow_priority: 0,
|
||||
}),
|
||||
Node::Split {
|
||||
axis: split_axis,
|
||||
a,
|
||||
b,
|
||||
..
|
||||
} => {
|
||||
let a = aggregate_subtree_policy(a, state, axis);
|
||||
let b = aggregate_subtree_policy(b, state, axis);
|
||||
if same_axis(*split_axis, axis) {
|
||||
PaneAxisPolicy {
|
||||
min: a.min + PANE_SPACING + b.min,
|
||||
preferred: a.preferred + PANE_SPACING + b.preferred,
|
||||
max: add_max(a.max, b.max, PANE_SPACING),
|
||||
grow_priority: a.grow_priority.max(b.grow_priority),
|
||||
}
|
||||
} else {
|
||||
PaneAxisPolicy {
|
||||
min: a.min.max(b.min),
|
||||
preferred: a.preferred.max(b.preferred),
|
||||
max: a.max.min(b.max),
|
||||
grow_priority: a.grow_priority.max(b.grow_priority),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebalances every split for a known grid size.
|
||||
///
|
||||
/// **Arguments:** `state` is the live PaneGrid state, `grid_size` excludes shell/sidebar/rail,
|
||||
/// `previous_grid_size` is required for pixel-preserving window resize, and `mode` selects the
|
||||
/// preservation strategy. **Returns:** Nothing. **Logic & Workflow:** The immutable tree is
|
||||
/// traversed first to collect legal ratios, then updates are applied to avoid aliasing state while
|
||||
/// policies are calculated. **Side Effects / Dependencies:** Mutates split ratios in `state`.
|
||||
pub fn rebalance_state(
|
||||
state: &mut State<PaneType>,
|
||||
grid_size: Size,
|
||||
previous_grid_size: Option<Size>,
|
||||
mode: RebalanceMode,
|
||||
) {
|
||||
if grid_size.width <= 0.0 || grid_size.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
collect_updates(
|
||||
state.layout(),
|
||||
state,
|
||||
grid_size,
|
||||
previous_grid_size,
|
||||
mode,
|
||||
&mut updates,
|
||||
);
|
||||
for (split, ratio) in updates {
|
||||
state.resize(split, ratio);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps one user-requested split ratio against subtree pixel policies.
|
||||
///
|
||||
/// **Arguments:** `state` supplies the split tree and panel types, `split` identifies the divider,
|
||||
/// `requested_ratio` is the pointer-derived ratio, and `grid_size` is the actual PaneGrid size.
|
||||
/// **Returns:** The nearest finite legal ratio, bounded only by content constraints and the
|
||||
/// persistence safety range. **Side Effects / Dependencies:** None.
|
||||
pub fn constrain_user_resize_ratio(
|
||||
state: &State<PaneType>,
|
||||
split: Split,
|
||||
requested_ratio: f32,
|
||||
grid_size: Size,
|
||||
) -> f32 {
|
||||
let Some((node, extent)) = find_split(state.layout(), split, grid_size) else {
|
||||
return sanitize_ratio(requested_ratio);
|
||||
};
|
||||
let Node::Split { axis, a, b, .. } = node else {
|
||||
return sanitize_ratio(requested_ratio);
|
||||
};
|
||||
clamp_ratio(
|
||||
requested_ratio,
|
||||
extent_for_axis(extent, *axis),
|
||||
aggregate_subtree_policy(a, state, *axis),
|
||||
aggregate_subtree_policy(b, state, *axis),
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively collects split updates using original ratios for all geometry calculations.
|
||||
fn collect_updates(
|
||||
node: &Node,
|
||||
state: &State<PaneType>,
|
||||
extent: Size,
|
||||
previous_extent: Option<Size>,
|
||||
mode: RebalanceMode,
|
||||
updates: &mut Vec<(Split, f32)>,
|
||||
) {
|
||||
let Node::Split {
|
||||
id,
|
||||
axis,
|
||||
ratio,
|
||||
a,
|
||||
b,
|
||||
} = node
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let total = extent_for_axis(extent, *axis);
|
||||
let a_policy = aggregate_subtree_policy(a, state, *axis);
|
||||
let b_policy = aggregate_subtree_policy(b, state, *axis);
|
||||
let policy_ratio = allocation_ratio(total, a_policy, b_policy);
|
||||
let current = sanitize_ratio(*ratio);
|
||||
let requested = match mode {
|
||||
RebalanceMode::InitialDefault => policy_ratio,
|
||||
RebalanceMode::StructureChanged if (current - 0.5).abs() <= 0.0001 => policy_ratio,
|
||||
RebalanceMode::StructureChanged | RebalanceMode::UserResize => current,
|
||||
RebalanceMode::WindowResize => {
|
||||
let old_total = previous_extent
|
||||
.map(|old| extent_for_axis(old, *axis))
|
||||
.unwrap_or(total);
|
||||
window_resize_ratio(total, old_total, current, a_policy, b_policy)
|
||||
}
|
||||
};
|
||||
let next = clamp_ratio(requested, total, a_policy, b_policy);
|
||||
if (next - *ratio).abs() > 0.0001 {
|
||||
updates.push((*id, next));
|
||||
}
|
||||
|
||||
let (next_a, next_b) = split_extent(extent, *axis, next);
|
||||
let (old_a, old_b) = previous_extent
|
||||
.map(|old| split_extent(old, *axis, current))
|
||||
.map_or((None, None), |(a, b)| (Some(a), Some(b)));
|
||||
collect_updates(a, state, next_a, old_a, mode, updates);
|
||||
collect_updates(b, state, next_b, old_b, mode, updates);
|
||||
}
|
||||
|
||||
/// Computes an allocation ratio from minima, preferred extents, maxima, and growth priority.
|
||||
fn allocation_ratio(total: f32, a: PaneAxisPolicy, b: PaneAxisPolicy) -> f32 {
|
||||
let available = (total - PANE_SPACING).max(1.0);
|
||||
let mut sizes = [a.min, b.min];
|
||||
let policies = [a, b];
|
||||
let mut remaining = (available - sizes[0] - sizes[1]).max(0.0);
|
||||
let preferences_fit = a.preferred + b.preferred <= available;
|
||||
let mut order = [0usize, 1usize];
|
||||
order.sort_by_key(|index| std::cmp::Reverse(policies[*index].grow_priority));
|
||||
|
||||
if preferences_fit {
|
||||
for index in 0..2 {
|
||||
let target = policies[index].preferred.min(policies[index].max);
|
||||
let addition = (target - sizes[index]).max(0.0).min(remaining);
|
||||
sizes[index] += addition;
|
||||
remaining -= addition;
|
||||
}
|
||||
} else {
|
||||
for index in order {
|
||||
let target = policies[index].preferred.min(policies[index].max);
|
||||
let addition = (target - sizes[index]).max(0.0).min(remaining);
|
||||
sizes[index] += addition;
|
||||
remaining -= addition;
|
||||
}
|
||||
}
|
||||
|
||||
for index in order {
|
||||
let capacity = (policies[index].max - sizes[index]).max(0.0);
|
||||
let addition = capacity.min(remaining);
|
||||
sizes[index] += addition;
|
||||
remaining -= addition;
|
||||
}
|
||||
if remaining > 0.0 {
|
||||
sizes[order[0]] += remaining;
|
||||
}
|
||||
(sizes[0] + PANE_SPACING / 2.0) / total.max(1.0)
|
||||
}
|
||||
|
||||
/// Preserves the lower-growth child's previous pixels while assigning the delta to its sibling.
|
||||
fn window_resize_ratio(
|
||||
total: f32,
|
||||
old_total: f32,
|
||||
current: f32,
|
||||
a: PaneAxisPolicy,
|
||||
b: PaneAxisPolicy,
|
||||
) -> f32 {
|
||||
let total = total.max(1.0);
|
||||
let old_total = old_total.max(1.0);
|
||||
let old_a = (old_total * current - PANE_SPACING / 2.0).max(0.0);
|
||||
let old_b = (old_total * (1.0 - current) - PANE_SPACING / 2.0).max(0.0);
|
||||
if a.grow_priority > b.grow_priority {
|
||||
1.0 - (old_b + PANE_SPACING / 2.0) / total
|
||||
} else if b.grow_priority > a.grow_priority {
|
||||
(old_a + PANE_SPACING / 2.0) / total
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts child constraints to a legal split interval and clamps a proposed ratio.
|
||||
fn clamp_ratio(
|
||||
requested: f32,
|
||||
total: f32,
|
||||
a: PaneAxisPolicy,
|
||||
b: PaneAxisPolicy,
|
||||
) -> f32 {
|
||||
let total = total.max(1.0);
|
||||
let half_spacing = PANE_SPACING / 2.0;
|
||||
let mut low = ((a.min + half_spacing) / total)
|
||||
.max(1.0 - (b.max + half_spacing) / total);
|
||||
let mut high = (1.0 - (b.min + half_spacing) / total)
|
||||
.min((a.max + half_spacing) / total);
|
||||
low = low.clamp(0.01, 0.99);
|
||||
high = high.clamp(0.01, 0.99);
|
||||
if low > high {
|
||||
let minimum_total = a.min + b.min;
|
||||
let fallback = if minimum_total > 0.0 {
|
||||
a.min / minimum_total
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
return sanitize_ratio(fallback);
|
||||
}
|
||||
sanitize_ratio(requested).clamp(low, high)
|
||||
}
|
||||
|
||||
/// Finds a split and its actual region without mutating the tree.
|
||||
fn find_split(node: &Node, split: Split, extent: Size) -> Option<(&Node, Size)> {
|
||||
match node {
|
||||
Node::Pane(_) => None,
|
||||
Node::Split {
|
||||
id,
|
||||
axis,
|
||||
ratio,
|
||||
a,
|
||||
b,
|
||||
} => {
|
||||
if *id == split {
|
||||
return Some((node, extent));
|
||||
}
|
||||
let (a_extent, b_extent) = split_extent(extent, *axis, sanitize_ratio(*ratio));
|
||||
find_split(a, split, a_extent).or_else(|| find_split(b, split, b_extent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a logical region using Iced's spacing semantics.
|
||||
fn split_extent(extent: Size, axis: Axis, ratio: f32) -> (Size, Size) {
|
||||
match axis {
|
||||
Axis::Horizontal => {
|
||||
let a = (extent.height * ratio - PANE_SPACING / 2.0)
|
||||
.max(0.0)
|
||||
.round();
|
||||
(
|
||||
Size::new(extent.width, a),
|
||||
Size::new(extent.width, (extent.height - a - PANE_SPACING).max(0.0)),
|
||||
)
|
||||
}
|
||||
Axis::Vertical => {
|
||||
let a = (extent.width * ratio - PANE_SPACING / 2.0)
|
||||
.max(0.0)
|
||||
.round();
|
||||
(
|
||||
Size::new(a, extent.height),
|
||||
Size::new((extent.width - a - PANE_SPACING).max(0.0), extent.height),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects one axis from a complete panel policy.
|
||||
fn policy_axis(policy: PaneSizePolicy, axis: Axis) -> PaneAxisPolicy {
|
||||
match axis {
|
||||
Axis::Horizontal => policy.height,
|
||||
Axis::Vertical => policy.width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the extent corresponding to an Iced split axis.
|
||||
fn extent_for_axis(size: Size, axis: Axis) -> f32 {
|
||||
match axis {
|
||||
Axis::Horizontal => size.height,
|
||||
Axis::Vertical => size.width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares Iced axes without requiring assumptions about their debug representation.
|
||||
fn same_axis(a: Axis, b: Axis) -> bool {
|
||||
matches!((a, b), (Axis::Horizontal, Axis::Horizontal) | (Axis::Vertical, Axis::Vertical))
|
||||
}
|
||||
|
||||
/// Adds maximum extents while preserving an unbounded result.
|
||||
fn add_max(a: f32, b: f32, spacing: f32) -> f32 {
|
||||
if a.is_infinite() || b.is_infinite() {
|
||||
f32::INFINITY
|
||||
} else {
|
||||
a + spacing + b
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts malformed ratios to a safe value and applies the persistence bounds.
|
||||
fn sanitize_ratio(ratio: f32) -> f32 {
|
||||
if ratio.is_finite() {
|
||||
ratio.clamp(0.01, 0.99)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::widget::pane_grid::{Configuration, State};
|
||||
|
||||
/// Creates a simple two-pane state and returns its only split.
|
||||
fn pair(a: PaneType, b: PaneType, axis: Axis) -> (State<PaneType>, Split) {
|
||||
let state = State::with_configuration(Configuration::Split {
|
||||
axis,
|
||||
ratio: 0.5,
|
||||
a: Box::new(Configuration::Pane(a)),
|
||||
b: Box::new(Configuration::Pane(b)),
|
||||
});
|
||||
let split = *state.layout().splits().next().unwrap();
|
||||
(state, split)
|
||||
}
|
||||
|
||||
/// Verifies leaf policy values encode the required growth hierarchy and Color bounds.
|
||||
#[test]
|
||||
fn policy_values_prioritize_canvas_and_bound_color_picker() {
|
||||
let canvas = panel_size_policy(PaneType::Canvas);
|
||||
let color = panel_size_policy(PaneType::ColorPicker);
|
||||
assert_eq!((canvas.width.min, canvas.height.min), (320.0, 240.0));
|
||||
assert!(canvas.width.max.is_infinite());
|
||||
assert_eq!((color.width.preferred, color.height.preferred), (280.0, 330.0));
|
||||
assert_eq!((color.width.max, color.height.max), (380.0, 380.0));
|
||||
assert!(canvas.width.grow_priority > color.width.grow_priority);
|
||||
}
|
||||
|
||||
/// Verifies additive and shared subtree aggregation includes spacing and child constraints.
|
||||
#[test]
|
||||
fn subtree_constraints_follow_split_axis() {
|
||||
let (state, _) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
|
||||
let width = aggregate_subtree_policy(state.layout(), &state, Axis::Vertical);
|
||||
let height = aggregate_subtree_policy(state.layout(), &state, Axis::Horizontal);
|
||||
assert_eq!(width.min, 320.0 + PANE_SPACING + 180.0);
|
||||
assert_eq!(height.min, 240.0);
|
||||
assert_eq!(height.max, 380.0);
|
||||
}
|
||||
|
||||
/// Ensures both requested audit viewports can satisfy every leaf minimum in the default tree.
|
||||
#[test]
|
||||
fn default_and_compact_viewports_have_feasible_bounds() {
|
||||
for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] {
|
||||
let mut dock = crate::dock::state::DockState::new();
|
||||
rebalance_state(&mut dock.pane_grid, size, None, RebalanceMode::InitialDefault);
|
||||
let regions = dock.pane_grid.layout().pane_regions(PANE_SPACING, size);
|
||||
for (pane, panel) in dock.pane_grid.iter() {
|
||||
let region = regions[pane];
|
||||
let policy = panel_size_policy(*panel);
|
||||
assert!(region.width + 0.1 >= policy.width.min, "{panel:?} width");
|
||||
assert!(region.height + 0.1 >= policy.height.min, "{panel:?} height");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures a bounded Color Palette does not absorb vertical surplus before a list sibling.
|
||||
#[test]
|
||||
fn color_picker_does_not_receive_vertical_surplus_with_list_sibling() {
|
||||
let (mut state, _) = pair(PaneType::ColorPicker, PaneType::Layers, Axis::Horizontal);
|
||||
let size = Size::new(300.0, 800.0);
|
||||
rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault);
|
||||
let regions = state.layout().pane_regions(PANE_SPACING, size);
|
||||
let color = state.iter().find(|(_, panel)| **panel == PaneType::ColorPicker).unwrap().0;
|
||||
let layers = state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0;
|
||||
assert!(regions[color].height <= 380.1);
|
||||
assert!(regions[layers].height > regions[color].height);
|
||||
}
|
||||
|
||||
/// Ensures a newly created Iced 50/50 split is corrected using panel content policy.
|
||||
#[test]
|
||||
fn structure_rebalance_avoids_oversized_color_picker() {
|
||||
let (mut state, split) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
|
||||
rebalance_state(
|
||||
&mut state,
|
||||
Size::new(1000.0, 700.0),
|
||||
None,
|
||||
RebalanceMode::StructureChanged,
|
||||
);
|
||||
let ratio = state.layout().split_regions(PANE_SPACING, Size::new(1000.0, 700.0))[&split].2;
|
||||
assert!(ratio > 0.6);
|
||||
}
|
||||
|
||||
/// Ensures valid user ratios survive while requests outside pixel constraints are clamped.
|
||||
#[test]
|
||||
fn user_resize_preserves_valid_and_clamps_invalid_ratios() {
|
||||
let (state, split) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
|
||||
let size = Size::new(1000.0, 700.0);
|
||||
assert_eq!(constrain_user_resize_ratio(&state, split, 0.7, size), 0.7);
|
||||
let clamped = constrain_user_resize_ratio(&state, split, 0.95, size);
|
||||
assert!(clamped < 0.83 && clamped > 0.6);
|
||||
}
|
||||
|
||||
/// Ensures added window width is assigned to Canvas while utility pixels remain stable.
|
||||
#[test]
|
||||
fn window_growth_gives_surplus_to_canvas() {
|
||||
let (mut state, _) = pair(PaneType::Canvas, PaneType::Layers, Axis::Vertical);
|
||||
let old = Size::new(900.0, 700.0);
|
||||
rebalance_state(&mut state, old, None, RebalanceMode::InitialDefault);
|
||||
let old_regions = state.layout().pane_regions(PANE_SPACING, old);
|
||||
let layers = *state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0;
|
||||
let old_utility = old_regions[&layers].width;
|
||||
let new = Size::new(1200.0, 700.0);
|
||||
rebalance_state(&mut state, new, Some(old), RebalanceMode::WindowResize);
|
||||
let new_regions = state.layout().pane_regions(PANE_SPACING, new);
|
||||
assert!((new_regions[&layers].width - old_utility).abs() < 0.2);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
//! Dock state — manages pane grid layout and panel assignments.
|
||||
//!
|
||||
//! Provides a resizable, draggable pane grid where each pane
|
||||
//! can host a different panel (layers, history, brushes, etc.).
|
||||
//! The default layout mirrors the egui GUI arrangement with Canvas centered:
|
||||
//!
|
||||
//! ```text
|
||||
//! +--------+------------+-----------+---------+
|
||||
//! | | | | Layers |
|
||||
//! | Brushes| | |---------|
|
||||
//! |--------| Canvas | ColorBox |Properties|
|
||||
//! | Filters| |-----------|---------|
|
||||
//! | | | History | |
|
||||
//! +--------+------------+-----------+---------+
|
||||
//! ```
|
||||
|
||||
use iced::widget::pane_grid::{Configuration, Pane, State};
|
||||
///! Dock state — manages pane grid layout and panel assignments.
|
||||
///!
|
||||
///! Provides a resizable, draggable pane grid where each pane
|
||||
///! can host a different panel (layers, history, brushes, etc.).
|
||||
///! The default layout mirrors the egui GUI arrangement with Canvas centered:
|
||||
///!
|
||||
///! ```text
|
||||
///! +--------+------------+-----------+---------+
|
||||
///! | | | | Layers |
|
||||
///! | Brushes| | |---------|
|
||||
///! |--------| Canvas | ColorBox |Properties|
|
||||
///! | Filters| |-----------|---------|
|
||||
///! | | | History | |
|
||||
///! +--------+------------+-----------+---------+
|
||||
///! ```
|
||||
use crate::dock::floating::FloatingDragState;
|
||||
use crate::dock::manager::{
|
||||
DockDragState, DockDropPolicy, DockDropZone, DockEdge, DockHeaderDragState, DockPlacement,
|
||||
};
|
||||
use crate::dock::persistence::{
|
||||
PersistedAutoHiddenPanel, PersistedDockLayout, PersistedFloatingPanel,
|
||||
};
|
||||
use crate::dock::preview::ApprovedTarget;
|
||||
use iced::widget::pane_grid::{Configuration, Pane, State, Target};
|
||||
use iced::Size;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Identifies which panel content a pane displays.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum PaneType {
|
||||
/// The main canvas viewport.
|
||||
@@ -49,6 +60,23 @@ pub enum PaneType {
|
||||
}
|
||||
|
||||
impl PaneType {
|
||||
/// All panel payloads in stable declaration order.
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::Canvas,
|
||||
Self::Layers,
|
||||
Self::History,
|
||||
Self::Brushes,
|
||||
Self::Filters,
|
||||
Self::ColorPicker,
|
||||
Self::Properties,
|
||||
Self::Script,
|
||||
Self::AiChat,
|
||||
Self::LayerDetails,
|
||||
Self::Geometry,
|
||||
Self::ToolSettings,
|
||||
Self::AiScript,
|
||||
];
|
||||
|
||||
/// Display name for the pane type.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -76,29 +104,45 @@ impl PaneType {
|
||||
/// command-line capture and dock UI naming cannot drift independently.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
pub fn from_label(label: &str) -> Option<Self> {
|
||||
[
|
||||
Self::Canvas,
|
||||
Self::Layers,
|
||||
Self::History,
|
||||
Self::Brushes,
|
||||
Self::Filters,
|
||||
Self::ColorPicker,
|
||||
Self::Properties,
|
||||
Self::Script,
|
||||
Self::AiChat,
|
||||
Self::LayerDetails,
|
||||
Self::Geometry,
|
||||
Self::ToolSettings,
|
||||
Self::AiScript,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|pane| pane.label() == label)
|
||||
Self::ALL.into_iter().find(|pane| pane.label() == label)
|
||||
}
|
||||
|
||||
/// Compact readable label used on narrow auto-hide rails.
|
||||
pub fn rail_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ColorPicker => "Color",
|
||||
Self::LayerDetails => "Details",
|
||||
Self::ToolSettings => "Tools",
|
||||
Self::AiScript => "AI Script",
|
||||
_ => self.label(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear fallback glyph used where an SVG is not available inline.
|
||||
pub fn icon_glyph(self) -> &'static str {
|
||||
match self {
|
||||
Self::Layers => "L",
|
||||
Self::History => "H",
|
||||
Self::Brushes => "B",
|
||||
Self::Filters => "F",
|
||||
Self::ColorPicker => "C",
|
||||
Self::Properties => "P",
|
||||
Self::Script => "S",
|
||||
Self::AiChat | Self::AiScript => "AI",
|
||||
Self::Geometry => "G",
|
||||
Self::LayerDetails => "D",
|
||||
Self::ToolSettings => "T",
|
||||
Self::Canvas => "□",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DockState, PaneType};
|
||||
use crate::dock::floating::FloatingDragState;
|
||||
use crate::dock::manager::DockDragState;
|
||||
use crate::dock::manager::DockEdge;
|
||||
use iced::widget::pane_grid::{Edge, Region, Target};
|
||||
|
||||
#[test]
|
||||
@@ -123,18 +167,82 @@ mod tests {
|
||||
let mut dock = DockState::new();
|
||||
let source = pane(&dock, PaneType::Brushes);
|
||||
let target = pane(&dock, PaneType::Layers);
|
||||
dock.pane_grid.drop(source, Target::Pane(target, region));
|
||||
let accepted = dock.apply_drop(source, Target::Pane(target, region));
|
||||
assert_eq!(accepted, !matches!(region, Region::Center));
|
||||
assert_canvas_invariant(&dock);
|
||||
}
|
||||
|
||||
for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] {
|
||||
let mut dock = DockState::new();
|
||||
let source = pane(&dock, PaneType::Brushes);
|
||||
dock.pane_grid.drop(source, Target::Edge(edge));
|
||||
assert!(dock.apply_drop(source, Target::Edge(edge)));
|
||||
assert_canvas_invariant(&dock);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_drop_is_rejected_and_auto_hide_restores_uniquely() {
|
||||
let mut dock = DockState::new();
|
||||
let canvas = pane(&dock, PaneType::Canvas);
|
||||
assert!(!dock.apply_drop(canvas, Target::Edge(Edge::Right)));
|
||||
let layers = pane(&dock, PaneType::Layers);
|
||||
assert!(dock.auto_hide_pane(layers, None));
|
||||
assert!(!dock.is_pane_open(PaneType::Layers));
|
||||
assert!(dock.restore_auto_hidden(PaneType::Layers));
|
||||
assert!(dock.invariants_hold());
|
||||
assert_eq!(
|
||||
dock.pane_grid
|
||||
.iter()
|
||||
.filter(|(_, panel)| **panel == PaneType::Layers)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_panel_redocks_at_preview_target_and_can_minimize() {
|
||||
let mut dock = DockState::new();
|
||||
let brushes = pane(&dock, PaneType::Brushes);
|
||||
assert!(dock.float_pane(brushes, (1280.0, 800.0)));
|
||||
let layers = pane(&dock, PaneType::Layers);
|
||||
assert!(dock.redock_floating_at(
|
||||
PaneType::Brushes,
|
||||
Target::Pane(layers, Region::Edge(Edge::Left)),
|
||||
));
|
||||
assert!(dock.is_pane_open(PaneType::Brushes));
|
||||
assert!(dock.invariants_hold());
|
||||
|
||||
let properties = pane(&dock, PaneType::Properties);
|
||||
assert!(dock.float_pane(properties, (1280.0, 800.0)));
|
||||
assert!(dock.auto_hide_floating(PaneType::Properties, DockEdge::Right));
|
||||
assert!(dock.auto_hidden[1].contains(&PaneType::Properties));
|
||||
assert!(dock.invariants_hold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_release_clears_click_only_and_floating_drag_state() {
|
||||
let mut dock = DockState::new();
|
||||
let layers = pane(&dock, PaneType::Layers);
|
||||
dock.drag = Some(DockDragState {
|
||||
source_pane: layers,
|
||||
source_type: PaneType::Layers,
|
||||
pointer: Some(iced::Point::new(120.0, 80.0)),
|
||||
candidate_target: None,
|
||||
accepted_preview: None,
|
||||
});
|
||||
dock.floating_drag = Some(FloatingDragState {
|
||||
panel: PaneType::Properties,
|
||||
pointer_offset: iced::Point::new(10.0, 8.0),
|
||||
});
|
||||
|
||||
dock.cancel_transient_drags();
|
||||
|
||||
assert!(dock.drag.is_none());
|
||||
assert!(dock.floating_drag.is_none());
|
||||
assert!(dock.floating_target.is_none());
|
||||
assert!(dock.invariants_hold());
|
||||
}
|
||||
|
||||
/// Finds the pane identifier containing `pane_type` in a test dock.
|
||||
fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane {
|
||||
dock.pane_grid
|
||||
@@ -165,6 +273,26 @@ pub struct DockState {
|
||||
pub root_pane: Pane,
|
||||
/// Currently focused pane.
|
||||
pub focused_pane: Option<Pane>,
|
||||
/// Active PaneGrid drag and approved candidate.
|
||||
pub drag: Option<DockDragState>,
|
||||
/// Visible-title press that becomes a custom dock drag after the pointer deadband.
|
||||
pub header_drag: Option<DockHeaderDragState>,
|
||||
/// Auto-hidden panels ordered left, right, top, bottom.
|
||||
pub auto_hidden: [Vec<PaneType>; 4],
|
||||
/// Temporary auto-hide overlay currently displayed.
|
||||
pub active_auto_hide: Option<PaneType>,
|
||||
/// Stable floating placements in back-to-front order.
|
||||
pub floating: Vec<PersistedFloatingPanel>,
|
||||
/// Active floating header drag.
|
||||
pub floating_drag: Option<FloatingDragState>,
|
||||
/// Approved edge target currently previewed by a floating-panel drag.
|
||||
pub floating_target: Option<ApprovedTarget>,
|
||||
/// Last measured PaneGrid size after fixed shell, sidebar, and auto-hide rail subtraction.
|
||||
pub last_grid_size: Option<Size>,
|
||||
/// Whether an interactive split change must be persisted on pointer release.
|
||||
pub resize_persistence_dirty: bool,
|
||||
/// Whether the first measured viewport should replace built-in placeholder ratios.
|
||||
apply_initial_defaults: bool,
|
||||
}
|
||||
|
||||
impl DockState {
|
||||
@@ -251,15 +379,67 @@ impl DockState {
|
||||
pane_grid,
|
||||
root_pane,
|
||||
focused_pane: Some(root_pane),
|
||||
drag: None,
|
||||
header_drag: None,
|
||||
auto_hidden: std::array::from_fn(|_| Vec::new()),
|
||||
active_auto_hide: None,
|
||||
floating: Vec::new(),
|
||||
floating_drag: None,
|
||||
floating_target: None,
|
||||
last_grid_size: None,
|
||||
resize_persistence_dirty: false,
|
||||
apply_initial_defaults: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores stable topology and placements while ignoring invalid detachments.
|
||||
pub fn from_persisted(layout: &PersistedDockLayout) -> Self {
|
||||
let pane_grid = State::with_configuration(
|
||||
crate::dock::persistence::sanitized_configuration(layout.tree.as_ref()),
|
||||
);
|
||||
let root_pane = pane_grid
|
||||
.iter()
|
||||
.find(|(_, t)| **t == PaneType::Canvas)
|
||||
.map(|(p, _)| *p)
|
||||
.expect("sanitized dock layout always has Canvas");
|
||||
let mut dock = Self {
|
||||
pane_grid,
|
||||
root_pane,
|
||||
focused_pane: Some(root_pane),
|
||||
drag: None,
|
||||
header_drag: None,
|
||||
auto_hidden: std::array::from_fn(|_| Vec::new()),
|
||||
active_auto_hide: None,
|
||||
floating: Vec::new(),
|
||||
floating_drag: None,
|
||||
floating_target: None,
|
||||
last_grid_size: None,
|
||||
resize_persistence_dirty: false,
|
||||
apply_initial_defaults: false,
|
||||
};
|
||||
for item in &layout.auto_hidden {
|
||||
if item.panel != PaneType::Canvas && !dock.contains_anywhere(item.panel) {
|
||||
dock.auto_hidden[edge_index(item.edge)].push(item.panel);
|
||||
}
|
||||
}
|
||||
for item in &layout.floating {
|
||||
if item.panel != PaneType::Canvas && !dock.contains_anywhere(item.panel) {
|
||||
dock.floating.push(item.clone());
|
||||
}
|
||||
}
|
||||
if let Some(panel) = layout.focused {
|
||||
dock.focused_pane = dock.pane_for_type(panel).or(Some(root_pane));
|
||||
}
|
||||
dock
|
||||
}
|
||||
|
||||
/// Split a pane horizontally with a new panel type.
|
||||
#[allow(dead_code)]
|
||||
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
|
||||
let (new_pane, _) =
|
||||
self.pane_grid
|
||||
.split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
|
||||
self.rebalance_structure();
|
||||
Some(new_pane)
|
||||
}
|
||||
|
||||
@@ -269,12 +449,20 @@ impl DockState {
|
||||
let (new_pane, _) =
|
||||
self.pane_grid
|
||||
.split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
|
||||
self.rebalance_structure();
|
||||
Some(new_pane)
|
||||
}
|
||||
|
||||
/// Close a pane, redistributing its content to neighbors.
|
||||
pub fn close_pane(&mut self, pane: Pane) -> bool {
|
||||
self.pane_grid.close(pane).is_some()
|
||||
if self.pane_type(pane) == Some(PaneType::Canvas) {
|
||||
return false;
|
||||
}
|
||||
let changed = self.pane_grid.close(pane).is_some();
|
||||
if changed {
|
||||
self.rebalance_structure();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Toggle maximize on a pane.
|
||||
@@ -291,19 +479,340 @@ impl DockState {
|
||||
self.focused_pane = Some(pane);
|
||||
}
|
||||
|
||||
/// Clears custom drag state after a global pointer release.
|
||||
///
|
||||
/// **Purpose:** Handles click-only PaneGrid picks that never emit `Dropped` or `Canceled`
|
||||
/// because Iced's drag deadband was not crossed. **Side Effects:** Removes only transient drag
|
||||
/// and preview state; dock topology, floating placements, and persisted layout are unchanged.
|
||||
pub fn cancel_transient_drags(&mut self) {
|
||||
self.drag = None;
|
||||
self.header_drag = None;
|
||||
self.floating_drag = None;
|
||||
self.floating_target = None;
|
||||
}
|
||||
|
||||
/// Get the pane type at a given pane ID.
|
||||
pub fn pane_type(&self, pane: Pane) -> Option<PaneType> {
|
||||
self.pane_grid.get(pane).copied()
|
||||
}
|
||||
|
||||
/// Returns the runtime ID currently carrying a stable panel payload.
|
||||
pub fn pane_for_type(&self, panel: PaneType) -> Option<Pane> {
|
||||
self.pane_grid
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == panel)
|
||||
.map(|(pane, _)| *pane)
|
||||
}
|
||||
|
||||
/// Applies one raw PaneGrid target only after policy and invariant validation.
|
||||
pub fn apply_drop(&mut self, pane: Pane, target: Target) -> bool {
|
||||
let Some(source) = self.pane_type(pane) else {
|
||||
return false;
|
||||
};
|
||||
if !self.invariants_hold()
|
||||
|| !DockDropPolicy::accepts(source, DockDropZone::from_target(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if matches!(target, Target::Pane(target, _) if target == pane || self.pane_type(target).is_none())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let previous = self.pane_grid.clone();
|
||||
self.pane_grid.drop(pane, target);
|
||||
if !self.invariants_hold() {
|
||||
self.pane_grid = previous;
|
||||
return false;
|
||||
}
|
||||
self.focused_pane = self.pane_for_type(source);
|
||||
self.rebalance_structure();
|
||||
true
|
||||
}
|
||||
|
||||
/// Verifies exactly one Canvas and one instance at most of every tool payload.
|
||||
pub fn invariants_hold(&self) -> bool {
|
||||
let mut seen = HashSet::new();
|
||||
self.pane_grid.iter().all(|(_, panel)| seen.insert(*panel))
|
||||
&& seen.contains(&PaneType::Canvas)
|
||||
&& self
|
||||
.auto_hidden
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|panel| *panel != PaneType::Canvas && seen.insert(*panel))
|
||||
&& self
|
||||
.floating
|
||||
.iter()
|
||||
.all(|item| item.panel != PaneType::Canvas && seen.insert(item.panel))
|
||||
}
|
||||
|
||||
/// Get the currently focused pane type.
|
||||
#[allow(dead_code)]
|
||||
pub fn focused_type(&self) -> Option<PaneType> {
|
||||
self.focused_pane.and_then(|p| self.pane_type(p))
|
||||
}
|
||||
|
||||
/// Removes a tool from PaneGrid and adds it to an auto-hide rail.
|
||||
pub fn auto_hide_pane(&mut self, pane: Pane, requested: Option<DockEdge>) -> bool {
|
||||
let Some(panel) = self.pane_type(pane) else {
|
||||
return false;
|
||||
};
|
||||
let edge = requested.unwrap_or_else(|| self.infer_edge(pane));
|
||||
if !DockDropPolicy::accepts_placement(panel, DockPlacement::AutoHidden(edge)) {
|
||||
return false;
|
||||
}
|
||||
let previous = self.pane_grid.clone();
|
||||
if self.pane_grid.close(pane).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.auto_hidden[edge_index(edge)].push(panel);
|
||||
self.focused_pane = self.pane_for_type(PaneType::Canvas);
|
||||
if !self.invariants_hold() {
|
||||
self.pane_grid = previous;
|
||||
self.auto_hidden[edge_index(edge)].retain(|candidate| *candidate != panel);
|
||||
return false;
|
||||
}
|
||||
self.rebalance_structure();
|
||||
true
|
||||
}
|
||||
|
||||
/// Restores an auto-hidden panel next to Canvas on its remembered edge.
|
||||
pub fn restore_auto_hidden(&mut self, panel: PaneType) -> bool {
|
||||
let Some(edge) = self
|
||||
.auto_hidden
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, panels)| panels.contains(&panel).then_some(index_edge(index)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for panels in &mut self.auto_hidden {
|
||||
panels.retain(|candidate| *candidate != panel);
|
||||
}
|
||||
self.active_auto_hide = None;
|
||||
if self.contains_anywhere(panel) {
|
||||
return false;
|
||||
}
|
||||
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
|
||||
return false;
|
||||
};
|
||||
let axis = match edge {
|
||||
DockEdge::Top | DockEdge::Bottom => iced::widget::pane_grid::Axis::Horizontal,
|
||||
_ => iced::widget::pane_grid::Axis::Vertical,
|
||||
};
|
||||
let Some((new_pane, _)) = self.pane_grid.split(axis, canvas, panel) else {
|
||||
return false;
|
||||
};
|
||||
if matches!(edge, DockEdge::Left | DockEdge::Top) {
|
||||
self.pane_grid.swap(canvas, new_pane);
|
||||
}
|
||||
self.focus(new_pane);
|
||||
self.rebalance_structure();
|
||||
self.invariants_hold()
|
||||
}
|
||||
|
||||
/// Removes a tool pane from PaneGrid and creates a clamped floating placement.
|
||||
pub fn float_pane(&mut self, pane: Pane, viewport: (f32, f32)) -> bool {
|
||||
let Some(panel) = self.pane_type(pane) else {
|
||||
return false;
|
||||
};
|
||||
let rect = crate::dock::floating::default_rect(viewport, self.floating.len());
|
||||
if !DockDropPolicy::accepts_placement(panel, DockPlacement::Floating(rect)) {
|
||||
return false;
|
||||
}
|
||||
let previous = self.pane_grid.clone();
|
||||
if self.pane_grid.close(pane).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.floating.push(PersistedFloatingPanel { panel, rect });
|
||||
self.focused_pane = self.pane_for_type(PaneType::Canvas);
|
||||
if !self.invariants_hold() {
|
||||
self.floating.retain(|item| item.panel != panel);
|
||||
self.pane_grid = previous;
|
||||
return false;
|
||||
}
|
||||
self.rebalance_structure();
|
||||
true
|
||||
}
|
||||
|
||||
/// Moves a floating panel to the top of z-order and returns its current rectangle.
|
||||
pub fn focus_floating(&mut self, panel: PaneType) -> Option<crate::dock::manager::DockRect> {
|
||||
let index = self.floating.iter().position(|item| item.panel == panel)?;
|
||||
let item = self.floating.remove(index);
|
||||
let rect = item.rect;
|
||||
self.floating.push(item);
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Updates a floating rectangle after clamping it to the current viewport.
|
||||
pub fn move_floating(
|
||||
&mut self,
|
||||
panel: PaneType,
|
||||
rect: crate::dock::manager::DockRect,
|
||||
viewport: (f32, f32),
|
||||
) -> bool {
|
||||
let Some(item) = self.floating.iter_mut().find(|item| item.panel == panel) else {
|
||||
return false;
|
||||
};
|
||||
item.rect = rect.clamped(viewport);
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes a floating placement without reopening it.
|
||||
pub fn close_floating(&mut self, panel: PaneType) -> bool {
|
||||
let previous = self.floating.len();
|
||||
self.floating.retain(|item| item.panel != panel);
|
||||
self.floating_drag = self.floating_drag.filter(|drag| drag.panel != panel);
|
||||
self.floating_target = None;
|
||||
self.floating.len() != previous
|
||||
}
|
||||
|
||||
/// Moves a floating panel into an auto-hide rail without reopening PaneGrid content.
|
||||
pub fn auto_hide_floating(&mut self, panel: PaneType, edge: DockEdge) -> bool {
|
||||
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
|
||||
return false;
|
||||
};
|
||||
if !DockDropPolicy::accepts_placement(panel, DockPlacement::AutoHidden(edge)) {
|
||||
return false;
|
||||
}
|
||||
let item = self.floating.remove(index);
|
||||
self.auto_hidden[edge_index(edge)].push(panel);
|
||||
self.floating_drag = None;
|
||||
self.floating_target = None;
|
||||
if !self.invariants_hold() {
|
||||
self.auto_hidden[edge_index(edge)].retain(|candidate| *candidate != panel);
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Restores a unique floating tool beside Canvas on its right edge.
|
||||
pub fn redock_floating(&mut self, panel: PaneType) -> bool {
|
||||
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
|
||||
return false;
|
||||
};
|
||||
let item = self.floating.remove(index);
|
||||
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
};
|
||||
let Some((new_pane, _)) =
|
||||
self.pane_grid
|
||||
.split(iced::widget::pane_grid::Axis::Vertical, canvas, panel)
|
||||
else {
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
};
|
||||
if !self.invariants_hold() {
|
||||
self.pane_grid.close(new_pane);
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
}
|
||||
self.focus(new_pane);
|
||||
self.floating_drag = None;
|
||||
self.floating_target = None;
|
||||
self.rebalance_structure();
|
||||
true
|
||||
}
|
||||
|
||||
/// Redocks a floating panel at the exact policy-approved preview target.
|
||||
pub fn redock_floating_at(&mut self, panel: PaneType, target: Target) -> bool {
|
||||
if !DockDropPolicy::accepts(panel, DockDropZone::from_target(target)) {
|
||||
return false;
|
||||
}
|
||||
let Some(index) = self.floating.iter().position(|item| item.panel == panel) else {
|
||||
return false;
|
||||
};
|
||||
let item = self.floating.remove(index);
|
||||
let previous = self.pane_grid.clone();
|
||||
let Some(canvas) = self.pane_for_type(PaneType::Canvas) else {
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
};
|
||||
let Some((new_pane, _)) = self.pane_grid.split(
|
||||
iced::widget::pane_grid::Axis::Vertical,
|
||||
canvas,
|
||||
panel,
|
||||
) else {
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
};
|
||||
self.pane_grid.drop(new_pane, target);
|
||||
if !self.invariants_hold() {
|
||||
self.pane_grid = previous;
|
||||
self.floating.insert(index, item);
|
||||
return false;
|
||||
}
|
||||
self.focused_pane = self.pane_for_type(panel);
|
||||
self.floating_drag = None;
|
||||
self.floating_target = None;
|
||||
self.rebalance_structure();
|
||||
true
|
||||
}
|
||||
|
||||
/// Clamps all restored floating rectangles to the current logical viewport.
|
||||
pub fn clamp_floating(&mut self, viewport: (f32, f32)) {
|
||||
for item in &mut self.floating {
|
||||
item.rect = item.rect.clamped(viewport);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns stable auto-hide placements for settings serialization.
|
||||
pub fn persisted_auto_hidden(&self) -> Vec<PersistedAutoHiddenPanel> {
|
||||
self.auto_hidden
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(index, panels)| {
|
||||
panels.iter().map(move |panel| PersistedAutoHiddenPanel {
|
||||
panel: *panel,
|
||||
edge: index_edge(index),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns whether a panel exists in any managed placement.
|
||||
fn contains_anywhere(&self, panel: PaneType) -> bool {
|
||||
self.is_pane_open(panel)
|
||||
|| self
|
||||
.auto_hidden
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|candidate| *candidate == panel)
|
||||
|| self.floating.iter().any(|item| item.panel == panel)
|
||||
}
|
||||
|
||||
/// Infers the nearest workspace edge from normalized PaneGrid geometry.
|
||||
fn infer_edge(&self, pane: Pane) -> DockEdge {
|
||||
let regions = self
|
||||
.pane_grid
|
||||
.layout()
|
||||
.pane_regions(4.0, iced::Size::new(1000.0, 1000.0));
|
||||
let Some(rect) = regions.get(&pane) else {
|
||||
return DockEdge::Right;
|
||||
};
|
||||
[
|
||||
(rect.x, DockEdge::Left),
|
||||
(1000.0 - rect.x - rect.width, DockEdge::Right),
|
||||
(rect.y, DockEdge::Top),
|
||||
(1000.0 - rect.y - rect.height, DockEdge::Bottom),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, edge)| edge)
|
||||
.unwrap_or(DockEdge::Right)
|
||||
}
|
||||
|
||||
/// Reopen a closed pane or focus it if it is already open.
|
||||
pub fn reopen_pane(&mut self, pane_type: PaneType) {
|
||||
if self
|
||||
.auto_hidden
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|panel| *panel == pane_type)
|
||||
{
|
||||
self.restore_auto_hidden(pane_type);
|
||||
return;
|
||||
}
|
||||
let already_open = self.pane_grid.iter().find(|(_, t)| **t == pane_type);
|
||||
if let Some((pane, _)) = already_open {
|
||||
self.focus(*pane);
|
||||
@@ -329,10 +838,53 @@ impl DockState {
|
||||
};
|
||||
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) {
|
||||
self.focus(new_pane);
|
||||
self.rebalance_structure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies initial or window-resize policy for a newly measured PaneGrid extent.
|
||||
///
|
||||
/// **Arguments:** `grid_size` is the logical size after fixed application chrome is removed.
|
||||
/// **Returns:** Nothing. **Logic & Workflow:** Built-in layouts receive InitialDefault once;
|
||||
/// persisted layouts retain valid ratios and are only clamped. Later changes preserve utility
|
||||
/// pixel extents and route growth toward Canvas. **Side Effects / Dependencies:** Updates split
|
||||
/// ratios and records the size for future structure and resize operations.
|
||||
pub fn set_grid_size(&mut self, grid_size: Size) {
|
||||
if grid_size.width <= 0.0 || grid_size.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let previous = self.last_grid_size;
|
||||
let mode = if previous.is_none() {
|
||||
if self.apply_initial_defaults {
|
||||
crate::dock::sizing::RebalanceMode::InitialDefault
|
||||
} else {
|
||||
crate::dock::sizing::RebalanceMode::UserResize
|
||||
}
|
||||
} else {
|
||||
crate::dock::sizing::RebalanceMode::WindowResize
|
||||
};
|
||||
crate::dock::sizing::rebalance_state(&mut self.pane_grid, grid_size, previous, mode);
|
||||
self.last_grid_size = Some(grid_size);
|
||||
self.apply_initial_defaults = false;
|
||||
}
|
||||
|
||||
/// Rebalances a changed topology when a measured grid extent is available.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing. **Logic & Workflow:** Newly created Iced splits
|
||||
/// begin at 50/50 and are replaced with policy allocation; valid established ratios remain.
|
||||
/// **Side Effects / Dependencies:** Mutates PaneGrid split ratios but performs no persistence.
|
||||
fn rebalance_structure(&mut self) {
|
||||
if let Some(size) = self.last_grid_size {
|
||||
crate::dock::sizing::rebalance_state(
|
||||
&mut self.pane_grid,
|
||||
size,
|
||||
None,
|
||||
crate::dock::sizing::RebalanceMode::StructureChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a pane type is currently open in the dock.
|
||||
pub fn is_pane_open(&self, pane_type: PaneType) -> bool {
|
||||
self.pane_grid.iter().any(|(_, t)| *t == pane_type)
|
||||
@@ -359,3 +911,23 @@ impl DockState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a stable edge into the auto-hide array index.
|
||||
fn edge_index(edge: DockEdge) -> usize {
|
||||
match edge {
|
||||
DockEdge::Left => 0,
|
||||
DockEdge::Right => 1,
|
||||
DockEdge::Top => 2,
|
||||
DockEdge::Bottom => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an auto-hide array index into its stable edge.
|
||||
fn index_edge(index: usize) -> DockEdge {
|
||||
match index {
|
||||
0 => DockEdge::Left,
|
||||
2 => DockEdge::Top,
|
||||
3 => DockEdge::Bottom,
|
||||
_ => DockEdge::Right,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::dock::state::{DockState, PaneType};
|
||||
use crate::panels::styles;
|
||||
use crate::panels::typography::TITLE;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::pane_grid::{Content, TitleBar};
|
||||
use iced::widget::pane_grid::{Content, Controls, TitleBar};
|
||||
use iced::widget::svg;
|
||||
use iced::widget::{button, column, container, pane_grid, row, text};
|
||||
use iced::{Element, Length};
|
||||
@@ -25,209 +25,260 @@ pub fn dock_view<'a>(
|
||||
app: &'a crate::app::HcieIcedApp,
|
||||
) -> Element<'a, Message> {
|
||||
let colors = app.theme_state.colors();
|
||||
let custom_drag = dock.drag.is_some() || dock.floating_drag.is_some();
|
||||
|
||||
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
|
||||
// Canvas pane has no title bar (the document tab bar serves as its header).
|
||||
// All other panes get a Photoshop-style title bar with close/maximize buttons.
|
||||
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
|
||||
None
|
||||
} else {
|
||||
Some(make_title_bar(pane, *pane_type, colors))
|
||||
};
|
||||
|
||||
let body: Element<'a, Message> = match pane_type {
|
||||
PaneType::Canvas => {
|
||||
// Canvas pane includes document tab bar as its header
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let pressure = app
|
||||
.tablet_state
|
||||
.lock()
|
||||
.map(|s| s.current_pressure())
|
||||
.unwrap_or(1.0);
|
||||
let ts = &app.settings.tool_settings;
|
||||
crate::canvas::view(
|
||||
doc,
|
||||
&app.tool_state,
|
||||
app.marching_ants_offset,
|
||||
pressure,
|
||||
ts.vector_points,
|
||||
ts.vector_sides,
|
||||
ts.vector_radius,
|
||||
)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
// Tools is no longer in the dock — toolbox is a fixed strip on the left
|
||||
PaneType::Layers => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layers::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
&doc.engine,
|
||||
let grid: Element<'a, Message> =
|
||||
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
|
||||
// Canvas pane has no title bar (the document tab bar serves as its header).
|
||||
// All other panes get a Photoshop-style title bar with close/maximize buttons.
|
||||
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
|
||||
None
|
||||
} else {
|
||||
Some(make_title_bar(
|
||||
pane,
|
||||
*pane_type,
|
||||
dock.focused_pane == Some(pane),
|
||||
colors,
|
||||
)
|
||||
))
|
||||
};
|
||||
|
||||
let body = panel_body(*pane_type, app, colors);
|
||||
|
||||
let mut content = Content::new(body);
|
||||
|
||||
// Apply per-pane background style. Canvas pane gets a dark workspace
|
||||
// background (no border); all other panes get panel background + border
|
||||
// so they are visually separated from neighbors.
|
||||
if *pane_type == PaneType::Canvas {
|
||||
content = content.style(move |_theme| styles::canvas_pane_background(colors));
|
||||
} else {
|
||||
content = content.style(move |_theme| styles::pane_background(colors));
|
||||
}
|
||||
PaneType::History => {
|
||||
|
||||
if let Some(title_bar) = title {
|
||||
content = content.title_bar(title_bar);
|
||||
}
|
||||
content
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.spacing(4)
|
||||
.on_click(Message::PaneClicked)
|
||||
.on_drag(Message::PaneDragged)
|
||||
.on_resize(4, Message::PaneResized)
|
||||
.style(move |_theme| pane_grid::Style {
|
||||
hovered_region: pane_grid::Highlight {
|
||||
background: iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
if custom_drag { 0.0 } else { 0.15 },
|
||||
)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.accent)
|
||||
.width(if custom_drag { 0 } else { 1 }),
|
||||
},
|
||||
picked_split: pane_grid::Line {
|
||||
color: colors.accent,
|
||||
width: 2.0,
|
||||
},
|
||||
hovered_split: pane_grid::Line {
|
||||
color: colors.border_high,
|
||||
width: 1.0,
|
||||
},
|
||||
})
|
||||
.into();
|
||||
|
||||
let rail = crate::dock::auto_hide::right_rail(dock.auto_hidden[1].iter().copied(), colors);
|
||||
let base: Element<'a, Message> = row![grid, rail]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
let base: Element<'a, Message> =
|
||||
if let Some(preview) = dock
|
||||
.drag
|
||||
.as_ref()
|
||||
.and_then(|drag| drag.accepted_preview)
|
||||
.or_else(|| dock.floating_target.map(|target| target.preview))
|
||||
{
|
||||
iced::widget::Stack::new()
|
||||
.push(base)
|
||||
.push(preview_overlay(preview, colors))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
if let Some(panel) = dock.active_auto_hide {
|
||||
let overlay =
|
||||
crate::dock::auto_hide::overlay(panel, panel_body(panel, app, colors), colors);
|
||||
iced::widget::Stack::new()
|
||||
.push(base)
|
||||
.push(overlay)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds reusable panel content for docked and temporary auto-hide placements.
|
||||
///
|
||||
/// Canvas remains exclusive to PaneGrid; callers must never request it for detached placement.
|
||||
pub fn panel_body<'a>(
|
||||
pane_type: PaneType,
|
||||
app: &'a crate::app::HcieIcedApp,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
match &pane_type {
|
||||
PaneType::Canvas => {
|
||||
if app.show_welcome {
|
||||
return crate::dock::welcome::view(colors);
|
||||
}
|
||||
// Canvas pane includes document tab bar as its header
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
|
||||
}
|
||||
PaneType::Brushes => crate::panels::brushes::view(
|
||||
app.tool_state.brush_style,
|
||||
let pressure = app
|
||||
.tablet_state
|
||||
.lock()
|
||||
.map(|s| s.current_pressure())
|
||||
.unwrap_or(1.0);
|
||||
let ts = &app.settings.tool_settings;
|
||||
crate::canvas::view(
|
||||
doc,
|
||||
&app.tool_state,
|
||||
app.marching_ants_offset,
|
||||
pressure,
|
||||
ts.vector_points,
|
||||
ts.vector_sides,
|
||||
ts.vector_radius,
|
||||
)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
// Tools is no longer in the dock — toolbox is a fixed strip on the left
|
||||
PaneType::Layers => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layers::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
&doc.engine,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::History => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
|
||||
}
|
||||
PaneType::Brushes => crate::panels::brushes::view(
|
||||
app.tool_state.brush_style,
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
colors,
|
||||
),
|
||||
PaneType::Filters => crate::panels::filters::view(
|
||||
app.selected_filter,
|
||||
&app.filter_params,
|
||||
app.filter_preview_active,
|
||||
app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_layer_is_editable(),
|
||||
colors,
|
||||
),
|
||||
PaneType::ColorPicker => crate::color_picker::view(
|
||||
&app.fg_color,
|
||||
&app.bg_color,
|
||||
&app.recent_colors,
|
||||
app.color_tab,
|
||||
),
|
||||
PaneType::Properties => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let active_id = doc.engine.active_layer_id();
|
||||
let layer_info = doc.cached_layers.iter().find(|l| l.id == active_id);
|
||||
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("—");
|
||||
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
|
||||
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
|
||||
let layer_blend_mode = layer_info
|
||||
.map(|l| l.blend_mode)
|
||||
.unwrap_or(hcie_engine_api::BlendMode::Normal);
|
||||
let layer_type = layer_info
|
||||
.map(|l| l.layer_type)
|
||||
.unwrap_or(hcie_engine_api::LayerType::Raster);
|
||||
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
|
||||
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
|
||||
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
||||
let ts2 = app.settings.tool_settings.clone();
|
||||
crate::panels::properties::view(
|
||||
&app.tool_state.active_tool,
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
doc.selection_rect,
|
||||
doc.vector_draw,
|
||||
colors,
|
||||
),
|
||||
PaneType::Filters => crate::panels::filters::view(
|
||||
app.selected_filter,
|
||||
&app.filter_params,
|
||||
app.filter_preview_active,
|
||||
app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_layer_is_editable(),
|
||||
colors,
|
||||
),
|
||||
PaneType::ColorPicker => crate::color_picker::view(
|
||||
&app.fg_color,
|
||||
&app.bg_color,
|
||||
&app.recent_colors,
|
||||
app.color_tab,
|
||||
),
|
||||
PaneType::Properties => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let active_id = doc.engine.active_layer_id();
|
||||
let layer_info = doc.cached_layers.iter().find(|l| l.id == active_id);
|
||||
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("—");
|
||||
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
|
||||
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
|
||||
let layer_blend_mode = layer_info
|
||||
.map(|l| l.blend_mode)
|
||||
.unwrap_or(hcie_engine_api::BlendMode::Normal);
|
||||
let layer_type = layer_info
|
||||
.map(|l| l.layer_type)
|
||||
.unwrap_or(hcie_engine_api::LayerType::Raster);
|
||||
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
|
||||
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
|
||||
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
||||
let ts2 = app.settings.tool_settings.clone();
|
||||
crate::panels::properties::view(
|
||||
&app.tool_state.active_tool,
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
doc.selection_rect,
|
||||
doc.vector_draw,
|
||||
colors,
|
||||
layer_name,
|
||||
layer_opacity,
|
||||
layer_visible,
|
||||
layer_blend_mode,
|
||||
layer_type,
|
||||
layer_width,
|
||||
layer_height,
|
||||
doc.selected_vector_shape,
|
||||
&vector_shapes,
|
||||
ts2.vector_stroke_width,
|
||||
ts2.vector_opacity,
|
||||
ts2.vector_fill,
|
||||
ts2.vector_radius,
|
||||
ts2.vector_points,
|
||||
ts2.vector_sides,
|
||||
active_id,
|
||||
app.fg_color,
|
||||
app.bg_color,
|
||||
)
|
||||
}
|
||||
PaneType::Script => crate::panels::script_panel::view(
|
||||
&app.script_content,
|
||||
app.script_error.as_deref(),
|
||||
app.script_error_line,
|
||||
app.script_is_running,
|
||||
colors,
|
||||
),
|
||||
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
|
||||
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
|
||||
PaneType::LayerDetails => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layer_details::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::Geometry => {
|
||||
let shapes = app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.unwrap_or_default();
|
||||
crate::panels::geometry::view(
|
||||
shapes,
|
||||
colors,
|
||||
app.documents[app.active_doc].selected_vector_shape,
|
||||
app.bool_shape_a,
|
||||
app.bool_shape_b,
|
||||
)
|
||||
}
|
||||
PaneType::ToolSettings => {
|
||||
let fg = app.fg_color;
|
||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
||||
crate::panels::tool_settings::view(
|
||||
&app.tool_state,
|
||||
&app.settings,
|
||||
fg,
|
||||
draft,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut content = Content::new(body);
|
||||
|
||||
// Apply per-pane background style. Canvas pane gets a dark workspace
|
||||
// background (no border); all other panes get panel background + border
|
||||
// so they are visually separated from neighbors.
|
||||
if *pane_type == PaneType::Canvas {
|
||||
content = content.style(move |_theme| styles::canvas_pane_background(colors));
|
||||
} else {
|
||||
content = content.style(move |_theme| styles::pane_background(colors));
|
||||
layer_name,
|
||||
layer_opacity,
|
||||
layer_visible,
|
||||
layer_blend_mode,
|
||||
layer_type,
|
||||
layer_width,
|
||||
layer_height,
|
||||
doc.selected_vector_shape,
|
||||
&vector_shapes,
|
||||
ts2.vector_stroke_width,
|
||||
ts2.vector_opacity,
|
||||
ts2.vector_fill,
|
||||
ts2.vector_radius,
|
||||
ts2.vector_points,
|
||||
ts2.vector_sides,
|
||||
active_id,
|
||||
app.fg_color,
|
||||
app.bg_color,
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(title_bar) = title {
|
||||
content = content.title_bar(title_bar);
|
||||
PaneType::Script => crate::panels::script_panel::view(
|
||||
&app.script_content,
|
||||
app.script_error.as_deref(),
|
||||
app.script_error_line,
|
||||
app.script_is_running,
|
||||
colors,
|
||||
),
|
||||
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
|
||||
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
|
||||
PaneType::LayerDetails => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layer_details::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
colors,
|
||||
)
|
||||
}
|
||||
content
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.spacing(4)
|
||||
.on_click(Message::PaneClicked)
|
||||
.on_drag(Message::PaneDragged)
|
||||
.on_resize(4, Message::PaneResized)
|
||||
.style(move |_theme| pane_grid::Style {
|
||||
hovered_region: pane_grid::Highlight {
|
||||
background: iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
0.15,
|
||||
)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
},
|
||||
picked_split: pane_grid::Line {
|
||||
color: colors.accent,
|
||||
width: 2.0,
|
||||
},
|
||||
hovered_split: pane_grid::Line {
|
||||
color: colors.border_high,
|
||||
width: 1.0,
|
||||
},
|
||||
})
|
||||
.into()
|
||||
PaneType::Geometry => {
|
||||
let shapes = app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.unwrap_or_default();
|
||||
crate::panels::geometry::view(
|
||||
shapes,
|
||||
colors,
|
||||
app.documents[app.active_doc].selected_vector_shape,
|
||||
app.bool_shape_a,
|
||||
app.bool_shape_b,
|
||||
)
|
||||
}
|
||||
PaneType::ToolSettings => {
|
||||
let fg = app.fg_color;
|
||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
||||
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the document tab bar.
|
||||
@@ -396,37 +447,38 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Photoshop-style title bar for a pane.
|
||||
///
|
||||
/// Shows a panel type icon + label with close and maximize buttons.
|
||||
/// Uses `panel_header` style from the theme for consistent header appearance.
|
||||
/// Buttons have hover feedback: transparent by default, bg_hover on hover.
|
||||
/// Creates a compact VS-style title bar whose controls do not initiate pane dragging.
|
||||
fn make_title_bar<'a>(
|
||||
pane: pane_grid::Pane,
|
||||
pane_type: PaneType,
|
||||
focused: bool,
|
||||
colors: ThemeColors,
|
||||
) -> TitleBar<'a, Message> {
|
||||
// Panel type icon
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from("hcie-iced-app/assets"),
|
||||
std::path::PathBuf::from("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets"),
|
||||
];
|
||||
|
||||
let title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) {
|
||||
if let Some(base) = icon_paths.iter().find(|p| p.join(icon_file).exists()) {
|
||||
let handle = svg::Handle::from_path(base.join(icon_file));
|
||||
let icon = svg(handle).width(14).height(14).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
},
|
||||
);
|
||||
if let Some(base) = icon_paths.iter().find(|path| path.join(icon_file).exists()) {
|
||||
let icon = svg(svg::Handle::from_path(base.join(icon_file)))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.style(move |_theme, _status| svg::Style {
|
||||
color: Some(if focused {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_secondary
|
||||
}),
|
||||
});
|
||||
row![
|
||||
icon,
|
||||
text(pane_type.label())
|
||||
.size(TITLE)
|
||||
.color(colors.text_secondary)
|
||||
text(pane_type.label()).size(TITLE).color(if focused {
|
||||
colors.text_primary
|
||||
} else {
|
||||
colors.text_secondary
|
||||
})
|
||||
]
|
||||
.spacing(4)
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
@@ -442,73 +494,180 @@ fn make_title_bar<'a>(
|
||||
.into()
|
||||
};
|
||||
|
||||
// Close button with hover state
|
||||
let close_btn = button(text("×").size(11))
|
||||
.on_press(Message::PaneClose(pane))
|
||||
.padding([2, 6])
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Maximize button with hover state
|
||||
let maximize_btn = button(text("□").size(11))
|
||||
.on_press(Message::PaneMaximize(pane))
|
||||
.padding([2, 6])
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let control = |label, message| {
|
||||
button(text(label).size(12))
|
||||
.on_press(message)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, status| chrome_button_style(colors, status))
|
||||
};
|
||||
let pin = iced::widget::tooltip(
|
||||
control("−", Message::PaneAutoHide(pane)),
|
||||
text("Minimize to side rail").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let float_control = iced::widget::tooltip(
|
||||
control("↗", Message::PaneFloat(pane)),
|
||||
text("Float panel").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let maximize = iced::widget::tooltip(
|
||||
control("□", Message::PaneMaximize(pane)),
|
||||
text("Maximize / restore").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let close = iced::widget::tooltip(
|
||||
control("×", Message::PaneClose(pane)),
|
||||
text("Close panel").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let compact_control = |label, message| {
|
||||
button(text(label).size(11))
|
||||
.on_press(message)
|
||||
.padding([3, 3])
|
||||
.style(move |_theme, status| chrome_button_style(colors, status))
|
||||
};
|
||||
let compact_pin = iced::widget::tooltip(
|
||||
compact_control("−", Message::PaneAutoHide(pane)),
|
||||
text("Minimize to side rail").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let compact_float = iced::widget::tooltip(
|
||||
compact_control("↗", Message::PaneFloat(pane)),
|
||||
text("Float panel").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let compact_maximize = iced::widget::tooltip(
|
||||
compact_control("□", Message::PaneMaximize(pane)),
|
||||
text("Maximize / restore").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let compact_close = iced::widget::tooltip(
|
||||
compact_control("×", Message::PaneClose(pane)),
|
||||
text("Close panel").size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
);
|
||||
let full_controls = row![pin, float_control, maximize, close]
|
||||
.spacing(1)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.height(28);
|
||||
let compact_controls = row![compact_pin, compact_float, compact_maximize, compact_close]
|
||||
.spacing(1)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.height(28);
|
||||
let title_drag = iced::widget::mouse_area(title_content)
|
||||
.on_press(Message::DockHeaderDragStart(pane))
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
let header_colors = colors;
|
||||
TitleBar::new(
|
||||
row![
|
||||
title_content,
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
maximize_btn,
|
||||
close_btn
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![title_drag]
|
||||
.spacing(1)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.height(28),
|
||||
)
|
||||
.style(move |_theme| styles::panel_header(colors))
|
||||
.controls(Controls::dynamic(full_controls, compact_controls))
|
||||
.always_show_controls()
|
||||
.padding([0, 6])
|
||||
.style(move |_theme| styles::modern_panel_header(header_colors, focused))
|
||||
}
|
||||
|
||||
/// Shared hover and pressed style for dock chrome and auto-hide controls.
|
||||
pub fn chrome_button_style(
|
||||
colors: ThemeColors,
|
||||
status: iced::widget::button::Status,
|
||||
) -> iced::widget::button::Style {
|
||||
let background = match status {
|
||||
iced::widget::button::Status::Hovered => Some(iced::Background::Color(colors.bg_hover)),
|
||||
iced::widget::button::Status::Pressed => Some(iced::Background::Color(colors.bg_active)),
|
||||
_ => None,
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background,
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border {
|
||||
radius: 2.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a translucent accepted rectangle and compact edge compass without capturing events.
|
||||
fn preview_overlay<'a>(rect: iced::Rectangle, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let fill = container(text(""))
|
||||
.width(rect.width)
|
||||
.height(rect.height)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
0.22,
|
||||
))),
|
||||
border: iced::Border::default().color(colors.accent).width(2),
|
||||
..Default::default()
|
||||
});
|
||||
let marker = |glyph, active| {
|
||||
container(text(glyph).size(12).color(if active {
|
||||
colors.text_primary
|
||||
} else {
|
||||
colors.text_disabled
|
||||
}))
|
||||
.center_x(24)
|
||||
.center_y(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.bg_app
|
||||
})),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let compass = column![
|
||||
row![
|
||||
iced::widget::Space::with_width(24),
|
||||
marker("↑", true),
|
||||
iced::widget::Space::with_width(24)
|
||||
],
|
||||
row![marker("←", true), marker("×", false), marker("→", true)],
|
||||
row![
|
||||
iced::widget::Space::with_width(24),
|
||||
marker("↓", true),
|
||||
iced::widget::Space::with_width(24)
|
||||
],
|
||||
]
|
||||
.spacing(1);
|
||||
let compass_x = (rect.center_x() - 36.0).max(0.0);
|
||||
let compass_y = (rect.center_y() - 36.0).max(0.0);
|
||||
iced::widget::Stack::new()
|
||||
.push(column![
|
||||
iced::widget::Space::with_height(rect.y),
|
||||
row![iced::widget::Space::with_width(rect.x), fill]
|
||||
])
|
||||
.push(column![
|
||||
iced::widget::Space::with_height(compass_y),
|
||||
row![iced::widget::Space::with_width(compass_x), compass]
|
||||
])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Guards the Iced drag contract: title and controls must be distinct widget regions.
|
||||
#[test]
|
||||
fn title_bar_uses_explicit_drag_handle_and_separate_controls() {
|
||||
let source = include_str!("view.rs");
|
||||
let start = source.find("fn make_title_bar").unwrap();
|
||||
let end = source[start..].find("pub fn chrome_button_style").unwrap() + start;
|
||||
let title_source = &source[start..end];
|
||||
assert!(title_source.contains("Controls::dynamic"));
|
||||
assert!(title_source.contains(".controls("));
|
||||
assert!(title_source.contains(".always_show_controls()"));
|
||||
assert!(title_source.contains("mouse_area(title_content)"));
|
||||
assert!(title_source.contains("DockHeaderDragStart(pane)"));
|
||||
assert!(!title_source.contains("Space::with_width(Length::Fill)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Center-document welcome surface shown when no user document remains open.
|
||||
//!
|
||||
//! **Purpose:** Keeps the editor shell and dock tools available after the last document closes.
|
||||
//! **Logic & Workflow:** Presents primary New and Open actions inside the unique Canvas host while
|
||||
//! a private placeholder engine keeps existing rendering assumptions safe.
|
||||
//! **Side Effects / Dependencies:** Buttons emit ordinary application messages; no I/O occurs here.
|
||||
|
||||
use crate::app::{ActiveDialog, Message};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Builds the responsive welcome card for the center document host.
|
||||
///
|
||||
/// **Arguments:** `colors` supplies the active theme palette.
|
||||
/// **Returns:** A centered full-size element with New Image and Open Image actions.
|
||||
/// **Side Effects / Dependencies:** None until a button emits its message.
|
||||
pub fn view(colors: ThemeColors) -> Element<'static, Message> {
|
||||
let action = move |label: &'static str, message| {
|
||||
button(text(label).size(13))
|
||||
.on_press(message)
|
||||
.padding([9, 18])
|
||||
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status))
|
||||
};
|
||||
let card = container(
|
||||
column![
|
||||
text("HCIE").size(30).color(colors.text_primary),
|
||||
text("Create a new image or open an existing project")
|
||||
.size(13)
|
||||
.color(colors.text_secondary),
|
||||
row![
|
||||
action(
|
||||
"New Image",
|
||||
Message::DialogOpen(ActiveDialog::NewImage)
|
||||
),
|
||||
action("Open Image", Message::OpenFileRfd),
|
||||
]
|
||||
.spacing(10),
|
||||
]
|
||||
.spacing(16)
|
||||
.align_x(iced::Alignment::Center),
|
||||
)
|
||||
.padding(28)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(1)
|
||||
.rounded(8),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.28),
|
||||
offset: iced::Vector::new(0.0, 6.0),
|
||||
blur_radius: 20.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
container(card)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
@@ -449,44 +449,48 @@ pub fn view<'a>(
|
||||
}
|
||||
|
||||
// ── Bottom toolbar ─────────────────────────────────────
|
||||
let add_btn = button(text("+Lay").size(10))
|
||||
let add_btn = button(text("+Layer").size(BODY))
|
||||
.on_press(Message::LayerAdd)
|
||||
.padding([3, 6])
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let group_btn = button(text("+Grp").size(10))
|
||||
let group_btn = button(text("+Group").size(BODY))
|
||||
.on_press(Message::LayerAddGroup)
|
||||
.padding([3, 6])
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]);
|
||||
let mask_btn = button(text("Mask").size(9)).padding([3, 6]);
|
||||
let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]);
|
||||
let flatten_btn = button(text("Flatten").size(10))
|
||||
let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]);
|
||||
let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]);
|
||||
let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]);
|
||||
let flatten_btn = button(text("Flatten").size(BODY))
|
||||
.on_press(Message::LayerFlatten)
|
||||
.padding([3, 6])
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let del_btn = button(text("\u{2716}").size(10))
|
||||
let del_btn = button(text("\u{2716}").size(BODY))
|
||||
.on_press(Message::LayerDelete(active_layer_id))
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let move_up_btn = button(text("\u{25B2}").size(10))
|
||||
let move_up_btn = button(text("\u{25B2}").size(BODY))
|
||||
.on_press(Message::LayerMoveUp(active_layer_id))
|
||||
.padding([3, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let move_down_btn = button(text("\u{25BC}").size(10))
|
||||
let move_down_btn = button(text("\u{25BC}").size(BODY))
|
||||
.on_press(Message::LayerMoveDown(active_layer_id))
|
||||
.padding([3, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
let fx_btn = button(text("fx").size(10))
|
||||
let fx_btn = button(text("fx").size(BODY))
|
||||
.on_press(Message::OpenLayerStyleDialog)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let toolbar = column![
|
||||
text("Disabled (engine API): duplicate / mask / rasterize").size(8),
|
||||
row![duplicate_btn, mask_btn, rasterize_btn]
|
||||
text("Unavailable: duplicate, mask, rasterize").size(BODY),
|
||||
row![duplicate_btn, mask_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![add_btn, group_btn, flatten_btn, fx_btn]
|
||||
row![rasterize_btn].align_y(iced::Alignment::Center),
|
||||
row![add_btn, group_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![flatten_btn, fx_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![move_up_btn, move_down_btn, del_btn]
|
||||
|
||||
@@ -154,3 +154,24 @@ pub fn inactive_tab_background(colors: ThemeColors) -> iced::widget::container::
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern compact pane header with a focused accent separator.
|
||||
pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(if focused {
|
||||
colors.bg_hover
|
||||
} else {
|
||||
colors.bg_active
|
||||
})),
|
||||
border: iced::Border {
|
||||
color: if focused {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.border_low
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! **Purpose:** Persists tool settings, panel layout, and preferences between sessions.
|
||||
//! Settings are saved to `~/.hcie/settings.json` and loaded on startup.
|
||||
|
||||
use crate::dock::persistence::PersistedDockLayout;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -143,6 +144,9 @@ pub struct PanelLayout {
|
||||
pub visible_panels: Vec<String>,
|
||||
/// Panel order in the dock.
|
||||
pub panel_order: Vec<String>,
|
||||
/// Stable dock tree plus auto-hidden and floating placements.
|
||||
#[serde(default)]
|
||||
pub dock_layout: PersistedDockLayout,
|
||||
}
|
||||
|
||||
/// Window settings.
|
||||
@@ -223,6 +227,7 @@ impl Default for PanelLayout {
|
||||
"History".to_string(),
|
||||
"Filters".to_string(),
|
||||
],
|
||||
dock_layout: PersistedDockLayout::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +242,30 @@ impl Default for WindowSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelLayout {
|
||||
/// Captures current dock placement without runtime Pane IDs.
|
||||
pub fn persist_current_placement(&mut self, dock: &crate::dock::state::DockState) {
|
||||
self.dock_layout = PersistedDockLayout::capture(dock);
|
||||
self.visible_panels = dock
|
||||
.pane_grid
|
||||
.iter()
|
||||
.map(|(_, panel)| panel.label().to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Restores current stable dock placement, preserving legacy defaults when absent.
|
||||
pub fn restore_dock(&self) -> crate::dock::state::DockState {
|
||||
if self.dock_layout.tree.is_none()
|
||||
&& self.dock_layout.auto_hidden.is_empty()
|
||||
&& self.dock_layout.floating.is_empty()
|
||||
{
|
||||
crate::dock::state::DockState::new()
|
||||
} else {
|
||||
self.dock_layout.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// Get the settings file path (~/.hcie/settings.json).
|
||||
pub fn file_path() -> PathBuf {
|
||||
@@ -322,6 +351,11 @@ mod tests {
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("panel layout object")
|
||||
.remove("sidebar_expanded");
|
||||
object
|
||||
.get_mut("panel_layout")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("panel layout object")
|
||||
.remove("dock_layout");
|
||||
|
||||
let settings: AppSettings =
|
||||
serde_json::from_value(legacy).expect("deserialize legacy settings");
|
||||
|
||||
@@ -166,9 +166,14 @@ fn screenshot_cli_uses_native_capture_and_panel_crop() {
|
||||
fn cycle_one_state_paths_are_complete_and_persisted() {
|
||||
let app = source("src/app.rs");
|
||||
let settings = source("src/settings.rs");
|
||||
let manager = source("src/dock/manager.rs");
|
||||
|
||||
assert!(
|
||||
app.contains("self.dock.pane_grid.drop(pane, target)"),
|
||||
app.contains("self.dock.apply_drop(pane, target)")
|
||||
&& manager.contains("DockDropZone::Center")
|
||||
&& manager.contains("DockDropZone::OuterEdge(_)")
|
||||
&& manager.contains("DockDropZone::PaneEdge(_)")
|
||||
&& manager.contains("Self::role(source) == DockRole::Tool"),
|
||||
"{PANE_FULL_DROP_TARGETS}"
|
||||
);
|
||||
assert!(
|
||||
@@ -257,10 +262,10 @@ fn cycle_four_panel_and_document_paths_are_connected() {
|
||||
);
|
||||
assert!(
|
||||
layers.contains("Fill opacity: unavailable in hcie-engine-api")
|
||||
&& layers.contains("Disabled (engine API): duplicate / mask / rasterize")
|
||||
&& layers.contains("button(text(\"Dup\")")
|
||||
&& layers.contains("Unavailable: duplicate, mask, rasterize")
|
||||
&& layers.contains("button(text(\"Duplicate\")")
|
||||
&& layers.contains("button(text(\"Mask\")")
|
||||
&& layers.contains("button(text(\"Raster\")"),
|
||||
&& layers.contains("button(text(\"Rasterize\")"),
|
||||
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -133,16 +133,14 @@ impl AiChatPanel {
|
||||
});
|
||||
|
||||
let bubble = container(
|
||||
column![
|
||||
text(label).size(10).font(iced::Font::MONOSPACE),
|
||||
msg_text,
|
||||
]
|
||||
.spacing(2)
|
||||
column![text(label).size(10).font(iced::Font::MONOSPACE), msg_text,].spacing(2),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(8)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.25, 0.25, 0.25))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.25, 0.25, 0.25,
|
||||
))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -183,8 +181,12 @@ impl AiChatPanel {
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.18, 0.18, 0.18,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -115,8 +115,12 @@ impl ScriptPanel {
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.18, 0.18, 0.18,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
@@ -139,7 +143,11 @@ fn execute_dsl_script(engine: &mut Engine, script: &str) -> Result<String, Strin
|
||||
|
||||
match cmd.as_str() {
|
||||
"layer" => {
|
||||
let name = if args.is_empty() { "Script Layer" } else { args };
|
||||
let name = if args.is_empty() {
|
||||
"Script Layer"
|
||||
} else {
|
||||
args
|
||||
};
|
||||
engine.add_layer(name);
|
||||
lines_executed += 1;
|
||||
}
|
||||
@@ -264,7 +272,10 @@ fn parse_color(s: &str) -> Option<[u8; 4]> {
|
||||
parts[1].trim().parse::<u8>(),
|
||||
parts[2].trim().parse::<u8>(),
|
||||
) {
|
||||
let a = parts.get(3).and_then(|a| a.trim().parse::<u8>().ok()).unwrap_or(255);
|
||||
let a = parts
|
||||
.get(3)
|
||||
.and_then(|a| a.trim().parse::<u8>().ok())
|
||||
.unwrap_or(255);
|
||||
return Some([r, g, b, a]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ fn main() {
|
||||
let mut layer = None;
|
||||
for l in &layers {
|
||||
for e in &l.effects {
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss {
|
||||
enabled: true, ..
|
||||
} = e
|
||||
{
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss { enabled: true, .. } = e {
|
||||
layer = Some(l);
|
||||
break;
|
||||
}
|
||||
@@ -113,7 +110,9 @@ fn main() {
|
||||
hcie_protocol::effects::BevelStyle::OuterBevel => {
|
||||
hcie_fx::types::BevelStyle::OuterBevel
|
||||
}
|
||||
hcie_protocol::effects::BevelStyle::Emboss => hcie_fx::types::BevelStyle::Emboss,
|
||||
hcie_protocol::effects::BevelStyle::Emboss => {
|
||||
hcie_fx::types::BevelStyle::Emboss
|
||||
}
|
||||
hcie_protocol::effects::BevelStyle::PillowEmboss => {
|
||||
hcie_fx::types::BevelStyle::PillowEmboss
|
||||
}
|
||||
@@ -129,30 +128,27 @@ fn main() {
|
||||
let alpha: Vec<u8> = layer.pixels.iter().skip(3).step_by(4).copied().collect();
|
||||
|
||||
let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned(
|
||||
&alpha,
|
||||
w,
|
||||
h,
|
||||
depth,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
&direction,
|
||||
&technique,
|
||||
&style,
|
||||
lb,
|
||||
hl,
|
||||
sh,
|
||||
1.0,
|
||||
tilt,
|
||||
&alpha, w, h, depth, size, soften, angle, altitude, &direction, &technique, &style, lb,
|
||||
hl, sh, 1.0, tilt,
|
||||
);
|
||||
|
||||
let mut out = layer.pixels.clone();
|
||||
for i in 0..px {
|
||||
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]];
|
||||
let hl_src = [hl_buf[i * 4], hl_buf[i * 4 + 1], hl_buf[i * 4 + 2], hl_buf[i * 4 + 3]];
|
||||
let sh_src = [sh_buf[i * 4], sh_buf[i * 4 + 1], sh_buf[i * 4 + 2], sh_buf[i * 4 + 3]];
|
||||
let blended = hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75);
|
||||
let hl_src = [
|
||||
hl_buf[i * 4],
|
||||
hl_buf[i * 4 + 1],
|
||||
hl_buf[i * 4 + 2],
|
||||
hl_buf[i * 4 + 3],
|
||||
];
|
||||
let sh_src = [
|
||||
sh_buf[i * 4],
|
||||
sh_buf[i * 4 + 1],
|
||||
sh_buf[i * 4 + 2],
|
||||
sh_buf[i * 4 + 3],
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75);
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(blended, sh_src, hcie_blend::BlendMode::Multiply, 0.75);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
@@ -171,8 +167,7 @@ fn main() {
|
||||
let sy = y as i32 + offset_y;
|
||||
if sx >= 0 && sx < w as i32 && sy >= 0 && sy < h as i32 {
|
||||
let s_idx = ((sy as usize) * w as usize + (sx as usize)) * 4;
|
||||
let t_idx =
|
||||
((y as usize) * target.width() as usize + (x as usize)) * 4;
|
||||
let t_idx = ((y as usize) * target.width() as usize + (x as usize)) * 4;
|
||||
|
||||
let src_a = layer.pixels[s_idx + 3];
|
||||
if src_a > 0 {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use image::GenericImageView;
|
||||
|
||||
fn main() {
|
||||
let ref_img = image::open("_images/_psd_stil_test/test_2/Emboss.png").unwrap().to_rgba8();
|
||||
let ref_img = image::open("_images/_psd_stil_test/test_2/Emboss.png")
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
let test_img = image::open("/tmp/test2_Emboss.png").unwrap().to_rgba8();
|
||||
|
||||
let mut diff_sum = 0.0;
|
||||
@@ -15,10 +17,10 @@ fn main() {
|
||||
let rp = ref_img.get_pixel(x, y);
|
||||
let tp = test_img.get_pixel(x, y);
|
||||
|
||||
let d = (rp[0] as i32 - tp[0] as i32).abs() +
|
||||
(rp[1] as i32 - tp[1] as i32).abs() +
|
||||
(rp[2] as i32 - tp[2] as i32).abs() +
|
||||
(rp[3] as i32 - tp[3] as i32).abs();
|
||||
let d = (rp[0] as i32 - tp[0] as i32).abs()
|
||||
+ (rp[1] as i32 - tp[1] as i32).abs()
|
||||
+ (rp[2] as i32 - tp[2] as i32).abs()
|
||||
+ (rp[3] as i32 - tp[3] as i32).abs();
|
||||
|
||||
diff_sum += d as f64;
|
||||
max_diff = max_diff.max(d);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros)]
|
||||
use hcie_blend::BlendMode;
|
||||
/// Visual inspection tool for Bevel & Emboss rendering.
|
||||
///
|
||||
/// Loads a PSD from `_tmp/psd_tunes/bevel_emboss_psd_set/`, applies the
|
||||
@@ -11,9 +12,7 @@
|
||||
/// cargo run --example inspect_emboss -p hcie-io -- be_L_emboss_d100_sz10_sf0_down_alt30_smooth
|
||||
///
|
||||
/// If no stem is given, runs a small representative subset.
|
||||
|
||||
use hcie_fx::types::{BevelStyle, Direction, Technique};
|
||||
use hcie_blend::BlendMode;
|
||||
|
||||
const PSD_DIR: &str = "_tmp/psd_tunes/bevel_emboss_psd_set";
|
||||
const PNG_DIR: &str = "_tmp/psd_tunes/bevel_emboss_png_set";
|
||||
@@ -22,7 +21,9 @@ const OUT_DIR: &str = "_tmp/emboss_inspect";
|
||||
fn parse_stem(stem: &str) -> Option<(String, String, f32, f32, f32, String, f32, String)> {
|
||||
// be_{shape}_{style}_d{depth}_sz{size}_sf{soften}_{dir}_alt{altitude}_{technique}
|
||||
let parts: Vec<&str> = stem.split('_').collect();
|
||||
if parts.len() < 9 || parts[0] != "be" { return None; }
|
||||
if parts.len() < 9 || parts[0] != "be" {
|
||||
return None;
|
||||
}
|
||||
let shape = parts[1].to_string();
|
||||
let style = parts[2].to_string();
|
||||
let depth = parts[3].strip_prefix('d')?.parse::<f32>().ok()?;
|
||||
@@ -31,13 +32,18 @@ fn parse_stem(stem: &str) -> Option<(String, String, f32, f32, f32, String, f32,
|
||||
let direction = parts[6].to_string();
|
||||
let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?;
|
||||
let technique = parts[8].to_string();
|
||||
Some((shape, style, depth, size, soften, direction, altitude, technique))
|
||||
Some((
|
||||
shape, style, depth, size, soften, direction, altitude, technique,
|
||||
))
|
||||
}
|
||||
|
||||
fn render_one(stem: &str) {
|
||||
let params = match parse_stem(stem) {
|
||||
Some(p) => p,
|
||||
None => { eprintln!(" cannot parse stem: {}", stem); return; }
|
||||
None => {
|
||||
eprintln!(" cannot parse stem: {}", stem);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (shape, style_str, depth, size, soften, direction_str, altitude, _technique) = params;
|
||||
|
||||
@@ -56,13 +62,19 @@ fn render_one(stem: &str) {
|
||||
|
||||
let layers = match hcie_psd::import_psd(std::path::Path::new(&psd_path)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => { eprintln!(" SKIP {}: {}", stem, e); return; }
|
||||
Err(e) => {
|
||||
eprintln!(" SKIP {}: {}", stem, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Find the layer with effects
|
||||
let layer = match layers.iter().find(|l| !l.effects.is_empty()) {
|
||||
Some(l) => l,
|
||||
None => { eprintln!(" SKIP {}: no effects layer", stem); return; }
|
||||
None => {
|
||||
eprintln!(" SKIP {}: no effects layer", stem);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let w = layer.width;
|
||||
@@ -83,7 +95,10 @@ fn render_one(stem: &str) {
|
||||
let mut sh_color = [0u8; 4];
|
||||
for e in &layer.effects {
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss {
|
||||
angle: a, highlight_color, shadow_color, ..
|
||||
angle: a,
|
||||
highlight_color,
|
||||
shadow_color,
|
||||
..
|
||||
} = e
|
||||
{
|
||||
angle = *a;
|
||||
@@ -95,21 +110,22 @@ fn render_one(stem: &str) {
|
||||
};
|
||||
|
||||
// Convert protocol effects to hcie-fx effects and run the full pipeline
|
||||
let fx_effects: Vec<hcie_fx::types::LayerEffect> = layer.effects.iter().map(|e| {
|
||||
hcie_fx::types::protocol_to_hcie_fx_effect(e)
|
||||
}).collect();
|
||||
let fx_effects: Vec<hcie_fx::types::LayerEffect> = layer
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| hcie_fx::types::protocol_to_hcie_fx_effect(e))
|
||||
.collect();
|
||||
|
||||
// Use apply_layer_effects for the full rendering pipeline
|
||||
let out = hcie_fx::apply_layer_effects(
|
||||
&layer.pixels, w, h,
|
||||
&fx_effects,
|
||||
layer.fill_opacity,
|
||||
);
|
||||
let out = hcie_fx::apply_layer_effects(&layer.pixels, w, h, &fx_effects, layer.fill_opacity);
|
||||
|
||||
// Compute MAE against reference
|
||||
let ref_img = match image::open(&png_path) {
|
||||
Ok(i) => i.to_rgba8(),
|
||||
Err(e) => { eprintln!(" SKIP ref {}: {}", stem, e); return; }
|
||||
Err(e) => {
|
||||
eprintln!(" SKIP ref {}: {}", stem, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (rw, rh) = (ref_img.width(), ref_img.height());
|
||||
let ref_raw = ref_img.into_raw();
|
||||
@@ -145,14 +161,22 @@ fn render_one(stem: &str) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize * 4;
|
||||
combined.put_pixel(x, y, image::Rgba([out[i], out[i+1], out[i+2], out[i+3]]));
|
||||
combined.put_pixel(
|
||||
x,
|
||||
y,
|
||||
image::Rgba([out[i], out[i + 1], out[i + 2], out[i + 3]]),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ref (right, after 4px gap)
|
||||
for y in 0..rh {
|
||||
for x in 0..rw {
|
||||
let i = (y * rw + x) as usize * 4;
|
||||
combined.put_pixel(w as u32 + 4 + x, y, image::Rgba([ref_raw[i], ref_raw[i+1], ref_raw[i+2], ref_raw[i+3]]));
|
||||
combined.put_pixel(
|
||||
w as u32 + 4 + x,
|
||||
y,
|
||||
image::Rgba([ref_raw[i], ref_raw[i + 1], ref_raw[i + 2], ref_raw[i + 3]]),
|
||||
);
|
||||
}
|
||||
}
|
||||
let cmp_path = format!("{}/{}_cmp.png", OUT_DIR, stem);
|
||||
@@ -184,9 +208,13 @@ fn main() {
|
||||
"be_star_emboss_d200_sz25_sf5_up_alt60_smooth",
|
||||
"be_star_inner_d200_sz25_sf5_up_alt60_smooth",
|
||||
];
|
||||
println!("Rendering {} representative samples to {}...", subset.len(), OUT_DIR);
|
||||
println!(
|
||||
"Rendering {} representative samples to {}...",
|
||||
subset.len(),
|
||||
OUT_DIR
|
||||
);
|
||||
for stem in &subset {
|
||||
render_one(stem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros)]
|
||||
use rayon::prelude::*;
|
||||
/// Grid-search Bevel & Emboss MAE tuning against Photoshop ground-truth PNGs.
|
||||
///
|
||||
/// Expects `_tmp/psd_tunes/bevel_emboss_png_set/` with transparent PNGs exported
|
||||
@@ -9,9 +10,7 @@
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo run --example tune_mae_bevel_emboss_grid -p hcie-io --release
|
||||
|
||||
use std::fs;
|
||||
use rayon::prelude::*;
|
||||
use std::io::Write;
|
||||
|
||||
const GT_DIR: &str = "_tmp/psd_tunes/bevel_emboss_png_set";
|
||||
@@ -56,14 +55,20 @@ fn main() {
|
||||
|
||||
let params = match parse_filename(&stem) {
|
||||
Some(p) => p,
|
||||
None => { eprintln!(" SKIP: {}", stem); continue; }
|
||||
None => {
|
||||
eprintln!(" SKIP: {}", stem);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (_shape, style, depth, size, soften, direction, altitude, _technique) = params;
|
||||
|
||||
let img = match image::open(&path) {
|
||||
Ok(i) => i,
|
||||
Err(e) => { eprintln!(" SKIP {}: {}", stem, e); continue; }
|
||||
Err(e) => {
|
||||
eprintln!(" SKIP {}: {}", stem, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let rgba = img.to_rgba8();
|
||||
@@ -77,17 +82,24 @@ fn main() {
|
||||
}
|
||||
|
||||
let psd_path_str = format!("_tmp/psd_tunes/bevel_emboss_psd_set/{}.psd", stem);
|
||||
let (layer_pixels, fx_effects, fill_opacity, angle) = if let Ok(layers) = hcie_psd::import_psd(std::path::Path::new(&psd_path_str)) {
|
||||
let (layer_pixels, fx_effects, fill_opacity, angle) = if let Ok(layers) =
|
||||
hcie_psd::import_psd(std::path::Path::new(&psd_path_str))
|
||||
{
|
||||
let mut found = None;
|
||||
for l in layers {
|
||||
if !l.effects.is_empty() {
|
||||
let fx_effects: Vec<hcie_fx::types::LayerEffect> = l.effects.iter()
|
||||
let fx_effects: Vec<hcie_fx::types::LayerEffect> = l
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| hcie_fx::types::protocol_to_hcie_fx_effect(e))
|
||||
.collect();
|
||||
// Extract angle from BevelEmboss effect
|
||||
let mut angle = 120.0f32;
|
||||
for e in &l.effects {
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss { angle: a, .. } = e {
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss {
|
||||
angle: a, ..
|
||||
} = e
|
||||
{
|
||||
angle = *a;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +109,10 @@ fn main() {
|
||||
}
|
||||
match found {
|
||||
Some(v) => v,
|
||||
None => { eprintln!(" SKIP no effects layer: {}", stem); continue; }
|
||||
None => {
|
||||
eprintln!(" SKIP no effects layer: {}", stem);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!(" SKIP PSD missing for {}: {}", stem, psd_path_str);
|
||||
@@ -106,14 +121,20 @@ fn main() {
|
||||
|
||||
samples.push(Sample {
|
||||
name: stem,
|
||||
style, depth, size, soften, direction, altitude,
|
||||
style,
|
||||
depth,
|
||||
size,
|
||||
soften,
|
||||
direction,
|
||||
altitude,
|
||||
angle,
|
||||
ref_pixels: raw,
|
||||
content_mask,
|
||||
layer_pixels,
|
||||
fx_effects,
|
||||
fill_opacity,
|
||||
w, h,
|
||||
w,
|
||||
h,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,7 +159,12 @@ fn main() {
|
||||
let tilt_scales: &[f32] = &[0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0, 8.0];
|
||||
|
||||
let total_combos = lighting_blurs.len() * hl_scales.len() * sh_scales.len() * tilt_scales.len();
|
||||
println!("Grid: {} combos x {} samples = {} evals", total_combos, samples.len(), total_combos * samples.len());
|
||||
println!(
|
||||
"Grid: {} combos x {} samples = {} evals",
|
||||
total_combos,
|
||||
samples.len(),
|
||||
total_combos * samples.len()
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
let mut best_avg_mae = f64::MAX;
|
||||
@@ -149,133 +175,206 @@ fn main() {
|
||||
for &hl_scale in hl_scales {
|
||||
for &sh_scale in sh_scales {
|
||||
for &tilt_scale in tilt_scales {
|
||||
let total_mae: f64 = samples.par_iter().map(|s| {
|
||||
// Use generate_bevel_tuned to get highlight/shadow buffers
|
||||
let style_enum = match s.style.as_str() {
|
||||
"emboss" => hcie_fx::types::BevelStyle::Emboss,
|
||||
"outer" => hcie_fx::types::BevelStyle::OuterBevel,
|
||||
_ => hcie_fx::types::BevelStyle::InnerBevel,
|
||||
};
|
||||
let dir_enum = match s.direction.as_str() {
|
||||
"down" => hcie_fx::types::Direction::Down,
|
||||
_ => hcie_fx::types::Direction::Up,
|
||||
};
|
||||
let total_mae: f64 = samples
|
||||
.par_iter()
|
||||
.map(|s| {
|
||||
// Use generate_bevel_tuned to get highlight/shadow buffers
|
||||
let style_enum = match s.style.as_str() {
|
||||
"emboss" => hcie_fx::types::BevelStyle::Emboss,
|
||||
"outer" => hcie_fx::types::BevelStyle::OuterBevel,
|
||||
_ => hcie_fx::types::BevelStyle::InnerBevel,
|
||||
};
|
||||
let dir_enum = match s.direction.as_str() {
|
||||
"down" => hcie_fx::types::Direction::Down,
|
||||
_ => hcie_fx::types::Direction::Up,
|
||||
};
|
||||
|
||||
let (highlight, shadow) = hcie_fx::tuned::generate_bevel_tuned(
|
||||
&s.layer_pixels.iter().skip(3).step_by(4).copied().collect::<Vec<u8>>(),
|
||||
s.w, s.h,
|
||||
s.depth, s.size, s.soften, s.angle, s.altitude,
|
||||
&dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum,
|
||||
lighting_blur, hl_scale, sh_scale, 1.0, tilt_scale,
|
||||
);
|
||||
let (highlight, shadow) = hcie_fx::tuned::generate_bevel_tuned(
|
||||
&s.layer_pixels
|
||||
.iter()
|
||||
.skip(3)
|
||||
.step_by(4)
|
||||
.copied()
|
||||
.collect::<Vec<u8>>(),
|
||||
s.w,
|
||||
s.h,
|
||||
s.depth,
|
||||
s.size,
|
||||
s.soften,
|
||||
s.angle,
|
||||
s.altitude,
|
||||
&dir_enum,
|
||||
&hcie_fx::types::Technique::Smooth,
|
||||
&style_enum,
|
||||
lighting_blur,
|
||||
hl_scale,
|
||||
sh_scale,
|
||||
1.0,
|
||||
tilt_scale,
|
||||
);
|
||||
|
||||
let px = (s.w * s.h) as usize;
|
||||
let mut out = vec![0u8; px * 4];
|
||||
let px = (s.w * s.h) as usize;
|
||||
let mut out = vec![0u8; px * 4];
|
||||
|
||||
// Base shape: fill with layer pixels (black fill)
|
||||
for i in 0..px {
|
||||
if s.layer_pixels[i * 4 + 3] > 0 {
|
||||
out[i * 4] = s.layer_pixels[i * 4];
|
||||
out[i * 4 + 1] = s.layer_pixels[i * 4 + 1];
|
||||
out[i * 4 + 2] = s.layer_pixels[i * 4 + 2];
|
||||
out[i * 4 + 3] = s.layer_pixels[i * 4 + 3];
|
||||
}
|
||||
}
|
||||
|
||||
// For emboss/outerbevel: outside highlight/shadow go to empty canvas (behind)
|
||||
let has_outer = matches!(style_enum, hcie_fx::types::BevelStyle::Emboss | hcie_fx::types::BevelStyle::OuterBevel);
|
||||
|
||||
// Get opacity and blend mode from fx_effects
|
||||
let (hl_op, sh_op, hl_bm, sh_bm) = {
|
||||
let mut hl_op = 0.75f32;
|
||||
let mut sh_op = 0.75f32;
|
||||
let mut hl_bm = hcie_blend::BlendMode::Screen;
|
||||
let mut sh_bm = hcie_blend::BlendMode::Multiply;
|
||||
for e in &s.fx_effects {
|
||||
if let hcie_fx::types::LayerEffect::BevelEmboss {
|
||||
highlight_opacity, shadow_opacity,
|
||||
highlight_blend, shadow_blend, ..
|
||||
} = e {
|
||||
hl_op = *highlight_opacity / 100.0;
|
||||
sh_op = *shadow_opacity / 100.0;
|
||||
hl_bm = *highlight_blend;
|
||||
sh_bm = *shadow_blend;
|
||||
// Base shape: fill with layer pixels (black fill)
|
||||
for i in 0..px {
|
||||
if s.layer_pixels[i * 4 + 3] > 0 {
|
||||
out[i * 4] = s.layer_pixels[i * 4];
|
||||
out[i * 4 + 1] = s.layer_pixels[i * 4 + 1];
|
||||
out[i * 4 + 2] = s.layer_pixels[i * 4 + 2];
|
||||
out[i * 4 + 3] = s.layer_pixels[i * 4 + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
(hl_op, sh_op, hl_bm, sh_bm)
|
||||
};
|
||||
|
||||
// Composite highlight — inside on fill, outside on empty canvas
|
||||
for i in 0..px {
|
||||
let sa = highlight[i * 4 + 3];
|
||||
if sa > 0 {
|
||||
let is_inner = s.layer_pixels[i * 4 + 3] > 0;
|
||||
if !has_outer || is_inner {
|
||||
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]];
|
||||
let src = [highlight[i * 4], highlight[i * 4 + 1], highlight[i * 4 + 2], sa];
|
||||
let blended = hcie_blend::blend_pixels(dst, src, hl_bm, hl_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
// For emboss/outerbevel: outside highlight/shadow go to empty canvas (behind)
|
||||
let has_outer = matches!(
|
||||
style_enum,
|
||||
hcie_fx::types::BevelStyle::Emboss
|
||||
| hcie_fx::types::BevelStyle::OuterBevel
|
||||
);
|
||||
|
||||
// Get opacity and blend mode from fx_effects
|
||||
let (hl_op, sh_op, hl_bm, sh_bm) = {
|
||||
let mut hl_op = 0.75f32;
|
||||
let mut sh_op = 0.75f32;
|
||||
let mut hl_bm = hcie_blend::BlendMode::Screen;
|
||||
let mut sh_bm = hcie_blend::BlendMode::Multiply;
|
||||
for e in &s.fx_effects {
|
||||
if let hcie_fx::types::LayerEffect::BevelEmboss {
|
||||
highlight_opacity,
|
||||
shadow_opacity,
|
||||
highlight_blend,
|
||||
shadow_blend,
|
||||
..
|
||||
} = e
|
||||
{
|
||||
hl_op = *highlight_opacity / 100.0;
|
||||
sh_op = *shadow_opacity / 100.0;
|
||||
hl_bm = *highlight_blend;
|
||||
sh_bm = *shadow_blend;
|
||||
}
|
||||
}
|
||||
(hl_op, sh_op, hl_bm, sh_bm)
|
||||
};
|
||||
|
||||
// Composite highlight — inside on fill, outside on empty canvas
|
||||
for i in 0..px {
|
||||
let sa = highlight[i * 4 + 3];
|
||||
if sa > 0 {
|
||||
let is_inner = s.layer_pixels[i * 4 + 3] > 0;
|
||||
if !has_outer || is_inner {
|
||||
let dst = [
|
||||
out[i * 4],
|
||||
out[i * 4 + 1],
|
||||
out[i * 4 + 2],
|
||||
out[i * 4 + 3],
|
||||
];
|
||||
let src = [
|
||||
highlight[i * 4],
|
||||
highlight[i * 4 + 1],
|
||||
highlight[i * 4 + 2],
|
||||
sa,
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, src, hl_bm, hl_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
} else {
|
||||
// Outside: composite highlight on empty canvas
|
||||
let dst = [0u8, 0, 0, 0];
|
||||
let src = [
|
||||
highlight[i * 4],
|
||||
highlight[i * 4 + 1],
|
||||
highlight[i * 4 + 2],
|
||||
sa,
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, src, hl_bm, hl_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Composite shadow
|
||||
for i in 0..px {
|
||||
let sa = shadow[i * 4 + 3];
|
||||
if sa > 0 {
|
||||
let is_inner = s.layer_pixels[i * 4 + 3] > 0;
|
||||
if !has_outer || is_inner {
|
||||
let dst = [
|
||||
out[i * 4],
|
||||
out[i * 4 + 1],
|
||||
out[i * 4 + 2],
|
||||
out[i * 4 + 3],
|
||||
];
|
||||
let src = [
|
||||
shadow[i * 4],
|
||||
shadow[i * 4 + 1],
|
||||
shadow[i * 4 + 2],
|
||||
sa,
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, src, sh_bm, sh_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
} else {
|
||||
let dst = [
|
||||
out[i * 4],
|
||||
out[i * 4 + 1],
|
||||
out[i * 4 + 2],
|
||||
out[i * 4 + 3],
|
||||
];
|
||||
let src = [
|
||||
shadow[i * 4],
|
||||
shadow[i * 4 + 1],
|
||||
shadow[i * 4 + 2],
|
||||
sa,
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, src, sh_bm, sh_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut diff_sum = 0.0f64;
|
||||
let mut content_pixels = 0u64;
|
||||
for i in 0..px {
|
||||
if !s.content_mask[i] {
|
||||
continue;
|
||||
}
|
||||
content_pixels += 1;
|
||||
for c in 0..4 {
|
||||
diff_sum +=
|
||||
(out[i * 4 + c] as i32 - s.ref_pixels[i * 4 + c] as i32)
|
||||
.abs() as f64;
|
||||
}
|
||||
}
|
||||
if content_pixels > 0 {
|
||||
diff_sum / content_pixels as f64 / 4.0
|
||||
} else {
|
||||
// Outside: composite highlight on empty canvas
|
||||
let dst = [0u8, 0, 0, 0];
|
||||
let src = [highlight[i * 4], highlight[i * 4 + 1], highlight[i * 4 + 2], sa];
|
||||
let blended = hcie_blend::blend_pixels(dst, src, hl_bm, hl_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
// Composite shadow
|
||||
for i in 0..px {
|
||||
let sa = shadow[i * 4 + 3];
|
||||
if sa > 0 {
|
||||
let is_inner = s.layer_pixels[i * 4 + 3] > 0;
|
||||
if !has_outer || is_inner {
|
||||
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]];
|
||||
let src = [shadow[i * 4], shadow[i * 4 + 1], shadow[i * 4 + 2], sa];
|
||||
let blended = hcie_blend::blend_pixels(dst, src, sh_bm, sh_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
} else {
|
||||
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]];
|
||||
let src = [shadow[i * 4], shadow[i * 4 + 1], shadow[i * 4 + 2], sa];
|
||||
let blended = hcie_blend::blend_pixels(dst, src, sh_bm, sh_op);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
|
||||
let mut diff_sum = 0.0f64;
|
||||
let mut content_pixels = 0u64;
|
||||
for i in 0..px {
|
||||
if !s.content_mask[i] { continue; }
|
||||
content_pixels += 1;
|
||||
for c in 0..4 {
|
||||
diff_sum += (out[i * 4 + c] as i32 - s.ref_pixels[i * 4 + c] as i32).abs() as f64;
|
||||
}
|
||||
}
|
||||
if content_pixels > 0 {
|
||||
diff_sum / content_pixels as f64 / 4.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}).sum();
|
||||
eval_count += 1;
|
||||
let avg_mae = total_mae / samples.len() as f64;
|
||||
|
||||
eval_count += 1;
|
||||
let avg_mae = total_mae / samples.len() as f64;
|
||||
|
||||
if avg_mae < best_avg_mae {
|
||||
best_avg_mae = avg_mae;
|
||||
best_params = (lighting_blur, hl_scale, sh_scale, tilt_scale);
|
||||
println!("*** [{}/{}] NEW BEST avg MAE = {:.4} (lb={}, hl={:.3}, sh={:.2}, tilt={:.2})",
|
||||
if avg_mae < best_avg_mae {
|
||||
best_avg_mae = avg_mae;
|
||||
best_params = (lighting_blur, hl_scale, sh_scale, tilt_scale);
|
||||
println!("*** [{}/{}] NEW BEST avg MAE = {:.4} (lb={}, hl={:.3}, sh={:.2}, tilt={:.2})",
|
||||
eval_count, total_combos, best_avg_mae, lighting_blur, hl_scale, sh_scale, tilt_scale);
|
||||
std::io::stdout().flush().ok();
|
||||
} else if eval_count % 100 == 0 {
|
||||
println!(" [{}/{}] avg={:.4} best={:.4}", eval_count, total_combos, avg_mae, best_avg_mae);
|
||||
std::io::stdout().flush().ok();
|
||||
std::io::stdout().flush().ok();
|
||||
} else if eval_count % 100 == 0 {
|
||||
println!(
|
||||
" [{}/{}] avg={:.4} best={:.4}",
|
||||
eval_count, total_combos, avg_mae, best_avg_mae
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (lb, hl, sh, ts) = best_params;
|
||||
println!("\n=== RESULT ===");
|
||||
@@ -286,19 +385,32 @@ fn main() {
|
||||
println!(" tilt_scale = {:.2}", ts);
|
||||
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true).write(true).truncate(true).open(RESULTS_FILE)
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(RESULTS_FILE)
|
||||
{
|
||||
let _ = writeln!(file,
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"lighting_blur={} hl_scale={:.3} sh_scale={:.2} tilt_scale={:.2}\n\
|
||||
avg_mae={:.4} samples={} combos={}",
|
||||
lb, hl, sh, ts, best_avg_mae, samples.len(), total_combos);
|
||||
lb,
|
||||
hl,
|
||||
sh,
|
||||
ts,
|
||||
best_avg_mae,
|
||||
samples.len(),
|
||||
total_combos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_filename(name: &str) -> Option<(String, String, f32, f32, f32, String, f32, String)> {
|
||||
// be_{shape}_{style}_d{depth}_sz{size}_sf{soften}_{dir}_alt{altitude}_{technique}
|
||||
let parts: Vec<&str> = name.split('_').collect();
|
||||
if parts.len() < 9 || parts[0] != "be" { return None; }
|
||||
if parts.len() < 9 || parts[0] != "be" {
|
||||
return None;
|
||||
}
|
||||
let shape = parts[1].to_string();
|
||||
let style = parts[2].to_string();
|
||||
let depth = parts[3].strip_prefix('d')?.parse::<f32>().ok()?;
|
||||
@@ -307,5 +419,7 @@ fn parse_filename(name: &str) -> Option<(String, String, f32, f32, f32, String,
|
||||
let direction = parts[6].to_string();
|
||||
let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?;
|
||||
let technique = parts[8].to_string();
|
||||
Some((shape, style, depth, size, soften, direction, altitude, technique))
|
||||
}
|
||||
Some((
|
||||
shape, style, depth, size, soften, direction, altitude, technique,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)]
|
||||
#![allow(
|
||||
dead_code,
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_macros,
|
||||
unreachable_patterns
|
||||
)]
|
||||
use std::io::Write;
|
||||
|
||||
const SHADOW_RS: &str = "hcie-fx/src/emboss.rs";
|
||||
@@ -39,7 +45,16 @@ fn main() {
|
||||
let mut sty = hcie_protocol::effects::BevelStyle::InnerBevel;
|
||||
for e in &layer.effects {
|
||||
if let hcie_protocol::effects::LayerEffect::BevelEmboss {
|
||||
enabled: true, depth, size, soften, angle, altitude, direction, technique, style, ..
|
||||
enabled: true,
|
||||
depth,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
direction,
|
||||
technique,
|
||||
style,
|
||||
..
|
||||
} = e
|
||||
{
|
||||
d = *depth;
|
||||
@@ -52,19 +67,43 @@ fn main() {
|
||||
sty = style.clone();
|
||||
}
|
||||
}
|
||||
(d, sz, sof, ang, alt,
|
||||
match dir { hcie_protocol::effects::Direction::Up => hcie_fx::types::Direction::Up, _ => hcie_fx::types::Direction::Down },
|
||||
match tech { hcie_protocol::effects::Technique::Smooth => hcie_fx::types::Technique::Smooth, hcie_protocol::effects::Technique::ChiselHard => hcie_fx::types::Technique::ChiselHard, _ => hcie_fx::types::Technique::ChiselSoft },
|
||||
match sty {
|
||||
hcie_protocol::effects::BevelStyle::InnerBevel => hcie_fx::types::BevelStyle::InnerBevel,
|
||||
hcie_protocol::effects::BevelStyle::OuterBevel => hcie_fx::types::BevelStyle::OuterBevel,
|
||||
hcie_protocol::effects::BevelStyle::Emboss => hcie_fx::types::BevelStyle::Emboss,
|
||||
hcie_protocol::effects::BevelStyle::PillowEmboss => hcie_fx::types::BevelStyle::PillowEmboss,
|
||||
_ => hcie_fx::types::BevelStyle::StrokeEmboss,
|
||||
})
|
||||
(
|
||||
d,
|
||||
sz,
|
||||
sof,
|
||||
ang,
|
||||
alt,
|
||||
match dir {
|
||||
hcie_protocol::effects::Direction::Up => hcie_fx::types::Direction::Up,
|
||||
_ => hcie_fx::types::Direction::Down,
|
||||
},
|
||||
match tech {
|
||||
hcie_protocol::effects::Technique::Smooth => hcie_fx::types::Technique::Smooth,
|
||||
hcie_protocol::effects::Technique::ChiselHard => {
|
||||
hcie_fx::types::Technique::ChiselHard
|
||||
}
|
||||
_ => hcie_fx::types::Technique::ChiselSoft,
|
||||
},
|
||||
match sty {
|
||||
hcie_protocol::effects::BevelStyle::InnerBevel => {
|
||||
hcie_fx::types::BevelStyle::InnerBevel
|
||||
}
|
||||
hcie_protocol::effects::BevelStyle::OuterBevel => {
|
||||
hcie_fx::types::BevelStyle::OuterBevel
|
||||
}
|
||||
hcie_protocol::effects::BevelStyle::Emboss => hcie_fx::types::BevelStyle::Emboss,
|
||||
hcie_protocol::effects::BevelStyle::PillowEmboss => {
|
||||
hcie_fx::types::BevelStyle::PillowEmboss
|
||||
}
|
||||
_ => hcie_fx::types::BevelStyle::StrokeEmboss,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
println!("Emboss params: depth={}, size={}, soften={}, angle={}, altitude={}", depth, size, soften, angle, altitude);
|
||||
println!(
|
||||
"Emboss params: depth={}, size={}, soften={}, angle={}, altitude={}",
|
||||
depth, size, soften, angle, altitude
|
||||
);
|
||||
println!("Image: {}x{}", w, h);
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
@@ -81,28 +120,80 @@ fn main() {
|
||||
println!("Phase 1: coarse grid ({} combos)", total);
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
search(original.as_str(), before, after, &alpha, w, h, px, &ref_pixels, &layer.pixels,
|
||||
depth, size, soften, angle, altitude, &direction, &technique, &style,
|
||||
coarse_blur, coarse_hl, coarse_sh,
|
||||
&mut best_mae, &mut best_code, &mut best_params);
|
||||
search(
|
||||
original.as_str(),
|
||||
before,
|
||||
after,
|
||||
&alpha,
|
||||
w,
|
||||
h,
|
||||
px,
|
||||
&ref_pixels,
|
||||
&layer.pixels,
|
||||
depth,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
&direction,
|
||||
&technique,
|
||||
&style,
|
||||
coarse_blur,
|
||||
coarse_hl,
|
||||
coarse_sh,
|
||||
&mut best_mae,
|
||||
&mut best_code,
|
||||
&mut best_params,
|
||||
);
|
||||
|
||||
// Phase 2: fine grid around best
|
||||
let (bb, bhl, bsh) = best_params;
|
||||
let fine_blur: Vec<i32> = (bb - 4 ..= bb + 4).filter(|&v| v > 0).collect();
|
||||
let fine_hl: Vec<f32> = ((bhl * 100.0 - 50.0) as i32 ..= (bhl * 100.0 + 50.0) as i32)
|
||||
.step_by(5).map(|v| v as f32 / 100.0).filter(|&v| v > 0.0).collect();
|
||||
let fine_sh: Vec<f32> = ((bsh * 100.0 - 50.0) as i32 ..= (bsh * 100.0 + 50.0) as i32)
|
||||
.step_by(5).map(|v| v as f32 / 100.0).filter(|&v| v > 0.0).collect();
|
||||
let fine_blur: Vec<i32> = (bb - 4..=bb + 4).filter(|&v| v > 0).collect();
|
||||
let fine_hl: Vec<f32> = ((bhl * 100.0 - 50.0) as i32..=(bhl * 100.0 + 50.0) as i32)
|
||||
.step_by(5)
|
||||
.map(|v| v as f32 / 100.0)
|
||||
.filter(|&v| v > 0.0)
|
||||
.collect();
|
||||
let fine_sh: Vec<f32> = ((bsh * 100.0 - 50.0) as i32..=(bsh * 100.0 + 50.0) as i32)
|
||||
.step_by(5)
|
||||
.map(|v| v as f32 / 100.0)
|
||||
.filter(|&v| v > 0.0)
|
||||
.collect();
|
||||
|
||||
println!("\nPhase 2: fine grid around best ({} x {} x {} = {} combos)",
|
||||
fine_blur.len(), fine_hl.len(), fine_sh.len(),
|
||||
fine_blur.len() * fine_hl.len() * fine_sh.len());
|
||||
println!(
|
||||
"\nPhase 2: fine grid around best ({} x {} x {} = {} combos)",
|
||||
fine_blur.len(),
|
||||
fine_hl.len(),
|
||||
fine_sh.len(),
|
||||
fine_blur.len() * fine_hl.len() * fine_sh.len()
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
search(original.as_str(), before, after, &alpha, w, h, px, &ref_pixels, &layer.pixels,
|
||||
depth, size, soften, angle, altitude, &direction, &technique, &style,
|
||||
&fine_blur, &fine_hl, &fine_sh,
|
||||
&mut best_mae, &mut best_code, &mut best_params);
|
||||
search(
|
||||
original.as_str(),
|
||||
before,
|
||||
after,
|
||||
&alpha,
|
||||
w,
|
||||
h,
|
||||
px,
|
||||
&ref_pixels,
|
||||
&layer.pixels,
|
||||
depth,
|
||||
size,
|
||||
soften,
|
||||
angle,
|
||||
altitude,
|
||||
&direction,
|
||||
&technique,
|
||||
&style,
|
||||
&fine_blur,
|
||||
&fine_hl,
|
||||
&fine_sh,
|
||||
&mut best_mae,
|
||||
&mut best_code,
|
||||
&mut best_params,
|
||||
);
|
||||
|
||||
if best_mae < f64::MAX {
|
||||
let (bb, bhl, bsh) = best_params;
|
||||
@@ -167,7 +258,12 @@ fn main() {
|
||||
std::fs::write(SHADOW_RS, &patched).expect("write final best");
|
||||
println!("\nCompleted. Best code saved with MAE = {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})",
|
||||
best_mae, bb, bhl, bsh);
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).write(true).truncate(true).open(RESULTS_FILE) {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(RESULTS_FILE)
|
||||
{
|
||||
let _ = writeln!(file, "=== Emboss Tuned ===\n Params: blur={}, hl_scale={:.2}, sh_scale={:.2}\n MAE: {:.3}",
|
||||
bb, bhl, bsh, best_mae);
|
||||
}
|
||||
@@ -177,15 +273,28 @@ fn main() {
|
||||
}
|
||||
|
||||
fn search(
|
||||
_original: &str, _before: &str, _after: &str,
|
||||
alpha: &[u8], w: u32, h: u32, px: usize, ref_pixels: &[u8],
|
||||
_original: &str,
|
||||
_before: &str,
|
||||
_after: &str,
|
||||
alpha: &[u8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
px: usize,
|
||||
ref_pixels: &[u8],
|
||||
src_pixels: &[u8],
|
||||
depth: f32, size: f32, soften: f32, angle: f32, altitude: f32,
|
||||
depth: f32,
|
||||
size: f32,
|
||||
soften: f32,
|
||||
angle: f32,
|
||||
altitude: f32,
|
||||
direction: &hcie_fx::types::Direction,
|
||||
technique: &hcie_fx::types::Technique,
|
||||
style: &hcie_fx::types::BevelStyle,
|
||||
blur_list: &[i32], hl_list: &[f32], sh_list: &[f32],
|
||||
best_mae: &mut f64, _best_code: &mut String,
|
||||
blur_list: &[i32],
|
||||
hl_list: &[f32],
|
||||
sh_list: &[f32],
|
||||
best_mae: &mut f64,
|
||||
_best_code: &mut String,
|
||||
best_params: &mut (i32, f32, f32),
|
||||
) {
|
||||
let total = blur_list.len() * hl_list.len() * sh_list.len();
|
||||
@@ -201,38 +310,57 @@ fn search(
|
||||
}
|
||||
|
||||
let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned(
|
||||
alpha, w, h, depth, size, soften, angle, altitude,
|
||||
direction, technique, style,
|
||||
alpha, w, h, depth, size, soften, angle, altitude, direction, technique, style,
|
||||
*blur, *hl_scale, *sh_scale, 1.0, 0.05,
|
||||
);
|
||||
|
||||
let mut out = src_pixels.to_vec();
|
||||
for i in 0..px {
|
||||
let dst = [out[i*4], out[i*4+1], out[i*4+2], out[i*4+3]];
|
||||
let hl_src = [hl_buf[i*4], hl_buf[i*4+1], hl_buf[i*4+2], hl_buf[i*4+3]];
|
||||
let sh_src = [sh_buf[i*4], sh_buf[i*4+1], sh_buf[i*4+2], sh_buf[i*4+3]];
|
||||
let blended = hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75);
|
||||
let blended = hcie_blend::blend_pixels(blended, sh_src, hcie_blend::BlendMode::Multiply, 0.75);
|
||||
out[i*4..i*4+4].copy_from_slice(&blended);
|
||||
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]];
|
||||
let hl_src = [
|
||||
hl_buf[i * 4],
|
||||
hl_buf[i * 4 + 1],
|
||||
hl_buf[i * 4 + 2],
|
||||
hl_buf[i * 4 + 3],
|
||||
];
|
||||
let sh_src = [
|
||||
sh_buf[i * 4],
|
||||
sh_buf[i * 4 + 1],
|
||||
sh_buf[i * 4 + 2],
|
||||
sh_buf[i * 4 + 3],
|
||||
];
|
||||
let blended =
|
||||
hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75);
|
||||
let blended = hcie_blend::blend_pixels(
|
||||
blended,
|
||||
sh_src,
|
||||
hcie_blend::BlendMode::Multiply,
|
||||
0.75,
|
||||
);
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&blended);
|
||||
}
|
||||
|
||||
let mut diff_sum = 0.0f64;
|
||||
for i in 0..px {
|
||||
let d = (0..4).map(|c| {
|
||||
(out[i * 4 + c] as i32 - ref_pixels[i * 4 + c] as i32).abs() as f64
|
||||
}).sum::<f64>();
|
||||
let d = (0..4)
|
||||
.map(|c| {
|
||||
(out[i * 4 + c] as i32 - ref_pixels[i * 4 + c] as i32).abs() as f64
|
||||
})
|
||||
.sum::<f64>();
|
||||
diff_sum += d;
|
||||
}
|
||||
let mae = diff_sum / px as f64 / 4.0;
|
||||
|
||||
if mae < *best_mae {
|
||||
*best_mae = mae;
|
||||
*best_params = (*blur, *hl_scale, *sh_scale);
|
||||
println!("*** NEW BEST: {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})",
|
||||
best_mae, blur, hl_scale, sh_scale);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
*best_mae = mae;
|
||||
*best_params = (*blur, *hl_scale, *sh_scale);
|
||||
println!(
|
||||
"*** NEW BEST: {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})",
|
||||
best_mae, blur, hl_scale, sh_scale
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1199
-447
File diff suppressed because it is too large
Load Diff
+40
-16
@@ -35,17 +35,23 @@ pub fn create_ellipse_mask(w: u32, h: u32, cx: u32, cy: u32, rx: u32, ry: u32) -
|
||||
|
||||
/// Grow a mask by `px` pixels (4-connected dilation).
|
||||
pub fn grow_mask(mask: &mut [u8], w: u32, h: u32, px: u32) {
|
||||
if px == 0 { return; }
|
||||
if px == 0 {
|
||||
return;
|
||||
}
|
||||
let mut temp = mask.to_vec();
|
||||
for _ in 0..px {
|
||||
let old = temp.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
if old[i] > 0 { continue; }
|
||||
if old[i] > 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbors = [
|
||||
(x.wrapping_sub(1), y), (x + 1, y),
|
||||
(x, y.wrapping_sub(1)), (x, y + 1),
|
||||
(x.wrapping_sub(1), y),
|
||||
(x + 1, y),
|
||||
(x, y.wrapping_sub(1)),
|
||||
(x, y + 1),
|
||||
];
|
||||
for (nx, ny) in neighbors {
|
||||
if nx < w && ny < h {
|
||||
@@ -64,17 +70,23 @@ pub fn grow_mask(mask: &mut [u8], w: u32, h: u32, px: u32) {
|
||||
|
||||
/// Shrink a mask by `px` pixels (4-connected erosion).
|
||||
pub fn shrink_mask(mask: &mut [u8], w: u32, h: u32, px: u32) {
|
||||
if px == 0 { return; }
|
||||
if px == 0 {
|
||||
return;
|
||||
}
|
||||
let mut temp = mask.to_vec();
|
||||
for _ in 0..px {
|
||||
let old = temp.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
if old[i] == 0 { continue; }
|
||||
if old[i] == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbors = [
|
||||
(x.wrapping_sub(1), y), (x + 1, y),
|
||||
(x, y.wrapping_sub(1)), (x, y + 1),
|
||||
(x.wrapping_sub(1), y),
|
||||
(x + 1, y),
|
||||
(x, y.wrapping_sub(1)),
|
||||
(x, y + 1),
|
||||
];
|
||||
let mut has_zero = false;
|
||||
for (nx, ny) in neighbors {
|
||||
@@ -96,7 +108,9 @@ pub fn shrink_mask(mask: &mut [u8], w: u32, h: u32, px: u32) {
|
||||
/// a smooth falloff. This properly creates soft transitions at the border
|
||||
/// by including previously-unselected pixels within the feather radius.
|
||||
pub fn feather_mask(mask: &mut [u8], w: u32, h: u32, radius: f32) {
|
||||
if radius <= 0.0 { return; }
|
||||
if radius <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let r = radius.ceil() as i32;
|
||||
let r_sq = (radius * radius) as f32;
|
||||
let size = (w * h) as usize;
|
||||
@@ -114,7 +128,9 @@ pub fn feather_mask(mask: &mut [u8], w: u32, h: u32, radius: f32) {
|
||||
// Check if any selected pixel is within radius
|
||||
'outer: for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if (dx * dx + dy * dy) as f32 > r_sq { continue; }
|
||||
if (dx * dx + dy * dy) as f32 > r_sq {
|
||||
continue;
|
||||
}
|
||||
let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as u32;
|
||||
let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as u32;
|
||||
if mask[(ny * w + nx) as usize] > 0 {
|
||||
@@ -294,7 +310,9 @@ pub fn lasso_fill_mask(mask: &mut [u8], w: u32, h: u32, points: &[(u32, u32)]) {
|
||||
/// - `w`, `h`: Canvas dimensions.
|
||||
/// - `thickness`: Border thickness in pixels.
|
||||
pub fn border_mask(mask: &mut [u8], w: u32, h: u32, thickness: u32) {
|
||||
if thickness == 0 { return; }
|
||||
if thickness == 0 {
|
||||
return;
|
||||
}
|
||||
let original = mask.to_vec();
|
||||
|
||||
// First, create the outer border by eroding
|
||||
@@ -304,10 +322,14 @@ pub fn border_mask(mask: &mut [u8], w: u32, h: u32, thickness: u32) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
if old[i] == 0 { continue; }
|
||||
if old[i] == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbors = [
|
||||
(x.wrapping_sub(1), y), (x + 1, y),
|
||||
(x, y.wrapping_sub(1)), (x, y + 1),
|
||||
(x.wrapping_sub(1), y),
|
||||
(x + 1, y),
|
||||
(x, y.wrapping_sub(1)),
|
||||
(x, y + 1),
|
||||
];
|
||||
let mut has_zero = false;
|
||||
for (nx, ny) in neighbors {
|
||||
@@ -343,7 +365,9 @@ pub fn border_mask(mask: &mut [u8], w: u32, h: u32, thickness: u32) {
|
||||
/// - `w`, `h`: Canvas dimensions.
|
||||
/// - `radius`: Blur radius in pixels.
|
||||
pub fn smooth_mask(mask: &mut [u8], w: u32, h: u32, radius: u32) {
|
||||
if radius == 0 { return; }
|
||||
if radius == 0 {
|
||||
return;
|
||||
}
|
||||
let r = radius as i32;
|
||||
let size = (w * h) as usize;
|
||||
let mut result = vec![0u8; size];
|
||||
@@ -365,4 +389,4 @@ pub fn smooth_mask(mask: &mut [u8], w: u32, h: u32, radius: u32) {
|
||||
}
|
||||
|
||||
mask.copy_from_slice(&result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,36 @@ fn main() {
|
||||
eprintln!("Usage: panel-tuner <config.json>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let config: Config = serde_json::from_reader(
|
||||
std::fs::File::open(&args[1]).expect("Failed to open config"),
|
||||
).expect("Failed to parse config");
|
||||
let config: Config =
|
||||
serde_json::from_reader(std::fs::File::open(&args[1]).expect("Failed to open config"))
|
||||
.expect("Failed to parse config");
|
||||
|
||||
std::fs::create_dir_all(&config.output_dir).ok();
|
||||
|
||||
let mut best: Option<(Vec<f32>, f64, PathBuf)> = None;
|
||||
let combinations = cartesian_product(&config.parameters.iter().map(|p| p.1.clone()).collect::<Vec<_>>());
|
||||
let combinations = cartesian_product(
|
||||
&config
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.1.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
for (idx, values) in combinations.iter().enumerate() {
|
||||
let out = config.output_dir.join(format!("capture_{}.png", idx));
|
||||
let mut cmd = Command::new(&config.gui_binary);
|
||||
for (name, value) in config.parameters.iter().map(|p| p.0.clone()).zip(values.iter()) {
|
||||
cmd.env(format!("HCIE_TUNER_{}", name.to_uppercase()), value.to_string());
|
||||
for (name, value) in config
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| p.0.clone())
|
||||
.zip(values.iter())
|
||||
{
|
||||
cmd.env(
|
||||
format!("HCIE_TUNER_{}", name.to_uppercase()),
|
||||
value.to_string(),
|
||||
);
|
||||
}
|
||||
cmd.arg("--screenshot-panel")
|
||||
.arg(&config.panel)
|
||||
.arg(&out);
|
||||
cmd.arg("--screenshot-panel").arg(&config.panel).arg(&out);
|
||||
|
||||
let status = cmd.status().expect("Failed to run GUI binary");
|
||||
if !status.success() {
|
||||
@@ -44,10 +56,7 @@ fn main() {
|
||||
|
||||
let diff_out = config.output_dir.join(format!("diff_{}.png", idx));
|
||||
let mae = run_diff(&config.target, &out, &diff_out);
|
||||
eprintln!(
|
||||
"Run {}: params={:?} MAE={:.6}",
|
||||
idx, values, mae
|
||||
);
|
||||
eprintln!("Run {}: params={:?} MAE={:.6}", idx, values, mae);
|
||||
if best.as_ref().map_or(true, |b| mae < b.1) {
|
||||
best = Some((values.clone(), mae, out.clone()));
|
||||
}
|
||||
|
||||
@@ -8,12 +8,23 @@ fn main() {
|
||||
}
|
||||
let target_path = PathBuf::from(&args[1]);
|
||||
let actual_path = PathBuf::from(&args[2]);
|
||||
let save_diff = args.iter().position(|a| a == "--save-diff").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
||||
let save_diff = args
|
||||
.iter()
|
||||
.position(|a| a == "--save-diff")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(PathBuf::from);
|
||||
|
||||
let target = image::open(&target_path).expect("Failed to open target").to_rgba8();
|
||||
let actual = image::open(&actual_path).expect("Failed to open actual").to_rgba8();
|
||||
let target = image::open(&target_path)
|
||||
.expect("Failed to open target")
|
||||
.to_rgba8();
|
||||
let actual = image::open(&actual_path)
|
||||
.expect("Failed to open actual")
|
||||
.to_rgba8();
|
||||
|
||||
let (w, h) = (target.width().min(actual.width()), target.height().min(actual.height()));
|
||||
let (w, h) = (
|
||||
target.width().min(actual.width()),
|
||||
target.height().min(actual.height()),
|
||||
);
|
||||
if w == 0 || h == 0 {
|
||||
eprintln!("One of the images has zero size");
|
||||
std::process::exit(1);
|
||||
|
||||
Reference in New Issue
Block a user