b09795ccff
- 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.
613 lines
21 KiB
Rust
613 lines
21 KiB
Rust
#![allow(deprecated)]
|
|
use eframe::egui;
|
|
use eframe::egui::Color32;
|
|
use egui::ColorImage;
|
|
use hcie_brush_engine::{presets::BrushPreset, BrushStyle};
|
|
|
|
// ── tunables ─────────────────────────────────────────────────────────────────
|
|
const TILE_SIZE: u32 = 512;
|
|
|
|
struct SliderCaps {
|
|
hardness: bool,
|
|
opacity: bool,
|
|
spacing: bool,
|
|
flow: bool,
|
|
jitter: bool,
|
|
scatter: bool,
|
|
angle: bool,
|
|
roundness: bool,
|
|
spray_particle_size: bool,
|
|
spray_density: bool,
|
|
}
|
|
|
|
fn slider_caps(style: BrushStyle) -> SliderCaps {
|
|
match style {
|
|
BrushStyle::Calligraphy => SliderCaps {
|
|
angle: true,
|
|
roundness: true,
|
|
jitter: false,
|
|
scatter: false,
|
|
spray_particle_size: false,
|
|
spray_density: false,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Pencil | BrushStyle::InkPen => SliderCaps {
|
|
jitter: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Spray | BrushStyle::Airbrush => SliderCaps {
|
|
jitter: true,
|
|
scatter: true,
|
|
spray_particle_size: true,
|
|
spray_density: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Charcoal => SliderCaps {
|
|
jitter: true,
|
|
scatter: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Crayon => SliderCaps {
|
|
jitter: true,
|
|
scatter: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Tree => SliderCaps {
|
|
jitter: true,
|
|
angle: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Meadow | BrushStyle::Rock | BrushStyle::Dirt => SliderCaps {
|
|
jitter: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Bristle => SliderCaps {
|
|
roundness: true,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::HardRound | BrushStyle::SoftRound => SliderCaps {
|
|
hardness: false,
|
|
..SliderCaps::common()
|
|
},
|
|
BrushStyle::Mixer | BrushStyle::Blender => SliderCaps {
|
|
flow: false,
|
|
..SliderCaps::common()
|
|
},
|
|
_ => SliderCaps::common(),
|
|
}
|
|
}
|
|
|
|
impl SliderCaps {
|
|
fn common() -> Self {
|
|
Self {
|
|
hardness: true,
|
|
opacity: true,
|
|
spacing: true,
|
|
flow: true,
|
|
jitter: false,
|
|
scatter: false,
|
|
angle: false,
|
|
roundness: false,
|
|
spray_particle_size: false,
|
|
spray_density: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum CanvasPreset {
|
|
S512,
|
|
S1024,
|
|
S2048,
|
|
S4096,
|
|
}
|
|
|
|
impl CanvasPreset {
|
|
fn size(self) -> (u32, u32) {
|
|
match self {
|
|
Self::S512 => (512, 512),
|
|
Self::S1024 => (1024, 1024),
|
|
Self::S2048 => (2048, 2048),
|
|
Self::S4096 => (4096, 4096),
|
|
}
|
|
}
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::S512 => "512",
|
|
Self::S1024 => "1024",
|
|
Self::S2048 => "2048",
|
|
Self::S4096 => "4096",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum FpsCap {
|
|
Unlimited,
|
|
Fps30,
|
|
Fps60,
|
|
Fps165,
|
|
}
|
|
|
|
impl FpsCap {
|
|
fn value(self) -> Option<f32> {
|
|
match self {
|
|
Self::Fps30 => Some(30.0),
|
|
Self::Fps60 => Some(60.0),
|
|
Self::Fps165 => Some(165.0),
|
|
Self::Unlimited => None,
|
|
}
|
|
}
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Fps30 => "30",
|
|
Self::Fps60 => "60",
|
|
Self::Fps165 => "165",
|
|
Self::Unlimited => "∞",
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── tiled canvas ─────────────────────────────────────────────────────────────
|
|
|
|
struct Tile {
|
|
texture: Option<egui::TextureHandle>,
|
|
dirty: bool,
|
|
}
|
|
|
|
struct TiledCanvas {
|
|
pixels: Vec<u8>,
|
|
tiles: Vec<Tile>,
|
|
w: u32,
|
|
h: u32,
|
|
cols: u32,
|
|
dirty_count: usize,
|
|
}
|
|
|
|
impl TiledCanvas {
|
|
fn new(w: u32, h: u32) -> Self {
|
|
let cols = w.div_ceil(TILE_SIZE);
|
|
let rows = h.div_ceil(TILE_SIZE);
|
|
let total = (cols * rows) as usize;
|
|
Self {
|
|
pixels: vec![255; (w * h * 4) as usize],
|
|
tiles: (0..total)
|
|
.map(|_| Tile {
|
|
texture: None,
|
|
dirty: true,
|
|
})
|
|
.collect(),
|
|
w,
|
|
h,
|
|
cols,
|
|
dirty_count: total,
|
|
}
|
|
}
|
|
|
|
fn mark_dirty_rect(&mut self, cx: f32, cy: f32, radius: f32) {
|
|
let tx0 = ((cx - radius).max(0.0) / TILE_SIZE as f32) as u32;
|
|
let tx1 = (((cx + radius).min(self.w as f32 - 1.0) / TILE_SIZE as f32) as u32)
|
|
.min(self.cols.saturating_sub(1));
|
|
let ty0 = ((cy - radius).max(0.0) / TILE_SIZE as f32) as u32;
|
|
let ty1 = (((cy + radius).min(self.h as f32 - 1.0) / TILE_SIZE as f32) as u32)
|
|
.min(self.h.div_ceil(TILE_SIZE).saturating_sub(1));
|
|
for ty in ty0..=ty1 {
|
|
for tx in tx0..=tx1 {
|
|
let idx = (ty * self.cols + tx) as usize;
|
|
if !self.tiles[idx].dirty {
|
|
self.tiles[idx].dirty = true;
|
|
self.dirty_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mark_line_dirty(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, radius: f32) {
|
|
let mut x0 = x0;
|
|
let mut y0 = y0;
|
|
let x1 = x1;
|
|
let y1 = y1;
|
|
let dx = (x1 - x0).abs();
|
|
let dy = (y1 - y0).abs();
|
|
let sx = if x0 < x1 { 1.0 } else { -1.0 };
|
|
let sy = if y0 < y1 { 1.0 } else { -1.0 };
|
|
let mut err = dx - dy;
|
|
let max_steps = (dx + dy) as u32 + 1;
|
|
|
|
for _ in 0..max_steps {
|
|
self.mark_dirty_rect(x0, y0, radius);
|
|
if (x0 - x1).abs() < f32::EPSILON && (y0 - y1).abs() < f32::EPSILON {
|
|
break;
|
|
}
|
|
let e2 = 2.0 * err;
|
|
if e2 > -dy {
|
|
err -= dy;
|
|
x0 += sx;
|
|
}
|
|
if e2 < dx {
|
|
err += dx;
|
|
y0 += sy;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mark_all_dirty(&mut self) {
|
|
for t in &mut self.tiles {
|
|
t.dirty = true;
|
|
}
|
|
self.dirty_count = self.tiles.len();
|
|
}
|
|
|
|
fn upload_dirty(&mut self, ctx: &egui::Context) {
|
|
if self.dirty_count == 0 {
|
|
return;
|
|
}
|
|
let rows = self.h.div_ceil(TILE_SIZE);
|
|
for ty in 0..rows {
|
|
let tile_h = if ty == rows - 1 {
|
|
self.h - ty * TILE_SIZE
|
|
} else {
|
|
TILE_SIZE
|
|
} as usize;
|
|
for tx in 0..self.cols {
|
|
let idx = (ty * self.cols + tx) as usize;
|
|
if !self.tiles[idx].dirty {
|
|
continue;
|
|
}
|
|
self.tiles[idx].dirty = false;
|
|
self.dirty_count = self.dirty_count.saturating_sub(1);
|
|
|
|
let tile_w = if tx == self.cols - 1 {
|
|
self.w - tx * TILE_SIZE
|
|
} else {
|
|
TILE_SIZE
|
|
} as usize;
|
|
|
|
let px = (tx * TILE_SIZE) as usize;
|
|
let py = (ty * TILE_SIZE) as usize;
|
|
let cap = tile_w * tile_h * 4;
|
|
let mut data = vec![0u8; cap];
|
|
for row in 0..tile_h {
|
|
let src = ((py + row) * self.w as usize + px) * 4;
|
|
let dst = row * tile_w * 4;
|
|
data[dst..dst + tile_w * 4]
|
|
.copy_from_slice(&self.pixels[src..src + tile_w * 4]);
|
|
}
|
|
|
|
let img = ColorImage::from_rgba_unmultiplied([tile_w, tile_h], &data);
|
|
let key = format!("t{}_{}", tx, ty);
|
|
if let Some(tex) = &mut self.tiles[idx].texture {
|
|
tex.set(img, egui::TextureOptions::NEAREST);
|
|
} else {
|
|
self.tiles[idx].texture =
|
|
Some(ctx.load_texture(key, img, egui::TextureOptions::NEAREST));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ── app state ────────────────────────────────────────────────────────────────
|
|
|
|
struct App {
|
|
canvas: TiledCanvas,
|
|
canvas_preset: CanvasPreset,
|
|
preset: BrushPreset,
|
|
color: [u8; 4],
|
|
last_pt: Option<(f32, f32, f32)>,
|
|
drawing: bool,
|
|
last_frame: std::time::Instant,
|
|
fps: f32,
|
|
stroke_count: u64,
|
|
fps_cap: FpsCap,
|
|
}
|
|
|
|
impl App {
|
|
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
|
let preset = CanvasPreset::S1024;
|
|
let (w, h) = preset.size();
|
|
let now = std::time::Instant::now();
|
|
Self {
|
|
canvas: TiledCanvas::new(w, h),
|
|
canvas_preset: preset,
|
|
preset: BrushPreset::basic_round(),
|
|
color: [0, 0, 0, 255],
|
|
last_pt: None,
|
|
drawing: false,
|
|
last_frame: now,
|
|
fps: 0.0,
|
|
stroke_count: 0,
|
|
fps_cap: FpsCap::Fps30,
|
|
}
|
|
}
|
|
|
|
fn set_canvas_size(&mut self, p: CanvasPreset) {
|
|
if self.canvas_preset == p {
|
|
return;
|
|
}
|
|
self.canvas_preset = p;
|
|
let (w, h) = p.size();
|
|
self.canvas = TiledCanvas::new(w, h);
|
|
}
|
|
}
|
|
|
|
impl eframe::App for App {
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
let ctx = ui.ctx().clone();
|
|
let now = std::time::Instant::now();
|
|
|
|
// real fps throttle — sleep remaining frame time
|
|
if let Some(limit) = self.fps_cap.value() {
|
|
let interval = std::time::Duration::from_secs_f32(1.0 / limit);
|
|
let elapsed = now - self.last_frame;
|
|
if elapsed < interval {
|
|
std::thread::sleep(interval - elapsed);
|
|
}
|
|
}
|
|
|
|
// fps counter — measure after sleep for accurate reading
|
|
let after_sleep = std::time::Instant::now();
|
|
let dt = (after_sleep - self.last_frame).as_secs_f32();
|
|
self.last_frame = after_sleep;
|
|
if dt > 0.0 {
|
|
self.fps = self.fps * 0.9 + (1.0 / dt) * 0.1;
|
|
}
|
|
|
|
self.canvas.upload_dirty(&ctx);
|
|
|
|
// ── left panel ───────────────────────────────────────────────────
|
|
egui::Panel::left("panel").show(&ctx, |ui| {
|
|
ui.heading("Brush Test");
|
|
let limit_label = match self.fps_cap {
|
|
FpsCap::Fps30 => "30",
|
|
FpsCap::Fps60 => "60",
|
|
FpsCap::Fps165 => "165",
|
|
FpsCap::Unlimited => "∞",
|
|
};
|
|
ui.label(format!(
|
|
"FPS: {:.0}/{} │ dirty: {} │ strokes: {}",
|
|
self.fps, limit_label, self.canvas.dirty_count, self.stroke_count
|
|
));
|
|
|
|
ui.separator();
|
|
|
|
ui.label("Canvas");
|
|
egui::ComboBox::from_id_salt("canvas_preset")
|
|
.selected_text(self.canvas_preset.name())
|
|
.show_ui(ui, |ui| {
|
|
for p in &[
|
|
CanvasPreset::S512,
|
|
CanvasPreset::S1024,
|
|
CanvasPreset::S2048,
|
|
CanvasPreset::S4096,
|
|
] {
|
|
if ui
|
|
.selectable_label(self.canvas_preset == *p, p.name())
|
|
.clicked()
|
|
{
|
|
self.set_canvas_size(*p);
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.label("FPS Cap");
|
|
egui::ComboBox::from_id_salt("fps_cap")
|
|
.selected_text(self.fps_cap.name())
|
|
.show_ui(ui, |ui| {
|
|
for c in &[
|
|
FpsCap::Fps30,
|
|
FpsCap::Fps60,
|
|
FpsCap::Fps165,
|
|
FpsCap::Unlimited,
|
|
] {
|
|
if ui.selectable_label(self.fps_cap == *c, c.name()).clicked() {
|
|
self.fps_cap = *c;
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.separator();
|
|
ui.label("Brush");
|
|
egui::ComboBox::from_id_salt("brush_preset")
|
|
.selected_text(&self.preset.name)
|
|
.show_ui(ui, |ui| {
|
|
for p in BrushPreset::all_defaults() {
|
|
ui.selectable_value(&mut self.preset, p.clone(), &p.name);
|
|
}
|
|
});
|
|
|
|
ui.add(egui::Slider::new(&mut self.preset.tip.size, 1.0..=500.0).text("Size"));
|
|
let caps = slider_caps(self.preset.style);
|
|
ui.add_enabled(
|
|
caps.hardness,
|
|
egui::Slider::new(&mut self.preset.tip.hardness, 0.0..=1.0).text("Hardness"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.opacity,
|
|
egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.spacing,
|
|
egui::Slider::new(&mut self.preset.tip.spacing, 0.01..=1.0).text("Spacing"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.flow,
|
|
egui::Slider::new(&mut self.preset.tip.flow, 0.01..=1.0).text("Flow"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.jitter,
|
|
egui::Slider::new(&mut self.preset.tip.jitter_amount, 0.0..=5.0).text("Jitter"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.scatter,
|
|
egui::Slider::new(&mut self.preset.tip.scatter_amount, 0.0..=1.0).text("Scatter"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.angle,
|
|
egui::Slider::new(&mut self.preset.tip.angle, 0.0..=360.0).text("Angle"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.roundness,
|
|
egui::Slider::new(&mut self.preset.tip.roundness, 0.0..=1.0).text("Roundness"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.spray_particle_size,
|
|
egui::Slider::new(&mut self.preset.tip.spray_particle_size, 0.5..=20.0)
|
|
.text("Spray Size"),
|
|
);
|
|
ui.add_enabled(
|
|
caps.spray_density,
|
|
egui::Slider::new(&mut self.preset.tip.spray_density, 1..=500)
|
|
.text("Spray Density"),
|
|
);
|
|
|
|
let mut rgba = [
|
|
self.color[0] as f32 / 255.,
|
|
self.color[1] as f32 / 255.,
|
|
self.color[2] as f32 / 255.,
|
|
self.color[3] as f32 / 255.,
|
|
];
|
|
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
|
|
self.color = [
|
|
(rgba[0] * 255.) as u8,
|
|
(rgba[1] * 255.) as u8,
|
|
(rgba[2] * 255.) as u8,
|
|
(rgba[3] * 255.) as u8,
|
|
];
|
|
}
|
|
|
|
if ui.button("Clear").clicked() {
|
|
self.canvas.pixels.fill(255);
|
|
self.canvas.mark_all_dirty();
|
|
self.stroke_count = 0;
|
|
}
|
|
});
|
|
|
|
// ── central canvas ────────────────────────────────────────────────
|
|
egui::CentralPanel::default().show(&ctx, |ui| {
|
|
let available = ui.available_size_before_wrap();
|
|
let view_w = available.x.max(64.0);
|
|
let view_h = available.y.max(64.0);
|
|
let scale = (view_w / self.canvas.w as f32).min(view_h / self.canvas.h as f32);
|
|
let disp_w = self.canvas.w as f32 * scale;
|
|
let disp_h = self.canvas.h as f32 * scale;
|
|
|
|
let (rect, resp) =
|
|
ui.allocate_exact_size(egui::vec2(disp_w, disp_h), egui::Sense::drag());
|
|
|
|
// paint tiles
|
|
if self
|
|
.canvas
|
|
.tiles
|
|
.first()
|
|
.and_then(|t| t.texture.as_ref())
|
|
.is_some()
|
|
{
|
|
let rows = self.canvas.h.div_ceil(TILE_SIZE);
|
|
for ty in 0..rows {
|
|
for tx in 0..self.canvas.cols {
|
|
let idx = (ty * self.canvas.cols + tx) as usize;
|
|
if let Some(tex) = &self.canvas.tiles[idx].texture {
|
|
let tile_w = if tx == self.canvas.cols - 1 {
|
|
self.canvas.w - tx * TILE_SIZE
|
|
} else {
|
|
TILE_SIZE
|
|
} as f32;
|
|
let tile_h = if ty == rows - 1 {
|
|
self.canvas.h - ty * TILE_SIZE
|
|
} else {
|
|
TILE_SIZE
|
|
} as f32;
|
|
let src_x = (tx * TILE_SIZE) as f32 * scale;
|
|
let src_y = (ty * TILE_SIZE) as f32 * scale;
|
|
let r = egui::Rect::from_min_size(
|
|
rect.min + egui::vec2(src_x, src_y),
|
|
egui::vec2(tile_w * scale, tile_h * scale),
|
|
);
|
|
ui.painter().image(
|
|
tex.id(),
|
|
r,
|
|
egui::Rect::from_min_max(egui::pos2(0., 0.), egui::pos2(1., 1.)),
|
|
Color32::WHITE,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// input
|
|
if resp.dragged() {
|
|
self.drawing = true;
|
|
if let Some(pos) = resp.interact_pointer_pos() {
|
|
let cx = (pos.x - rect.min.x) / scale;
|
|
let cy = (pos.y - rect.min.y) / scale;
|
|
if cx >= 0.0
|
|
&& cy >= 0.0
|
|
&& cx < self.canvas.w as f32
|
|
&& cy < self.canvas.h as f32
|
|
{
|
|
let pt = (cx, cy, 1.0);
|
|
let seg = if let Some(l) = self.last_pt {
|
|
vec![l, pt]
|
|
} else {
|
|
vec![pt]
|
|
};
|
|
self.last_pt = Some(pt);
|
|
self.canvas.mark_line_dirty(
|
|
seg[0].0,
|
|
seg[0].1,
|
|
seg[seg.len() - 1].0,
|
|
seg[seg.len() - 1].1,
|
|
self.preset.tip.size,
|
|
);
|
|
hcie_brush_engine::draw_specialized_stroke(
|
|
&mut self.canvas.pixels,
|
|
self.canvas.w,
|
|
self.canvas.h,
|
|
&seg,
|
|
self.preset.style,
|
|
self.preset.tip.size,
|
|
self.preset.tip.hardness,
|
|
self.color,
|
|
self.preset.tip.opacity,
|
|
self.preset.tip.spacing,
|
|
false,
|
|
None,
|
|
self.preset.tip.spray_particle_size,
|
|
self.preset.tip.spray_density,
|
|
None,
|
|
None,
|
|
None,
|
|
self.preset.tip.color_variant,
|
|
self.preset.tip.variant_amount,
|
|
self.preset.tip.density,
|
|
);
|
|
self.stroke_count += 1;
|
|
|
|
// force upload now so stroke appears immediately
|
|
self.canvas.upload_dirty(&ctx);
|
|
}
|
|
}
|
|
} else if self.drawing {
|
|
self.drawing = false;
|
|
self.last_pt = None;
|
|
}
|
|
});
|
|
|
|
// keep requesting repaints so egui keeps calling update
|
|
ctx.request_repaint();
|
|
}
|
|
}
|
|
|
|
fn main() -> eframe::Result<()> {
|
|
eframe::run_native(
|
|
"HCIE Brush Engine — Tiled GPU Test",
|
|
eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default().with_inner_size([1400., 900.]),
|
|
vsync: true,
|
|
..Default::default()
|
|
},
|
|
Box::new(|cc| Ok(Box::new(App::new(cc)))),
|
|
)
|
|
}
|