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:
2026-07-16 22:10:22 +03:00
parent 1240d25011
commit b09795ccff
72 changed files with 11898 additions and 3115 deletions
+246
View File
@@ -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.
+160 -54
View File
@@ -2,7 +2,7 @@
use eframe::egui; use eframe::egui;
use eframe::egui::Color32; use eframe::egui::Color32;
use egui::ColorImage; use egui::ColorImage;
use hcie_brush_engine::{BrushStyle, presets::BrushPreset}; use hcie_brush_engine::{presets::BrushPreset, BrushStyle};
// ── tunables ───────────────────────────────────────────────────────────────── // ── tunables ─────────────────────────────────────────────────────────────────
const TILE_SIZE: u32 = 512; const TILE_SIZE: u32 = 512;
@@ -23,38 +23,55 @@ struct SliderCaps {
fn slider_caps(style: BrushStyle) -> SliderCaps { fn slider_caps(style: BrushStyle) -> SliderCaps {
match style { match style {
BrushStyle::Calligraphy => SliderCaps { BrushStyle::Calligraphy => SliderCaps {
angle: true, roundness: true, jitter: false, scatter: false, angle: true,
spray_particle_size: false, spray_density: false, roundness: true,
jitter: false,
scatter: false,
spray_particle_size: false,
spray_density: false,
..SliderCaps::common() ..SliderCaps::common()
}, },
BrushStyle::Pencil | BrushStyle::InkPen => SliderCaps { BrushStyle::Pencil | BrushStyle::InkPen => SliderCaps {
jitter: true, ..SliderCaps::common() jitter: true,
..SliderCaps::common()
}, },
BrushStyle::Spray | BrushStyle::Airbrush => SliderCaps { BrushStyle::Spray | BrushStyle::Airbrush => SliderCaps {
jitter: true, scatter: true, jitter: true,
spray_particle_size: true, spray_density: true, scatter: true,
spray_particle_size: true,
spray_density: true,
..SliderCaps::common() ..SliderCaps::common()
}, },
BrushStyle::Charcoal => SliderCaps { BrushStyle::Charcoal => SliderCaps {
jitter: true, scatter: true, ..SliderCaps::common() jitter: true,
scatter: true,
..SliderCaps::common()
}, },
BrushStyle::Crayon => SliderCaps { BrushStyle::Crayon => SliderCaps {
jitter: true, scatter: true, ..SliderCaps::common() jitter: true,
scatter: true,
..SliderCaps::common()
}, },
BrushStyle::Tree => SliderCaps { BrushStyle::Tree => SliderCaps {
jitter: true, angle: true, ..SliderCaps::common() jitter: true,
angle: true,
..SliderCaps::common()
}, },
BrushStyle::Meadow | BrushStyle::Rock | BrushStyle::Dirt => SliderCaps { BrushStyle::Meadow | BrushStyle::Rock | BrushStyle::Dirt => SliderCaps {
jitter: true, ..SliderCaps::common() jitter: true,
..SliderCaps::common()
}, },
BrushStyle::Bristle => SliderCaps { BrushStyle::Bristle => SliderCaps {
roundness: true, ..SliderCaps::common() roundness: true,
..SliderCaps::common()
}, },
BrushStyle::HardRound | BrushStyle::SoftRound => SliderCaps { BrushStyle::HardRound | BrushStyle::SoftRound => SliderCaps {
hardness: false, ..SliderCaps::common() hardness: false,
..SliderCaps::common()
}, },
BrushStyle::Mixer | BrushStyle::Blender => SliderCaps { BrushStyle::Mixer | BrushStyle::Blender => SliderCaps {
flow: false, ..SliderCaps::common() flow: false,
..SliderCaps::common()
}, },
_ => SliderCaps::common(), _ => SliderCaps::common(),
} }
@@ -63,20 +80,32 @@ fn slider_caps(style: BrushStyle) -> SliderCaps {
impl SliderCaps { impl SliderCaps {
fn common() -> Self { fn common() -> Self {
Self { Self {
hardness: true, opacity: true, spacing: true, flow: true, hardness: true,
jitter: false, scatter: false, angle: false, roundness: false, opacity: true,
spray_particle_size: false, spray_density: false, spacing: true,
flow: true,
jitter: false,
scatter: false,
angle: false,
roundness: false,
spray_particle_size: false,
spray_density: false,
} }
} }
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
enum CanvasPreset { S512, S1024, S2048, S4096 } enum CanvasPreset {
S512,
S1024,
S2048,
S4096,
}
impl CanvasPreset { impl CanvasPreset {
fn size(self) -> (u32, u32) { fn size(self) -> (u32, u32) {
match self { match self {
Self::S512 => (512, 512), Self::S512 => (512, 512),
Self::S1024 => (1024, 1024), Self::S1024 => (1024, 1024),
Self::S2048 => (2048, 2048), Self::S2048 => (2048, 2048),
Self::S4096 => (4096, 4096), Self::S4096 => (4096, 4096),
@@ -84,7 +113,7 @@ impl CanvasPreset {
} }
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::S512 => "512", Self::S512 => "512",
Self::S1024 => "1024", Self::S1024 => "1024",
Self::S2048 => "2048", Self::S2048 => "2048",
Self::S4096 => "4096", Self::S4096 => "4096",
@@ -93,21 +122,26 @@ impl CanvasPreset {
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
enum FpsCap { Unlimited, Fps30, Fps60, Fps165 } enum FpsCap {
Unlimited,
Fps30,
Fps60,
Fps165,
}
impl FpsCap { impl FpsCap {
fn value(self) -> Option<f32> { fn value(self) -> Option<f32> {
match self { match self {
Self::Fps30 => Some(30.0), Self::Fps30 => Some(30.0),
Self::Fps60 => Some(60.0), Self::Fps60 => Some(60.0),
Self::Fps165 => Some(165.0), Self::Fps165 => Some(165.0),
Self::Unlimited => None, Self::Unlimited => None,
} }
} }
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Fps30 => "30", Self::Fps30 => "30",
Self::Fps60 => "60", Self::Fps60 => "60",
Self::Fps165 => "165", Self::Fps165 => "165",
Self::Unlimited => "", Self::Unlimited => "",
} }
@@ -137,7 +171,12 @@ impl TiledCanvas {
let total = (cols * rows) as usize; let total = (cols * rows) as usize;
Self { Self {
pixels: vec![255; (w * h * 4) as usize], 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, w,
h, h,
cols, cols,
@@ -181,18 +220,28 @@ impl TiledCanvas {
break; break;
} }
let e2 = 2.0 * err; let e2 = 2.0 * err;
if e2 > -dy { err -= dy; x0 += sx; } if e2 > -dy {
if e2 < dx { err += dx; y0 += sy; } err -= dy;
x0 += sx;
}
if e2 < dx {
err += dx;
y0 += sy;
}
} }
} }
fn mark_all_dirty(&mut self) { 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(); self.dirty_count = self.tiles.len();
} }
fn upload_dirty(&mut self, ctx: &egui::Context) { 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); let rows = self.h.div_ceil(TILE_SIZE);
for ty in 0..rows { for ty in 0..rows {
let tile_h = if ty == rows - 1 { let tile_h = if ty == rows - 1 {
@@ -202,7 +251,9 @@ impl TiledCanvas {
} as usize; } as usize;
for tx in 0..self.cols { for tx in 0..self.cols {
let idx = (ty * self.cols + tx) as usize; 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.tiles[idx].dirty = false;
self.dirty_count = self.dirty_count.saturating_sub(1); 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]); .copy_from_slice(&self.pixels[src..src + tile_w * 4]);
} }
let img = ColorImage::from_rgba_unmultiplied( let img = ColorImage::from_rgba_unmultiplied([tile_w, tile_h], &data);
[tile_w, tile_h],
&data,
);
let key = format!("t{}_{}", tx, ty); let key = format!("t{}_{}", tx, ty);
if let Some(tex) = &mut self.tiles[idx].texture { if let Some(tex) = &mut self.tiles[idx].texture {
tex.set(img, egui::TextureOptions::NEAREST); tex.set(img, egui::TextureOptions::NEAREST);
@@ -274,7 +322,9 @@ impl App {
} }
fn set_canvas_size(&mut self, p: CanvasPreset) { fn set_canvas_size(&mut self, p: CanvasPreset) {
if self.canvas_preset == p { return; } if self.canvas_preset == p {
return;
}
self.canvas_preset = p; self.canvas_preset = p;
let (w, h) = p.size(); let (w, h) = p.size();
self.canvas = TiledCanvas::new(w, h); self.canvas = TiledCanvas::new(w, h);
@@ -325,8 +375,16 @@ impl eframe::App for App {
egui::ComboBox::from_id_salt("canvas_preset") egui::ComboBox::from_id_salt("canvas_preset")
.selected_text(self.canvas_preset.name()) .selected_text(self.canvas_preset.name())
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for p in &[CanvasPreset::S512, CanvasPreset::S1024, CanvasPreset::S2048, CanvasPreset::S4096] { for p in &[
if ui.selectable_label(self.canvas_preset == *p, p.name()).clicked() { CanvasPreset::S512,
CanvasPreset::S1024,
CanvasPreset::S2048,
CanvasPreset::S4096,
] {
if ui
.selectable_label(self.canvas_preset == *p, p.name())
.clicked()
{
self.set_canvas_size(*p); self.set_canvas_size(*p);
} }
} }
@@ -336,7 +394,12 @@ impl eframe::App for App {
egui::ComboBox::from_id_salt("fps_cap") egui::ComboBox::from_id_salt("fps_cap")
.selected_text(self.fps_cap.name()) .selected_text(self.fps_cap.name())
.show_ui(ui, |ui| { .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() { if ui.selectable_label(self.fps_cap == *c, c.name()).clicked() {
self.fps_cap = *c; 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")); ui.add(egui::Slider::new(&mut self.preset.tip.size, 1.0..=500.0).text("Size"));
let caps = slider_caps(self.preset.style); 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(
ui.add_enabled(caps.opacity, egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity")); caps.hardness,
ui.add_enabled(caps.spacing, egui::Slider::new(&mut self.preset.tip.spacing, 0.01..=1.0).text("Spacing")); egui::Slider::new(&mut self.preset.tip.hardness, 0.0..=1.0).text("Hardness"),
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(
ui.add_enabled(caps.scatter, egui::Slider::new(&mut self.preset.tip.scatter_amount, 0.0..=1.0).text("Scatter")); caps.opacity,
ui.add_enabled(caps.angle, egui::Slider::new(&mut self.preset.tip.angle, 0.0..=360.0).text("Angle")); egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity"),
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(
ui.add_enabled(caps.spray_density, egui::Slider::new(&mut self.preset.tip.spray_density, 1..=500).text("Spray Density")); 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 = [ let mut rgba = [
self.color[0] as f32 / 255., 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() { if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
self.color = [ self.color = [
(rgba[0] * 255.) as u8, (rgba[1] * 255.) as u8, (rgba[0] * 255.) as u8,
(rgba[2] * 255.) as u8, (rgba[3] * 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()); ui.allocate_exact_size(egui::vec2(disp_w, disp_h), egui::Sense::drag());
// paint tiles // 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); let rows = self.canvas.h.div_ceil(TILE_SIZE);
for ty in 0..rows { for ty in 0..rows {
for tx in 0..self.canvas.cols { for tx in 0..self.canvas.cols {
@@ -438,7 +541,8 @@ impl eframe::App for App {
if let Some(pos) = resp.interact_pointer_pos() { if let Some(pos) = resp.interact_pointer_pos() {
let cx = (pos.x - rect.min.x) / scale; let cx = (pos.x - rect.min.x) / scale;
let cy = (pos.y - rect.min.y) / 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 && cx < self.canvas.w as f32
&& cy < self.canvas.h as f32 && cy < self.canvas.h as f32
{ {
@@ -450,8 +554,10 @@ impl eframe::App for App {
}; };
self.last_pt = Some(pt); self.last_pt = Some(pt);
self.canvas.mark_line_dirty( self.canvas.mark_line_dirty(
seg[0].0, seg[0].1, seg[0].0,
seg[seg.len() - 1].0, seg[seg.len() - 1].1, seg[0].1,
seg[seg.len() - 1].0,
seg[seg.len() - 1].1,
self.preset.tip.size, self.preset.tip.size,
); );
hcie_brush_engine::draw_specialized_stroke( hcie_brush_engine::draw_specialized_stroke(
+23 -9
View File
@@ -5,9 +5,9 @@ use hcie_protocol::BrushTip;
/// Per-stroke dynamic parameters computed from input state. /// Per-stroke dynamic parameters computed from input state.
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct StrokeDynamics { pub struct StrokeDynamics {
pub pressure: f32, // 0.01.0 pub pressure: f32, // 0.01.0
pub velocity: f32, // Pixels per frame pub velocity: f32, // Pixels per frame
pub tilt: (f32, f32), // (x_tilt, y_tilt) in degrees pub tilt: (f32, f32), // (x_tilt, y_tilt) in degrees
} }
/// Compute effective brush size from base size, dynamics, and pressure. /// 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. /// 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 { if !pressure_sensitive {
return base_opacity; return base_opacity;
} }
@@ -47,7 +51,12 @@ pub fn velocity_size_modifier(velocity: f32) -> f32 {
} }
/// Build a `BrushTip` with dynamics applied. /// 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(); let mut tip = base.clone();
tip.size = effective_size(base.size, dynamics, pressure_size); tip.size = effective_size(base.size, dynamics, pressure_size);
tip.opacity = effective_opacity(base.opacity, dynamics, pressure_opacity); tip.opacity = effective_opacity(base.opacity, dynamics, pressure_opacity);
@@ -70,7 +79,9 @@ pub struct VelocityTracker {
} }
impl 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 { pub fn update(&mut self, x: f32, y: f32, time: f32) -> f32 {
let velocity = match (self.last_pos, self.last_time) { 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] { pub fn jitter_color(color: [u8; 4], variation: u8, rng: &mut rand::rngs::ThreadRng) -> [u8; 4] {
use rand::Rng; use rand::Rng;
let jitter = variation as f32 / 255.0; 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 r = (color[0] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0)
let g = (color[1] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8; 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 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]] [r, g, b, color[3]]
} }
+1156 -189
View File
File diff suppressed because it is too large Load Diff
+37 -34
View File
@@ -1,6 +1,6 @@
//! Brush presets factory — ported from V2 `hcie-core/src/brush.rs`. //! 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 { fn round_soft(size: f32) -> BrushTip {
BrushTip { BrushTip {
@@ -59,7 +59,8 @@ fn pencil(size: f32) -> BrushTip {
opacity: 1.0, opacity: 1.0,
hardness: 1.0, hardness: 1.0,
spacing: 0.05, spacing: 0.05,
flow: 1.0, jitter_amount: 0.15, flow: 1.0,
jitter_amount: 0.15,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -83,7 +84,8 @@ fn spray(size: f32) -> BrushTip {
opacity: 0.6, opacity: 0.6,
hardness: 0.3, hardness: 0.3,
spacing: 0.35, spacing: 0.35,
flow: 1.0, jitter_amount: 0.4, flow: 1.0,
jitter_amount: 0.4,
scatter_amount: 0.6, scatter_amount: 0.6,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -107,7 +109,8 @@ fn calligraphy(size: f32) -> BrushTip {
opacity: 0.9, opacity: 0.9,
hardness: 0.7, hardness: 0.7,
spacing: 0.08, spacing: 0.08,
flow: 1.0, jitter_amount: 0.0, flow: 1.0,
jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 45.0_f32.to_radians(), angle: 45.0_f32.to_radians(),
roundness: 0.15, roundness: 0.15,
@@ -196,8 +199,8 @@ impl BrushPreset {
opacity: 0.4, opacity: 0.4,
hardness: 0.0, hardness: 0.0,
spacing: 0.03, spacing: 0.03,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -247,8 +250,8 @@ impl BrushPreset {
opacity: 1.0, opacity: 1.0,
hardness: 0.95, hardness: 0.95,
spacing: 0.03, spacing: 0.03,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -283,8 +286,8 @@ impl BrushPreset {
opacity: 0.7, opacity: 0.7,
hardness: 0.5, hardness: 0.5,
spacing: 0.15, spacing: 0.15,
flow: 1.0, flow: 1.0,
jitter_amount: 2.0, jitter_amount: 2.0,
scatter_amount: 0.5, scatter_amount: 0.5,
angle: 0.0, angle: 0.0,
roundness: 0.8, roundness: 0.8,
@@ -319,8 +322,8 @@ impl BrushPreset {
opacity: 0.85, opacity: 0.85,
hardness: 0.9, hardness: 0.9,
spacing: 0.08, spacing: 0.08,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -355,8 +358,8 @@ impl BrushPreset {
opacity: 0.25, opacity: 0.25,
hardness: 0.0, hardness: 0.0,
spacing: 0.08, spacing: 0.08,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -391,8 +394,8 @@ impl BrushPreset {
opacity: 0.35, opacity: 0.35,
hardness: 0.0, hardness: 0.0,
spacing: 0.06, spacing: 0.06,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -427,8 +430,8 @@ impl BrushPreset {
opacity: 0.95, opacity: 0.95,
hardness: 0.85, hardness: 0.85,
spacing: 0.04, spacing: 0.04,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -463,8 +466,8 @@ impl BrushPreset {
opacity: 0.95, opacity: 0.95,
hardness: 0.7, hardness: 0.7,
spacing: 0.04, spacing: 0.04,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -499,8 +502,8 @@ impl BrushPreset {
opacity: 0.85, opacity: 0.85,
hardness: 0.5, hardness: 0.5,
spacing: 0.12, spacing: 0.12,
flow: 1.0, flow: 1.0,
jitter_amount: 1.0, jitter_amount: 1.0,
scatter_amount: 0.2, scatter_amount: 0.2,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -612,8 +615,8 @@ impl BrushPreset {
opacity: 0.9, opacity: 0.9,
hardness: 0.8, hardness: 0.8,
spacing: 0.40, spacing: 0.40,
flow: 1.0, flow: 1.0,
jitter_amount: 0.8, jitter_amount: 0.8,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -648,8 +651,8 @@ impl BrushPreset {
opacity: 0.75, opacity: 0.75,
hardness: 0.6, hardness: 0.6,
spacing: 0.35, spacing: 0.35,
flow: 1.0, flow: 1.0,
jitter_amount: 0.3, jitter_amount: 0.3,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -699,8 +702,8 @@ impl BrushPreset {
opacity: 0.85, opacity: 0.85,
hardness: 0.7, hardness: 0.7,
spacing: 0.18, spacing: 0.18,
flow: 1.0, flow: 1.0,
jitter_amount: 0.5, jitter_amount: 0.5,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -735,8 +738,8 @@ impl BrushPreset {
opacity: 0.9, opacity: 0.9,
hardness: 0.6, hardness: 0.6,
spacing: 0.30, spacing: 0.30,
flow: 1.0, flow: 1.0,
jitter_amount: 0.3, jitter_amount: 0.3,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 90.0, angle: 90.0,
roundness: 1.0, roundness: 1.0,
@@ -773,8 +776,8 @@ impl BrushPreset {
opacity: 0.8, opacity: 0.8,
hardness: 1.0, hardness: 1.0,
spacing: 0.40, spacing: 0.40,
flow: 1.0, flow: 1.0,
jitter_amount: 4.0, jitter_amount: 4.0,
scatter_amount: 0.6, scatter_amount: 0.6,
angle: 0.0, angle: 0.0,
roundness: 1.0, roundness: 1.0,
@@ -839,8 +842,8 @@ impl BrushPreset {
opacity: 0.9, opacity: 0.9,
hardness: 0.8, hardness: 0.8,
spacing: 0.03, spacing: 0.03,
flow: 1.0, flow: 1.0,
jitter_amount: 0.0, jitter_amount: 0.0,
scatter_amount: 0.0, scatter_amount: 0.0,
angle: 0.0, angle: 0.0,
roundness: 0.8, roundness: 0.8,
+16 -4
View File
@@ -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 { fn make_round_tip(size: f32, hardness: f32) -> BrushTip {
BrushTip { BrushTip {
@@ -14,7 +14,11 @@ fn test_generate_brush_stamp_size() {
let tip = make_round_tip(10.0, 0.5); let tip = make_round_tip(10.0, 0.5);
let stamp = hcie_brush_engine::generate_brush_stamp(&tip); let stamp = hcie_brush_engine::generate_brush_stamp(&tip);
let expected_d = 20usize; 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] #[test]
@@ -30,7 +34,11 @@ fn test_generate_brush_stamp_center_nonzero() {
fn test_generate_brush_stamp_hardness_gradient() { fn test_generate_brush_stamp_hardness_gradient() {
let soft = hcie_brush_engine::generate_brush_stamp(&make_round_tip(10.0, 0.0)); 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)); 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] #[test]
@@ -38,7 +46,11 @@ fn test_brush_spacing_pixels() {
let spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.5); let spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.5);
assert_eq!(spacing, 10.0, "spacing should be size * ratio"); assert_eq!(spacing, 10.0, "spacing should be size * ratio");
let min_spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.0); 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] #[test]
+14 -2
View File
@@ -15,8 +15,20 @@ pub fn composite_layers_region(
layers: &[Layer], layers: &[Layer],
canvas_width: u32, canvas_width: u32,
canvas_height: u32, canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32, x0: u32,
y0: u32,
x1: u32,
y1: u32,
output: &mut [u8], 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,
)
} }
+281 -89
View File
@@ -1,26 +1,34 @@
use hcie_protocol::Layer;
use hcie_blend::blend_pixels; use hcie_blend::blend_pixels;
use hcie_fx::protocol_to_hcie_fx_effect; use hcie_fx::protocol_to_hcie_fx_effect;
use hcie_protocol::Layer;
use rayon::prelude::*; 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] { pub(crate) fn apply_curves(
[ r: u8,
lut_r[r as usize], g: u8,
lut_g[g as usize], b: u8,
lut_b[b as usize], 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 dr = r as f32;
let dg = g as f32; let dg = g as f32;
let db = b 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; let l = (0.30 * dr + 0.59 * dg + 0.11 * db)
[ .clamp(0.0, 255.0)
lut_r[l], .round() as usize;
lut_g[l], [lut_r[l], lut_g[l], lut_b[l]]
lut_b[l],
]
} }
pub(crate) fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { pub(crate) fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
@@ -33,7 +41,11 @@ pub(crate) fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let l = (c_max + c_min) / 2.0; let l = (c_max + c_min) / 2.0;
if delta > 1e-5 { 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 { if c_max == r {
h = (g - b) / delta + (if g < b { 6.0 } else { 0.0 }); h = (g - b) / delta + (if g < b { 6.0 } else { 0.0 });
} else if c_max == g { } else if c_max == g {
@@ -78,8 +90,12 @@ pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i3
// 1. Hue shift // 1. Hue shift
let mut new_h = h + dh as f32; let mut new_h = h + dh as f32;
while new_h < 0.0 { new_h += 360.0; } while new_h < 0.0 {
while new_h >= 360.0 { new_h -= 360.0; } new_h += 360.0;
}
while new_h >= 360.0 {
new_h -= 360.0;
}
// 2. Saturation relative/clamped shift // 2. Saturation relative/clamped shift
let s_factor = ds as f32 / 100.0; let s_factor = ds as f32 / 100.0;
@@ -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 px_count = (canvas_width * canvas_height) as usize;
let mut output = vec![0u8; px_count * 4]; let mut output = vec![0u8; px_count * 4];
log::debug!("[composite_layers_parallel] START: {} layers, canvas={}x{}, px_count={}", log::debug!(
layers.len(), canvas_width, canvas_height, px_count); "[composite_layers_parallel] START: {} layers, canvas={}x{}, px_count={}",
layers.len(),
canvas_width,
canvas_height,
px_count
);
for (i, l) in layers.iter().enumerate() { for (i, l) in layers.iter().enumerate() {
log::debug!("[composite_layers_parallel] input layer[{}]: name='{}' visible={} w={} h={} opacity={} blend={:?} clip={} pixels_len={}", 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()); 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() { for (rev_i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; } if !layer.visible {
continue;
}
if layer.layer_type == hcie_protocol::LayerType::Group { if layer.layer_type == hcie_protocol::LayerType::Group {
continue; continue;
} }
let is_adj = layer.adjustment.is_some(); 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 i = rev_i;
let blend: hcie_blend::BlendMode = layer.blend_mode.into(); 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]; let base_idx = base_indices[i];
// Apply layer effects if present // 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 { let effect_pixels = if has_effects && !is_adj {
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() { if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
if cached.width == layer.width && cached.height == layer.height { 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 None
} }
} else { } else {
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect(); let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s))); .effects
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity)) .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 { } else {
None 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); let pixels = effect_pixels.as_deref().unwrap_or(&layer.pixels);
if is_adj { if is_adj {
output.par_chunks_exact_mut(canvas_width as usize * 4) output
.par_chunks_exact_mut(canvas_width as usize * 4)
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; let gy = y as u32;
for x in 0..canvas_width { for x in 0..canvas_width {
let out_idx = x as usize * 4; let out_idx = x as usize * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]]; let dst = [
if dst[3] == 0 { continue; } 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); 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() { let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => { hcie_blend::Adjustment::Curves {
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b) lut_r,
} lut_g,
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => { lut_b,
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b) } => apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
} hcie_blend::Adjustment::GradientMap {
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => { lut_r,
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness) 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 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 blended_opaque = blend_pixels(
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8; [dst[0], dst[1], dst[2], 255],
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8; [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8; 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] = out_r;
row[out_idx + 1] = out_g; row[out_idx + 1] = out_g;
row[out_idx + 2] = out_b; row[out_idx + 2] = out_b;
@@ -206,21 +285,34 @@ pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_hei
}); });
} else { } else {
let (offset_x, offset_y) = match &layer.data { 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), _ => (0, 0),
}; };
let cx0 = (0).max(offset_x).max(0) as u32; 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 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; let cy1 = (canvas_height as i32)
if cx0 >= cx1 || cy0 >= cy1 { continue; } .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() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; 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; let ly = (y as i32 - offset_y) as u32;
for x in cx0 as usize..cx1 as usize { for x in cx0 as usize..cx1 as usize {
let lx = (x as i32 - offset_x) as u32; 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); 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 is_clipping {
if let Some(bi) = base_idx { 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; } if src[3] == 0 {
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]]; 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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[out_idx..out_idx + 4].copy_from_slice(&blended); 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(); 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={}/{} ({}%)", log::debug!(
non_zero_alpha, px_count, if px_count > 0 { non_zero_alpha * 100 / px_count } else { 0 }); "[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 output
} }
@@ -264,12 +373,17 @@ pub fn composite_layers_region_parallel(
layers: &[Layer], layers: &[Layer],
canvas_width: u32, canvas_width: u32,
canvas_height: u32, canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32, x0: u32,
y0: u32,
x1: u32,
y1: u32,
output: &mut [u8], output: &mut [u8],
) { ) {
let w = x1.saturating_sub(x0); let w = x1.saturating_sub(x0);
let h = y1.saturating_sub(y0); 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()); let mut base_indices = Vec::with_capacity(layers.len());
for i in 0..layers.len() { for i in 0..layers.len() {
@@ -288,7 +402,9 @@ pub fn composite_layers_region_parallel(
} }
for (rev_i, layer) in layers.iter().enumerate() { for (rev_i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; } if !layer.visible {
continue;
}
if layer.layer_type == hcie_protocol::LayerType::Group { if layer.layer_type == hcie_protocol::LayerType::Group {
continue; continue;
} }
@@ -302,7 +418,8 @@ pub fn composite_layers_region_parallel(
let is_adj = layer.adjustment.is_some(); let is_adj = layer.adjustment.is_some();
// Apply layer effects if present // 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 { let effect_pixels = if has_effects && !is_adj {
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() { if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
if cached.width == layer.width && cached.height == layer.height { if cached.width == layer.width && cached.height == layer.height {
@@ -311,9 +428,24 @@ pub fn composite_layers_region_parallel(
None None
} }
} else { } else {
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect(); let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s))); .effects
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity)) .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 { } else {
None None
@@ -328,62 +460,113 @@ pub fn composite_layers_region_parallel(
let row_stride = canvas_width as usize * 4; let row_stride = canvas_width as usize * 4;
output.par_chunks_exact_mut(row_stride) output
.par_chunks_exact_mut(row_stride)
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; 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 { for x in region_x0..region_x1 {
let out_idx = (x as usize) * 4; let out_idx = (x as usize) * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]]; let dst = [
if dst[3] == 0 { continue; } 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); 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() { let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => { hcie_blend::Adjustment::Curves {
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b) lut_r,
} lut_g,
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => { lut_b,
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b) } => apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b),
} hcie_blend::Adjustment::GradientMap {
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => { lut_r,
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness) 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 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 blended_opaque = blend_pixels(
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8; [dst[0], dst[1], dst[2], 255],
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8; [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8; 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] = out_r;
row[out_idx + 1] = out_g; row[out_idx + 1] = out_g;
row[out_idx + 2] = out_b; row[out_idx + 2] = out_b;
} }
}); });
} else { } 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 { 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), _ => (0, 0),
}; };
let cx0 = (x0 as i32).max(offset_x).max(0) as u32; 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 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)
if cx0 >= cx1 || cy0 >= cy1 { continue; } .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; let row_stride = canvas_width as usize * 4;
output.par_chunks_exact_mut(row_stride) output
.par_chunks_exact_mut(row_stride)
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; 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; let ly = (gy as i32 - offset_y) as u32;
for x in cx0..cx1 { for x in cx0..cx1 {
let lx = (x as i32 - offset_x) as u32; 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); let mask_val = layer.get_mask_value(x, gy);
if mask_val == 0 { continue; } if mask_val == 0 {
continue;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { if let Some(bi) = base_idx {
@@ -407,8 +592,15 @@ pub fn composite_layers_region_parallel(
} }
} }
if src[3] == 0 { continue; } if src[3] == 0 {
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]]; 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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[out_idx..out_idx + 4].copy_from_slice(&blended); row[out_idx..out_idx + 4].copy_from_slice(&blended);
+17 -5
View File
@@ -9,7 +9,9 @@ fn test_composite_basic() {
name: "Red".into(), name: "Red".into(),
layer_type: hcie_protocol::LayerType::Raster, layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::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, width: 2,
height: 2, height: 2,
visible: true, visible: true,
@@ -37,13 +39,18 @@ fn test_composite_basic() {
}; };
let layers = vec![layer]; let layers = vec![layer];
let out = crate::composite_layers(&layers, 2, 2); 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 { let layer0 = hcie_protocol::Layer {
name: "Red".into(), name: "Red".into(),
layer_type: hcie_protocol::LayerType::Raster, layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::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, width: 2,
height: 2, height: 2,
visible: true, visible: true,
@@ -73,7 +80,9 @@ fn test_composite_basic() {
name: "Blue".into(), name: "Blue".into(),
layer_type: hcie_protocol::LayerType::Raster, layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::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, width: 2,
height: 2, height: 2,
visible: true, visible: true,
@@ -101,5 +110,8 @@ fn test_composite_basic() {
}; };
let layers2 = vec![layer0, layer1]; let layers2 = vec![layer0, layer1];
let out2 = crate::composite_layers(&layers2, 2, 2); 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
View File
@@ -1,8 +1,8 @@
use hcie_tile::{TiledLayer, TILE_SIZE};
use hcie_protocol::Layer;
use hcie_blend::blend_pixels; use hcie_blend::blend_pixels;
use hcie_fx::apply_layer_effects; use hcie_fx::apply_layer_effects;
use hcie_fx::protocol_to_hcie_fx_effect; use hcie_fx::protocol_to_hcie_fx_effect;
use hcie_protocol::Layer;
use hcie_tile::{TiledLayer, TILE_SIZE};
use rayon::prelude::*; use rayon::prelude::*;
/// Pixel-area threshold below which the compositor uses a sequential row /// Pixel-area threshold below which the compositor uses a sequential row
@@ -24,7 +24,17 @@ pub fn composite_tiled(
) -> Vec<u8> { ) -> Vec<u8> {
let px_count = (canvas_width * canvas_height) as usize; let px_count = (canvas_width * canvas_height) as usize;
let mut output = vec![0u8; px_count * 4]; 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 output
} }
@@ -34,12 +44,17 @@ pub fn composite_tiled_into(
tile_layers: &[Option<TiledLayer>], tile_layers: &[Option<TiledLayer>],
canvas_width: u32, canvas_width: u32,
canvas_height: u32, canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32, x0: u32,
y0: u32,
x1: u32,
y1: u32,
output: &mut [u8], output: &mut [u8],
) { ) {
let region_w = x1.saturating_sub(x0); let region_w = x1.saturating_sub(x0);
let region_h = y1.saturating_sub(y0); 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 use_sequential = region_w.saturating_mul(region_h) <= SEQUENTIAL_PIXEL_THRESHOLD;
let row_stride = canvas_width as usize * 4; let row_stride = canvas_width as usize * 4;
@@ -63,7 +78,9 @@ pub fn composite_tiled_into(
} }
for (i, layer) in layers.iter().enumerate() { for (i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; } if !layer.visible {
continue;
}
if layer.layer_type == hcie_protocol::LayerType::Group { if layer.layer_type == hcie_protocol::LayerType::Group {
continue; continue;
} }
@@ -73,14 +90,22 @@ pub fn composite_tiled_into(
let base_idx = base_indices[i]; let base_idx = base_indices[i];
let (offset_x, offset_y) = match &layer.data { 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), _ => (0, 0),
}; };
let cx0 = (x0 as i32).max(offset_x).max(0) as u32; 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 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_w = layer.width.min(canvas_width);
let layer_h = layer.height.min(canvas_height); let layer_h = layer.height.min(canvas_height);
@@ -101,11 +126,14 @@ pub fn composite_tiled_into(
} }
} }
} else { } else {
output.par_chunks_exact_mut(row_stride) output
.par_chunks_exact_mut(row_stride)
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; 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 { for x in region_x0..region_x1 {
blend_adjustment_pixel(row, x, gy, layer, blend, opacity); 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. // If layer has effects or non-default fill_opacity, compute effects buffer.
// Prefer the cached `effects_cache` when available to avoid re-running the // Prefer the cached `effects_cache` when available to avoid re-running the
// expensive effects pipeline on every partial composite. // expensive effects pipeline on every partial composite.
let has_effects = !layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0); let has_effects =
let mut fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(hcie_fx::protocol_to_hcie_fx_effect).collect(); !layer.effects.is_empty() || !layer.styles.is_empty() || (layer.fill_opacity < 1.0);
fx_effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s))); 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 { let effect_buf = if has_effects {
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() { if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
if cached.width == layer.width && cached.height == layer.height { if cached.width == layer.width && cached.height == layer.height {
@@ -129,94 +167,167 @@ pub fn composite_tiled_into(
} }
} else { } else {
None None
}.unwrap_or_else(|| apply_layer_effects( }
&layer.pixels, layer.width, layer.height, .unwrap_or_else(|| {
&fx_effects, layer.fill_opacity, apply_layer_effects(
)) &layer.pixels,
layer.width,
layer.height,
&fx_effects,
layer.fill_opacity,
)
})
} else { } else {
Vec::new() Vec::new()
}; };
// Try tile path first (only if no effects — effects need full dense buffer) // Try tile path first (only if no effects — effects need full dense buffer)
if !has_effects { if !has_effects {
if let Some(Some(tl)) = tile_layers.get(i).filter(|_| layer.layer_type != hcie_protocol::LayerType::Text) { if let Some(Some(tl)) = tile_layers
if tl.tile_count() == 0 && i > 0 { .get(i)
continue; // fully transparent layer with no tiles .filter(|_| layer.layer_type != hcie_protocol::LayerType::Text)
} {
let t_start_x = x0 / TILE_SIZE; if tl.tile_count() == 0 && i > 0 {
let _t_start_y = y0 / TILE_SIZE; continue; // fully transparent layer with no tiles
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);
}
}
}
} }
} else { let t_start_x = x0 / TILE_SIZE;
output.par_chunks_exact_mut(row_stride) let _t_start_y = y0 / TILE_SIZE;
.enumerate() let t_end_x = div_ceil(x1, TILE_SIZE);
.for_each(|(y, row)| { let _t_end_y = div_ceil(y1, TILE_SIZE);
let gy = y as u32;
if gy < y0 || gy >= y1 || gy >= layer_h { return; } 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 ty = gy / TILE_SIZE;
let ly = (gy % TILE_SIZE) as usize; let ly = (gy % TILE_SIZE) as usize;
for tx in t_start_x..t_end_x { for tx in t_start_x..t_end_x {
let gx_start = tx * TILE_SIZE; let gx_start = tx * TILE_SIZE;
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w); 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) { if let Some(tile) = tl.get_tile(tx, ty) {
let start_x = gx_start.max(x0); let start_x = gx_start.max(x0);
for gx in start_x..gx_end { 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 { } else {
output.par_chunks_exact_mut(row_stride) // Fallback to dense pixel path (no effects)
.enumerate() if cx0 >= cx1 || cy0 >= cy1 {
.for_each(|(y, row)| { continue;
let gy = y as u32; }
if gy < cy0 || gy >= cy1 { return; } 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; let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 { for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32; 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 { } else {
// Effects path — use pre-computed effects buffer // 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(); let eb = effect_buf.as_slice();
if use_sequential { if use_sequential {
for y in cy0..cy1 { for y in cy0..cy1 {
@@ -225,19 +336,48 @@ pub fn composite_tiled_into(
let ly = (gy as i32 - offset_y) as u32; let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 { for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32; 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 { } else {
output.par_chunks_exact_mut(row_stride) output
.par_chunks_exact_mut(row_stride)
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
let gy = y as u32; 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; let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 { for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32; 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, opacity: f32,
) { ) {
let out_idx = (x as usize) * 4; let out_idx = (x as usize) * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]]; let dst = [
if dst[3] == 0 { return; } 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); 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() { let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => { hcie_blend::Adjustment::Curves {
crate::parallel::apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b) lut_r,
} lut_g,
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => { lut_b,
crate::parallel::apply_gradient_map(dst[0], dst[1], dst[2], 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 {
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => { lut_r,
crate::parallel::apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness) 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 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 blended_opaque = blend_pixels(
row[out_idx] = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8; [dst[0], dst[1], dst[2], 255],
row[out_idx + 1] = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8; [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
row[out_idx + 2] = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8; 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] #[inline]
@@ -305,33 +477,50 @@ fn blend_tile_pixel(
// Inline get_mask_value — hot path // Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) { 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 { if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left; let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize; 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 { } else {
layer.mask_default_color layer.mask_default_color
} }
} else { } else {
255u8 255u8
}; };
if mask_val == 0 { return; } if mask_val == 0 {
return;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer // Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4; 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; src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else { } else {
src[3] = 0; src[3] = 0;
} }
} }
if src[3] == 0 { return; } if src[3] == 0 {
return;
}
let dst_idx = (gx as usize) * 4; 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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended); row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
@@ -363,33 +552,50 @@ fn blend_dense_pixel(
// Inline get_mask_value — hot path // Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) { 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 { if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left; let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize; 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 { } else {
layer.mask_default_color layer.mask_default_color
} }
} else { } else {
255u8 255u8
}; };
if mask_val == 0 { return; } if mask_val == 0 {
return;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer // Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4; 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; src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else { } else {
src[3] = 0; src[3] = 0;
} }
} }
if src[3] == 0 { return; } if src[3] == 0 {
return;
}
let dst_idx = (gx as usize) * 4; 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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended); row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
@@ -418,33 +624,50 @@ fn blend_effects_pixel(
// Inline get_mask_value — hot path // Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) { 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 { if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left; let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize; 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 { } else {
layer.mask_default_color layer.mask_default_color
} }
} else { } else {
255u8 255u8
}; };
if mask_val == 0 { return; } if mask_val == 0 {
return;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer // Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4; 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; src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else { } else {
src[3] = 0; src[3] = 0;
} }
} }
if src[3] == 0 { return; } if src[3] == 0 {
return;
}
let dst_idx = (gx as usize) * 4; 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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended); 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 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 { if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left; let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize; 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 { } else {
layer.mask_default_color layer.mask_default_color
} }
} else { } else {
255u8 255u8
}; };
if mask_val == 0 { return; } if mask_val == 0 {
return;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { 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_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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended); 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 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 { if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left; let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize; 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 { } else {
layer.mask_default_color layer.mask_default_color
} }
} else { } else {
255u8 255u8
}; };
if mask_val == 0 { return; } if mask_val == 0 {
return;
}
if is_clipping { if is_clipping {
if let Some(bi) = base_idx { 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_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 eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity); let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended); row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
+41 -17
View File
@@ -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_composite::composite_layers;
use hcie_protocol::Layer as ProtoLayer; use hcie_protocol::Layer as ProtoLayer;
@@ -13,30 +19,48 @@ fn proto_to_comp(l: &ProtoLayer) -> ProtoLayer {
#[test] #[test]
fn test_composite_visibility_from_protocol_layer() { fn test_composite_visibility_from_protocol_layer() {
let _ = env_logger::try_init(); 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) // Build layers using hcie_protocol::Layer (like Engine does)
let mut bg = ProtoLayer::new_transparent("bg", w, h); let mut bg = ProtoLayer::new_transparent("bg", w, h);
bg.id = 1; 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); let mut top = ProtoLayer::new_transparent("top", w, h);
top.id = 2; top.id = 2;
top.pixels = vec![0u8; (w*h*4) as usize]; top.pixels = vec![0u8; (w * h * 4) as usize];
for y in 20..80 { for x in 20..80 { for y in 20..80 {
let i = (y*w+x) as usize * 4; for x in 20..80 {
top.pixels[i]=0; top.pixels[i+1]=0; top.pixels[i+2]=0; top.pixels[i+3]=255; 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> { let run_composite = |layers: &[ProtoLayer]| -> Vec<u8> { composite_layers(layers, w, h) };
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; bg.visible = false;
eprintln!("BG HIDDEN: opaque={}/10000 (expect 3600)", count_opaque(&run_composite(&[bg.clone(), top.clone()]))); eprintln!(
bg.visible = true; top.visible = false; "BG HIDDEN: opaque={}/10000 (expect 3600)",
eprintln!("TOP HIDDEN: opaque={}/10000 (expect 10000)", count_opaque(&run_composite(&[bg.clone(), top.clone()]))); 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()]))); 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()]))
);
} }
+19 -9
View File
@@ -1,9 +1,13 @@
use hcie_composite::composite_layers; use hcie_composite::composite_layers;
use hcie_protocol::{BlendMode, Layer, LayerType}; use hcie_protocol::{BlendMode, Layer, LayerType};
fn make_layer(name: &str, w: u32, h: u32, rgba: [u8; 4]) -> Layer { 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.blend_mode = BlendMode::Normal;
l l
} }
@@ -39,9 +43,13 @@ fn test_pass_through_group_skipped_in_composite() {
// PassThrough: child blends directly onto bg. // PassThrough: child blends directly onto bg.
// Multiply: 255 * 128 / 255 = 128 for each channel. // Multiply: 255 * 128 / 255 = 128 for each channel.
// Expected: every pixel [128, 128, 128, 255] // Expected: every pixel [128, 128, 128, 255]
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255, let expected = vec![
128, 128, 128, 255, 128, 128, 128, 255]; 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"); ];
assert_eq!(
out, expected,
"PassThrough group should let child blend directly onto backdrop"
);
} }
#[test] #[test]
@@ -60,8 +68,9 @@ fn test_pass_through_group_invisible_children() {
let out = composite_layers(&layers, w, h); let out = composite_layers(&layers, w, h);
// Child hidden → only bg visible // Child hidden → only bg visible
let expected = vec![255u8, 255, 255, 255, 255, 255, 255, 255, let expected = vec![
255, 255, 255, 255, 255, 255, 255, 255]; 255u8, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
];
assert_eq!(out, expected); assert_eq!(out, expected);
} }
@@ -90,7 +99,8 @@ fn test_pass_through_group_with_opacity() {
let out = composite_layers(&layers, w, h); let out = composite_layers(&layers, w, h);
// Multiply full-strength // Multiply full-strength
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255, let expected = vec![
128, 128, 128, 255, 128, 128, 128, 255]; 128u8, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255, 128, 128, 128, 255,
];
assert_eq!(out, expected); assert_eq!(out, expected);
} }
+12 -4
View File
@@ -49,7 +49,10 @@ fn test_composite_visibility_toggle() {
let layers = vec![bg_hidden, top.clone()]; let layers = vec![bg_hidden, top.clone()];
let p = composite_layers(&layers, w, h); let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p); 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"); assert_eq!(opaque, 3600, "only black square should be visible");
// Test 3: hide top → only white background 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 layers = vec![bg.clone(), top_hidden];
let p = composite_layers(&layers, w, h); let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p); 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"); assert_eq!(opaque, 10000, "white background should be visible");
// Test 4: hide both → fully transparent // Test 4: hide both → fully transparent
let mut bg_h = bg.clone(); bg_h.visible = false; let mut bg_h = bg.clone();
let mut top_h = top.clone(); top_h.visible = false; bg_h.visible = false;
let mut top_h = top.clone();
top_h.visible = false;
let layers = vec![bg_h, top_h]; let layers = vec![bg_h, top_h];
let p = composite_layers(&layers, w, h); let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p); let opaque = count_opaque_pixels(&p);
+31 -13
View File
@@ -3,7 +3,7 @@
//! Compiles only when running `cargo run --example draw_tester`. //! Compiles only when running `cargo run --example draw_tester`.
use eframe::egui; 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; use hcie_protocol::Layer;
fn main() -> eframe::Result { 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.width as usize, self.layer.height as usize],
&self.layer.pixels, &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(); 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.label("Click & drag to draw on this canvas:");
ui.add_space(5.0); 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); let resp = ui.add(image_widget);
if resp.dragged() || resp.clicked() { if resp.dragged() || resp.clicked() {
if let Some(hover_pos) = resp.hover_pos() { if let Some(hover_pos) = resp.hover_pos() {
let rect = resp.rect; 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 px = ((hover_pos.x - rect.min.x) / rect.width()
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); * 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 { match self.selected_tool {
"Line" => { "Line" => {
if let Some(last_pos) = self.last_drag_pos { if let Some(last_pos) = self.last_drag_pos {
draw_line( draw_line(
&mut self.layer, &mut self.layer,
last_pos.x, last_pos.y, last_pos.x,
px, py, last_pos.y,
px,
py,
self.current_color, self.current_color,
self.thickness, self.thickness,
None, None,
@@ -127,7 +135,8 @@ impl eframe::App for DrawTesterApp {
if resp.drag_started() || resp.clicked() { if resp.drag_started() || resp.clicked() {
draw_filled_circle( draw_filled_circle(
&mut self.layer, &mut self.layer,
px, py, px,
py,
self.thickness * 2.0, self.thickness * 2.0,
self.current_color, self.current_color,
None, None,
@@ -140,8 +149,10 @@ impl eframe::App for DrawTesterApp {
let size = self.thickness * 3.0; let size = self.thickness * 3.0;
draw_filled_rect( draw_filled_rect(
&mut self.layer, &mut self.layer,
px - size, py - size, px - size,
px + size, py + size, py - size,
px + size,
py + size,
self.current_color, self.current_color,
None, None,
); );
@@ -152,7 +163,8 @@ impl eframe::App for DrawTesterApp {
if resp.clicked() { if resp.clicked() {
flood_fill( flood_fill(
&mut self.layer, &mut self.layer,
px as u32, py as u32, px as u32,
py as u32,
self.current_color, self.current_color,
20, 20,
None, None,
@@ -195,7 +207,10 @@ impl eframe::App for DrawTesterApp {
self.current_color[2] as f32 / 255.0, self.current_color[2] as f32 / 255.0,
self.current_color[3] 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 = [ self.current_color = [
(color_f32[0] * 255.0).round() as u8, (color_f32[0] * 255.0).round() as u8,
(color_f32[1] * 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.add_space(35.0);
ui.colored_label(egui::Color32::LIGHT_GREEN, "Layer-based drawing API"); 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, "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",
);
}); });
}); });
}); });
+124 -41
View File
@@ -1,13 +1,15 @@
#![allow(dead_code)] #![allow(dead_code)]
pub use hcie_protocol::{Layer, BlendMode};
pub use hcie_brush_engine::{BrushTip, BrushStyle};
use hcie_blend::blend_pixels; use hcie_blend::blend_pixels;
use hcie_brush_engine::{brush_spacing_pixels, jitter_offset}; 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( pub fn draw_brush_stamp(
layer: &mut Layer, layer: &mut Layer,
cx: f32, cy: f32, cx: f32,
stamp: &[u8], stamp_diameter: usize, cy: f32,
stamp: &[u8],
stamp_diameter: usize,
color: [u8; 4], color: [u8; 4],
mask: Option<&[u8]>, mask: Option<&[u8]>,
) { ) {
@@ -27,15 +29,21 @@ pub fn draw_brush_stamp(
if let Some(m) = mask { if let Some(m) = mask {
let idx = (py_u * layer.width + px_u) as usize; 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; 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 dst = layer.get_pixel(px_u, py_u);
let src_color: [u8; 4] = [ 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, (color[3] as f32 * stamp_alpha).round() as u8,
]; ];
let out = blend_pixels(dst, src_color, hcie_protocol::BlendMode::Normal.into(), 1.0); 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]>, mut stroke_mask: Option<&mut [u8]>,
bg_pixels: Option<&[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 spacing = brush_spacing_pixels(tip.size, tip.spacing);
let use_color_variant = tip.color_variant && tip.variant_amount > 0.0; let use_color_variant = tip.color_variant && tip.variant_amount > 0.0;
@@ -89,13 +99,26 @@ pub fn draw_brush_stroke(
color color
}; };
let first_px_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, (first_color[3] as f32 * first_opacity).round() as u8,
]; ];
hcie_brush_engine::draw_dab_capped_with_stamp( hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height, &mut layer.pixels,
first_x, first_y, first_size, tip.hardness, first_px_color, 1.0, is_eraser, mask, layer.width,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity, 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, stamp_ref,
); );
@@ -103,7 +126,9 @@ pub fn draw_brush_stroke(
let dx = p.0 - last_p.0; let dx = p.0 - last_p.0;
let dy = p.1 - last_p.1; let dy = p.1 - last_p.1;
let dist = (dx * dx + dy * dy).sqrt(); 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 steps = ((dist / spacing).max(1.0)).ceil() as i32;
let step_x = dx / steps as f32; let step_x = dx / steps as f32;
@@ -127,13 +152,26 @@ pub fn draw_brush_stroke(
color color
}; };
let px_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, (dab_color[3] as f32 * pressure_opacity).round() as u8,
]; ];
hcie_brush_engine::draw_dab_capped_with_stamp( hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height, &mut layer.pixels,
final_x, final_y, effective_size, tip.hardness, px_color, 1.0, is_eraser, mask, layer.width,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity, 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, stamp_ref,
); );
} }
@@ -144,7 +182,10 @@ pub fn draw_brush_stroke(
pub fn draw_line( pub fn draw_line(
layer: &mut Layer, layer: &mut Layer,
x0: f32, y0: f32, x1: f32, y1: f32, x0: f32,
y0: f32,
x1: f32,
y1: f32,
color: [u8; 4], color: [u8; 4],
width: f32, width: f32,
mask: Option<&[u8]>, mask: Option<&[u8]>,
@@ -152,7 +193,9 @@ pub fn draw_line(
let dx = x1 - x0; let dx = x1 - x0;
let dy = y1 - y0; let dy = y1 - y0;
let dist = (dx * dx + dy * dy).sqrt(); 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; let steps = (dist * 2.0).max(1.0).ceil() as i32;
for i in 0..=steps { for i in 0..=steps {
@@ -165,29 +208,44 @@ pub fn draw_line(
pub fn draw_filled_circle( pub fn draw_filled_circle(
layer: &mut Layer, layer: &mut Layer,
cx: f32, cy: f32, r: f32, cx: f32,
cy: f32,
r: f32,
color: [u8; 4], color: [u8; 4],
mask: Option<&[u8]>, mask: Option<&[u8]>,
) { ) {
let r_i = r.ceil() as i32; let r_i = r.ceil() as i32;
for dy in -r_i..=r_i { for dy in -r_i..=r_i {
let py = (cy + dy as f32).round() as i32; 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_max = (r * r - dy as f32 * dy as f32).sqrt();
let dx_i = dx_max.ceil() as i32; let dx_i = dx_max.ceil() as i32;
for dx in -dx_i..=dx_i { for dx in -dx_i..=dx_i {
let px = (cx + dx as f32).round() as i32; 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 { if let Some(m) = mask {
let idx = (py as u32 * layer.width + px as u32) as usize; 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 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); let alpha_t = 1.0 - (dist - (r - 1.0)).max(0.0).min(1.0);
if alpha_t <= 0.0 { continue; } if alpha_t <= 0.0 {
let src = [color[0], color[1], color[2], (color[3] as f32 * alpha_t).round() as u8]; 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 dst = layer.get_pixel(px as u32, py as u32);
let out = blend_pixels(dst, src, hcie_protocol::BlendMode::Normal.into(), 1.0); let out = blend_pixels(dst, src, hcie_protocol::BlendMode::Normal.into(), 1.0);
layer.set_pixel(px as u32, py as u32, out); layer.set_pixel(px as u32, py as u32, out);
@@ -197,20 +255,25 @@ pub fn draw_filled_circle(
pub fn draw_filled_rect( pub fn draw_filled_rect(
layer: &mut Layer, layer: &mut Layer,
x1: f32, y1: f32, x2: f32, y2: f32, x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: [u8; 4], color: [u8; 4],
mask: Option<&[u8]>, mask: Option<&[u8]>,
) { ) {
let x_start = x1.min(x2).max(0.0).floor() as u32; 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_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 y in y_start..y_end {
for x in x_start..x_end { for x in x_start..x_end {
if let Some(m) = mask { if let Some(m) = mask {
let idx = (y * layer.width + x) as usize; 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 dst = layer.get_pixel(x, y);
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0); 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( pub fn draw_filled_ellipse(
layer: &mut Layer, layer: &mut Layer,
cx: f32, cy: f32, rx: f32, ry: f32, cx: f32,
cy: f32,
rx: f32,
ry: f32,
color: [u8; 4], color: [u8; 4],
mask: Option<&[u8]>, mask: Option<&[u8]>,
) { ) {
let x_start = (cx - rx).max(0.0).floor() as u32; 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_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 y in y_start..y_end {
for x in x_start..x_end { for x in x_start..x_end {
@@ -238,7 +304,9 @@ pub fn draw_filled_ellipse(
if dist <= 1.0 { if dist <= 1.0 {
if let Some(m) = mask { if let Some(m) = mask {
let idx = (y * layer.width + x) as usize; 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 dst = layer.get_pixel(x, y);
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0); 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( pub fn flood_fill(
layer: &mut Layer, layer: &mut Layer,
x: u32, y: u32, x: u32,
y: u32,
fill_color: [u8; 4], fill_color: [u8; 4],
tolerance: u8, tolerance: u8,
mask: Option<&[u8]>, mask: Option<&[u8]>,
) { ) {
if let Some(m) = mask { if let Some(m) = mask {
let idx = (y * layer.width + x) as usize; 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); let start_color = layer.get_pixel(x, y);
@@ -271,19 +342,31 @@ pub fn flood_fill(
while let Some((px, py)) = stack.pop() { while let Some((px, py)) = stack.pop() {
let c = layer.get_pixel(px, py); 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 { if let Some(m) = mask {
let idx = (py * w + px) as usize; 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); layer.set_pixel(px, py, fill_color);
if px > 0 { stack.push((px - 1, py)); } if px > 0 {
if px + 1 < w { stack.push((px + 1, py)); } stack.push((px - 1, py));
if py > 0 { stack.push((px, py - 1)); } }
if py + 1 < h { stack.push((px, py + 1)); } 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));
}
} }
} }
+19 -2
View File
@@ -37,7 +37,16 @@ fn test_draw_filled_ellipse() {
#[test] #[test]
fn test_draw_line() { fn test_draw_line() {
let mut layer = Layer::new_transparent("test", 10, 10); 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); let px = layer.get_pixel(5, 5);
assert_eq!(px[0], 255, "line pixel should be yellow"); assert_eq!(px[0], 255, "line pixel should be yellow");
assert_eq!(px[1], 255); 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 layer = Layer::new_transparent("test", 10, 10);
let mut mask = vec![0u8; 100]; let mut mask = vec![0u8; 100];
mask[0] = 255; 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); let in_mask = layer.get_pixel(0, 0);
assert_eq!(in_mask[0], 255, "masked pixel should be red"); assert_eq!(in_mask[0], 255, "masked pixel should be red");
let out_mask = layer.get_pixel(5, 5); let out_mask = layer.get_pixel(5, 5);
+69 -19
View File
@@ -59,23 +59,38 @@ fn init_templates() -> Vec<ShapeTemplateDef> {
} }
pub fn get_template_def(name: &str) -> Option<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> { 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> { pub fn search_templates(query: &str) -> Vec<ShapeTemplateDef> {
let q_lower = query.to_lowercase(); let q_lower = query.to_lowercase();
TEMPLATES.get_or_init(init_templates) TEMPLATES
.get_or_init(init_templates)
.iter() .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() .cloned()
.collect() .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 num_vars = HashMap::new();
let mut str_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 { 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) 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" => { "circle" => {
let cx = eval(&c.cx); let cx = eval(&c.cx);
let cy = eval(&c.cy); let cy = eval(&c.cy);
let rx_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.rx) }; let rx_val = if !c.r.is_empty() {
let ry_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.ry) }; 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)); pts = circle_points(cx, cy, rx_val, ry_val, c.n.max(8));
} }
"star" => { "star" => {
@@ -129,7 +154,12 @@ pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, S
} }
if !pts.is_empty() { 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 out
@@ -145,7 +175,9 @@ fn tokenize(expr: &str) -> Vec<String> {
let mut tokens = Vec::new(); let mut tokens = Vec::new();
let mut current = String::new(); let mut current = String::new();
for c in expr.chars() { for c in expr.chars() {
if c.is_whitespace() { continue; } if c.is_whitespace() {
continue;
}
if "+-*/()".contains(c) { if "+-*/()".contains(c) {
if !current.is_empty() { if !current.is_empty() {
tokens.push(current.clone()); tokens.push(current.clone());
@@ -156,7 +188,9 @@ fn tokenize(expr: &str) -> Vec<String> {
current.push(c); current.push(c);
} }
} }
if !current.is_empty() { tokens.push(current); } if !current.is_empty() {
tokens.push(current);
}
tokens 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)?; let mut left = parse_term(tokens, pos, vars)?;
while *pos < tokens.len() { while *pos < tokens.len() {
let op = &tokens[*pos]; let op = &tokens[*pos];
if op == "+" { *pos += 1; left += parse_term(tokens, pos, vars)?; } if op == "+" {
else if op == "-" { *pos += 1; left -= parse_term(tokens, pos, vars)?; } *pos += 1;
else { break; } left += parse_term(tokens, pos, vars)?;
} else if op == "-" {
*pos += 1;
left -= parse_term(tokens, pos, vars)?;
} else {
break;
}
} }
Some(left) 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)?; let mut left = parse_factor(tokens, pos, vars)?;
while *pos < tokens.len() { while *pos < tokens.len() {
let op = &tokens[*pos]; let op = &tokens[*pos];
if op == "*" { *pos += 1; left *= parse_factor(tokens, pos, vars)?; } if op == "*" {
else if op == "/" { *pos += 1; left /= parse_factor(tokens, pos, vars)?; } *pos += 1;
else { break; } left *= parse_factor(tokens, pos, vars)?;
} else if op == "/" {
*pos += 1;
left /= parse_factor(tokens, pos, vars)?;
} else {
break;
}
} }
Some(left) Some(left)
} }
fn parse_factor(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> { 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]; let t = &tokens[*pos];
if t == "(" { if t == "(" {
*pos += 1; *pos += 1;
let val = parse_expr(tokens, pos, vars)?; 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); return Some(val);
} }
if let Ok(n) = t.parse::<f32>() { if let Ok(n) = t.parse::<f32>() {
+548 -98
View File
@@ -1,9 +1,18 @@
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)] #![allow(
use std::sync::{OnceLock, atomic::{AtomicU8, Ordering}}; dead_code,
use std::path::{Path, PathBuf}; unused_imports,
use std::os::raw::c_char; unused_variables,
use std::ffi::CString; unused_macros,
unreachable_patterns
)]
use libloading::Library; 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. /// 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. /// 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) hcie_blend::blend_pixels(dst, src, mode.into(), opacity)
} }
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) { pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) {
hcie_blend::blend_buffers(dst, src, mode.into(), opacity) 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); let mut fl = hcie_protocol::Layer::new_transparent("", width, height);
fl.pixels.copy_from_slice(pixels); fl.pixels.copy_from_slice(pixels);
hcie_filter::apply_filter(&mut fl, filter_id, params); 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(()) 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]>) { pub fn draw_line(
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true; 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]>) { pub fn draw_filled_rect(
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true; 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]>) { pub fn draw_filled_ellipse(
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true; 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]>) { pub fn flood_fill(
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true; 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 style = map_brush_style(tip.style);
let ct = hcie_brush_engine::BrushTip { let ct = hcie_brush_engine::BrushTip {
style, style,
size: tip.size, opacity: tip.opacity, hardness: tip.hardness, size: tip.size,
spacing: tip.spacing, flow: tip.flow, opacity: tip.opacity,
jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount, hardness: tip.hardness,
angle: tip.angle, roundness: tip.roundness, spacing: tip.spacing,
spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density, flow: tip.flow,
bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h, jitter_amount: tip.jitter_amount,
color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density, 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, drawing_angle: false,
rotation_random: 0.0, 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); 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(layers: &[hcie_protocol::Layer], cw: u32, ch: u32) -> Vec<u8> {
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) } 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 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]) { pub fn composite_tiled_into(
hcie_composite::tiled::composite_tiled_into(layers, tile_layers, cw, ch, x0, y0, x1, y1, out) 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 load_image(path: &Path) -> Result<hcie_protocol::Layer, String> {
pub fn save_image(layer: &hcie_protocol::Layer, path: &Path, format: &str) -> Result<(), String> { hcie_io::save_image(layer, path, format) } hcie_io::load_image(path)
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 save_image(layer: &hcie_protocol::Layer, path: &Path, format: &str) -> Result<(), String> {
pub fn import_kra(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_kra::import_kra(path) } hcie_io::save_image(layer, path, format)
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 import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
pub fn save_native(layers: &[hcie_protocol::Layer], path: &Path) -> Result<(), String> { hcie_native::save_native(layers, path) } 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 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 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)] #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum BooleanOp { Union, Intersect, Subtract, Xor } pub enum BooleanOp {
pub fn boolean_shapes(op: BooleanOp, a: &hcie_protocol::VectorShape, b: &hcie_protocol::VectorShape) -> Option<hcie_protocol::VectorShape> { Union,
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 }; 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) 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> { 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 candidates: Vec<PathBuf> = {
let mut v = Vec::new(); let mut v = Vec::new();
// 1. Traditional plugins directory next to the executable or cwd. // 1. Traditional plugins directory next to the executable or cwd.
v.push(PathBuf::from("plugins")); 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()); v.push(exe_plugins.clone());
} }
// 2. Cargo workspace layout: the dynamic library is placed directly in // 2. Cargo workspace layout: the dynamic library is placed directly in
// the target profile directory (e.g. target/debug/libhcie_ai.so). // 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()); v.push(exe_dir.clone());
// 3. Also check the deps subdirectory used by Cargo. // 3. Also check the deps subdirectory used by Cargo.
v.push(exe_dir.join("deps")); 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("debug").join("deps"));
v.push(c.join("target").join("release")); v.push(c.join("target").join("release"));
v.push(c.join("target").join("release").join("deps")); v.push(c.join("target").join("release").join("deps"));
if !c.pop() { break; } if !c.pop() {
break;
}
} }
} }
v v
}; };
for base in candidates { for base in candidates {
let p = base.join(&filename); let p = base.join(&filename);
if p.exists() { return Ok(p); } if p.exists() {
return Ok(p);
}
} }
Err(format!("Plugin '{}' not found", name)) 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 { struct AiPlugins {
_lib_ai: Library, _lib_vision: Library, _lib_ai: Library,
ai_chat_complete_fn: unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool, _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_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_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, 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_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_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_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_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), 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_background_removal_fn:
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, unsafe extern "C" fn(*const u8, usize, u32, u32, *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_object_segment_fn: unsafe extern "C" fn(
vision_inpaint_fn: unsafe extern "C" fn(*mut u8, usize, u32, u32, *const u8, usize, i32) -> bool, *const u8,
vision_build_polygon_mask_fn: unsafe extern "C" fn(*const c_char, u32, u32, *mut *mut u8, *mut usize) -> bool, usize,
vision_build_stroke_mask_fn: unsafe extern "C" fn(*const c_char, *const u8, usize, f32, u32, u32, *mut *mut u8, *mut usize) -> bool, 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_set_backend_fn: unsafe extern "C" fn(u8),
vision_free_buffer_fn: unsafe extern "C" fn(*mut u8, usize), 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_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_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_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_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_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(); 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_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_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 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_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_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_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_polygon_mask_fn =
let vision_build_stroke_mask_fn = *lv.get(b"hcie_vision_build_stroke_mask_c\0").unwrap(); *lv.get(b"hcie_vision_build_polygon_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_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(); let vision_free_buffer_fn = *lv.get(b"free_buffer_c\0").unwrap();
// Mirror the current backend preference into the freshly loaded plugin. // Mirror the current backend preference into the freshly loaded plugin.
let backend = VISION_BACKEND.load(Ordering::Relaxed); let backend = VISION_BACKEND.load(Ordering::Relaxed);
(vision_set_backend_fn)(backend); (vision_set_backend_fn)(backend);
AiPlugins { AiPlugins {
_lib_ai: la, _lib_vision: lv, _lib_ai: la,
ai_chat_complete_fn, ai_session_create_fn, ai_session_push_user_fn, ai_session_push_assistant_fn, _lib_vision: lv,
ai_session_clear_fn, ai_session_destroy_fn, ai_session_serialize_fn, ai_template_names_fn, ai_chat_complete_fn,
ai_template_get_fn, ai_template_search_fn, ai_template_execute_fn, ai_execute_action_fn, ai_free_buffer_fn, ai_session_create_fn,
vision_background_removal_fn, vision_object_segment_fn, vision_super_resolution_fn, ai_session_push_user_fn,
vision_inpaint_fn, vision_build_polygon_mask_fn, vision_build_stroke_mask_fn, ai_session_push_assistant_fn,
vision_set_backend_fn, vision_free_buffer_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 { pub mod ai {
use super::*; 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> { pub fn chat_complete(config: &str, messages: &str) -> Result<Vec<u8>, String> {
let (mut op, mut ol) = (std::ptr::null_mut(), 0); 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) { if ai!(
let data = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Ok(data) ai_chat_complete_fn,
} else { Err("AI chat failed".into()) } 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>> { pub fn session_serialize(sid: i64) -> Option<Vec<u8>> {
let (mut op, mut ol) = (std::ptr::null_mut(), 0); 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 /// # Purpose
/// Searches for AI prompt/action templates matching a specific query or tag. /// Searches for AI prompt/action templates matching a specific query or tag.
/// ///
@@ -322,36 +647,161 @@ pub mod ai {
None 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()?); 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 } } if ai!(
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()?; ai_template_execute_fn,
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 } } 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 { pub mod vision {
use super::*; use super::*;
pub fn background_removal(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> { pub fn background_removal(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
let (mut op, mut ol) = (std::ptr::null_mut(), 0); 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 (mut op, mut ol) = (std::ptr::null_mut(), 0);
let mp = CString::new(model_path).unwrap_or_default(); 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>> { pub fn super_resolution(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
let (mut op, mut ol) = (std::ptr::null_mut(), 0); 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>> { 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()?; let (mut op, mut ol) = (std::ptr::null_mut(), 0);
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 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>> { 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()?; let (mut op, mut ol) = (std::ptr::null_mut(), 0);
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 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. /// Forward the CPU/GPU backend preference to the dynamic `hcie-vision` plugin.
pub fn set_backend(backend: u8) { pub fn set_backend(backend: u8) {
+93 -58
View File
@@ -55,7 +55,9 @@ where
#[no_mangle] #[no_mangle]
pub extern "C" fn hcie_engine_create(w: u32, h: u32) -> *mut EngineHandle { pub extern "C" fn hcie_engine_create(w: u32, h: u32) -> *mut EngineHandle {
let engine = Engine::new(w, h); 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) 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. /// Merge the active layer down into the layer below.
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_merge_down(handle: *mut EngineHandle) { 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. /// Flatten all layers into a single background.
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_flatten_image(handle: *mut EngineHandle) { 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). /// Clear the active layer's pixels (make transparent).
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_clear_active_layer(handle: *mut EngineHandle) { 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. /// Rename a layer.
@@ -228,9 +236,13 @@ pub unsafe extern "C" fn hcie_engine_rename_layer(
id: u64, id: u64,
name: *const c_char, 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(); 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_select_all(handle: *mut EngineHandle) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_deselect(handle: *mut EngineHandle) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_invert_selection(handle: *mut EngineHandle) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_grow_selection(handle: *mut EngineHandle, amount: u32) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_shrink_selection(handle: *mut EngineHandle, amount: u32) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_feather_selection(handle: *mut EngineHandle, amount: u32) { 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] #[no_mangle]
@@ -320,7 +344,9 @@ pub unsafe extern "C" fn hcie_engine_erode_selection(handle: *mut EngineHandle,
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_fade_selection(handle: *mut EngineHandle, amount: u32) { 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, filter_name: *const c_char,
params_json: *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 name = CStr::from_ptr(filter_name).to_string_lossy().into_owned();
let params_str = if params_json.is_null() { let params_str = if params_json.is_null() {
"{}".to_string() "{}".to_string()
} else { } else {
CStr::from_ptr(params_json).to_string_lossy().into_owned() CStr::from_ptr(params_json).to_string_lossy().into_owned()
}; };
let params: serde_json::Value = serde_json::from_str(&params_str).unwrap_or(serde_json::json!({})); let params: serde_json::Value =
serde_json::from_str(&params_str).unwrap_or(serde_json::json!({}));
// Internal bridge commands used by the Qt6 frontend to set brush / color // Internal bridge commands used by the Qt6 frontend to set brush / color
// through the single FFI entry point until dedicated functions are added. // through the single FFI entry point until dedicated functions are added.
with_engine(handle, |e| { with_engine(handle, |e| match name.as_str() {
match name.as_str() { "__internal_set_brush" => {
"__internal_set_brush" => { let mut tip = e.current_tip.clone();
let mut tip = e.current_tip.clone(); if let Some(sz) = params["size"].as_f64() {
if let Some(sz) = params["size"].as_f64() { tip.size = sz as f32;
tip.size = sz as f32;
}
if let Some(o) = params["opacity"].as_f64() {
tip.opacity = o as f32;
}
e.set_brush_tip(tip);
} }
"__internal_set_color" => { if let Some(o) = params["opacity"].as_f64() {
if let Some(hex) = params["hex"].as_str() { tip.opacity = o as f32;
let hex = hex.trim_start_matches('#'); }
if hex.len() >= 6 { e.set_brush_tip(tip);
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); "__internal_set_color" => {
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); if let Some(hex) = params["hex"].as_str() {
let a = if hex.len() >= 8 { let hex = hex.trim_start_matches('#');
u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) if hex.len() >= 6 {
} else { let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
255 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);
e.set_color([r, g, b, a]); 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_undo(handle: *mut EngineHandle) { 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] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_redo(handle: *mut EngineHandle) { 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] #[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. /// Jump to a specific history step by index. Pass -1 for original image.
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_jump_to_history(handle: *mut EngineHandle, idx: i32) { 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| { with_engine(handle, |e| {
use crate::Tool; use crate::Tool;
let tool = match tool_id { let tool = match tool_id {
0 => Tool::Eyedropper, 0 => Tool::Eyedropper,
1 => Tool::Pen, 1 => Tool::Pen,
2 => Tool::Brush, 2 => Tool::Brush,
3 => Tool::Eraser, 3 => Tool::Eraser,
4 => Tool::Spray, 4 => Tool::Spray,
5 => Tool::FloodFill, 5 => Tool::FloodFill,
6 => Tool::MagicWand, 6 => Tool::MagicWand,
7 => Tool::Select, 7 => Tool::Select,
8 => Tool::Lasso, 8 => Tool::Lasso,
9 => Tool::PolygonSelect, 9 => Tool::PolygonSelect,
10 => Tool::Move, 10 => Tool::Move,
11 => Tool::Crop, 11 => Tool::Crop,
12 => Tool::Text, 12 => Tool::Text,
@@ -482,7 +515,7 @@ pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle,
21 => Tool::AiObjectRemoval, 21 => Tool::AiObjectRemoval,
22 => Tool::SmartSelect, 22 => Tool::SmartSelect,
23 => Tool::VisionSelect, 23 => Tool::VisionSelect,
_ => Tool::Brush, _ => Tool::Brush,
}; };
e.set_tool(tool); e.set_tool(tool);
}); });
@@ -499,5 +532,7 @@ pub unsafe extern "C" fn hcie_engine_is_modified(handle: *mut EngineHandle) -> b
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn hcie_engine_set_modified(handle: *mut EngineHandle, modified: bool) { 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);
});
} }
+36 -12
View File
@@ -18,8 +18,8 @@
//! Mutates `Engine.document`, `Engine.below_cache_dirty`, and (for structural //! Mutates `Engine.document`, `Engine.below_cache_dirty`, and (for structural
//! changes) `Engine.tile_layers` / `Engine.below_cache`. //! changes) `Engine.tile_layers` / `Engine.below_cache`.
use hcie_protocol::{BlendMode, LayerType};
use crate::Engine; use crate::Engine;
use hcie_protocol::{BlendMode, LayerType};
impl Engine { impl Engine {
/// Removes all layers and resets layer-dependent caches without marking /// 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) { if let Some(idx) = self.document.layer_index_by_id(id) {
log::trace!( log::trace!(
"[set_active_layer] switching from layer idx={} to idx={} (id={})", "[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); self.document.set_active_layer(idx);
// Active layer change invalidates the below-layer composite cache // 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) { 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!( log::trace!(
"[set_layer_visible] BEFORE: active_layer={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}", "[set_layer_visible] BEFORE: active_layer={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}",
self.document.active_layer, self.document.active_layer,
@@ -163,7 +169,11 @@ impl Engine {
); );
for (i, tl) in self.tile_layers.iter().enumerate() { for (i, tl) in self.tile_layers.iter().enumerate() {
let tile_count = tl.as_ref().map(|t| t.tile_count()).unwrap_or(0); 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); log::trace!("[set_layer_visible] ===== END id={} =====", id);
} else { } else {
@@ -191,14 +201,19 @@ impl Engine {
/// Group and Mask layers are not editable because they do not own pixel /// Group and Mask layers are not editable because they do not own pixel
/// buffers. Locked layers are also not editable. /// buffers. Locked layers are also not editable.
pub fn is_layer_editable(&self, id: u64) -> bool { pub fn is_layer_editable(&self, id: u64) -> bool {
let Some(layer) = self.document.get_layer_by_id(id) else { return false }; let Some(layer) = self.document.get_layer_by_id(id) else {
if layer.locked { return false; } return false;
};
if layer.locked {
return false;
}
!matches!(layer.layer_type, LayerType::Group | LayerType::Mask) !matches!(layer.layer_type, LayerType::Group | LayerType::Mask)
} }
/// Returns whether the currently active layer can receive pixel edits. /// Returns whether the currently active layer can receive pixel edits.
pub fn active_layer_is_editable(&self) -> bool { pub fn active_layer_is_editable(&self) -> bool {
self.document.active_layer() self.document
.active_layer()
.map(|l| self.is_layer_editable(l.id)) .map(|l| self.is_layer_editable(l.id))
.unwrap_or(false) .unwrap_or(false)
} }
@@ -292,10 +307,18 @@ impl Engine {
self.below_cache_dirty = true; self.below_cache_dirty = true;
} }
pub fn can_undo(&self) -> bool { self.document.can_undo() } pub fn can_undo(&self) -> bool {
pub fn can_redo(&self) -> bool { self.document.can_redo() } self.document.can_undo()
pub fn history_len(&self) -> usize { self.document.history_len() } }
pub fn history_current(&self) -> i32 { self.document.history_current() } 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> { pub fn history_description(&self, idx: usize) -> Option<String> {
self.document.history_description(idx) self.document.history_description(idx)
} }
@@ -354,7 +377,8 @@ impl Engine {
// Push undo snapshot for the below layer // Push undo snapshot for the below layer
if let Some(idx) = self.document.layer_index_by_id(below_id) { 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; self.document.composite_dirty = true;
+1468 -413
View File
File diff suppressed because it is too large Load Diff
+121 -57
View File
@@ -21,10 +21,10 @@
//! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the //! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the
//! dynamic `tiled::composite_tiled_into` / `composite_layers` compositors. //! 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::vector::render_vector_shapes;
use crate::dynamic_loader::{composite_layers, tiled};
use crate::Engine;
use hcie_tile::TiledLayer;
impl Engine { impl Engine {
/// **Purpose:** /// **Purpose:**
@@ -62,7 +62,13 @@ impl Engine {
if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] } if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] }
); );
for (i, l) in self.document.layers.iter().enumerate() { 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; let total = l.pixels.len() / 4;
log::trace!( log::trace!(
"[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)", "[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)",
@@ -100,7 +106,8 @@ impl Engine {
} }
log::trace!( log::trace!(
"[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====", "[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====",
has_dirty, dirty has_dirty,
dirty
); );
let w = self.document.canvas_width; let w = self.document.canvas_width;
let h = self.document.canvas_height; let h = self.document.canvas_height;
@@ -109,15 +116,17 @@ impl Engine {
self.apply_effects_and_sync_tiles(); self.apply_effects_and_sync_tiles();
let (x0, y0, x1, y1) = match dirty { let (x0, y0, x1, y1) = match dirty {
Some([x0, y0, x1, y1]) => { Some([x0, y0, x1, y1]) => (x0.min(w), y0.min(h), x1.min(w), y1.min(h)),
(x0.min(w), y0.min(h), x1.min(w), y1.min(h))
}
None => (0, 0, w, h), None => (0, 0, w, h),
}; };
// Now safe to borrow composite_scratch — no more mutable calls to self // Now safe to borrow composite_scratch — no more mutable calls to self
// that could touch this buffer until we return. // 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]); self.composite_scratch = Some(vec![0u8; buf_size]);
} }
let buf = self.composite_scratch.as_mut().unwrap(); 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 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() { 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!( log::trace!(
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}", "[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
@@ -162,8 +175,12 @@ impl Engine {
tiled::composite_tiled_into( tiled::composite_tiled_into(
&self.document.layers[active_idx..], &self.document.layers[active_idx..],
&self.tile_layers[tile_start..], &self.tile_layers[tile_start..],
w, h, w,
x0, y0, x1, y1, h,
x0,
y0,
x1,
y1,
buf, buf,
); );
log::trace!( log::trace!(
@@ -183,13 +200,20 @@ impl Engine {
tiled::composite_tiled_into( tiled::composite_tiled_into(
&self.document.layers, &self.document.layers,
&self.tile_layers, &self.tile_layers,
w, h, w,
x0, y0, x1, y1, h,
x0,
y0,
x1,
y1,
buf, buf,
); );
log::trace!( log::trace!(
"[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]", "[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 // Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
// have a backup. // 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| { .filter(|l| {
(!l.effects.is_empty() || !l.styles.is_empty()) (!l.effects.is_empty() || !l.styles.is_empty())
&& !l.pixels.is_empty() && !l.pixels.is_empty()
@@ -238,48 +265,54 @@ impl Engine {
// on top of clean pixels (not on effects-applied pixels). The composite // on top of clean pixels (not on effects-applied pixels). The composite
// uses effects_cache for layers with effects, not layer.pixels. // uses effects_cache for layers with effects, not layer.pixels.
for layer in &mut self.document.layers { 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 // Check if there are any active/enabled effects or styles
let has_enabled = layer.effects.iter().any(|e| { let has_enabled = layer.effects.iter().any(|e| match e {
match e { hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. }
hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. } | hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. }
| hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. } | hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. }
| hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. } | hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. }
| hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. } | hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. }
| hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. } | hcie_protocol::effects::LayerEffect::Satin { enabled, .. }
| hcie_protocol::effects::LayerEffect::Satin { enabled, .. } | hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. }
| hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. }
| hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. }
| hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled,
| hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled, }) || layer.styles.iter().any(|s| match s {
} hcie_protocol::LayerStyle::DropShadow { enabled, .. }
}) || layer.styles.iter().any(|s| { | hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
match s { | hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
hcie_protocol::LayerStyle::DropShadow { enabled, .. } | hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. } | hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. } | hcie_protocol::LayerStyle::Satin { enabled, .. }
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. } | hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. } | hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
| hcie_protocol::LayerStyle::Satin { enabled, .. } | hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. } | hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
}
}); });
if !has_enabled { if !has_enabled {
// No active effects: clean up cached rendering and backup to restore raw performance. // No active effects: clean up cached rendering and backup to restore raw performance.
*layer.effects_cache.lock().unwrap() = None; *layer.effects_cache.lock().unwrap() = None;
self.raw_pixel_backup.remove(&layer.id); 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; layer.dirty = true;
continue; continue;
} }
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) { if layer
log::trace!("Applying active effects/styles for layer ID {} because effects are dirty", layer.id); .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 // 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 // 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. // 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)) .filter(|e| !matches!(e, hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0))
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e)) .map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
.collect(); .collect();
effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s))); effects.extend(
if effects.is_empty() { continue; } 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( let processed = hcie_fx::apply_layer_effects(
&layer.pixels, &layer.pixels,
layer.width, layer.width,
@@ -301,7 +341,9 @@ impl Engine {
width: layer.width, width: layer.width,
height: layer.height, 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; layer.dirty = true;
} }
} }
@@ -321,7 +363,9 @@ impl Engine {
let db = self.document.dirty_bounds; let db = self.document.dirty_bounds;
let mut synced_count = 0usize; let mut synced_count = 0usize;
for (i, layer) in self.document.layers.iter().enumerate() { 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([dx0, dy0, dx1, dy1]) = db {
if let Some(ref mut tl) = self.tile_layers[i] { if let Some(ref mut tl) = self.tile_layers[i] {
if tl.width() == layer.width && tl.height() == layer.height { if tl.width() == layer.width && tl.height() == layer.height {
@@ -347,7 +391,11 @@ impl Engine {
); );
} }
} else { } 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!( log::trace!(
"[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)", "[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)",
i, layer.id, layer.visible i, layer.id, layer.visible
@@ -357,7 +405,12 @@ impl Engine {
} }
self.tile_layers.truncate(count); self.tile_layers.truncate(count);
if synced_count > 0 { 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 // Build tile slices for the compositor
let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len()); let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len());
let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer] 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]; let mut buf = vec![0u8; buf_size];
@@ -443,8 +499,12 @@ pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
tiled::composite_tiled_into( tiled::composite_tiled_into(
&snapshot.layers[..snapshot.active_layer], &snapshot.layers[..snapshot.active_layer],
&snapshot.tile_layers[..tile_slice_len], &snapshot.tile_layers[..tile_slice_len],
w, h, w,
0, 0, w, h, h,
0,
0,
w,
h,
&mut buf, &mut buf,
); );
} }
@@ -454,8 +514,12 @@ pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
tiled::composite_tiled_into( tiled::composite_tiled_into(
&snapshot.layers[snapshot.active_layer..], &snapshot.layers[snapshot.active_layer..],
&snapshot.tile_layers[tile_start..], &snapshot.tile_layers[tile_start..],
w, h, w,
0, 0, w, h, h,
0,
0,
w,
h,
&mut buf, &mut buf,
); );
+87 -27
View File
@@ -17,11 +17,11 @@
//! engine's stroke state. Calls into `hcie-draw` and `hcie-brush-engine` via //! engine's stroke state. Calls into `hcie-draw` and `hcie-brush-engine` via
//! the dynamic loader. //! the dynamic loader.
use hcie_protocol::{BrushStyle, BrushTip};
use crate::Engine;
use crate::dynamic_loader::{ use crate::dynamic_loader::{
draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke, draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke,
}; };
use crate::Engine;
use hcie_protocol::{BrushStyle, BrushTip};
impl Engine { impl Engine {
/// Interpolate a brush property along the stroke using accumulated distance. /// 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 /// `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 /// value is returned unchanged. Otherwise the value is lerped from start
/// to end and multiplied with the base. /// to end and multiplied with the base.
fn apply_time_interpolation(base: f32, start: f32, end: f32, time_enabled: bool, t: f32) -> f32 { fn apply_time_interpolation(
if !time_enabled { return base; } 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); let lerp = start + (end - start) * t.clamp(0.0, 1.0);
base * lerp base * lerp
} }
@@ -116,7 +124,11 @@ impl Engine {
tip.opacity, tip.opacity,
tip.spacing, tip.spacing,
self.is_eraser, 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_particle_size,
tip.spray_density, tip.spray_density,
mask_ref, mask_ref,
@@ -164,7 +176,9 @@ impl Engine {
); );
layer.dirty = true; layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() { 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); let dirty_r = tip.size.max(2.0);
if let Some((lx, ly, _)) = self.last_stroke_pos { 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. /// Pen tool stroke — draws a true anti-aliased line segment.
/// Uses begin_stroke/end_stroke for undo. /// 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 tip = self.current_tip.clone();
let color = self.apply_cyclic_color(self.current_color); let color = self.apply_cyclic_color(self.current_color);
@@ -192,11 +213,18 @@ impl Engine {
let w = self.document.canvas_width as f32; let w = self.document.canvas_width as f32;
let h = self.document.canvas_height as f32; let h = self.document.canvas_height as f32;
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { 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); draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
layer.dirty = true; layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() { 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); let dirty_r = tip.size.max(2.0);
if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h { if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h {
@@ -235,7 +263,11 @@ impl Engine {
tip.opacity, tip.opacity,
tip.spacing, tip.spacing,
self.is_eraser, 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_particle_size,
tip.spray_density, tip.spray_density,
mask_ref, mask_ref,
@@ -259,7 +291,9 @@ impl Engine {
); );
} }
if !layer.effects.is_empty() || !layer.styles.is_empty() { 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.composite_dirty = true;
self.document.modified = true; self.document.modified = true;
@@ -273,17 +307,19 @@ impl Engine {
_ => tip.size.max(2.0), _ => tip.size.max(2.0),
}; };
for &(px, py, _) in points { 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); self.document.expand_dirty(px as u32, py as u32, dirty_r);
} }
} }
} }
if let Some(before) = before_pixels { if let Some(before) = before_pixels {
self.document.push_draw_snapshot( self.document
layer_idx, before, None, .push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
"Brush Stroke".to_string(),
);
} }
} }
@@ -302,7 +338,8 @@ impl Engine {
self.document.modified = true; self.document.modified = true;
} }
if let Some(before) = before_pixels { 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; self.document.modified = true;
} }
if let Some(before) = before_pixels { 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; self.document.modified = true;
} }
if let Some(before) = before_pixels { 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; self.document.modified = true;
} }
if let Some(before) = before_pixels { 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. /// Check if the given brush style needs specialized procedural rendering.
/// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false. /// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false.
pub(crate) fn is_specialized_style(s: BrushStyle) -> bool { pub(crate) fn is_specialized_style(s: BrushStyle) -> bool {
matches!(s, BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Watercolor | matches!(
BrushStyle::Calligraphy | BrushStyle::Marker | BrushStyle::Glow | BrushStyle::Airbrush | s,
BrushStyle::Spray | BrushStyle::Oil
BrushStyle::Pencil | BrushStyle::Crayon | BrushStyle::Tree | BrushStyle::Meadow | | BrushStyle::Charcoal
BrushStyle::Rock | BrushStyle::Clouds | BrushStyle::Dirt | BrushStyle::Star | | BrushStyle::Watercolor
BrushStyle::Bristle | BrushStyle::Wood | BrushStyle::Sketch | BrushStyle::Hatch | | BrushStyle::Calligraphy
BrushStyle::Square | BrushStyle::WetPaint | BrushStyle::Leaf | BrushStyle::Mixer) | 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
)
} }
} }
+66 -24
View File
@@ -20,10 +20,10 @@
//! buffers, dirty flags). They depend on `hcie_tile::TiledLayer` and the //! buffers, dirty flags). They depend on `hcie_tile::TiledLayer` and the
//! dynamic `tiled::composite_tiled_into` compositor. //! dynamic `tiled::composite_tiled_into` compositor.
use crate::dynamic_loader::tiled;
use crate::Engine;
use hcie_protocol::LayerData; use hcie_protocol::LayerData;
use hcie_tile::TiledLayer; 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`. /// 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); struct SendPtr(usize);
impl SendPtr { impl SendPtr {
#[inline] #[inline]
fn new(p: *const u8) -> Self { Self(p as usize) } fn new(p: *const u8) -> Self {
Self(p as usize)
}
#[inline] #[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. // SAFETY: The usize encodes a pointer to layer.pixels which is stable for the engine lifetime.
// The background thread only reads, never writes. // The background thread only reads, never writes.
@@ -74,7 +78,11 @@ impl Engine {
for item in items { for item in items {
// Recycle returned before buffer back into the pool // Recycle returned before buffer back into the pool
if let Some(buf) = item.return_before { 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); self.stroke_before_buf = Some(buf);
} }
} }
@@ -127,8 +135,8 @@ impl Engine {
self.last_stroke_pos = Some((x, y, 1.0)); self.last_stroke_pos = Some((x, y, 1.0));
self.sketch_history.clear(); self.sketch_history.clear();
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { 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_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_size = (layer.width * layer.height) as usize; // pixel count (for mask)
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate. // Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
// This avoids a ~16MB allocation per stroke on a 4K canvas. // 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) { if let Some(idx) = self.document.layer_index_by_id(layer_id) {
let layer = &mut self.document.layers[idx]; let layer = &mut self.document.layers[idx];
let lw = layer.width; let lw = layer.width;
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
// Restore layer effects from backup so they are re-composited correctly // Restore layer effects from backup so they are re-composited correctly
if let Some(backup) = self.stroke_effects_backup.take() { if let Some(backup) = self.stroke_effects_backup.take() {
layer.effects = backup; layer.effects = backup;
} }
@@ -221,12 +229,18 @@ impl Engine {
} }
if !layer.effects.is_empty() || !layer.styles.is_empty() { 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.composite_dirty = true;
} }
// Take the pooled before buffer (no allocation) // 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() { let before = match self.stroke_before_buf.take() {
Some(buf) if buf.len() == layer_pixels => buf, Some(buf) if buf.len() == layer_pixels => buf,
_ => vec![0u8; layer_pixels], _ => vec![0u8; layer_pixels],
@@ -252,7 +266,8 @@ impl Engine {
let t_start = std::time::Instant::now(); let t_start = std::time::Instant::now();
// SAFETY: after_ptr points to layer.pixels which is valid for the // SAFETY: after_ptr points to layer.pixels which is valid for the
// engine lifetime. No mutations happen between end_stroke() and next begin_stroke(). // 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 { let mut item = PendingHistoryItem {
layer_idx: idx, layer_idx: idx,
before_pixels: Vec::new(), before_pixels: Vec::new(),
@@ -291,7 +306,10 @@ impl Engine {
item.return_before = Some(before); item.return_before = Some(before);
pending_history.lock().unwrap().push(item); 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; return;
} }
} }
@@ -302,7 +320,10 @@ impl Engine {
// Copy into owned memory for PendingHistoryItem. // Copy into owned memory for PendingHistoryItem.
item.after_pixels = after_slice.to_vec(); item.after_pixels = after_slice.to_vec();
pending_history.lock().unwrap().push(item); 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 // Stroke changed layer.pixels — invalidate effects backup so
@@ -364,7 +385,10 @@ impl Engine {
let can_reuse = !self.below_cache_dirty let can_reuse = !self.below_cache_dirty
&& self.below_cache_active_idx == Some(active_idx) && self.below_cache_active_idx == Some(active_idx)
&& active_idx > 0 && 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!( log::trace!(
"[begin_stroke] below_cache state: active_idx={}, reuse={}, dirty={}, existing_cache={}, existing_active_idx={:?}", "[begin_stroke] below_cache state: active_idx={}, reuse={}, dirty={}, existing_cache={}, existing_active_idx={:?}",
@@ -372,7 +396,10 @@ impl Engine {
); );
if can_reuse { 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; self.below_cache_dirty = false;
return; return;
} }
@@ -384,13 +411,18 @@ impl Engine {
} }
let mut tiles_built = 0usize; let mut tiles_built = 0usize;
for (ti, tl) in self.document.layers.iter().enumerate() { 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() { if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
self.tile_layers[ti] = Some(TiledLayer::from_dense( self.tile_layers[ti] =
&tl.pixels, tl.width, tl.height, Some(TiledLayer::from_dense(&tl.pixels, tl.width, tl.height));
));
tiles_built += 1; 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() { 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 tile_slice_len = active_idx.min(self.tile_layers.len());
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx] 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!( log::trace!(
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}", "[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
active_idx, 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( tiled::composite_tiled_into(
&self.document.layers[..active_idx], &self.document.layers[..active_idx],
&self.tile_layers[..tile_slice_len], &self.tile_layers[..tile_slice_len],
cw, ch, 0, 0, cw, ch, cw,
ch,
0,
0,
cw,
ch,
&mut cache, &mut cache,
); );
let non_zero = cache.iter().filter(|&&b| b != 0).count(); let non_zero = cache.iter().filter(|&&b| b != 0).count();
log::trace!( log::trace!(
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}", "[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 = Some(cache);
self.below_cache_active_idx = Some(active_idx); self.below_cache_active_idx = Some(active_idx);
+33 -10
View File
@@ -1,11 +1,13 @@
use hcie_engine_api::{Engine, BrushTip, BrushStyle}; use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test] #[test]
fn bitmap_brush_stamp_shape() { fn bitmap_brush_stamp_shape() {
let mut engine = Engine::new(64, 64); let mut engine = Engine::new(64, 64);
// 4x4 solid white square bitmap stamp // 4x4 solid white square bitmap stamp
let mut pixels = vec![0u8; 16]; 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 { let brush = BrushTip {
style: BrushStyle::Bitmap, style: BrushStyle::Bitmap,
size: 8.0, size: 8.0,
@@ -25,25 +27,46 @@ fn bitmap_brush_stamp_shape() {
// Check that the painted area is roughly 4x4, not a circular blob. // Check that the painted area is roughly 4x4, not a circular blob.
// Count alpha > 0 pixels and bounding box. // 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; let mut count = 0;
for y in 0..64 { for y in 0..64 {
for x 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 { if a > 0 {
count += 1; count += 1;
if x < min_x { min_x = x; } if x < min_x {
if x > max_x { max_x = x; } min_x = x;
if y < min_y { min_y = y; } }
if y > max_y { max_y = y; } 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 w = (max_x - min_x + 1) as i32;
let h = (max_y - min_y + 1) as i32; let h = (max_y - min_y + 1) as i32;
// Should fit inside ~8x8 because size=8 and stamp 4x4 scaled up // 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"); 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?) // 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
);
} }
+8 -3
View File
@@ -4,7 +4,7 @@
//! public engine API and saves the result as a PNG. Used for manual inspection //! public engine API and saves the result as a PNG. Used for manual inspection
//! and regression detection. //! and regression detection.
use hcie_engine_api::{Engine, BrushTip, BrushStyle}; use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test] #[test]
#[ignore = "manual visual check: run with --ignored --test leaves_check and inspect target/leaves_check.png"] #[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 pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512); let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone(); 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(); std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("leaves_check.png"); let path = out_dir.join("leaves_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save 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"); assert!(has_color, "Leaves check produced no colored pixels");
} }
+8 -3
View File
@@ -11,7 +11,7 @@
//! 3. Draw multiple short strokes at different positions. //! 3. Draw multiple short strokes at different positions.
//! 4. Save the composited pixels to `target/meadow_check.png`. //! 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] #[test]
#[ignore = "manual visual check: run with --ignored --test meadow_check and inspect target/meadow_check.png"] #[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 pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512); let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone(); 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(); std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("meadow_check.png"); let path = out_dir.join("meadow_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save 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. // 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"); assert!(has_color, "Meadow check produced no colored pixels");
} }
+16 -5
View File
@@ -25,7 +25,7 @@
//! - Allocates ~400MB of pixel/tile/mask buffers. //! - Allocates ~400MB of pixel/tile/mask buffers.
//! - Uses `std::time::Instant` for measurement; results depend on the host CPU. //! - 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 W: u32 = 3840;
const H: u32 = 2160; 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 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_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_segment_us = segment_times.iter().copied().fold(0.0, f64::max);
let max_composite_us = composite_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!("\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!("Average stroke_to: {:>8.1} us", avg_segment_us);
println!("Max stroke_to: {:>8.1} us", max_segment_us); println!("Max stroke_to: {:>8.1} us", max_segment_us);
println!("Average composite: {:>8.1} us", avg_composite_us); println!("Average composite: {:>8.1} us", avg_composite_us);
println!("Max composite: {:>8.1} us", max_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!(
println!("Segments per second: {:>8.1}", SEGMENTS as f64 / (total_us / 1_000_000.0)); "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 // Soft sanity check: the engine must still report a valid top layer and the
// document must be marked modified after painting. // document must be marked modified after painting.
+8 -3
View File
@@ -4,7 +4,7 @@
//! using the public engine API and saves the result as a PNG. Used for manual //! using the public engine API and saves the result as a PNG. Used for manual
//! inspection and regression detection. //! inspection and regression detection.
use hcie_engine_api::{Engine, BrushTip, BrushStyle}; use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test] #[test]
#[ignore = "manual visual check: run with --ignored --test stamp_floor_check and inspect target/stamp_floor_check.png"] #[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 pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512); let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone(); 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(); std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("stamp_floor_check.png"); let path = out_dir.join("stamp_floor_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save 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"); assert!(has_color, "Stamp Floor check produced no colored pixels");
} }
+58 -15
View File
@@ -23,7 +23,7 @@
//! - undo_after_rect_golden: validates history snapshot and restoration //! - undo_after_rect_golden: validates history snapshot and restoration
use hcie_engine_api::Engine; use hcie_engine_api::Engine;
use hcie_engine_api::{BrushTip, BrushStyle, VectorShape}; use hcie_engine_api::{BrushStyle, BrushTip, VectorShape};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
/// Compute SHA-256 hash of pixel buffer for golden comparison. /// 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) { for i in (0..pixels.len()).step_by(4) {
assert_eq!(pixels[i + 3], 0, "Alpha channel should be 0 (transparent)"); 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. /// 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 + 1], 255);
assert_eq!(pixels[center_idx + 2], 0); assert_eq!(pixels[center_idx + 2], 0);
assert_eq!(pixels[center_idx + 3], 255); 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. /// 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], 255);
assert_eq!(pixels[center_idx + 1], 0); assert_eq!(pixels[center_idx + 1], 0);
assert_eq!(pixels[center_idx + 2], 0); assert_eq!(pixels[center_idx + 2], 0);
assert_eq!(hash_pixels(&pixels), "c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750"); assert_eq!(
hash_pixels(&pixels),
"c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750"
);
} }
/// Test: Vector shape rendering. /// Test: Vector shape rendering.
@@ -100,7 +109,10 @@ fn red_rect_over_green_rect() {
fn vector_rect_golden() { fn vector_rect_golden() {
let mut engine = Engine::new(16, 16); let mut engine = Engine::new(16, 16);
let shape = VectorShape::Rect { 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, stroke: 1.0,
color: [255, 0, 0, 255], color: [255, 0, 0, 255],
fill_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); assert_eq!(pixels.len(), 16 * 16 * 4);
// Verify at least one red pixel (vector rect was drawn) // 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!(has_red, "Vector rect should contain red pixels");
assert_eq!(hash_pixels(&pixels), "71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d"); assert_eq!(
hash_pixels(&pixels),
"71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d"
);
} }
/// Test: Brush stroke rendering. /// Test: Brush stroke rendering.
@@ -144,8 +161,14 @@ fn brush_stroke_golden() {
// Brush stroke should produce non-white pixels // Brush stroke should produce non-white pixels
let has_non_white = pixels.iter().any(|&p| p < 255); let has_non_white = pixels.iter().any(|&p| p < 255);
assert!(has_non_white, "Brush stroke should produce non-white pixels"); assert!(
assert_eq!(hash_pixels(&pixels), "e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087"); has_non_white,
"Brush stroke should produce non-white pixels"
);
assert_eq!(
hash_pixels(&pixels),
"e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087"
);
} }
/// Test: Invert filter. /// Test: Invert filter.
@@ -164,7 +187,10 @@ fn invert_filter_golden() {
assert_eq!(pixels[idx], 155); // 255 - 100 assert_eq!(pixels[idx], 155); // 255 - 100
assert_eq!(pixels[idx + 1], 105); // 255 - 150 assert_eq!(pixels[idx + 1], 105); // 255 - 150
assert_eq!(pixels[idx + 2], 55); // 255 - 200 assert_eq!(pixels[idx + 2], 55); // 255 - 200
assert_eq!(hash_pixels(&pixels), "35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f"); assert_eq!(
hash_pixels(&pixels),
"35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f"
);
} }
/// Test: Undo after rectangle. /// Test: Undo after rectangle.
@@ -196,7 +222,10 @@ fn vector_fill_toggle_and_delete() {
// Create a rect with fill=false // Create a rect with fill=false
let shape = VectorShape::Rect { 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, stroke: 1.0,
color: [255, 0, 0, 255], color: [255, 0, 0, 255],
fill_color: [0, 0, 255, 255], fill_color: [0, 0, 255, 255],
@@ -211,22 +240,36 @@ fn vector_fill_toggle_and_delete() {
// Verify fill is initially false // Verify fill is initially false
let shapes = engine.active_vector_shapes().unwrap(); 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 // Toggle fill ON
engine.set_vector_shape_fill(layer_id, 0, true); engine.set_vector_shape_fill(layer_id, 0, true);
let shapes = engine.active_vector_shapes().unwrap(); 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 // Verify composite pixels contain blue fill
let pixels = engine.get_composite_pixels(); 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"); assert!(has_blue, "should have blue fill pixels after enabling fill");
// Toggle fill OFF // Toggle fill OFF
engine.set_vector_shape_fill(layer_id, 0, false); engine.set_vector_shape_fill(layer_id, 0, false);
let shapes = engine.active_vector_shapes().unwrap(); 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 // Delete the shape
engine.delete_vector_shape(layer_id, 0); engine.delete_vector_shape(layer_id, 0);
+221 -53
View File
@@ -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. /// Effect application orchestrator.
/// ///
/// Applies all enabled layer effects to a layer's pixel buffer in standard /// Applies all enabled layer effects to a layer's pixel buffer in standard
/// Photoshop order. Delegates to individual effect modules for rendering. /// Photoshop order. Delegates to individual effect modules for rendering.
use crate::types::LayerEffect; 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; use hcie_blend::blend_pixels;
/// Apply all enabled layer effects to a layer's pixel buffer in standard Photoshop order. /// 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 { } else {
let mut out = pixels.to_vec(); let mut out = pixels.to_vec();
for i in 0..px_count { 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; return out;
} }
@@ -55,31 +56,62 @@ pub fn apply_layer_effects(
inside.copy_from_slice(pixels); inside.copy_from_slice(pixels);
if fill_opacity < 1.0 { if fill_opacity < 1.0 {
for i in 0..px_count { 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: // Inside effects — Photoshop spec order:
// a. Bevel & Emboss // a. Bevel & Emboss
if let Some(LayerEffect::BevelEmboss { if let Some(LayerEffect::BevelEmboss {
enabled: true, style, technique, depth, direction, size, soften, enabled: true,
angle, altitude, highlight_blend, highlight_color, highlight_opacity, style,
shadow_blend, shadow_color, shadow_opacity, contour, .. technique,
}) = effects.iter().find(|e| matches!(e, LayerEffect::BevelEmboss { .. })) { 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 // 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 // it with a hard-coded grey. Photoshop keeps the original pixel color
// and applies highlight/shadow blends over it. // and applies highlight/shadow blends over it.
let (highlight, shadow) = generate_bevel_emboss( let (highlight, shadow) = generate_bevel_emboss(
&alpha, canvas_w, canvas_h, &alpha,
*depth, *size, *soften, *angle, *altitude, canvas_w,
*direction, *technique, *style, canvas_h,
*highlight_color, *shadow_color, *depth,
*size,
*soften,
*angle,
*altitude,
*direction,
*technique,
*style,
*highlight_color,
*shadow_color,
contour, contour,
); );
// For Emboss and OuterBevel, the effect extends outside the shape. // For Emboss and OuterBevel, the effect extends outside the shape.
// Inside pixels go to `inside`, outside pixels go to `behind`. // 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 { if has_outer {
// Split highlight/shadow into inside and outside portions // Split highlight/shadow into inside and outside portions
let mut hl_inside = vec![0u8; px_count * 4]; 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]); 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(
composite_effect(&mut inside, &sh_inside, *shadow_blend, *shadow_opacity, px_count); &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 // Store outside portions to apply to `behind` later
bevel_hl_outside = Some(hl_outside); bevel_hl_outside = Some(hl_outside);
bevel_sh_outside = Some(sh_outside); bevel_sh_outside = Some(sh_outside);
@@ -105,65 +149,146 @@ pub fn apply_layer_effects(
bevel_hl_op = *highlight_opacity; bevel_hl_op = *highlight_opacity;
bevel_sh_op = *shadow_opacity; bevel_sh_op = *shadow_opacity;
} else { } else {
composite_effect(&mut inside, &highlight, *highlight_blend, *highlight_opacity, px_count); composite_effect(
composite_effect(&mut inside, &shadow, *shadow_blend, *shadow_opacity, px_count); &mut inside,
&highlight,
*highlight_blend,
*highlight_opacity,
px_count,
);
composite_effect(
&mut inside,
&shadow,
*shadow_blend,
*shadow_opacity,
px_count,
);
} }
} }
// b. Satin // b. Satin
if let Some(LayerEffect::Satin { if let Some(LayerEffect::Satin {
enabled: true, blend_mode, color, opacity, angle, distance, size, invert, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::Satin { .. })) { blend_mode,
let satin = generate_satin(&alpha, canvas_w, canvas_h, *angle, *distance, *size, *color, *invert); 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); composite_effect(&mut inside, &satin, *blend_mode, *opacity, px_count);
} }
// c. Color Overlay // c. Color Overlay
if let Some(LayerEffect::ColorOverlay { if let Some(LayerEffect::ColorOverlay {
enabled: true, blend_mode, color, opacity, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::ColorOverlay { .. })) { blend_mode,
color,
opacity,
..
}) = effects
.iter()
.find(|e| matches!(e, LayerEffect::ColorOverlay { .. }))
{
let overlay = generate_color_overlay(&alpha, canvas_w, canvas_h, *color); let overlay = generate_color_overlay(&alpha, canvas_w, canvas_h, *color);
composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count); composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
} }
// d. Gradient Overlay // d. Gradient Overlay
if let Some(LayerEffect::GradientOverlay { if let Some(LayerEffect::GradientOverlay {
enabled: true, blend_mode, opacity, angle, scale, gradient, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::GradientOverlay { .. })) { blend_mode,
let overlay = generate_gradient_overlay(&alpha, canvas_w, canvas_h, *angle, *scale, gradient); 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); composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
} }
// e. Pattern Overlay // e. Pattern Overlay
if let Some(LayerEffect::PatternOverlay { if let Some(LayerEffect::PatternOverlay {
enabled: true, blend_mode, opacity, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::PatternOverlay { .. })) { 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]); 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); composite_inside_effect(&mut inside, &overlay, *blend_mode, *opacity, px_count);
} }
// f. Inner Glow // f. Inner Glow
if let Some(LayerEffect::InnerGlow { if let Some(LayerEffect::InnerGlow {
enabled: true, blend_mode, color, opacity, choke, size, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::InnerGlow { .. })) { 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); let glow = generate_glow(&alpha, canvas_w, canvas_h, *choke, *size, *color, true);
composite_effect(&mut inside, &glow, *blend_mode, *opacity, px_count); composite_effect(&mut inside, &glow, *blend_mode, *opacity, px_count);
} }
// g. Inner Shadow // g. Inner Shadow
if let Some(LayerEffect::InnerShadow { if let Some(LayerEffect::InnerShadow {
enabled: true, blend_mode, color, opacity, angle, distance, choke, size, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::InnerShadow { .. })) { blend_mode,
let shadow = generate_inner_shadow(&alpha, canvas_w, canvas_h, *angle, *distance, *choke, *size, *color); 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); composite_effect(&mut inside, &shadow, *blend_mode, *opacity, px_count);
} }
// h. Stroke (Inside and Center — Outside handled in behind phase) // h. Stroke (Inside and Center — Outside handled in behind phase)
if let Some(LayerEffect::Stroke { if let Some(LayerEffect::Stroke {
enabled: true, position, blend_mode, opacity, size, color, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::Stroke { .. })) { position,
blend_mode,
opacity,
size,
color,
..
}) = effects
.iter()
.find(|e| matches!(e, LayerEffect::Stroke { .. }))
{
if !matches!(position, crate::types::StrokePosition::Outside) { 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); 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: // Behind effects — Photoshop spec order:
// a. Drop Shadow // a. Drop Shadow
if let Some(LayerEffect::DropShadow { if let Some(LayerEffect::DropShadow {
enabled: true, blend_mode, color, opacity, angle, distance, spread, size, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::DropShadow { .. })) { blend_mode,
let shadow = generate_shadow(&alpha, canvas_w, canvas_h, *angle, *distance, *spread, *size, *color, false); 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); composite_effect(&mut behind, &shadow, *blend_mode, *opacity, px_count);
} }
// b. Outer Glow // b. Outer Glow
if let Some(LayerEffect::OuterGlow { if let Some(LayerEffect::OuterGlow {
enabled: true, blend_mode, color, opacity, spread, size, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::OuterGlow { .. })) { 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); let glow = generate_glow(&alpha, canvas_w, canvas_h, *spread, *size, *color, false);
composite_effect(&mut behind, &glow, *blend_mode, *opacity, px_count); composite_effect(&mut behind, &glow, *blend_mode, *opacity, px_count);
} }
// c. Outside Stroke // c. Outside Stroke
if let Some(LayerEffect::Stroke { if let Some(LayerEffect::Stroke {
enabled: true, position, blend_mode, opacity, size, color, .. enabled: true,
}) = effects.iter().find(|e| matches!(e, LayerEffect::Stroke { .. })) { position,
blend_mode,
opacity,
size,
color,
..
}) = effects
.iter()
.find(|e| matches!(e, LayerEffect::Stroke { .. }))
{
if matches!(position, crate::types::StrokePosition::Outside) { 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); 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 // 3. Blend inside ON TOP of behind using Normal blend
for i in 0..px_count { for i in 0..px_count {
let b = [behind[i * 4], behind[i * 4 + 1], behind[i * 4 + 2], behind[i * 4 + 3]]; let b = [
let ins = [inside[i * 4], inside[i * 4 + 1], inside[i * 4 + 2], inside[i * 4 + 3]]; 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 { if ins[3] == 0 {
behind[i * 4..i * 4 + 4].copy_from_slice(&b); behind[i * 4..i * 4 + 4].copy_from_slice(&b);
} else { } else {
+1 -6
View File
@@ -1,9 +1,4 @@
pub fn generate_color_overlay( pub fn generate_color_overlay(alpha: &[u8], w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
alpha: &[u8],
w: u32,
h: u32,
color: [u8; 4],
) -> Vec<u8> {
let px = (w * h) as usize; let px = (w * h) as usize;
let mut buf = vec![0u8; px * 4]; let mut buf = vec![0u8; px * 4];
+3 -2
View File
@@ -2,7 +2,6 @@
/// ///
/// Renders a drop shadow by offsetting the layer alpha, applying spread, /// Renders a drop shadow by offsetting the layer alpha, applying spread,
/// blurring, and filling with the shadow color. Supports knock-out mode. /// blurring, and filling with the shadow color. Supports knock-out mode.
use crate::helpers::{apply_spread, box_blur_f32}; use crate::helpers::{apply_spread, box_blur_f32};
/// Generate a drop shadow buffer. /// Generate a drop shadow buffer.
@@ -56,7 +55,9 @@ pub(crate) fn generate_shadow(
for y in 0..h as i32 { for y in 0..h as i32 {
for x in 0..w as i32 { for x in 0..w as i32 {
let a = alpha[(y as u32 * w + x as u32) as usize]; 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 dx = x + dx_raw;
let dy = y + dy_raw; let dy = y + dy_raw;
if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 { if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 {
+80 -24
View File
@@ -1,5 +1,5 @@
use crate::types;
use crate::helpers::box_blur_f32; use crate::helpers::box_blur_f32;
use crate::types;
/// Look up a value in a contour curve using linear interpolation. /// Look up a value in a contour curve using linear interpolation.
/// input is in [0.0, 1.0], output is remapped through the curve. /// 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. // 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 // This is critical: without it, alpha[i]==0 outside the shape produces
// zero-strength highlight/shadow, making Emboss/OuterBevel invisible. // 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) edt_outside_src(alpha, iw, ih)
} else { } else {
vec![0usize; px] vec![0usize; px]
@@ -112,10 +115,18 @@ pub fn generate_bevel_emboss(
if alpha[i] > 0 { if alpha[i] > 0 {
let x = i % iw; let x = i % iw;
let y = i / iw; let y = i / iw;
if x < min_x { min_x = x; } if x < min_x {
if x > max_x { max_x = x; } min_x = x;
if y < min_y { min_y = y; } }
if y > max_y { max_y = y; } 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) ((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. // Apply soften box-blur on the height field.
// Even at soften=0, apply minimal 2px blur for smoother gradient estimation at edges. // 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. // 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); let soften_radius = (soften.max(0.0).round() as i32).max(base_blur);
height = box_blur_f32(&height, w, h, soften_radius); height = box_blur_f32(&height, w, h, soften_radius);
@@ -200,20 +215,32 @@ pub fn generate_bevel_emboss(
types::BevelStyle::Emboss => true, types::BevelStyle::Emboss => true,
_ => alpha[i] > 0, _ => alpha[i] > 0,
}; };
if !show { continue; } if !show {
continue;
}
let is_inner = alpha[i] > 0; 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 { let technique_show = match technique {
types::Technique::Smooth => true, types::Technique::Smooth => true,
_ => dist_abs <= radius, _ => 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 x0 = if x > 0 { x - 1 } else { x };
let x1 = if x < iw - 1 { 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. // Inner/Outer bevel need blur for smooth corners.
// MAE-optimized against Photoshop ground truth (288 emboss samples, 256×256). // MAE-optimized against Photoshop ground truth (288 emboss samples, 256×256).
// BEGIN_TUNING_ZONE // 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); lighting = box_blur_f32(&lighting, w, h, lighting_blur);
for i in 0..px { for i in 0..px {
@@ -261,20 +292,31 @@ pub fn generate_bevel_emboss(
crate::types::BevelStyle::Emboss => true, crate::types::BevelStyle::Emboss => true,
_ => is_inner, _ => is_inner,
}; };
if !show { continue; } if !show {
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() }; continue;
if !is_inner && dist_abs > radius { 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 { let technique_show = match technique {
crate::types::Technique::Smooth => true, crate::types::Technique::Smooth => true,
_ => dist_abs <= radius, _ => dist_abs <= radius,
}; };
if !technique_show { continue; } if !technique_show {
continue;
}
let ndl = lighting[i]; let ndl = lighting[i];
// Emboss has tilt so the interior is never truly flat — skip the // Emboss has tilt so the interior is never truly flat — skip the
// is_flat shortcut for emboss to allow tilt-based highlight/shadow // is_flat shortcut for emboss to allow tilt-based highlight/shadow
// across the entire shape interior. // 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 { if is_flat {
continue; continue;
} }
@@ -417,7 +459,9 @@ fn edt_2d(dt: &mut [f32], w: usize, h: usize) {
/// 1D EDT (Felzenszwalb & Huttenlocher). /// 1D EDT (Felzenszwalb & Huttenlocher).
fn edt_1d(f: &mut [f32]) { fn edt_1d(f: &mut [f32]) {
let n = f.len(); let n = f.len();
if n == 0 { return; } if n == 0 {
return;
}
let mut d = vec![0.0f32; n]; let mut d = vec![0.0f32; n];
let mut v = vec![0usize; 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); let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
while s <= z[k] { while s <= z[k] {
k = k.wrapping_sub(1); k = k.wrapping_sub(1);
if k == usize::MAX { k = 0; break; } if k == usize::MAX {
k = 0;
break;
}
let vk2 = v[k]; let vk2 = v[k];
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32; let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * 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; k = 0;
for q in 0..n { for q in 0..n {
let qf = q as f32; let qf = q as f32;
while z[k + 1] < qf { k += 1; } while z[k + 1] < qf {
k += 1;
}
let vk = v[k]; let vk = v[k];
let diff = qf - vk as f32; let diff = qf - vk as f32;
d[q] = diff * diff + f[vk]; 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). /// 1D EDT with source index tracking (Felzenszwalb & Huttenlocher).
fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) { fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
let n = f.len(); let n = f.len();
if n == 0 { return; } if n == 0 {
return;
}
let mut d = vec![0.0f32; n]; let mut d = vec![0.0f32; n];
let mut d_src = vec![0usize; 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); let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
while s <= z[k] { while s <= z[k] {
k = k.wrapping_sub(1); k = k.wrapping_sub(1);
if k == usize::MAX { k = 0; break; } if k == usize::MAX {
k = 0;
break;
}
let vk2 = v[k]; let vk2 = v[k];
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32; let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * 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; k = 0;
for q in 0..n { for q in 0..n {
let qf = q as f32; let qf = q as f32;
while z[k + 1] < qf { k += 1; } while z[k + 1] < qf {
k += 1;
}
let vk = v[k]; let vk = v[k];
let diff = qf - vk as f32; let diff = qf - vk as f32;
d[q] = diff * diff + f[vk]; d[q] = diff * diff + f[vk];
+3 -1
View File
@@ -22,7 +22,9 @@ pub fn generate_gradient_overlay(
for x in 0..w as usize { for x in 0..w as usize {
let i = y * w as usize + x; let i = y * w as usize + x;
let a = alpha[i]; let a = alpha[i];
if a == 0 { continue; } if a == 0 {
continue;
}
let fx = x as f32 - cx; let fx = x as f32 - cx;
let fy = y as f32 - cy; let fy = y as f32 - cy;
+21 -5
View File
@@ -40,8 +40,15 @@ pub(crate) fn composite_effect(
) { ) {
for i in 0..px_count { 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 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]]; let src_px = [
if src_px[3] == 0 { continue; } 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); let out = hcie_blend::blend_pixels(dst_px, src_px, blend_mode, opacity);
dst[i * 4..i * 4 + 4].copy_from_slice(&out); dst[i * 4..i * 4 + 4].copy_from_slice(&out);
} }
@@ -68,8 +75,15 @@ pub(crate) fn composite_inside_effect(
) { ) {
for i in 0..px_count { 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 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]]; let src_px = [
if src_px[3] == 0 || dst_px[3] == 0 { continue; } 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; let eff_sa = (src_px[3] as f32 / 255.0) * opacity;
@@ -110,7 +124,9 @@ pub(crate) fn apply_spread(alpha: &mut [u8], spread: f32) {
} }
if spread >= 100.0 { if spread >= 100.0 {
for a in alpha.iter_mut() { for a in alpha.iter_mut() {
if *a > 0 { *a = 255; } if *a > 0 {
*a = 255;
}
} }
return; return;
} }
-1
View File
@@ -3,7 +3,6 @@
/// Renders an inner shadow by offsetting the inverted layer alpha, /// Renders an inner shadow by offsetting the inverted layer alpha,
/// blurring, and masking with the original alpha. MAE-optimized /// blurring, and masking with the original alpha. MAE-optimized
/// parameters for Photoshop-compatible output. /// parameters for Photoshop-compatible output.
use crate::helpers::{apply_spread, box_blur_f32}; use crate::helpers::{apply_spread, box_blur_f32};
/// Generate an inner shadow buffer. /// Generate an inner shadow buffer.
+14 -11
View File
@@ -25,22 +25,25 @@
//! ## Dependencies //! ## Dependencies
//! - `hcie-blend` — Blend mode math for compositing effects with layers //! - `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 apply_effects;
pub mod drop_shadow;
pub mod inner_shadow;
pub mod emboss;
pub mod outer_glow;
pub mod color_overlay; pub mod color_overlay;
pub mod satin; pub mod drop_shadow;
pub mod emboss;
pub mod gradient_overlay; 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 stroke;
pub mod tuned; 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 apply_effects::apply_layer_effects;
pub use helpers::box_blur_f32; 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,
};
+529 -163
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -29,9 +29,13 @@ pub fn generate_stroke(
crate::types::StrokePosition::Inside => { crate::types::StrokePosition::Inside => {
let dist_sq = crate::tuned::edt_inside(alpha, iw, ih); let dist_sq = crate::tuned::edt_inside(alpha, iw, ih);
for i in 0..px { for i in 0..px {
if alpha[i] == 0 { continue; } if alpha[i] == 0 {
continue;
}
let d = dist_sq[i].sqrt(); let d = dist_sq[i].sqrt();
if d >= radius { continue; } if d >= radius {
continue;
}
let outer_dist = radius - d; let outer_dist = radius - d;
let coverage = (outer_dist / aa).clamp(0.0, 1.0); let coverage = (outer_dist / aa).clamp(0.0, 1.0);
stroke_alpha[i] = coverage * alpha[i] as f32; 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); let nearest = crate::tuned::edt_outside_src(alpha, iw, ih);
for i in 0..px { for i in 0..px {
let d = dist_sq[i].sqrt(); 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 x_i = (i % iw) as f32;
let y_i = (i / iw) as f32; let y_i = (i / iw) as f32;
let x_n = (nearest[i] % iw) as f32; let x_n = (nearest[i] % iw) as f32;
@@ -76,7 +82,9 @@ pub fn generate_stroke(
} else { } else {
(dist_out_sq[i].sqrt(), false) (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 { let src_a = if is_inside {
alpha[i] as f32 alpha[i] as f32
+108 -34
View File
@@ -36,7 +36,10 @@ pub fn generate_bevel_tuned(
// For Emboss and OuterBevel, track the nearest opaque source pixel so // For Emboss and OuterBevel, track the nearest opaque source pixel so
// the outer bevel band can inherit the alpha of the nearest content pixel. // 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) edt_outside_src(alpha, iw, ih)
} else { } else {
vec![0usize; px] vec![0usize; px]
@@ -52,10 +55,18 @@ pub fn generate_bevel_tuned(
if alpha[i] > 0 { if alpha[i] > 0 {
let x = i % iw; let x = i % iw;
let y = i / iw; let y = i / iw;
if x < min_x { min_x = x; } if x < min_x {
if x > max_x { max_x = x; } min_x = x;
if y < min_y { min_y = y; } }
if y > max_y { max_y = y; } 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) ((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, types::BevelStyle::Emboss => true,
_ => alpha[i] > 0, _ => alpha[i] > 0,
}; };
if !show { continue; } if !show {
continue;
}
let is_inner = alpha[i] > 0; 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 {
if !is_inner && dist_abs > radius { continue; } dist_inside_sq[i].sqrt()
let x0 = if x > 0 { x - 1 } else { x }; } 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 x1 = if x < iw - 1 { x + 1 } else { x };
let y0 = if y > 0 { y - 1 } else { y }; let y0 = if y > 0 { y - 1 } else { y };
let y1 = if y < ih - 1 { 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, types::BevelStyle::Emboss => true,
_ => is_inner, _ => is_inner,
}; };
if !show { continue; } if !show {
let dist_abs = if is_inner { dist_inside_sq[i].sqrt() } else { dist_outside_sq[i].sqrt() }; continue;
if !is_inner && dist_abs > radius { 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]; let ndl = lighting[i];
// Emboss has tilt so the interior is never truly flat — skip the // Emboss has tilt so the interior is never truly flat — skip the
// is_flat shortcut for emboss to allow tilt-based highlight/shadow // 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 y in 0..h as i32 {
for x in 0..w as i32 { for x in 0..w as i32 {
let a = alpha[(y as u32 * w + x as u32) as usize]; 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 dx = x + dx_raw;
let dy = y + dy_raw; let dy = y + dy_raw;
if dx >= 0 && dx < w as i32 && dy >= 0 && dy < h as i32 { 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 => { types::StrokePosition::Inside => {
let dist_sq = edt_inside(alpha, iw, ih); let dist_sq = edt_inside(alpha, iw, ih);
for i in 0..px { for i in 0..px {
if alpha[i] == 0 { continue; } if alpha[i] == 0 {
continue;
}
let d = dist_sq[i].sqrt(); 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 outer_dist = radius - d; // distance to outer stroke edge
let coverage = (outer_dist / aa).clamp(0.0, 1.0); let coverage = (outer_dist / aa).clamp(0.0, 1.0);
stroke_alpha[i] = coverage * alpha[i] as f32; 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); let nearest = edt_outside_src(alpha, iw, ih);
for i in 0..px { for i in 0..px {
let d = dist_sq[i].sqrt(); 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 src_a = alpha[nearest[i]] as f32;
let outer_dist = radius - d; // distance to outer stroke edge let outer_dist = radius - d; // distance to outer stroke edge
let coverage = (outer_dist / aa).clamp(0.0, 1.0); let coverage = (outer_dist / aa).clamp(0.0, 1.0);
@@ -618,7 +653,9 @@ pub fn generate_stroke_tuned(
} else { } else {
(dist_out_sq[i].sqrt(), alpha[nearest[i]] as f32) (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 outer_dist = half_r - d; // distance to outer stroke edge
let coverage = (outer_dist / aa).clamp(0.0, 1.0); let coverage = (outer_dist / aa).clamp(0.0, 1.0);
stroke_alpha[i] = coverage * base_a; 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 y in 0..h {
for x in 0..w { for x in 0..w {
let i = y * w + x; 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); 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 y in 0..h {
for x in 0..w { for x in 0..w {
let i = y * w + x; 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); 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]) { fn edt_1d_src(f: &mut [f32], src_idx: &mut [usize]) {
let n = f.len(); let n = f.len();
if n == 0 { return; } if n == 0 {
return;
}
let mut d = vec![0.0f32; n]; let mut d = vec![0.0f32; n];
let mut d_src = vec![0usize; n]; let mut d_src = vec![0usize; n];
let mut v = 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); let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
while s <= z[k] { while s <= z[k] {
k = k.wrapping_sub(1); k = k.wrapping_sub(1);
if k == usize::MAX { k = 0; break; } if k == usize::MAX {
k = 0;
break;
}
let vk2 = v[k]; let vk2 = v[k];
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32; let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * 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; k = 0;
for q in 0..n { for q in 0..n {
let qf = q as f32; let qf = q as f32;
while z[k + 1] < qf { k += 1; } while z[k + 1] < qf {
k += 1;
}
let vk = v[k]; let vk = v[k];
let diff = qf - vk as f32; let diff = qf - vk as f32;
d[q] = diff * diff + f[vk]; 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 = vec![0.0f32; h];
let mut col_src = vec![0usize; h]; let mut col_src = vec![0usize; h];
for x in 0..w { 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); 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 = vec![0.0f32; w];
let mut row_src = vec![0usize; w]; let mut row_src = vec![0usize; w];
for y in 0..h { 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); 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) { fn edt_2d(dt: &mut [f32], w: usize, h: usize) {
let mut col = vec![0.0f32; h]; let mut col = vec![0.0f32; h];
for x in 0..w { 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); 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]; let mut row = vec![0.0f32; w];
for y in 0..h { 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); 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]) { fn edt_1d(f: &mut [f32]) {
let n = f.len(); let n = f.len();
if n == 0 { return; } if n == 0 {
return;
}
let mut d = vec![0.0f32; n]; let mut d = vec![0.0f32; n];
let mut v = vec![0usize; n]; let mut v = vec![0usize; n];
let mut z = vec![0.0f32; n + 1]; 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); let mut s = (fq - fv_vk) / (2.0 * qf - 2.0 * vk as f32);
while s <= z[k] { while s <= z[k] {
k = k.wrapping_sub(1); k = k.wrapping_sub(1);
if k == usize::MAX { k = 0; break; } if k == usize::MAX {
k = 0;
break;
}
let vk2 = v[k]; let vk2 = v[k];
let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32; let fv_vk2 = f[vk2] + vk2 as f32 * vk2 as f32;
s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32); s = (fq - fv_vk2) / (2.0 * qf - 2.0 * vk2 as f32);
@@ -801,7 +873,9 @@ fn edt_1d(f: &mut [f32]) {
k = 0; k = 0;
for q in 0..n { for q in 0..n {
let qf = q as f32; let qf = q as f32;
while z[k + 1] < qf { k += 1; } while z[k + 1] < qf {
k += 1;
}
let vk = v[k]; let vk = v[k];
let diff = qf - vk as f32; let diff = qf - vk as f32;
d[q] = diff * diff + f[vk]; d[q] = diff * diff + f[vk];
+664 -194
View File
@@ -2,19 +2,40 @@ use hcie_blend::BlendMode;
use hcie_protocol::effects; use hcie_protocol::effects;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Technique { Smooth, ChiselHard, ChiselSoft } pub enum Technique {
Smooth,
ChiselHard,
ChiselSoft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction { Up, Down } pub enum Direction {
Up,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StrokePosition { Inside, Center, Outside } pub enum StrokePosition {
Inside,
Center,
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StrokeFillType { Color, Gradient, Pattern } pub enum StrokeFillType {
Color,
Gradient,
Pattern,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContourCurve { pub struct ContourCurve {
@@ -25,20 +46,74 @@ pub struct ContourCurve {
pub fn contour_name_to_curve(name: &str) -> Option<ContourCurve> { pub fn contour_name_to_curve(name: &str) -> Option<ContourCurve> {
match name { match name {
"Linear" => None, // Linear is the default (no remapping needed) "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" => Some(ContourCurve {
"Cone-Inverted" => Some(ContourCurve { points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)] }), points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.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)] }), "Cone-Inverted" => Some(ContourCurve {
"Round" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }), points: vec![(0.0, 1.0), (0.5, 0.0), (1.0, 1.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)] }), "Gaussian" => Some(ContourCurve {
"Sawtooth" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 1.0), (0.5, 0.0), (1.0, 1.0)] }), points: vec![(0.0, 0.0), (0.5, 0.85), (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)] }), "Half Round" => Some(ContourCurve {
"Shallow-Slope" => Some(ContourCurve { points: vec![(0.0, 0.0), (0.5, 0.25), (1.0, 1.0)] }), points: vec![
"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)] }), (0.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)] }), (0.25, 0.75),
"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)] }), (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 _ => None, // Default to linear
} }
} }
@@ -223,7 +298,8 @@ pub fn blend_mode_to_string(mode: &BlendMode) -> String {
BlendMode::Color => "color", BlendMode::Color => "color",
BlendMode::PassThrough => "passthrough", BlendMode::PassThrough => "passthrough",
BlendMode::Luminosity => "luminosity", BlendMode::Luminosity => "luminosity",
}.to_string() }
.to_string()
} }
pub fn blend_mode_from_string(s: &str) -> BlendMode { 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(). /// Used by engine-api before calling apply_layer_effects().
pub fn protocol_to_hcie_fx_effect(fx: &effects::LayerEffect) -> LayerEffect { pub fn protocol_to_hcie_fx_effect(fx: &effects::LayerEffect) -> LayerEffect {
match fx { match fx {
effects::LayerEffect::DropShadow { enabled, blend_mode, color, opacity, angle, distance, spread, size, noise, contour } => { effects::LayerEffect::DropShadow {
LayerEffect::DropShadow { enabled,
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, blend_mode,
opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size, color,
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }), opacity,
} angle,
} distance,
effects::LayerEffect::InnerShadow { enabled, blend_mode, color, opacity, angle, distance, choke, size, noise, contour } => { spread,
LayerEffect::InnerShadow { size,
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, noise,
opacity: *opacity, angle: *angle, distance: *distance, choke: *choke, size: *size, contour,
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }), } => LayerEffect::DropShadow {
} enabled: *enabled,
} blend_mode: blend_mode_from_string(blend_mode),
effects::LayerEffect::OuterGlow { enabled, blend_mode, color, opacity, spread, size, noise, contour } => { color: *color,
LayerEffect::OuterGlow { opacity: *opacity,
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, angle: *angle,
opacity: *opacity, spread: *spread, size: *size, distance: *distance,
noise: *noise, contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }), spread: *spread,
} size: *size,
} noise: *noise,
effects::LayerEffect::InnerGlow { enabled, blend_mode, color, opacity, choke, size, noise, contour, source } => { contour: contour.as_ref().map(|c| ContourCurve {
LayerEffect::InnerGlow { points: c.points.clone(),
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() }), effects::LayerEffect::InnerShadow {
source: *source, enabled,
} blend_mode,
} color,
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 } => { opacity,
LayerEffect::BevelEmboss { angle,
enabled: *enabled, distance,
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 }, choke,
technique: match technique { effects::Technique::Smooth => Technique::Smooth, effects::Technique::ChiselHard => Technique::ChiselHard, effects::Technique::ChiselSoft => Technique::ChiselSoft }, size,
depth: *depth, noise,
direction: match direction { effects::Direction::Up => Direction::Up, effects::Direction::Down => Direction::Down }, contour,
size: *size, soften: *soften, angle: *angle, altitude: *altitude, } => LayerEffect::InnerShadow {
highlight_blend: blend_mode_from_string(highlight_blend), highlight_color: *highlight_color, highlight_opacity: *highlight_opacity, enabled: *enabled,
shadow_blend: blend_mode_from_string(shadow_blend), shadow_color: *shadow_color, shadow_opacity: *shadow_opacity, blend_mode: blend_mode_from_string(blend_mode),
contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }), color: *color,
} opacity: *opacity,
} angle: *angle,
effects::LayerEffect::Satin { enabled, blend_mode, color, opacity, angle, distance, size, invert, contour } => { distance: *distance,
LayerEffect::Satin { choke: *choke,
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, size: *size,
opacity: *opacity, angle: *angle, distance: *distance, size: *size, invert: *invert, noise: *noise,
contour: contour.as_ref().map(|c| ContourCurve { points: c.points.clone() }), contour: contour.as_ref().map(|c| ContourCurve {
} points: c.points.clone(),
} }),
effects::LayerEffect::ColorOverlay { enabled, blend_mode, color, opacity } => { },
LayerEffect::ColorOverlay { effects::LayerEffect::OuterGlow {
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), color: *color, opacity: *opacity, enabled,
} blend_mode,
} color,
effects::LayerEffect::GradientOverlay { enabled, blend_mode, opacity, angle, scale, gradient } => { opacity,
LayerEffect::GradientOverlay { spread,
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity, size,
angle: *angle, scale: *scale, noise,
gradient: GradientDef { contour,
color_stops: gradient.color_stops.clone(), } => LayerEffect::OuterGlow {
transparency_stops: gradient.transparency_stops.clone(), enabled: *enabled,
midpoint: gradient.midpoint, blend_mode: blend_mode_from_string(blend_mode),
angle: gradient.angle, color: *color,
scale: gradient.scale, opacity: *opacity,
gradient_type: gradient.gradient_type, spread: *spread,
}, size: *size,
} noise: *noise,
} contour: contour.as_ref().map(|c| ContourCurve {
effects::LayerEffect::PatternOverlay { enabled, blend_mode, opacity, scale, pattern_id } => { points: c.points.clone(),
LayerEffect::PatternOverlay { }),
enabled: *enabled, blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity, },
scale: *scale, pattern_id: pattern_id.clone(), effects::LayerEffect::InnerGlow {
} enabled,
} blend_mode,
effects::LayerEffect::Stroke { enabled, position, fill_type, blend_mode, opacity, size, color } => { color,
LayerEffect::Stroke { opacity,
enabled: *enabled, choke,
position: match position { effects::StrokePosition::Inside => StrokePosition::Inside, effects::StrokePosition::Center => StrokePosition::Center, effects::StrokePosition::Outside => StrokePosition::Outside }, size,
fill_type: match fill_type { effects::StrokeFillType::Color => StrokeFillType::Color, effects::StrokeFillType::Gradient => StrokeFillType::Gradient, effects::StrokeFillType::Pattern => StrokeFillType::Pattern }, noise,
blend_mode: blend_mode_from_string(blend_mode), opacity: *opacity, size: *size, color: *color, 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). /// to protocol-layer effect (serialization format with String blend_mode).
pub fn hcie_fx_effect_to_protocol(fx: &LayerEffect) -> effects::LayerEffect { pub fn hcie_fx_effect_to_protocol(fx: &LayerEffect) -> effects::LayerEffect {
match fx { match fx {
LayerEffect::DropShadow { enabled, blend_mode, color, opacity, angle, distance, spread, size, noise, contour } => { LayerEffect::DropShadow {
effects::LayerEffect::DropShadow { enabled,
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, blend_mode,
opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size, color,
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }), opacity,
} angle,
} distance,
LayerEffect::InnerShadow { enabled, blend_mode, color, opacity, angle, distance, choke, size, noise, contour } => { spread,
effects::LayerEffect::InnerShadow { size,
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, noise,
opacity: *opacity, angle: *angle, distance: *distance, choke: *choke, size: *size, contour,
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }), } => effects::LayerEffect::DropShadow {
} enabled: *enabled,
} blend_mode: blend_mode_to_string(blend_mode),
LayerEffect::OuterGlow { enabled, blend_mode, color, opacity, spread, size, noise, contour } => { color: *color,
effects::LayerEffect::OuterGlow { opacity: *opacity,
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, angle: *angle,
opacity: *opacity, spread: *spread, size: *size, distance: *distance,
noise: *noise, contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }), spread: *spread,
} size: *size,
} noise: *noise,
LayerEffect::InnerGlow { enabled, blend_mode, color, opacity, choke, size, noise, contour, source } => { contour: contour.as_ref().map(|c| effects::ContourCurve {
effects::LayerEffect::InnerGlow { points: c.points.clone(),
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() }), LayerEffect::InnerShadow {
source: *source, enabled,
} blend_mode,
} color,
LayerEffect::BevelEmboss { enabled, style, technique, depth, direction, size, soften, angle, altitude, highlight_blend, highlight_color, highlight_opacity, shadow_blend, shadow_color, shadow_opacity, contour } => { opacity,
effects::LayerEffect::BevelEmboss { angle,
enabled: *enabled, distance,
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 }, choke,
technique: match technique { Technique::Smooth => effects::Technique::Smooth, Technique::ChiselHard => effects::Technique::ChiselHard, Technique::ChiselSoft => effects::Technique::ChiselSoft }, size,
depth: *depth, noise,
direction: match direction { Direction::Up => effects::Direction::Up, Direction::Down => effects::Direction::Down }, contour,
size: *size, soften: *soften, angle: *angle, altitude: *altitude, } => effects::LayerEffect::InnerShadow {
highlight_blend: blend_mode_to_string(highlight_blend), highlight_color: *highlight_color, highlight_opacity: *highlight_opacity, enabled: *enabled,
shadow_blend: blend_mode_to_string(shadow_blend), shadow_color: *shadow_color, shadow_opacity: *shadow_opacity, blend_mode: blend_mode_to_string(blend_mode),
contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }), color: *color,
} opacity: *opacity,
} angle: *angle,
LayerEffect::Satin { enabled, blend_mode, color, opacity, angle, distance, size, invert, contour } => { distance: *distance,
effects::LayerEffect::Satin { choke: *choke,
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, size: *size,
opacity: *opacity, angle: *angle, distance: *distance, size: *size, invert: *invert, noise: *noise,
contour: contour.as_ref().map(|c| effects::ContourCurve { points: c.points.clone() }), contour: contour.as_ref().map(|c| effects::ContourCurve {
} points: c.points.clone(),
} }),
LayerEffect::ColorOverlay { enabled, blend_mode, color, opacity } => { },
effects::LayerEffect::ColorOverlay { LayerEffect::OuterGlow {
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), color: *color, opacity: *opacity, enabled,
} blend_mode,
} color,
LayerEffect::GradientOverlay { enabled, blend_mode, opacity, angle, scale, gradient } => { opacity,
effects::LayerEffect::GradientOverlay { spread,
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity, size,
angle: *angle, scale: *scale, noise,
gradient: effects::GradientDef { contour,
color_stops: gradient.color_stops.clone(), } => effects::LayerEffect::OuterGlow {
transparency_stops: gradient.transparency_stops.clone(), enabled: *enabled,
midpoint: gradient.midpoint, blend_mode: blend_mode_to_string(blend_mode),
angle: gradient.angle, color: *color,
scale: gradient.scale, opacity: *opacity,
gradient_type: gradient.gradient_type, spread: *spread,
}, size: *size,
} noise: *noise,
} contour: contour.as_ref().map(|c| effects::ContourCurve {
LayerEffect::PatternOverlay { enabled, blend_mode, opacity, scale, pattern_id } => { points: c.points.clone(),
effects::LayerEffect::PatternOverlay { }),
enabled: *enabled, blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity, },
scale: *scale, pattern_id: pattern_id.clone(), LayerEffect::InnerGlow {
} enabled,
} blend_mode,
LayerEffect::Stroke { enabled, position, fill_type, blend_mode, opacity, size, color } => { color,
effects::LayerEffect::Stroke { opacity,
enabled: *enabled, choke,
position: match position { StrokePosition::Inside => effects::StrokePosition::Inside, StrokePosition::Center => effects::StrokePosition::Center, StrokePosition::Outside => effects::StrokePosition::Outside }, size,
fill_type: match fill_type { StrokeFillType::Color => effects::StrokeFillType::Color, StrokeFillType::Gradient => effects::StrokeFillType::Gradient, StrokeFillType::Pattern => effects::StrokeFillType::Pattern }, noise,
blend_mode: blend_mode_to_string(blend_mode), opacity: *opacity, size: *size, color: *color, 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> { pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerEffect> {
use hcie_protocol::LayerStyle; use hcie_protocol::LayerStyle;
match style { 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
color: *color, color: *color,
@@ -457,7 +854,16 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
noise: 0.0, noise: 0.0,
contour: None, 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
color: *color, color: *color,
@@ -469,7 +875,14 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
noise: 0.0, noise: 0.0,
contour: None, 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
color: *color, color: *color,
@@ -479,7 +892,14 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
noise: 0.0, noise: 0.0,
contour: None, 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
color: *color, color: *color,
@@ -490,7 +910,24 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
contour: None, contour: None,
source: 0, 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, enabled: *enabled,
style: match style.as_str() { style: match style.as_str() {
"InnerBevel" => BevelStyle::InnerBevel, "InnerBevel" => BevelStyle::InnerBevel,
@@ -524,7 +961,15 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
shadow_opacity: *shadow_opacity, shadow_opacity: *shadow_opacity,
contour: contour_name_to_curve(contour), 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, enabled: *enabled,
blend_mode: BlendMode::Multiply, blend_mode: BlendMode::Multiply,
color: *color, color: *color,
@@ -535,13 +980,25 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
invert: *invert, invert: *invert,
contour: None, contour: None,
}), }),
LayerStyle::ColorOverlay { enabled, opacity, color, blend_mode } => Some(LayerEffect::ColorOverlay { LayerStyle::ColorOverlay {
enabled,
opacity,
color,
blend_mode,
} => Some(LayerEffect::ColorOverlay {
enabled: *enabled, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
color: *color, color: *color,
opacity: *opacity, 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
opacity: *opacity, opacity: *opacity,
@@ -556,14 +1013,27 @@ pub fn layer_style_to_effect(style: &hcie_protocol::LayerStyle) -> Option<LayerE
gradient_type: 0, 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, enabled: *enabled,
blend_mode: blend_mode_from_string(blend_mode), blend_mode: blend_mode_from_string(blend_mode),
opacity: *opacity, opacity: *opacity,
scale: *scale, scale: *scale,
pattern_id: pattern_name.clone(), 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, enabled: *enabled,
position: match position.as_str() { position: match position.as_str() {
"Inside" => StrokePosition::Inside, "Inside" => StrokePosition::Inside,
+472 -33
View File
@@ -14,6 +14,7 @@
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs; use crate::dialogs;
use crate::dock::manager::DockDragState;
use crate::dock::state::{DockState, PaneType}; use crate::dock::state::{DockState, PaneType};
use crate::i18n; use crate::i18n;
use crate::io::tablet::TabletState; use crate::io::tablet::TabletState;
@@ -117,6 +118,8 @@ pub enum ActiveDialog {
pub struct HcieIcedApp { pub struct HcieIcedApp {
/// All open documents. /// All open documents.
pub documents: Vec<IcedDocument>, 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. /// Index of the currently active document.
pub active_doc: usize, pub active_doc: usize,
/// Current tool state. /// Current tool state.
@@ -284,6 +287,8 @@ pub struct HcieIcedApp {
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>, pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
/// Last drag position during vector shape editing (canvas coordinates). /// Last drag position during vector shape editing (canvas coordinates).
pub vector_drag_last: Option<(f32, f32)>, 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. /// Immutable basis for the active vector drag.
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>, pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
/// Boolean operation shape A selector index (geometry panel). /// Boolean operation shape A selector index (geometry panel).
@@ -838,9 +843,39 @@ pub enum Message {
// ── Dock Layout ───────────────────────────────────── // ── Dock Layout ─────────────────────────────────────
PaneClicked(iced::widget::pane_grid::Pane), PaneClicked(iced::widget::pane_grid::Pane),
PaneDragged(iced::widget::pane_grid::DragEvent), 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), PaneResized(iced::widget::pane_grid::ResizeEvent),
PaneClose(iced::widget::pane_grid::Pane), PaneClose(iced::widget::pane_grid::Pane),
PaneMaximize(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 ──────────────────────────────────────── // ── File I/O ────────────────────────────────────────
NewDocument(u32, u32), NewDocument(u32, u32),
@@ -1178,10 +1213,11 @@ impl HcieIcedApp {
}; };
// Load saved settings // Load saved settings
let mut settings = crate::settings::AppSettings::load(); let settings = crate::settings::AppSettings::load();
let mut app = Self { let mut app = Self {
documents: vec![doc], documents: vec![doc],
show_welcome: false,
active_doc: 0, active_doc: 0,
tool_state: ToolState::default(), tool_state: ToolState::default(),
fg_color: [220, 50, 50, 255], fg_color: [220, 50, 50, 255],
@@ -1225,7 +1261,7 @@ impl HcieIcedApp {
layer_style_baseline: None, layer_style_baseline: None,
pending_file_operation: None, pending_file_operation: None,
pending_document_close: None, pending_document_close: None,
dock: DockState::new(), dock: settings.panel_layout.restore_dock(),
tablet_state: Arc::new(Mutex::new(TabletState::new())), tablet_state: Arc::new(Mutex::new(TabletState::new())),
initialized: false, initialized: false,
theme_state: ThemeState::new(settings.theme_preset), theme_state: ThemeState::new(settings.theme_preset),
@@ -1274,6 +1310,7 @@ impl HcieIcedApp {
colors_dirty: false, colors_dirty: false,
vector_shapes_snapshot: None, vector_shapes_snapshot: None,
vector_drag_last: None, vector_drag_last: None,
vector_drag_last_render: None,
vector_drag_session: None, vector_drag_session: None,
bool_shape_a: None, bool_shape_a: None,
bool_shape_b: None, bool_shape_b: None,
@@ -1288,6 +1325,38 @@ impl HcieIcedApp {
{ {
app.dock.reopen_pane(panel); 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 // Apply saved settings to tool state
settings.apply_to_tool_state(&mut app.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: /// 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 /// 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. /// that position or the preceding final tab. Side Effects: Drops the document and edit state.
fn close_document_tab(&mut self, document_index: usize) { 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; return;
} }
self.documents.remove(document_index); self.documents.remove(document_index);
@@ -1745,6 +1827,9 @@ impl HcieIcedApp {
} }
} }
Message::OverlayOutsideClick => { Message::OverlayOutsideClick => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
if self.sub_tools_open { if self.sub_tools_open {
self.sub_tools_open = false; self.sub_tools_open = false;
} else { } else {
@@ -3595,7 +3680,89 @@ impl HcieIcedApp {
// The actual offset computation uses delta from the last position. // The actual offset computation uses delta from the last position.
self.layer_style_dragging = true; 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, &regions,
)
.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, &regions,
)
.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 self.layer_style_dragging {
if let Some((lx, ly)) = self.layer_style_drag_start { if let Some((lx, ly)) = self.layer_style_drag_start {
// Compute delta from last position and add to offset // Compute delta from last position and add to offset
@@ -3607,9 +3774,41 @@ impl HcieIcedApp {
self.layer_style_drag_start = Some((x, y)); self.layer_style_drag_start = Some((x, y));
} }
} }
Message::LayerStyleDialogDragEnd => { Message::LayerStyleDialogDragEnd | Message::DockPointerReleased => {
self.layer_style_dragging = false; self.layer_style_dragging = false;
self.layer_style_drag_start = None; 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 ─────────────────────────────────── // ── Brushes ───────────────────────────────────
@@ -4879,28 +5078,68 @@ impl HcieIcedApp {
); );
let (x1, y1, x2, y2) = shape.normalized_bounds(); let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle(); let angle = shape.angle();
let engine = &mut self.documents[self.active_doc].engine; if let Some(shapes) = self.documents[self.active_doc]
engine.set_vector_shape_bounds( .engine
session.layer_id, .get_layer_shapes_direct(session.layer_id)
session.shape_index, {
x1, if let Some(current) = shapes.get_mut(session.shape_index) {
y1, *current = shape;
x2, }
y2, }
);
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y)); 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 => { Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() { 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. // Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_last_render = None;
self.vector_drag_session = None; self.vector_drag_session = None;
self.documents[self.active_doc].vector_edit_handle = self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None; hcie_engine_api::VectorEditHandle::None;
self.refresh_composite_if_needed();
} }
} }
Message::VectorShapeDelete => { Message::VectorShapeDelete => {
@@ -5973,6 +6212,7 @@ impl HcieIcedApp {
}; };
match result { match result {
Ok(()) => { Ok(()) => {
self.show_welcome = false;
self.documents[self.active_doc].engine.pre_tile_all_layers(); self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path self.documents[self.active_doc].name = path
.file_name() .file_name()
@@ -6010,28 +6250,193 @@ impl HcieIcedApp {
// ── Dock Layout ─────────────────────────────── // ── Dock Layout ───────────────────────────────
Message::PaneClicked(pane) => { Message::PaneClicked(pane) => {
self.dock.active_auto_hide = None;
self.dock.focus(pane); 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) => { Message::PaneDragged(drag_event) => {
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event { use iced::widget::pane_grid::DragEvent;
self.dock.pane_grid.drop(pane, target); 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) => { Message::PaneResized(resize_event) => {
self.dock let grid_size = self.dock.last_grid_size.unwrap_or_else(|| {
.pane_grid crate::dock::preview::DockGeometry {
.resize(resize_event.split, resize_event.ratio); 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) => { Message::PaneClose(pane) => {
// Don't close canvas panes // Don't close canvas panes
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) { if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
return Task::none(); 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) => { Message::PaneMaximize(pane) => {
self.dock.toggle_maximize(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 ───────────────────────────── // ── Dock Profiles ─────────────────────────────
Message::DockProfileSave(name) => { Message::DockProfileSave(name) => {
@@ -6164,6 +6569,7 @@ impl HcieIcedApp {
// ── File I/O ───────────────────────────────── // ── File I/O ─────────────────────────────────
Message::NewDocument(w, h) => { Message::NewDocument(w, h) => {
self.show_welcome = false;
let mut engine = Engine::new(w, h); let mut engine = Engine::new(w, h);
engine.pre_tile_all_layers(); engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels(); let composite_raw = engine.get_composite_pixels();
@@ -6236,8 +6642,9 @@ impl HcieIcedApp {
.is_some_and(|document| document.modified); .is_some_and(|document| document.modified);
match document_close_disposition(self.documents.len(), idx, modified) { match document_close_disposition(self.documents.len(), idx, modified) {
DocumentCloseDisposition::Invalid => return Task::none(), DocumentCloseDisposition::Invalid => return Task::none(),
DocumentCloseDisposition::Window => return self.update(Message::WindowClose), DocumentCloseDisposition::Welcome
DocumentCloseDisposition::Confirm | DocumentCloseDisposition::Close => { | DocumentCloseDisposition::Confirm
| DocumentCloseDisposition::Close => {
if self.preview_baseline.is_some() { if self.preview_baseline.is_some() {
self.restore_preview(); self.restore_preview();
self.filter_preview_active = false; self.filter_preview_active = false;
@@ -6544,6 +6951,9 @@ impl HcieIcedApp {
.map_or(0, |duration| duration.as_secs()); .map_or(0, |duration| duration.as_secs());
self.screenshot_request = Some(crate::cli::ScreenshotRequest { self.screenshot_request = Some(crate::cli::ScreenshotRequest {
panel: None, panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: std::path::PathBuf::from(format!( output: std::path::PathBuf::from(format!(
"target/screenshots/iced_{timestamp}.png" "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_expanded = self.sidebar_expanded;
self.settings.panel_layout.sidebar_width = self.settings.panel_layout.sidebar_width =
if self.sidebar_expanded { 72.0 } else { 36.0 }; 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(); let _ = self.settings.save();
} }
@@ -6732,10 +7152,16 @@ impl HcieIcedApp {
ts.reset_pressure(); ts.reset_pressure();
} }
} }
if !pressed {
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
}
} }
// ── Contextual key actions ─────────────────────── // ── Contextual key actions ───────────────────────
Message::EscapePressed => { Message::EscapePressed => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
// Mirrors egui's Escape handling. Priority: viewer > crop > // Mirrors egui's Escape handling. Priority: viewer > crop >
// transform > vector selection > selection > dialog > viewer fallback. // transform > vector selection > selection > dialog > viewer fallback.
match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) { 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_dock_profile_dialog = self.show_dock_profile_dialog;
let has_context_menu = self.canvas_context_menu.is_some(); let has_context_menu = self.canvas_context_menu.is_some();
let has_color_picker = self.color_picker_target.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); 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 { if has_dialog {
stack = stack.push(dialog_overlay); stack = stack.push(dialog_overlay);
} }
@@ -7266,13 +7701,17 @@ impl HcieIcedApp {
== iced::event::Status::Ignored) == iced::event::Status::Ignored)
.then_some(Message::OverlayOutsideClick), .then_some(Message::OverlayOutsideClick),
iced::mouse::Event::CursorMoved { position } => { 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) => { iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
Some(Message::LayerStyleDialogDragEnd) Some(Message::DockPointerReleased)
} }
_ => None, _ => 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::Event::Touch(touch_event) => {
// iced 0.13 touch events do not carry a force/pressure field, // iced 0.13 touch events do not carry a force/pressure field,
// so we can only forward position. A touch press/move marks // 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DocumentCloseDisposition { enum DocumentCloseDisposition {
Invalid, Invalid,
Window, Welcome,
Confirm, Confirm,
Close, Close,
} }
@@ -7368,7 +7807,7 @@ enum DocumentCloseDisposition {
/// Resolves tab-close behavior without mutating application state. /// Resolves tab-close behavior without mutating application state.
/// ///
/// Arguments: open document count, requested index, and modified state. Returns: whether to ignore, /// 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( fn document_close_disposition(
document_count: usize, document_count: usize,
document_index: usize, document_index: usize,
@@ -7376,10 +7815,10 @@ fn document_close_disposition(
) -> DocumentCloseDisposition { ) -> DocumentCloseDisposition {
if document_index >= document_count { if document_index >= document_count {
DocumentCloseDisposition::Invalid DocumentCloseDisposition::Invalid
} else if document_count == 1 {
DocumentCloseDisposition::Window
} else if modified { } else if modified {
DocumentCloseDisposition::Confirm DocumentCloseDisposition::Confirm
} else if document_count == 1 {
DocumentCloseDisposition::Welcome
} else { } else {
DocumentCloseDisposition::Close DocumentCloseDisposition::Close
} }
@@ -7440,7 +7879,7 @@ mod cycle_one_ux_tests {
); );
assert_eq!( assert_eq!(
document_close_disposition(1, 0, false), document_close_disposition(1, 0, false),
DocumentCloseDisposition::Window DocumentCloseDisposition::Welcome
); );
assert_eq!( assert_eq!(
document_close_disposition(2, 2, false), document_close_disposition(2, 2, false),
+57 -1
View File
@@ -13,6 +13,12 @@ use std::path::PathBuf;
pub struct ScreenshotRequest { pub struct ScreenshotRequest {
/// Optional exact dock-pane label. `None` captures the complete viewport. /// Optional exact dock-pane label. `None` captures the complete viewport.
pub panel: Option<String>, 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. /// PNG output path supplied by the caller.
pub output: PathBuf, pub output: PathBuf,
} }
@@ -64,17 +70,64 @@ where
let output = required_operand(&tokens, index + 1, "--screenshot")?; let output = required_operand(&tokens, index + 1, "--screenshot")?;
parsed.screenshot = Some(ScreenshotRequest { parsed.screenshot = Some(ScreenshotRequest {
panel: None, panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 2; index += 2;
continue; 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" => { "--screenshot-panel" => {
ensure_no_screenshot(&parsed)?; ensure_no_screenshot(&parsed)?;
let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?; let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?;
let output = required_operand(&tokens, index + 2, "--screenshot-panel")?; let output = required_operand(&tokens, index + 2, "--screenshot-panel")?;
parsed.screenshot = Some(ScreenshotRequest { parsed.screenshot = Some(ScreenshotRequest {
panel: Some(panel.to_owned()), 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), output: PathBuf::from(output),
}); });
index += 3; index += 3;
@@ -120,7 +173,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
/// Returns the command-line help shown by the binary. /// Returns the command-line help shown by the binary.
pub fn help_text() -> &'static str { 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)] #[cfg(test)]
@@ -136,6 +189,9 @@ mod tests {
args.screenshot, args.screenshot,
Some(ScreenshotRequest { Some(ScreenshotRequest {
panel: None, panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: PathBuf::from("full.png"), output: PathBuf::from("full.png"),
}) })
); );
@@ -271,7 +271,10 @@ pub fn popup_view(
let body: Element<'static, Message> = match tab { let body: Element<'static, Message> = match tab {
0 => rgb_popup_section(color), 0 => rgb_popup_section(color),
1 => Canvas::new(ColorWheel { color }) 1 => Canvas::new(ColorWheel {
color,
output: WheelOutput::Popup,
})
.width(Length::Fixed(220.0)) .width(Length::Fixed(220.0))
.height(Length::Fixed(220.0)) .height(Length::Fixed(220.0))
.into(), .into(),
@@ -425,6 +428,16 @@ fn popup_slider_row<'a>(
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct ColorWheel { struct ColorWheel {
color: [u8; 4], 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. /// Tracks whether a wheel drag began over an editable region.
@@ -434,6 +447,14 @@ struct ColorWheelState {
} }
impl ColorWheel { 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 /// Maps a local wheel position to HSL, preserving the current component when
/// the pointer lies outside both the ring and center square. /// the pointer lies outside both the ring and center square.
fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> { fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> {
@@ -565,7 +586,7 @@ impl canvas::Program<Message> for ColorWheel {
state.dragging = true; state.dragging = true;
return ( return (
canvas::event::Status::Captured, 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)) { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return ( return (
canvas::event::Status::Captured, canvas::event::Status::Captured,
Some(Message::PopupColorChanged(color)), Some(self.message(color)),
); );
} }
} }
@@ -729,7 +750,7 @@ pub fn view<'a>(
// ── 4. Tab content ── // ── 4. Tab content ──
let tab_content: Element<'_, Message> = match color_tab { let tab_content: Element<'_, Message> = match color_tab {
0 => build_wheel_view(h, s, l, fg_color), 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), _ => build_grid_view(fg_color),
}; };
@@ -775,6 +796,7 @@ pub fn view<'a>(
}); });
let clickable = iced::widget::mouse_area(swatch) let clickable = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged(c)) .on_press(Message::FgColorChanged(c))
.on_right_press(Message::BgColorChanged(c))
.interaction(iced::mouse::Interaction::Pointer); .interaction(iced::mouse::Interaction::Pointer);
recent_items.push(clickable.into()); recent_items.push(clickable.into());
} }
@@ -784,7 +806,7 @@ pub fn view<'a>(
row(recent_items).spacing(2).into() row(recent_items).spacing(2).into()
}; };
column![ let content = column![
text("Color").size(11).font(iced::Font::MONOSPACE), text("Color").size(11).font(iced::Font::MONOSPACE),
swatch_row, swatch_row,
hex_row, hex_row,
@@ -796,118 +818,21 @@ pub fn view<'a>(
recent_row, recent_row,
] ]
.width(Length::Fill) .width(Length::Fill)
.height(Length::Shrink)
.spacing(3) .spacing(3)
.padding(Padding::from([4, 4])) .padding(Padding::from([4, 4]));
.into()
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. /// The wheel fills narrow pane widths while retaining intrinsic vertical height, avoiding the
fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Element<'a, Message> { /// former fixed 360-pixel hue strip and allowing the outer panel scrollable to handle short panes.
// Hue ring: 36 cells, each 10 degrees fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Element<'a, Message> {
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
let readout = text(format!( let readout = text(format!(
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", "H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
h, h,
@@ -917,32 +842,33 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
.size(9) .size(9)
.font(iced::Font::MONOSPACE); .font(iced::Font::MONOSPACE);
column![ let wheel = Canvas::new(ColorWheel {
text("Hue").size(9), color: *fg_color,
hue_ring, output: WheelOutput::Foreground,
text("Saturation / Lightness").size(9), })
sl_grid, .width(Length::Fill)
readout, .height(Length::Fixed(220.0));
]
column![wheel, readout]
.spacing(3) .spacing(3)
.into() .into()
} }
/// Build the HSL sliders view (tab H). /// 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 h_slider = slider(0.0..=360.0, h, move |v: f32| {
let (r, g, b) = hsl_to_rgb(v, s, l); 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); .step(1.0);
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { 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); 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); .step(1.0);
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { 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); 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); .step(1.0);
@@ -1013,7 +939,8 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
..Default::default() ..Default::default()
}); });
let clickable = iced::widget::mouse_area(swatch) 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); .interaction(iced::mouse::Interaction::Pointer);
row_widgets = row_widgets.push(clickable); 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 //! Provides a resizable, draggable pane grid where each pane
//! can host a different panel (layers, history, brushes, etc.). //! 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 state;
pub mod view; 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,
&regions
)
.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,
&regions,
)
.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,
&regions,
)
.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. ///! Dock state — manages pane grid layout and panel assignments.
//! ///!
//! Provides a resizable, draggable pane grid where each pane ///! Provides a resizable, draggable pane grid where each pane
//! can host a different panel (layers, history, brushes, etc.). ///! can host a different panel (layers, history, brushes, etc.).
//! The default layout mirrors the egui GUI arrangement with Canvas centered: ///! The default layout mirrors the egui GUI arrangement with Canvas centered:
//! ///!
//! ```text ///! ```text
//! +--------+------------+-----------+---------+ ///! +--------+------------+-----------+---------+
//! | | | | Layers | ///! | | | | Layers |
//! | Brushes| | |---------| ///! | Brushes| | |---------|
//! |--------| Canvas | ColorBox |Properties| ///! |--------| Canvas | ColorBox |Properties|
//! | Filters| |-----------|---------| ///! | Filters| |-----------|---------|
//! | | | History | | ///! | | | History | |
//! +--------+------------+-----------+---------+ ///! +--------+------------+-----------+---------+
//! ``` ///! ```
use crate::dock::floating::FloatingDragState;
use iced::widget::pane_grid::{Configuration, Pane, State}; 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. /// 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)] #[allow(dead_code)]
pub enum PaneType { pub enum PaneType {
/// The main canvas viewport. /// The main canvas viewport.
@@ -49,6 +60,23 @@ pub enum PaneType {
} }
impl 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. /// Display name for the pane type.
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
@@ -76,29 +104,45 @@ impl PaneType {
/// command-line capture and dock UI naming cannot drift independently. /// command-line capture and dock UI naming cannot drift independently.
/// **Side Effects / Dependencies:** None. /// **Side Effects / Dependencies:** None.
pub fn from_label(label: &str) -> Option<Self> { pub fn from_label(label: &str) -> Option<Self> {
[ Self::ALL.into_iter().find(|pane| pane.label() == label)
Self::Canvas, }
Self::Layers,
Self::History, /// Compact readable label used on narrow auto-hide rails.
Self::Brushes, pub fn rail_label(self) -> &'static str {
Self::Filters, match self {
Self::ColorPicker, Self::ColorPicker => "Color",
Self::Properties, Self::LayerDetails => "Details",
Self::Script, Self::ToolSettings => "Tools",
Self::AiChat, Self::AiScript => "AI Script",
Self::LayerDetails, _ => self.label(),
Self::Geometry, }
Self::ToolSettings, }
Self::AiScript,
] /// Clear fallback glyph used where an SVG is not available inline.
.into_iter() pub fn icon_glyph(self) -> &'static str {
.find(|pane| pane.label() == label) 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)] #[cfg(test)]
mod tests { mod tests {
use super::{DockState, PaneType}; 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}; use iced::widget::pane_grid::{Edge, Region, Target};
#[test] #[test]
@@ -123,18 +167,82 @@ mod tests {
let mut dock = DockState::new(); let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes); let source = pane(&dock, PaneType::Brushes);
let target = pane(&dock, PaneType::Layers); 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); assert_canvas_invariant(&dock);
} }
for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] { for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] {
let mut dock = DockState::new(); let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes); 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); 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. /// Finds the pane identifier containing `pane_type` in a test dock.
fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane { fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane {
dock.pane_grid dock.pane_grid
@@ -165,6 +273,26 @@ pub struct DockState {
pub root_pane: Pane, pub root_pane: Pane,
/// Currently focused pane. /// Currently focused pane.
pub focused_pane: Option<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 { impl DockState {
@@ -251,15 +379,67 @@ impl DockState {
pane_grid, pane_grid,
root_pane, root_pane,
focused_pane: Some(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. /// Split a pane horizontally with a new panel type.
#[allow(dead_code)] #[allow(dead_code)]
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> { pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) = let (new_pane, _) =
self.pane_grid self.pane_grid
.split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?; .split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
self.rebalance_structure();
Some(new_pane) Some(new_pane)
} }
@@ -269,12 +449,20 @@ impl DockState {
let (new_pane, _) = let (new_pane, _) =
self.pane_grid self.pane_grid
.split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?; .split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
self.rebalance_structure();
Some(new_pane) Some(new_pane)
} }
/// Close a pane, redistributing its content to neighbors. /// Close a pane, redistributing its content to neighbors.
pub fn close_pane(&mut self, pane: Pane) -> bool { 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. /// Toggle maximize on a pane.
@@ -291,19 +479,340 @@ impl DockState {
self.focused_pane = Some(pane); 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. /// Get the pane type at a given pane ID.
pub fn pane_type(&self, pane: Pane) -> Option<PaneType> { pub fn pane_type(&self, pane: Pane) -> Option<PaneType> {
self.pane_grid.get(pane).copied() 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. /// Get the currently focused pane type.
#[allow(dead_code)]
pub fn focused_type(&self) -> Option<PaneType> { pub fn focused_type(&self) -> Option<PaneType> {
self.focused_pane.and_then(|p| self.pane_type(p)) 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. /// Reopen a closed pane or focus it if it is already open.
pub fn reopen_pane(&mut self, pane_type: PaneType) { 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); let already_open = self.pane_grid.iter().find(|(_, t)| **t == pane_type);
if let Some((pane, _)) = already_open { if let Some((pane, _)) = already_open {
self.focus(*pane); self.focus(*pane);
@@ -329,10 +838,53 @@ impl DockState {
}; };
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) { if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) {
self.focus(new_pane); 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. /// Check if a pane type is currently open in the dock.
pub fn is_pane_open(&self, pane_type: PaneType) -> bool { pub fn is_pane_open(&self, pane_type: PaneType) -> bool {
self.pane_grid.iter().any(|(_, t)| *t == pane_type) 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::styles;
use crate::panels::typography::TITLE; use crate::panels::typography::TITLE;
use crate::theme::ThemeColors; 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::svg;
use iced::widget::{button, column, container, pane_grid, row, text}; use iced::widget::{button, column, container, pane_grid, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
@@ -25,209 +25,260 @@ pub fn dock_view<'a>(
app: &'a crate::app::HcieIcedApp, app: &'a crate::app::HcieIcedApp,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let colors = app.theme_state.colors(); 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| { let grid: Element<'a, Message> =
// Canvas pane has no title bar (the document tab bar serves as its header). pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
// All other panes get a Photoshop-style title bar with close/maximize buttons. // Canvas pane has no title bar (the document tab bar serves as its header).
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas { // All other panes get a Photoshop-style title bar with close/maximize buttons.
None let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
} else { None
Some(make_title_bar(pane, *pane_type, colors)) } else {
}; Some(make_title_bar(
pane,
let body: Element<'a, Message> = match pane_type { *pane_type,
PaneType::Canvas => { dock.focused_pane == Some(pane),
// 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,
colors, 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]; let doc = &app.documents[app.active_doc];
crate::panels::history::view(&doc.cached_history, doc.history_current, colors) let pressure = app
} .tablet_state
PaneType::Brushes => crate::panels::brushes::view( .lock()
app.tool_state.brush_style, .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_size,
app.tool_state.brush_opacity, app.tool_state.brush_opacity,
app.tool_state.brush_hardness, app.tool_state.brush_hardness,
doc.selection_rect,
doc.vector_draw,
colors, colors,
), layer_name,
PaneType::Filters => crate::panels::filters::view( layer_opacity,
app.selected_filter, layer_visible,
&app.filter_params, layer_blend_mode,
app.filter_preview_active, layer_type,
app.documents[app.active_doc] layer_width,
.engine layer_height,
.active_layer_is_editable(), doc.selected_vector_shape,
colors, &vector_shapes,
), ts2.vector_stroke_width,
PaneType::ColorPicker => crate::color_picker::view( ts2.vector_opacity,
&app.fg_color, ts2.vector_fill,
&app.bg_color, ts2.vector_radius,
&app.recent_colors, ts2.vector_points,
app.color_tab, ts2.vector_sides,
), active_id,
PaneType::Properties => { app.fg_color,
let doc = &app.documents[app.active_doc]; app.bg_color,
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));
} }
PaneType::Script => crate::panels::script_panel::view(
if let Some(title_bar) = title { &app.script_content,
content = content.title_bar(title_bar); 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 PaneType::Geometry => {
}) let shapes = app.documents[app.active_doc]
.width(Length::Fill) .engine
.height(Length::Fill) .active_vector_shapes()
.spacing(4) .unwrap_or_default();
.on_click(Message::PaneClicked) crate::panels::geometry::view(
.on_drag(Message::PaneDragged) shapes,
.on_resize(4, Message::PaneResized) colors,
.style(move |_theme| pane_grid::Style { app.documents[app.active_doc].selected_vector_shape,
hovered_region: pane_grid::Highlight { app.bool_shape_a,
background: iced::Background::Color(iced::Color::from_rgba( app.bool_shape_b,
colors.accent.r, )
colors.accent.g, }
colors.accent.b, PaneType::ToolSettings => {
0.15, let fg = app.fg_color;
)), let draft = app.documents[app.active_doc].text_draft.as_ref();
border: iced::Border::default().color(colors.accent).width(1), crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
}, }
picked_split: pane_grid::Line { }
color: colors.accent,
width: 2.0,
},
hovered_split: pane_grid::Line {
color: colors.border_high,
width: 1.0,
},
})
.into()
} }
/// Build the document tab bar. /// 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. /// Creates a compact VS-style title bar whose controls do not initiate pane dragging.
///
/// 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.
fn make_title_bar<'a>( fn make_title_bar<'a>(
pane: pane_grid::Pane, pane: pane_grid::Pane,
pane_type: PaneType, pane_type: PaneType,
focused: bool,
colors: ThemeColors, colors: ThemeColors,
) -> TitleBar<'a, Message> { ) -> TitleBar<'a, Message> {
// Panel type icon
let icon_paths = [ let icon_paths = [
std::path::PathBuf::from("hcie-iced-app/assets"), std::path::PathBuf::from("hcie-iced-app/assets"),
std::path::PathBuf::from("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/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) { 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()) { if let Some(base) = icon_paths.iter().find(|path| path.join(icon_file).exists()) {
let handle = svg::Handle::from_path(base.join(icon_file)); let icon = svg(svg::Handle::from_path(base.join(icon_file)))
let icon = svg(handle).width(14).height(14).style( .width(14)
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { .height(14)
color: Some(colors.text_secondary), .style(move |_theme, _status| svg::Style {
}, color: Some(if focused {
); colors.accent
} else {
colors.text_secondary
}),
});
row![ row![
icon, icon,
text(pane_type.label()) text(pane_type.label()).size(TITLE).color(if focused {
.size(TITLE) colors.text_primary
.color(colors.text_secondary) } else {
colors.text_secondary
})
] ]
.spacing(4) .spacing(6)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.into() .into()
} else { } else {
@@ -442,73 +494,180 @@ fn make_title_bar<'a>(
.into() .into()
}; };
// Close button with hover state let control = |label, message| {
let close_btn = button(text("×").size(11)) button(text(label).size(12))
.on_press(Message::PaneClose(pane)) .on_press(message)
.padding([2, 6]) .padding([3, 6])
.style( .style(move |_theme, status| chrome_button_style(colors, status))
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { };
iced::widget::button::Status::Hovered => iced::widget::button::Style { let pin = iced::widget::tooltip(
background: Some(iced::Background::Color(colors.bg_hover)), control("", Message::PaneAutoHide(pane)),
text_color: colors.text_primary, text("Minimize to side rail").size(11),
border: iced::Border::default(), iced::widget::tooltip::Position::Bottom,
..Default::default() );
}, let float_control = iced::widget::tooltip(
iced::widget::button::Status::Pressed => iced::widget::button::Style { control("", Message::PaneFloat(pane)),
background: Some(iced::Background::Color(colors.bg_active)), text("Float panel").size(11),
text_color: colors.text_primary, iced::widget::tooltip::Position::Bottom,
border: iced::Border::default(), );
..Default::default() let maximize = iced::widget::tooltip(
}, control("", Message::PaneMaximize(pane)),
_ => iced::widget::button::Style { text("Maximize / restore").size(11),
background: Some(iced::Background::Color(iced::Color::from_rgba( iced::widget::tooltip::Position::Bottom,
0.0, 0.0, 0.0, 0.0, );
))), let close = iced::widget::tooltip(
text_color: colors.text_secondary, control("×", Message::PaneClose(pane)),
border: iced::Border::default(), text("Close panel").size(11),
..Default::default() iced::widget::tooltip::Position::Bottom,
}, );
}, let compact_control = |label, message| {
); button(text(label).size(11))
.on_press(message)
// Maximize button with hover state .padding([3, 3])
let maximize_btn = button(text("").size(11)) .style(move |_theme, status| chrome_button_style(colors, status))
.on_press(Message::PaneMaximize(pane)) };
.padding([2, 6]) let compact_pin = iced::widget::tooltip(
.style( compact_control("", Message::PaneAutoHide(pane)),
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { text("Minimize to side rail").size(11),
iced::widget::button::Status::Hovered => iced::widget::button::Style { iced::widget::tooltip::Position::Bottom,
background: Some(iced::Background::Color(colors.bg_hover)), );
text_color: colors.text_primary, let compact_float = iced::widget::tooltip(
border: iced::Border::default(), compact_control("", Message::PaneFloat(pane)),
..Default::default() text("Float panel").size(11),
}, iced::widget::tooltip::Position::Bottom,
iced::widget::button::Status::Pressed => iced::widget::button::Style { );
background: Some(iced::Background::Color(colors.bg_active)), let compact_maximize = iced::widget::tooltip(
text_color: colors.text_primary, compact_control("", Message::PaneMaximize(pane)),
border: iced::Border::default(), text("Maximize / restore").size(11),
..Default::default() iced::widget::tooltip::Position::Bottom,
}, );
_ => iced::widget::button::Style { let compact_close = iced::widget::tooltip(
background: Some(iced::Background::Color(iced::Color::from_rgba( compact_control("×", Message::PaneClose(pane)),
0.0, 0.0, 0.0, 0.0, text("Close panel").size(11),
))), iced::widget::tooltip::Position::Bottom,
text_color: colors.text_secondary, );
border: iced::Border::default(), let full_controls = row![pin, float_control, maximize, close]
..Default::default() .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( TitleBar::new(
row![ row![title_drag]
title_content, .spacing(1)
iced::widget::Space::with_width(Length::Fill), .align_y(iced::Alignment::Center)
maximize_btn, .height(28),
close_btn
]
.spacing(2)
.align_y(iced::Alignment::Center),
) )
.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 ───────────────────────────────────── // ── Bottom toolbar ─────────────────────────────────────
let add_btn = button(text("+Lay").size(10)) let add_btn = button(text("+Layer").size(BODY))
.on_press(Message::LayerAdd) .on_press(Message::LayerAdd)
.padding([3, 6]) .padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors)); .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) .on_press(Message::LayerAddGroup)
.padding([3, 6]) .padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors)); .style(move |_theme, _status| flat_btn_style(colors));
let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]); let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]);
let mask_btn = button(text("Mask").size(9)).padding([3, 6]); let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]);
let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]); let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]);
let flatten_btn = button(text("Flatten").size(10)) let flatten_btn = button(text("Flatten").size(BODY))
.on_press(Message::LayerFlatten) .on_press(Message::LayerFlatten)
.padding([3, 6]) .padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors)); .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)) .on_press(Message::LayerDelete(active_layer_id))
.padding([3, 6]) .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors)); .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)) .on_press(Message::LayerMoveUp(active_layer_id))
.padding([3, 4]) .padding([3, 4])
.style(move |_theme, _status| flat_btn_style(colors)); .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)) .on_press(Message::LayerMoveDown(active_layer_id))
.padding([3, 4]) .padding([3, 4])
.style(move |_theme, _status| flat_btn_style(colors)); .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) .on_press(Message::OpenLayerStyleDialog)
.padding([3, 6]) .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors)); .style(move |_theme, _status| flat_btn_style(colors));
let toolbar = column![ let toolbar = column![
text("Disabled (engine API): duplicate / mask / rasterize").size(8), text("Unavailable: duplicate, mask, rasterize").size(BODY),
row![duplicate_btn, mask_btn, rasterize_btn] row![duplicate_btn, mask_btn]
.spacing(2) .spacing(2)
.align_y(iced::Alignment::Center), .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) .spacing(2)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
row![move_up_btn, move_down_btn, del_btn] 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() ..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. //! **Purpose:** Persists tool settings, panel layout, and preferences between sessions.
//! Settings are saved to `~/.hcie/settings.json` and loaded on startup. //! Settings are saved to `~/.hcie/settings.json` and loaded on startup.
use crate::dock::persistence::PersistedDockLayout;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@@ -143,6 +144,9 @@ pub struct PanelLayout {
pub visible_panels: Vec<String>, pub visible_panels: Vec<String>,
/// Panel order in the dock. /// Panel order in the dock.
pub panel_order: Vec<String>, pub panel_order: Vec<String>,
/// Stable dock tree plus auto-hidden and floating placements.
#[serde(default)]
pub dock_layout: PersistedDockLayout,
} }
/// Window settings. /// Window settings.
@@ -223,6 +227,7 @@ impl Default for PanelLayout {
"History".to_string(), "History".to_string(),
"Filters".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 { impl AppSettings {
/// Get the settings file path (~/.hcie/settings.json). /// Get the settings file path (~/.hcie/settings.json).
pub fn file_path() -> PathBuf { pub fn file_path() -> PathBuf {
@@ -322,6 +351,11 @@ mod tests {
.and_then(serde_json::Value::as_object_mut) .and_then(serde_json::Value::as_object_mut)
.expect("panel layout object") .expect("panel layout object")
.remove("sidebar_expanded"); .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 = let settings: AppSettings =
serde_json::from_value(legacy).expect("deserialize legacy settings"); 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() { fn cycle_one_state_paths_are_complete_and_persisted() {
let app = source("src/app.rs"); let app = source("src/app.rs");
let settings = source("src/settings.rs"); let settings = source("src/settings.rs");
let manager = source("src/dock/manager.rs");
assert!( 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}" "{PANE_FULL_DROP_TARGETS}"
); );
assert!( assert!(
@@ -257,10 +262,10 @@ fn cycle_four_panel_and_document_paths_are_connected() {
); );
assert!( assert!(
layers.contains("Fill opacity: unavailable in hcie-engine-api") layers.contains("Fill opacity: unavailable in hcie-engine-api")
&& layers.contains("Disabled (engine API): duplicate / mask / rasterize") && layers.contains("Unavailable: duplicate, mask, rasterize")
&& layers.contains("button(text(\"Dup\")") && layers.contains("button(text(\"Duplicate\")")
&& layers.contains("button(text(\"Mask\")") && layers.contains("button(text(\"Mask\")")
&& layers.contains("button(text(\"Raster\")"), && layers.contains("button(text(\"Rasterize\")"),
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled" "{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
); );
assert!( assert!(
@@ -133,16 +133,14 @@ impl AiChatPanel {
}); });
let bubble = container( let bubble = container(
column![ column![text(label).size(10).font(iced::Font::MONOSPACE), msg_text,].spacing(2),
text(label).size(10).font(iced::Font::MONOSPACE),
msg_text,
]
.spacing(2)
) )
.width(Length::Fill) .width(Length::Fill)
.padding(8) .padding(8)
.style(|_theme| iced::widget::container::Style { .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), border: iced::Border::default().rounded(6),
..Default::default() ..Default::default()
}); });
@@ -183,8 +181,12 @@ impl AiChatPanel {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))), background: Some(iced::Background::Color(iced::Color::from_rgb(
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), 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() ..Default::default()
}) })
.into() .into()
@@ -115,8 +115,12 @@ impl ScriptPanel {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))), background: Some(iced::Background::Color(iced::Color::from_rgb(
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), 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() ..Default::default()
}) })
.into() .into()
@@ -139,7 +143,11 @@ fn execute_dsl_script(engine: &mut Engine, script: &str) -> Result<String, Strin
match cmd.as_str() { match cmd.as_str() {
"layer" => { "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); engine.add_layer(name);
lines_executed += 1; lines_executed += 1;
} }
@@ -264,7 +272,10 @@ fn parse_color(s: &str) -> Option<[u8; 4]> {
parts[1].trim().parse::<u8>(), parts[1].trim().parse::<u8>(),
parts[2].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]); return Some([r, g, b, a]);
} }
} }
+21 -26
View File
@@ -39,10 +39,7 @@ fn main() {
let mut layer = None; let mut layer = None;
for l in &layers { for l in &layers {
for e in &l.effects { for e in &l.effects {
if let hcie_protocol::effects::LayerEffect::BevelEmboss { if let hcie_protocol::effects::LayerEffect::BevelEmboss { enabled: true, .. } = e {
enabled: true, ..
} = e
{
layer = Some(l); layer = Some(l);
break; break;
} }
@@ -113,7 +110,9 @@ fn main() {
hcie_protocol::effects::BevelStyle::OuterBevel => { hcie_protocol::effects::BevelStyle::OuterBevel => {
hcie_fx::types::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_protocol::effects::BevelStyle::PillowEmboss => {
hcie_fx::types::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 alpha: Vec<u8> = layer.pixels.iter().skip(3).step_by(4).copied().collect();
let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned( let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned(
&alpha, &alpha, w, h, depth, size, soften, angle, altitude, &direction, &technique, &style, lb,
w, hl, sh, 1.0, tilt,
h,
depth,
size,
soften,
angle,
altitude,
&direction,
&technique,
&style,
lb,
hl,
sh,
1.0,
tilt,
); );
let mut out = layer.pixels.clone(); let mut out = layer.pixels.clone();
for i in 0..px { for i in 0..px {
let dst = [out[i * 4], out[i * 4 + 1], out[i * 4 + 2], out[i * 4 + 3]]; 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 hl_src = [
let sh_src = [sh_buf[i * 4], sh_buf[i * 4 + 1], sh_buf[i * 4 + 2], sh_buf[i * 4 + 3]]; hl_buf[i * 4],
let blended = hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75); 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 = let blended =
hcie_blend::blend_pixels(blended, sh_src, hcie_blend::BlendMode::Multiply, 0.75); hcie_blend::blend_pixels(blended, sh_src, hcie_blend::BlendMode::Multiply, 0.75);
out[i * 4..i * 4 + 4].copy_from_slice(&blended); out[i * 4..i * 4 + 4].copy_from_slice(&blended);
@@ -171,8 +167,7 @@ fn main() {
let sy = y as i32 + offset_y; let sy = y as i32 + offset_y;
if sx >= 0 && sx < w as i32 && sy >= 0 && sy < h as i32 { 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 s_idx = ((sy as usize) * w as usize + (sx as usize)) * 4;
let t_idx = let t_idx = ((y as usize) * target.width() as usize + (x as usize)) * 4;
((y as usize) * target.width() as usize + (x as usize)) * 4;
let src_a = layer.pixels[s_idx + 3]; let src_a = layer.pixels[s_idx + 3];
if src_a > 0 { if src_a > 0 {
+7 -5
View File
@@ -1,7 +1,9 @@
use image::GenericImageView; use image::GenericImageView;
fn main() { 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 test_img = image::open("/tmp/test2_Emboss.png").unwrap().to_rgba8();
let mut diff_sum = 0.0; let mut diff_sum = 0.0;
@@ -15,10 +17,10 @@ fn main() {
let rp = ref_img.get_pixel(x, y); let rp = ref_img.get_pixel(x, y);
let tp = test_img.get_pixel(x, y); let tp = test_img.get_pixel(x, y);
let d = (rp[0] as i32 - tp[0] as i32).abs() + let d = (rp[0] as i32 - tp[0] as i32).abs()
(rp[1] as i32 - tp[1] as i32).abs() + + (rp[1] as i32 - tp[1] as i32).abs()
(rp[2] as i32 - tp[2] as i32).abs() + + (rp[2] as i32 - tp[2] as i32).abs()
(rp[3] as i32 - tp[3] as i32).abs(); + (rp[3] as i32 - tp[3] as i32).abs();
diff_sum += d as f64; diff_sum += d as f64;
max_diff = max_diff.max(d); max_diff = max_diff.max(d);
+48 -20
View File
@@ -1,4 +1,5 @@
#![allow(dead_code, unused_imports, unused_variables, unused_macros)] #![allow(dead_code, unused_imports, unused_variables, unused_macros)]
use hcie_blend::BlendMode;
/// Visual inspection tool for Bevel & Emboss rendering. /// Visual inspection tool for Bevel & Emboss rendering.
/// ///
/// Loads a PSD from `_tmp/psd_tunes/bevel_emboss_psd_set/`, applies the /// 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 /// 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. /// If no stem is given, runs a small representative subset.
use hcie_fx::types::{BevelStyle, Direction, Technique}; use hcie_fx::types::{BevelStyle, Direction, Technique};
use hcie_blend::BlendMode;
const PSD_DIR: &str = "_tmp/psd_tunes/bevel_emboss_psd_set"; const PSD_DIR: &str = "_tmp/psd_tunes/bevel_emboss_psd_set";
const PNG_DIR: &str = "_tmp/psd_tunes/bevel_emboss_png_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)> { 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} // be_{shape}_{style}_d{depth}_sz{size}_sf{soften}_{dir}_alt{altitude}_{technique}
let parts: Vec<&str> = stem.split('_').collect(); 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 shape = parts[1].to_string();
let style = parts[2].to_string(); let style = parts[2].to_string();
let depth = parts[3].strip_prefix('d')?.parse::<f32>().ok()?; 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 direction = parts[6].to_string();
let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?; let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?;
let technique = parts[8].to_string(); 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) { fn render_one(stem: &str) {
let params = match parse_stem(stem) { let params = match parse_stem(stem) {
Some(p) => p, 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; 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)) { let layers = match hcie_psd::import_psd(std::path::Path::new(&psd_path)) {
Ok(l) => l, Ok(l) => l,
Err(e) => { eprintln!(" SKIP {}: {}", stem, e); return; } Err(e) => {
eprintln!(" SKIP {}: {}", stem, e);
return;
}
}; };
// Find the layer with effects // Find the layer with effects
let layer = match layers.iter().find(|l| !l.effects.is_empty()) { let layer = match layers.iter().find(|l| !l.effects.is_empty()) {
Some(l) => l, Some(l) => l,
None => { eprintln!(" SKIP {}: no effects layer", stem); return; } None => {
eprintln!(" SKIP {}: no effects layer", stem);
return;
}
}; };
let w = layer.width; let w = layer.width;
@@ -83,7 +95,10 @@ fn render_one(stem: &str) {
let mut sh_color = [0u8; 4]; let mut sh_color = [0u8; 4];
for e in &layer.effects { for e in &layer.effects {
if let hcie_protocol::effects::LayerEffect::BevelEmboss { if let hcie_protocol::effects::LayerEffect::BevelEmboss {
angle: a, highlight_color, shadow_color, .. angle: a,
highlight_color,
shadow_color,
..
} = e } = e
{ {
angle = *a; angle = *a;
@@ -95,21 +110,22 @@ fn render_one(stem: &str) {
}; };
// Convert protocol effects to hcie-fx effects and run the full pipeline // 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| { let fx_effects: Vec<hcie_fx::types::LayerEffect> = layer
hcie_fx::types::protocol_to_hcie_fx_effect(e) .effects
}).collect(); .iter()
.map(|e| hcie_fx::types::protocol_to_hcie_fx_effect(e))
.collect();
// Use apply_layer_effects for the full rendering pipeline // Use apply_layer_effects for the full rendering pipeline
let out = hcie_fx::apply_layer_effects( let out = hcie_fx::apply_layer_effects(&layer.pixels, w, h, &fx_effects, layer.fill_opacity);
&layer.pixels, w, h,
&fx_effects,
layer.fill_opacity,
);
// Compute MAE against reference // Compute MAE against reference
let ref_img = match image::open(&png_path) { let ref_img = match image::open(&png_path) {
Ok(i) => i.to_rgba8(), 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 (rw, rh) = (ref_img.width(), ref_img.height());
let ref_raw = ref_img.into_raw(); let ref_raw = ref_img.into_raw();
@@ -145,14 +161,22 @@ fn render_one(stem: &str) {
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
let i = (y * w + x) as usize * 4; 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) // ref (right, after 4px gap)
for y in 0..rh { for y in 0..rh {
for x in 0..rw { for x in 0..rw {
let i = (y * rw + x) as usize * 4; 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); let cmp_path = format!("{}/{}_cmp.png", OUT_DIR, stem);
@@ -184,7 +208,11 @@ fn main() {
"be_star_emboss_d200_sz25_sf5_up_alt60_smooth", "be_star_emboss_d200_sz25_sf5_up_alt60_smooth",
"be_star_inner_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 { for stem in &subset {
render_one(stem); render_one(stem);
} }
+243 -129
View File
@@ -1,4 +1,5 @@
#![allow(dead_code, unused_imports, unused_variables, unused_macros)] #![allow(dead_code, unused_imports, unused_variables, unused_macros)]
use rayon::prelude::*;
/// Grid-search Bevel & Emboss MAE tuning against Photoshop ground-truth PNGs. /// Grid-search Bevel & Emboss MAE tuning against Photoshop ground-truth PNGs.
/// ///
/// Expects `_tmp/psd_tunes/bevel_emboss_png_set/` with transparent PNGs exported /// Expects `_tmp/psd_tunes/bevel_emboss_png_set/` with transparent PNGs exported
@@ -9,9 +10,7 @@
/// ///
/// Usage: /// Usage:
/// cargo run --example tune_mae_bevel_emboss_grid -p hcie-io --release /// cargo run --example tune_mae_bevel_emboss_grid -p hcie-io --release
use std::fs; use std::fs;
use rayon::prelude::*;
use std::io::Write; use std::io::Write;
const GT_DIR: &str = "_tmp/psd_tunes/bevel_emboss_png_set"; const GT_DIR: &str = "_tmp/psd_tunes/bevel_emboss_png_set";
@@ -56,14 +55,20 @@ fn main() {
let params = match parse_filename(&stem) { let params = match parse_filename(&stem) {
Some(p) => p, Some(p) => p,
None => { eprintln!(" SKIP: {}", stem); continue; } None => {
eprintln!(" SKIP: {}", stem);
continue;
}
}; };
let (_shape, style, depth, size, soften, direction, altitude, _technique) = params; let (_shape, style, depth, size, soften, direction, altitude, _technique) = params;
let img = match image::open(&path) { let img = match image::open(&path) {
Ok(i) => i, Ok(i) => i,
Err(e) => { eprintln!(" SKIP {}: {}", stem, e); continue; } Err(e) => {
eprintln!(" SKIP {}: {}", stem, e);
continue;
}
}; };
let rgba = img.to_rgba8(); 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 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; let mut found = None;
for l in layers { for l in layers {
if !l.effects.is_empty() { 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)) .map(|e| hcie_fx::types::protocol_to_hcie_fx_effect(e))
.collect(); .collect();
// Extract angle from BevelEmboss effect // Extract angle from BevelEmboss effect
let mut angle = 120.0f32; let mut angle = 120.0f32;
for e in &l.effects { 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; angle = *a;
} }
} }
@@ -97,7 +109,10 @@ fn main() {
} }
match found { match found {
Some(v) => v, Some(v) => v,
None => { eprintln!(" SKIP no effects layer: {}", stem); continue; } None => {
eprintln!(" SKIP no effects layer: {}", stem);
continue;
}
} }
} else { } else {
eprintln!(" SKIP PSD missing for {}: {}", stem, psd_path_str); eprintln!(" SKIP PSD missing for {}: {}", stem, psd_path_str);
@@ -106,14 +121,20 @@ fn main() {
samples.push(Sample { samples.push(Sample {
name: stem, name: stem,
style, depth, size, soften, direction, altitude, style,
depth,
size,
soften,
direction,
altitude,
angle, angle,
ref_pixels: raw, ref_pixels: raw,
content_mask, content_mask,
layer_pixels, layer_pixels,
fx_effects, fx_effects,
fill_opacity, 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 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(); 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(); std::io::stdout().flush().ok();
let mut best_avg_mae = f64::MAX; let mut best_avg_mae = f64::MAX;
@@ -149,133 +175,206 @@ fn main() {
for &hl_scale in hl_scales { for &hl_scale in hl_scales {
for &sh_scale in sh_scales { for &sh_scale in sh_scales {
for &tilt_scale in tilt_scales { for &tilt_scale in tilt_scales {
let total_mae: f64 = samples.par_iter().map(|s| { let total_mae: f64 = samples
// Use generate_bevel_tuned to get highlight/shadow buffers .par_iter()
let style_enum = match s.style.as_str() { .map(|s| {
"emboss" => hcie_fx::types::BevelStyle::Emboss, // Use generate_bevel_tuned to get highlight/shadow buffers
"outer" => hcie_fx::types::BevelStyle::OuterBevel, let style_enum = match s.style.as_str() {
_ => hcie_fx::types::BevelStyle::InnerBevel, "emboss" => hcie_fx::types::BevelStyle::Emboss,
}; "outer" => hcie_fx::types::BevelStyle::OuterBevel,
let dir_enum = match s.direction.as_str() { _ => hcie_fx::types::BevelStyle::InnerBevel,
"down" => hcie_fx::types::Direction::Down, };
_ => hcie_fx::types::Direction::Up, 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( let (highlight, shadow) = hcie_fx::tuned::generate_bevel_tuned(
&s.layer_pixels.iter().skip(3).step_by(4).copied().collect::<Vec<u8>>(), &s.layer_pixels
s.w, s.h, .iter()
s.depth, s.size, s.soften, s.angle, s.altitude, .skip(3)
&dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum, .step_by(4)
lighting_blur, hl_scale, sh_scale, 1.0, tilt_scale, .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 px = (s.w * s.h) as usize;
let mut out = vec![0u8; px * 4]; let mut out = vec![0u8; px * 4];
// Base shape: fill with layer pixels (black fill) // Base shape: fill with layer pixels (black fill)
for i in 0..px { for i in 0..px {
if s.layer_pixels[i * 4 + 3] > 0 { if s.layer_pixels[i * 4 + 3] > 0 {
out[i * 4] = s.layer_pixels[i * 4]; out[i * 4] = s.layer_pixels[i * 4];
out[i * 4 + 1] = s.layer_pixels[i * 4 + 1]; out[i * 4 + 1] = s.layer_pixels[i * 4 + 1];
out[i * 4 + 2] = s.layer_pixels[i * 4 + 2]; out[i * 4 + 2] = s.layer_pixels[i * 4 + 2];
out[i * 4 + 3] = s.layer_pixels[i * 4 + 3]; 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;
} }
}
(hl_op, sh_op, hl_bm, sh_bm)
};
// Composite highlight — inside on fill, outside on empty canvas // For emboss/outerbevel: outside highlight/shadow go to empty canvas (behind)
for i in 0..px { let has_outer = matches!(
let sa = highlight[i * 4 + 3]; style_enum,
if sa > 0 { hcie_fx::types::BevelStyle::Emboss
let is_inner = s.layer_pixels[i * 4 + 3] > 0; | hcie_fx::types::BevelStyle::OuterBevel
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]; // Get opacity and blend mode from fx_effects
let blended = hcie_blend::blend_pixels(dst, src, hl_bm, hl_op); let (hl_op, sh_op, hl_bm, sh_bm) = {
out[i * 4..i * 4 + 4].copy_from_slice(&blended); 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 { } else {
// Outside: composite highlight on empty canvas 0.0
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);
} }
} })
} .sum();
// 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; eval_count += 1;
let mut content_pixels = 0u64; let avg_mae = total_mae / samples.len() as f64;
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; if avg_mae < best_avg_mae {
let avg_mae = total_mae / samples.len() as f64; best_avg_mae = avg_mae;
best_params = (lighting_blur, hl_scale, sh_scale, tilt_scale);
if avg_mae < best_avg_mae { println!("*** [{}/{}] NEW BEST avg MAE = {:.4} (lb={}, hl={:.3}, sh={:.2}, tilt={:.2})",
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); eval_count, total_combos, best_avg_mae, lighting_blur, hl_scale, sh_scale, tilt_scale);
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
} else if eval_count % 100 == 0 { } else if eval_count % 100 == 0 {
println!(" [{}/{}] avg={:.4} best={:.4}", eval_count, total_combos, avg_mae, best_avg_mae); println!(
std::io::stdout().flush().ok(); " [{}/{}] 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; let (lb, hl, sh, ts) = best_params;
println!("\n=== RESULT ==="); println!("\n=== RESULT ===");
@@ -286,19 +385,32 @@ fn main() {
println!(" tilt_scale = {:.2}", ts); println!(" tilt_scale = {:.2}", ts);
if let Ok(mut file) = std::fs::OpenOptions::new() 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\ "lighting_blur={} hl_scale={:.3} sh_scale={:.2} tilt_scale={:.2}\n\
avg_mae={:.4} samples={} combos={}", 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)> { 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} // be_{shape}_{style}_d{depth}_sz{size}_sf{soften}_{dir}_alt{altitude}_{technique}
let parts: Vec<&str> = name.split('_').collect(); 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 shape = parts[1].to_string();
let style = parts[2].to_string(); let style = parts[2].to_string();
let depth = parts[3].strip_prefix('d')?.parse::<f32>().ok()?; 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 direction = parts[6].to_string();
let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?; let altitude = parts[7].strip_prefix("alt")?.parse::<f32>().ok()?;
let technique = parts[8].to_string(); let technique = parts[8].to_string();
Some((shape, style, depth, size, soften, direction, altitude, technique)) Some((
shape, style, depth, size, soften, direction, altitude, technique,
))
} }
+180 -52
View File
@@ -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; use std::io::Write;
const SHADOW_RS: &str = "hcie-fx/src/emboss.rs"; const SHADOW_RS: &str = "hcie-fx/src/emboss.rs";
@@ -39,7 +45,16 @@ fn main() {
let mut sty = hcie_protocol::effects::BevelStyle::InnerBevel; let mut sty = hcie_protocol::effects::BevelStyle::InnerBevel;
for e in &layer.effects { for e in &layer.effects {
if let hcie_protocol::effects::LayerEffect::BevelEmboss { 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 } = e
{ {
d = *depth; d = *depth;
@@ -52,19 +67,43 @@ fn main() {
sty = style.clone(); 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 }, d,
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 }, sz,
match sty { sof,
hcie_protocol::effects::BevelStyle::InnerBevel => hcie_fx::types::BevelStyle::InnerBevel, ang,
hcie_protocol::effects::BevelStyle::OuterBevel => hcie_fx::types::BevelStyle::OuterBevel, alt,
hcie_protocol::effects::BevelStyle::Emboss => hcie_fx::types::BevelStyle::Emboss, match dir {
hcie_protocol::effects::BevelStyle::PillowEmboss => hcie_fx::types::BevelStyle::PillowEmboss, hcie_protocol::effects::Direction::Up => hcie_fx::types::Direction::Up,
_ => hcie_fx::types::BevelStyle::StrokeEmboss, _ => 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); println!("Image: {}x{}", w, h);
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
@@ -81,28 +120,80 @@ fn main() {
println!("Phase 1: coarse grid ({} combos)", total); println!("Phase 1: coarse grid ({} combos)", total);
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
search(original.as_str(), before, after, &alpha, w, h, px, &ref_pixels, &layer.pixels, search(
depth, size, soften, angle, altitude, &direction, &technique, &style, original.as_str(),
coarse_blur, coarse_hl, coarse_sh, before,
&mut best_mae, &mut best_code, &mut best_params); 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 // Phase 2: fine grid around best
let (bb, bhl, bsh) = best_params; let (bb, bhl, bsh) = best_params;
let fine_blur: Vec<i32> = (bb - 4 ..= bb + 4).filter(|&v| v > 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) 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(); .step_by(5)
let fine_sh: Vec<f32> = ((bsh * 100.0 - 50.0) as i32 ..= (bsh * 100.0 + 50.0) as i32) .map(|v| v as f32 / 100.0)
.step_by(5).map(|v| v as f32 / 100.0).filter(|&v| v > 0.0).collect(); .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)", println!(
fine_blur.len(), fine_hl.len(), fine_sh.len(), "\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(),
fine_blur.len() * fine_hl.len() * fine_sh.len()
);
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
search(original.as_str(), before, after, &alpha, w, h, px, &ref_pixels, &layer.pixels, search(
depth, size, soften, angle, altitude, &direction, &technique, &style, original.as_str(),
&fine_blur, &fine_hl, &fine_sh, before,
&mut best_mae, &mut best_code, &mut best_params); 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 { if best_mae < f64::MAX {
let (bb, bhl, bsh) = best_params; let (bb, bhl, bsh) = best_params;
@@ -167,7 +258,12 @@ fn main() {
std::fs::write(SHADOW_RS, &patched).expect("write final best"); 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})", println!("\nCompleted. Best code saved with MAE = {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})",
best_mae, bb, bhl, bsh); 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}", let _ = writeln!(file, "=== Emboss Tuned ===\n Params: blur={}, hl_scale={:.2}, sh_scale={:.2}\n MAE: {:.3}",
bb, bhl, bsh, best_mae); bb, bhl, bsh, best_mae);
} }
@@ -177,15 +273,28 @@ fn main() {
} }
fn search( fn search(
_original: &str, _before: &str, _after: &str, _original: &str,
alpha: &[u8], w: u32, h: u32, px: usize, ref_pixels: &[u8], _before: &str,
_after: &str,
alpha: &[u8],
w: u32,
h: u32,
px: usize,
ref_pixels: &[u8],
src_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, direction: &hcie_fx::types::Direction,
technique: &hcie_fx::types::Technique, technique: &hcie_fx::types::Technique,
style: &hcie_fx::types::BevelStyle, style: &hcie_fx::types::BevelStyle,
blur_list: &[i32], hl_list: &[f32], sh_list: &[f32], blur_list: &[i32],
best_mae: &mut f64, _best_code: &mut String, hl_list: &[f32],
sh_list: &[f32],
best_mae: &mut f64,
_best_code: &mut String,
best_params: &mut (i32, f32, f32), best_params: &mut (i32, f32, f32),
) { ) {
let total = blur_list.len() * hl_list.len() * sh_list.len(); let total = blur_list.len() * hl_list.len() * sh_list.len();
@@ -201,37 +310,56 @@ fn search(
} }
let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned( let (hl_buf, sh_buf) = hcie_fx::tuned::generate_bevel_tuned(
alpha, w, h, depth, size, soften, angle, altitude, alpha, w, h, depth, size, soften, angle, altitude, direction, technique, style,
direction, technique, style,
*blur, *hl_scale, *sh_scale, 1.0, 0.05, *blur, *hl_scale, *sh_scale, 1.0, 0.05,
); );
let mut out = src_pixels.to_vec(); let mut out = src_pixels.to_vec();
for i in 0..px { for i in 0..px {
let dst = [out[i*4], out[i*4+1], out[i*4+2], out[i*4+3]]; 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 hl_src = [
let sh_src = [sh_buf[i*4], sh_buf[i*4+1], sh_buf[i*4+2], sh_buf[i*4+3]]; hl_buf[i * 4],
let blended = hcie_blend::blend_pixels(dst, hl_src, hcie_blend::BlendMode::Screen, 0.75); hl_buf[i * 4 + 1],
let blended = hcie_blend::blend_pixels(blended, sh_src, hcie_blend::BlendMode::Multiply, 0.75); hl_buf[i * 4 + 2],
out[i*4..i*4+4].copy_from_slice(&blended); 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; let mut diff_sum = 0.0f64;
for i in 0..px { for i in 0..px {
let d = (0..4).map(|c| { let d = (0..4)
(out[i * 4 + c] as i32 - ref_pixels[i * 4 + c] as i32).abs() as f64 .map(|c| {
}).sum::<f64>(); (out[i * 4 + c] as i32 - ref_pixels[i * 4 + c] as i32).abs() as f64
})
.sum::<f64>();
diff_sum += d; diff_sum += d;
} }
let mae = diff_sum / px as f64 / 4.0; let mae = diff_sum / px as f64 / 4.0;
if mae < *best_mae { if mae < *best_mae {
*best_mae = mae; *best_mae = mae;
*best_params = (*blur, *hl_scale, *sh_scale); *best_params = (*blur, *hl_scale, *sh_scale);
println!("*** NEW BEST: {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})", println!(
best_mae, blur, hl_scale, sh_scale); "*** NEW BEST: {:.3} (blur={}, hl_scale={:.2}, sh_scale={:.2})",
std::io::stdout().flush().ok(); best_mae, blur, hl_scale, sh_scale
} );
std::io::stdout().flush().ok();
}
} }
} }
} }
+1199 -447
View File
File diff suppressed because it is too large Load Diff
+39 -15
View File
@@ -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). /// Grow a mask by `px` pixels (4-connected dilation).
pub fn grow_mask(mask: &mut [u8], w: u32, h: u32, px: u32) { 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(); let mut temp = mask.to_vec();
for _ in 0..px { for _ in 0..px {
let old = temp.clone(); let old = temp.clone();
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
let i = (y * w + x) as usize; let i = (y * w + x) as usize;
if old[i] > 0 { continue; } if old[i] > 0 {
continue;
}
let neighbors = [ let neighbors = [
(x.wrapping_sub(1), y), (x + 1, y), (x.wrapping_sub(1), y),
(x, y.wrapping_sub(1)), (x, y + 1), (x + 1, y),
(x, y.wrapping_sub(1)),
(x, y + 1),
]; ];
for (nx, ny) in neighbors { for (nx, ny) in neighbors {
if nx < w && ny < h { 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). /// Shrink a mask by `px` pixels (4-connected erosion).
pub fn shrink_mask(mask: &mut [u8], w: u32, h: u32, px: u32) { 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(); let mut temp = mask.to_vec();
for _ in 0..px { for _ in 0..px {
let old = temp.clone(); let old = temp.clone();
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
let i = (y * w + x) as usize; let i = (y * w + x) as usize;
if old[i] == 0 { continue; } if old[i] == 0 {
continue;
}
let neighbors = [ let neighbors = [
(x.wrapping_sub(1), y), (x + 1, y), (x.wrapping_sub(1), y),
(x, y.wrapping_sub(1)), (x, y + 1), (x + 1, y),
(x, y.wrapping_sub(1)),
(x, y + 1),
]; ];
let mut has_zero = false; let mut has_zero = false;
for (nx, ny) in neighbors { 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 /// a smooth falloff. This properly creates soft transitions at the border
/// by including previously-unselected pixels within the feather radius. /// by including previously-unselected pixels within the feather radius.
pub fn feather_mask(mask: &mut [u8], w: u32, h: u32, radius: f32) { 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 = radius.ceil() as i32;
let r_sq = (radius * radius) as f32; let r_sq = (radius * radius) as f32;
let size = (w * h) as usize; 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 // Check if any selected pixel is within radius
'outer: for dy in -r..=r { 'outer: for dy in -r..=r {
for dx 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 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; let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as u32;
if mask[(ny * w + nx) as usize] > 0 { 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. /// - `w`, `h`: Canvas dimensions.
/// - `thickness`: Border thickness in pixels. /// - `thickness`: Border thickness in pixels.
pub fn border_mask(mask: &mut [u8], w: u32, h: u32, thickness: u32) { 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(); let original = mask.to_vec();
// First, create the outer border by eroding // 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 y in 0..h {
for x in 0..w { for x in 0..w {
let i = (y * w + x) as usize; let i = (y * w + x) as usize;
if old[i] == 0 { continue; } if old[i] == 0 {
continue;
}
let neighbors = [ let neighbors = [
(x.wrapping_sub(1), y), (x + 1, y), (x.wrapping_sub(1), y),
(x, y.wrapping_sub(1)), (x, y + 1), (x + 1, y),
(x, y.wrapping_sub(1)),
(x, y + 1),
]; ];
let mut has_zero = false; let mut has_zero = false;
for (nx, ny) in neighbors { 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. /// - `w`, `h`: Canvas dimensions.
/// - `radius`: Blur radius in pixels. /// - `radius`: Blur radius in pixels.
pub fn smooth_mask(mask: &mut [u8], w: u32, h: u32, radius: u32) { 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 r = radius as i32;
let size = (w * h) as usize; let size = (w * h) as usize;
let mut result = vec![0u8; size]; let mut result = vec![0u8; size];
+22 -13
View File
@@ -17,24 +17,36 @@ fn main() {
eprintln!("Usage: panel-tuner <config.json>"); eprintln!("Usage: panel-tuner <config.json>");
std::process::exit(1); std::process::exit(1);
} }
let config: Config = serde_json::from_reader( let config: Config =
std::fs::File::open(&args[1]).expect("Failed to open config"), serde_json::from_reader(std::fs::File::open(&args[1]).expect("Failed to open config"))
).expect("Failed to parse config"); .expect("Failed to parse config");
std::fs::create_dir_all(&config.output_dir).ok(); std::fs::create_dir_all(&config.output_dir).ok();
let mut best: Option<(Vec<f32>, f64, PathBuf)> = None; 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() { for (idx, values) in combinations.iter().enumerate() {
let out = config.output_dir.join(format!("capture_{}.png", idx)); let out = config.output_dir.join(format!("capture_{}.png", idx));
let mut cmd = Command::new(&config.gui_binary); let mut cmd = Command::new(&config.gui_binary);
for (name, value) in config.parameters.iter().map(|p| p.0.clone()).zip(values.iter()) { for (name, value) in config
cmd.env(format!("HCIE_TUNER_{}", name.to_uppercase()), value.to_string()); .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") cmd.arg("--screenshot-panel").arg(&config.panel).arg(&out);
.arg(&config.panel)
.arg(&out);
let status = cmd.status().expect("Failed to run GUI binary"); let status = cmd.status().expect("Failed to run GUI binary");
if !status.success() { if !status.success() {
@@ -44,10 +56,7 @@ fn main() {
let diff_out = config.output_dir.join(format!("diff_{}.png", idx)); let diff_out = config.output_dir.join(format!("diff_{}.png", idx));
let mae = run_diff(&config.target, &out, &diff_out); let mae = run_diff(&config.target, &out, &diff_out);
eprintln!( eprintln!("Run {}: params={:?} MAE={:.6}", idx, values, mae);
"Run {}: params={:?} MAE={:.6}",
idx, values, mae
);
if best.as_ref().map_or(true, |b| mae < b.1) { if best.as_ref().map_or(true, |b| mae < b.1) {
best = Some((values.clone(), mae, out.clone())); best = Some((values.clone(), mae, out.clone()));
} }
+15 -4
View File
@@ -8,12 +8,23 @@ fn main() {
} }
let target_path = PathBuf::from(&args[1]); let target_path = PathBuf::from(&args[1]);
let actual_path = PathBuf::from(&args[2]); 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 target = image::open(&target_path)
let actual = image::open(&actual_path).expect("Failed to open actual").to_rgba8(); .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 { if w == 0 || h == 0 {
eprintln!("One of the images has zero size"); eprintln!("One of the images has zero size");
std::process::exit(1); std::process::exit(1);