diff --git a/Cargo.lock b/Cargo.lock index 85037f9b..421e740c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,8 +8,10 @@ version = "0.1.3" dependencies = [ "acadrust", "bytemuck", + "flate2", "glam 0.27.0", "iced", + "image", "open", "printpdf", "rfd", diff --git a/Cargo.toml b/Cargo.toml index 83f85dea..eeaaafc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ acadrust = "0.3.3" open = "5" printpdf = "0.9.1" flate2 = "1" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/commands.rs b/src/app/commands.rs index 9688032f..e2592226 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -410,6 +410,10 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(wo_cmd)); } + cmd if cmd == "IMAGE" || cmd == "IMAGEATTACH" || cmd == "IM" => { + return Task::done(Message::ImagePick); + } + "REVCLOUD" => { use crate::modules::home::draw::revcloud::RevCloudCommand; let cmd = RevCloudCommand::new(); diff --git a/src/app/history.rs b/src/app/history.rs index 75b05346..43535d4e 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -35,8 +35,10 @@ impl H7CAD { .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) .collect::>(); self.tabs[i].scene.populate_hatches_from_document(); + self.tabs[i].scene.populate_images_from_document(); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].scene.meshes.clear(); + self.tabs[i].scene.images.clear(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].active_grip = None; diff --git a/src/app/mod.rs b/src/app/mod.rs index 5396f1d8..f5140fe7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -397,6 +397,11 @@ pub enum Message { // Field edit messages: DsEdit(DsField, String), DsToggle(DsField), + // ── Raster Image ────────────────────────────────────────────────────── + /// Open file-picker dialog for IMAGE command (async). + ImagePick, + /// Result of the image file picker + pixel dimension decode. + ImagePickResult(Result<(std::path::PathBuf, u32, u32), String>), } impl H7CAD { diff --git a/src/app/update.rs b/src/app/update.rs index 3013418c..a3d2b63f 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -49,6 +49,7 @@ impl H7CAD { self.tabs[i].current_path = Some(path); self.tabs[i].scene.document = doc; self.tabs[i].scene.populate_hatches_from_document(); + self.tabs[i].scene.populate_images_from_document(); self.tabs[i].scene.selected = std::collections::HashSet::new(); self.tabs[i].scene.preview_wires = vec![]; self.tabs[i].scene.current_layout = "Model".to_string(); @@ -71,6 +72,35 @@ impl H7CAD { Task::none() } + Message::ImagePick => { + Task::perform(crate::io::pick_image_file(), Message::ImagePickResult) + } + + Message::ImagePickResult(Ok((path, pw, ph))) => { + use crate::command::CadCommand; + use crate::modules::home::draw::raster_image::ImageCommand; + let path_str = path.to_string_lossy().into_owned(); + let short = std::path::Path::new(&path_str) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&path_str) + .to_string(); + self.command_line + .push_output(&format!("IMAGE \"{short}\": {pw}×{ph} px")); + let cmd = ImageCommand::new(path_str, pw, ph); + let i = self.active_tab; + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + Task::none() + } + + Message::ImagePickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&format!("IMAGE: {e}")); + } + Task::none() + } + Message::SaveFile => { let i = self.active_tab; if let Some(path) = &self.tabs[i].current_path { diff --git a/src/io/mod.rs b/src/io/mod.rs index a5e6d817..bc7d3043 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -113,6 +113,26 @@ pub async fn pick_plot_style() -> Option { plot_style::PlotStyleTable::load(handle.path()).ok() } +// ── Image file picker ───────────────────────────────────────────────────── + +/// Show a file-open dialog for raster images and decode the selected file. +/// Returns `(path, pixel_width, pixel_height)` or an error string. +pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { + let handle = rfd::AsyncFileDialog::new() + .set_title("Select Image File") + .add_filter("Images", &["png", "jpg", "jpeg", "bmp", "tiff", "tif"]) + .add_filter("PNG", &["png"]) + .add_filter("JPEG", &["jpg", "jpeg"]) + .add_filter("All Files", &["*"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let path = handle.path().to_path_buf(); + let img = image::open(&path).map_err(|e| e.to_string())?; + let (w, h) = image::GenericImageView::dimensions(&img); + Ok((path, w, h)) +} + // ── Save ────────────────────────────────────────────────────────────────── /// Save the document to the given path. diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 397bb256..b3903bbf 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -87,6 +87,7 @@ impl CadCommand for AlignedDimensionCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } @@ -119,6 +120,7 @@ fn preview_aligned(p1: Vec3, p2: Vec3, dim_pt: Vec3) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 3324adb3..b25c9a25 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -122,7 +122,8 @@ fn preview_wire(points: Vec) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index fdbc2d92..bf021551 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -86,6 +86,7 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 2691c3e5..a715062e 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -128,6 +128,7 @@ impl CadCommand for DimBaselineCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index f6987a06..8f7dd6e7 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -125,6 +125,7 @@ impl CadCommand for DimContinueCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 34a02bef..abfcc85b 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -199,7 +199,8 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 27bff5a5..02b33e5c 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -110,7 +110,8 @@ fn preview_wire(points: Vec) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index d5f14d51..d3c5a413 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -152,7 +152,8 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 189cb7c4..860c60d0 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -109,6 +109,7 @@ fn preview_wire(points: Vec) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index c381712e..891bc3e8 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -130,7 +130,8 @@ impl CadCommand for TableCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], }) } else { None diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index e65f3077..55f3519c 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -83,6 +83,7 @@ impl CadCommand for ToleranceCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/draw/attdef.rs b/src/modules/home/draw/attdef.rs index a8185d5e..83aba5cd 100644 --- a/src/modules/home/draw/attdef.rs +++ b/src/modules/home/draw/attdef.rs @@ -131,6 +131,7 @@ impl CadCommand for AttdefCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/draw/ellipse.rs b/src/modules/home/draw/ellipse.rs index 4a40932e..b87da83a 100644 --- a/src/modules/home/draw/ellipse.rs +++ b/src/modules/home/draw/ellipse.rs @@ -524,6 +524,7 @@ fn line_wire(from: Vec3, to: Vec3) -> WireModel { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], } } diff --git a/src/modules/home/draw/line.rs b/src/modules/home/draw/line.rs index cb67ba22..1262a844 100644 --- a/src/modules/home/draw/line.rs +++ b/src/modules/home/draw/line.rs @@ -85,6 +85,7 @@ impl CadCommand for LineCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/draw/mod.rs b/src/modules/home/draw/mod.rs index 565589ab..55b0e5b3 100644 --- a/src/modules/home/draw/mod.rs +++ b/src/modules/home/draw/mod.rs @@ -8,6 +8,7 @@ pub mod line; pub mod mline; pub mod point; pub mod polyline; +pub mod raster_image; pub mod ray; pub mod revcloud; pub mod shapes; diff --git a/src/modules/home/draw/raster_image.rs b/src/modules/home/draw/raster_image.rs new file mode 100644 index 00000000..e207ca71 --- /dev/null +++ b/src/modules/home/draw/raster_image.rs @@ -0,0 +1,123 @@ +// IMAGE / IMAGEATTACH command — place a raster image in the drawing. +// +// Workflow: +// 1. File dialog opens (async, handled in update.rs). +// 2. User picks insertion point (first click). +// 3. User drags to pick width; height is computed from the image's aspect ratio. +// 4. Entity is committed. + +use acadrust::entities::RasterImage; +use acadrust::types::Vector3; +use acadrust::EntityType; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::wire_model::WireModel; + +pub struct ImageCommand { + file_path: String, + pixel_width: u32, + pixel_height: u32, + origin: Option, +} + +impl ImageCommand { + pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self { + Self { file_path, pixel_width, pixel_height, origin: None } + } + + fn aspect(&self) -> f64 { + if self.pixel_height == 0 { + 1.0 + } else { + self.pixel_width as f64 / self.pixel_height as f64 + } + } + + fn make_entity(&self, origin: Vec3, width_pt: Vec3) -> EntityType { + let world_width = ((width_pt.x - origin.x) as f64).abs().max(0.001); + let world_height = world_width / self.aspect(); + + let ins = Vector3::new(origin.x as f64, origin.z as f64, origin.y as f64); + + let mut img = RasterImage::with_size( + &self.file_path, + ins, + self.pixel_width as f64, + self.pixel_height as f64, + world_width, + world_height, + ); + img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE + | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; + EntityType::RasterImage(img) + } +} + +impl CadCommand for ImageCommand { + fn name(&self) -> &'static str { + "IMAGE" + } + + fn prompt(&self) -> String { + if self.origin.is_none() { + format!("IMAGE Specify insertion point ({}): ", short_name(&self.file_path)) + } else { + "IMAGE Specify width (drag right):".into() + } + } + + fn on_point(&mut self, pt: Vec3) -> CmdResult { + if let Some(origin) = self.origin { + let entity = self.make_entity(origin, pt); + CmdResult::CommitAndExit(entity) + } else { + self.origin = Some(pt); + CmdResult::NeedPoint + } + } + + fn on_enter(&mut self) -> CmdResult { + // If origin is set, place with a default width of 1 unit * pixel count / 100 + if let Some(origin) = self.origin { + let default_w = (self.pixel_width as f64 / 100.0).max(1.0); + let width_pt = Vec3::new(origin.x + default_w as f32, origin.y, origin.z); + let entity = self.make_entity(origin, width_pt); + CmdResult::CommitAndExit(entity) + } else { + CmdResult::Cancel + } + } + + fn on_mouse_move(&mut self, pt: Vec3) -> Option { + let origin = self.origin?; + let world_width = (pt.x - origin.x).abs().max(0.001); + let world_height = world_width / self.aspect() as f32; + + let p0 = [origin.x, origin.y, origin.z]; + let p1 = [origin.x + world_width, origin.y, origin.z]; + let p2 = [origin.x + world_width, origin.y + world_height, origin.z]; + let p3 = [origin.x, origin.y + world_height, origin.z]; + + Some(WireModel { + name: "image_preview".into(), + points: vec![p0, p1, p2, p3, p0], + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + aci: 0, + key_vertices: vec![], + }) + } +} + +fn short_name(path: &str) -> &str { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) +} diff --git a/src/modules/home/draw/ray.rs b/src/modules/home/draw/ray.rs index a67a7809..ee1e7795 100644 --- a/src/modules/home/draw/ray.rs +++ b/src/modules/home/draw/ray.rs @@ -83,6 +83,7 @@ impl CadCommand for RayCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } @@ -159,6 +160,7 @@ impl CadCommand for XLineCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/draw/revcloud.rs b/src/modules/home/draw/revcloud.rs index 89ed69a4..aca3e22c 100644 --- a/src/modules/home/draw/revcloud.rs +++ b/src/modules/home/draw/revcloud.rs @@ -68,6 +68,7 @@ impl CadCommand for RevCloudCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/draw/wipeout.rs b/src/modules/home/draw/wipeout.rs index a38c78d6..93c17758 100644 --- a/src/modules/home/draw/wipeout.rs +++ b/src/modules/home/draw/wipeout.rs @@ -104,7 +104,8 @@ impl CadCommand for WipeoutCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], }) } WipeoutMode::Polygonal => { @@ -123,7 +124,8 @@ impl CadCommand for WipeoutCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], }) } } diff --git a/src/modules/home/inquiry/area.rs b/src/modules/home/inquiry/area.rs index 4a2f2a7d..2a79824f 100644 --- a/src/modules/home/inquiry/area.rs +++ b/src/modules/home/inquiry/area.rs @@ -73,6 +73,7 @@ impl CadCommand for AreaCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/inquiry/dist.rs b/src/modules/home/inquiry/dist.rs index 07bdd6dc..52a30b03 100644 --- a/src/modules/home/inquiry/dist.rs +++ b/src/modules/home/inquiry/dist.rs @@ -68,6 +68,7 @@ impl CadCommand for DistCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/home/modify/stretch.rs b/src/modules/home/modify/stretch.rs index 92f76724..c0d1b0aa 100644 --- a/src/modules/home/modify/stretch.rs +++ b/src/modules/home/modify/stretch.rs @@ -100,7 +100,8 @@ impl CadCommand for StretchCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], - key_vertices: vec![], + aci: 0, + key_vertices: vec![], }) } else { None diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 24215ddc..24bb81a4 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -95,6 +95,7 @@ impl CadCommand for MviewCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 6c847065..a3cfdc8f 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -60,6 +60,7 @@ impl CadCommand for PlotWindowCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index 052bd319..8e3b1ad7 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -66,6 +66,7 @@ impl CadCommand for ZoomWindowCommand { line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) } diff --git a/src/scene/complex_lt.rs b/src/scene/complex_lt.rs index e9b851d9..29689b30 100644 --- a/src/scene/complex_lt.rs +++ b/src/scene/complex_lt.rs @@ -159,6 +159,7 @@ pub fn apply_along( line_weight_px, snap_pts: vec![], tangent_geoms: vec![], + aci: 0, key_vertices: vec![], }) .collect() diff --git a/src/scene/image_model.rs b/src/scene/image_model.rs new file mode 100644 index 00000000..31201771 --- /dev/null +++ b/src/scene/image_model.rs @@ -0,0 +1,61 @@ +// ImageModel — CPU-side data for a raster image quad. +// +// Holds decoded RGBA pixel data and the world-space quad geometry derived +// from the RasterImage entity's insertion point, u/v vectors, and pixel size. + +use std::path::Path; + +#[derive(Clone, Debug)] +pub struct ImageModel { + /// Original file path (used for reload / display in properties). + pub file_path: String, + /// RGBA8 pixel data in row-major order. + pub pixels: Vec, + pub width: u32, + pub height: u32, + /// Opacity: 1.0 = opaque, 0.0 = transparent. + pub opacity: f32, + /// World-space quad corners (CCW), same order as image_corners() helper: + /// [0] origin (bottom-left) + /// [1] origin + U*W (bottom-right) + /// [2] origin + U*W + V*H (top-right) + /// [3] origin + V*H (top-left) + pub corners: [[f32; 3]; 4], +} + +impl ImageModel { + /// Build an ImageModel from a DXF RasterImage entity. + /// Returns `None` if the image file cannot be opened or decoded. + pub fn from_raster_image(img: &acadrust::entities::RasterImage) -> Option { + let w = img.size.x; + let h = img.size.y; + let ox = img.insertion_point.x as f32; + let oy = img.insertion_point.y as f32; + let oz = img.insertion_point.z as f32; + let ux = (img.u_vector.x * w) as f32; + let uy = (img.u_vector.y * w) as f32; + let uz = (img.u_vector.z * w) as f32; + let vx = (img.v_vector.x * h) as f32; + let vy = (img.v_vector.y * h) as f32; + let vz = (img.v_vector.z * h) as f32; + let corners = [ + [ox, oy, oz], + [ox + ux, oy + uy, oz + uz], + [ox + ux + vx, oy + uy + vy, oz + uz + vz], + [ox + vx, oy + vy, oz + vz], + ]; + let opacity = 1.0 - img.fade as f32 / 100.0; + + let (pixels, width, height) = load_pixels(&img.file_path)?; + Some(Self { file_path: img.file_path.clone(), pixels, width, height, opacity, corners }) + } +} + +/// Decode a raster image file into RGBA8 pixels. +/// Returns `None` if the file does not exist or cannot be decoded. +pub fn load_pixels(path_str: &str) -> Option<(Vec, u32, u32)> { + let img = image::open(Path::new(path_str)).ok()?; + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + Some((rgba.into_raw(), w, h)) +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 47bd58c9..4152b597 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7,6 +7,7 @@ pub mod grip; pub mod hatch_model; pub mod hatch_patterns; pub mod hit_test; +pub mod image_model; pub mod mesh_model; pub mod object; pub mod pipeline; @@ -21,6 +22,7 @@ pub mod wire_model; use camera::Camera; pub use camera::Projection; pub use hatch_model::HatchModel; +pub use image_model::ImageModel; pub use mesh_model::MeshModel; pub use object::{GripApply, GripDef}; pub use pipeline::uniforms::Uniforms; @@ -61,6 +63,8 @@ pub struct Scene { pub hatches: HashMap, /// GPU render data for solid meshes (truck Shell/Solid tessellation). pub meshes: HashMap, + /// GPU render data for raster images (RasterImage entities), keyed by handle. + pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). /// `None` = paper space editing (PSPACE). Only meaningful when /// `current_layout != "Model"`. @@ -80,6 +84,7 @@ impl Scene { current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), + images: HashMap::new(), active_viewport: None, } } @@ -1025,6 +1030,11 @@ impl Scene { } else { None }; + let image_seed = if let EntityType::RasterImage(img) = &entity { + ImageModel::from_raster_image(img) + } else { + None + }; // Route to the correct block based on current editing mode: // - PSPACE (paper layout, no active viewport): paper-space layout block. @@ -1042,6 +1052,9 @@ impl Scene { if let Some(model) = hatch_seed { self.hatches.insert(handle, model); } + if let Some(model) = image_seed { + self.images.insert(handle, model); + } } handle } @@ -1434,6 +1447,28 @@ impl Scene { }) } + /// Decode and cache all RasterImage entities from the current document. + /// Silently skips images whose files cannot be read. + pub fn populate_images_from_document(&mut self) { + self.images.clear(); + let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self + .document + .entities() + .filter_map(|e| { + if let EntityType::RasterImage(img) = e { + Some((img.common.handle, img.clone())) + } else { + None + } + }) + .collect(); + for (handle, img) in entries { + if let Some(model) = ImageModel::from_raster_image(&img) { + self.images.insert(handle, model); + } + } + } + pub fn populate_hatches_from_document(&mut self) { self.hatches.clear(); diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs new file mode 100644 index 00000000..b4ea236b --- /dev/null +++ b/src/scene/pipeline/image_gpu.rs @@ -0,0 +1,169 @@ +// Image GPU buffers — renders raster images as textured quads. +// +// Group 1 bindings per image: +// binding 0 — texture_2d (RGBA image texture) +// binding 1 — sampler (bilinear filtering) +// binding 2 — ImageParams (opacity uniform, 16 bytes) + +use crate::scene::image_model::ImageModel; +use iced::wgpu; +use iced::wgpu::util::DeviceExt; + +// ── Vertex ──────────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct ImageVertex { + pub pos: [f32; 3], + pub uv: [f32; 2], +} + +impl ImageVertex { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: 12, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + ], + } + } +} + +// ── Uniform ─────────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct ImageParams { + opacity: f32, + _pad: [f32; 3], +} // 16 bytes + +// ── Per-image GPU handle ────────────────────────────────────────────────── + +pub struct ImageGpu { + pub vertex_buffer: wgpu::Buffer, + pub bind_group: wgpu::BindGroup, + _texture: wgpu::Texture, + _sampler: wgpu::Sampler, + _params_buf: wgpu::Buffer, +} + +impl ImageGpu { + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + model: &ImageModel, + bgl1: &wgpu::BindGroupLayout, + ) -> Option { + if model.pixels.is_empty() || model.width == 0 || model.height == 0 { + return None; + } + + // ── Upload texture ──────────────────────────────────────────────── + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("image.texture"), + size: wgpu::Extent3d { + width: model.width, + height: model.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + &model.pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * model.width), + rows_per_image: Some(model.height), + }, + wgpu::Extent3d { + width: model.width, + height: model.height, + depth_or_array_layers: 1, + }, + ); + let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // ── Sampler ─────────────────────────────────────────────────────── + let _sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("image.sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + // ── Opacity uniform ─────────────────────────────────────────────── + let params = ImageParams { opacity: model.opacity.clamp(0.0, 1.0), _pad: [0.0; 3] }; + let _params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("image.params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + // ── Bind group ──────────────────────────────────────────────────── + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("image.bind_group1"), + layout: bgl1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&tex_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: _params_buf.as_entire_binding(), + }, + ], + }); + + // ── Vertex buffer — two triangles ───────────────────────────────── + // corners: [p0=BL, p1=BR, p2=TR, p3=TL] + // UV: BL=(0,1), BR=(1,1), TR=(1,0), TL=(0,0) + let [p0, p1, p2, p3] = model.corners; + let verts = [ + ImageVertex { pos: p0, uv: [0.0, 1.0] }, + ImageVertex { pos: p1, uv: [1.0, 1.0] }, + ImageVertex { pos: p2, uv: [1.0, 0.0] }, + ImageVertex { pos: p0, uv: [0.0, 1.0] }, + ImageVertex { pos: p2, uv: [1.0, 0.0] }, + ImageVertex { pos: p3, uv: [0.0, 0.0] }, + ]; + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("image.vbuf"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + + Some(Self { + vertex_buffer, + bind_group, + _texture: texture, + _sampler, + _params_buf, + }) + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index fcb4fc36..020d02df 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,4 +1,5 @@ pub mod hatch_gpu; +pub mod image_gpu; pub mod mesh_gpu; pub mod uniforms; pub mod viewcube; @@ -8,26 +9,31 @@ use iced::wgpu; use iced::{Rectangle, Size}; pub use hatch_gpu::HatchGpu; +pub use image_gpu::ImageGpu; pub use mesh_gpu::MeshGpu; pub use uniforms::Uniforms; pub use viewcube::ViewCubePipeline; pub use wire_gpu::WireGpu; use crate::scene::hatch_model::HatchModel; +use crate::scene::image_model::ImageModel; use crate::scene::mesh_model::MeshModel; use crate::scene::wire_model::WireModel; pub struct Pipeline { wire_pipeline: wgpu::RenderPipeline, hatch_pipeline: wgpu::RenderPipeline, + image_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, hatch_bgl1: wgpu::BindGroupLayout, + image_bgl1: wgpu::BindGroupLayout, depth_texture_size: Size, depth_view: wgpu::TextureView, gpu_wires: Vec, gpu_hatches: Vec, + gpu_images: Vec, gpu_meshes: Vec, pub viewcube: ViewCubePipeline, } @@ -246,19 +252,111 @@ impl Pipeline { cache: None, }); + // ── Image pipeline ───────────────────────────────────────────────── + let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("image.bgl1"), + entries: &[ + // binding 0: texture + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 1: sampler + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // binding 2: ImageParams uniform + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("image.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &image_bgl1], + push_constant_ranges: &[], + }); + + let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("image.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/image.wgsl" + ))), + }); + + let image_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("image.pipeline"), + layout: Some(&image_layout), + vertex: wgpu::VertexState { + module: &image_shader, + entry_point: Some("vs_main"), + buffers: &[image_gpu::ImageVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &image_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + let viewcube = ViewCubePipeline::new(device, queue, format); Self { wire_pipeline, hatch_pipeline, + image_pipeline, mesh_pipeline, uniform_buffer, uniform_bind_group, hatch_bgl1, + image_bgl1, depth_texture_size: Size::new(1, 1), depth_view, gpu_wires: vec![], gpu_hatches: vec![], + gpu_images: vec![], gpu_meshes: vec![], viewcube, } @@ -284,6 +382,18 @@ impl Pipeline { .collect(); } + pub fn upload_images( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + images: &[ImageModel], + ) { + self.gpu_images = images + .iter() + .filter_map(|m| ImageGpu::new(device, queue, m, &self.image_bgl1)) + .collect(); + } + pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) { queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); } @@ -339,7 +449,48 @@ impl Pipeline { } } - // ── Pass 2: solid meshes ─────────────────────────────────────────── + // ── Pass 2: raster images ───────────────────────────────────────── + if !self.gpu_images.is_empty() { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("image.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport( + vp.x as f32, + vp.y as f32, + vp.width as f32, + vp.height as f32, + 0.0, + 1.0, + ); + pass.set_pipeline(&self.image_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + for img in &self.gpu_images { + pass.set_bind_group(1, &img.bind_group, &[]); + pass.set_vertex_buffer(0, img.vertex_buffer.slice(..)); + pass.draw(0..6, 0..1); + } + } + + // ── Pass 4: solid meshes ────────────────────────────────────────── if !self.gpu_meshes.is_empty() { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mesh.render_pass"), @@ -382,7 +533,7 @@ impl Pipeline { } } - // ── Pass 3: wires ────────────────────────────────────────────────── + // ── Pass 5: wires ───────────────────────────────────────────────── { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("wire.render_pass"), diff --git a/src/scene/render.rs b/src/scene/render.rs index f55fdb92..23e3b85f 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -12,7 +12,7 @@ use iced::{Event, Rectangle, Size}; use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX}; use super::pipeline::Pipeline; use super::tessellate; -use super::{HatchModel, MeshModel, Scene, Uniforms, WireModel}; +use super::{HatchModel, ImageModel, MeshModel, Scene, Uniforms, WireModel}; // ── Camera hover state (shader::Program::State) ─────────────────────────── @@ -27,6 +27,7 @@ pub struct CameraState { pub struct Primitive { pub(super) wires: Vec, pub(super) hatches: Vec, + pub(super) images: Vec, pub(super) meshes: Vec, pub(super) uniforms: Uniforms, /// Camera rotation matrix derived from the quaternion. @@ -59,6 +60,7 @@ impl shader::Program for Scene { Primitive { wires: all_wires, hatches: self.synced_hatch_models(), + images: self.images.values().cloned().collect(), meshes: self.meshes.values().cloned().collect(), uniforms: Uniforms::new(&cam, bounds), cam_rotation: cam.view_rotation_mat(), @@ -123,6 +125,7 @@ impl shader::Primitive for Primitive { pipeline.viewcube.ensure_depth_texture(device, size); pipeline.upload_uniforms(queue, &self.uniforms); pipeline.upload_hatches(device, &self.hatches); + pipeline.upload_images(device, queue, &self.images); pipeline.upload_meshes(device, &self.meshes); pipeline.upload_wires(device, &self.wires); let logical = viewport.logical_size(); diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl new file mode 100644 index 00000000..bc74abaf --- /dev/null +++ b/src/shaders/image.wgsl @@ -0,0 +1,53 @@ +// Textured-quad shader for raster images (RasterImage entity). +// Renders a four-vertex quad (two triangles) with a sampled texture. + +// ── Bind group 0: shared projection uniforms ───────────────────────────────── +struct Uniforms { + mvp: mat4x4, + view: mat4x4, + width: f32, + height: f32, + cam_z: f32, + _pad: f32, +}; + +@group(0) @binding(0) var u: Uniforms; + +// ── Bind group 1: per-image texture + sampler ──────────────────────────────── +@group(1) @binding(0) var img_texture: texture_2d; +@group(1) @binding(1) var img_sampler: sampler; + +// Per-image params (fade, clip flag, etc.) +struct ImageParams { + opacity: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +}; +@group(1) @binding(2) var img_params: ImageParams; + +// ── Vertex stage ────────────────────────────────────────────────────────────── +struct VertIn { + @location(0) pos: vec3, + @location(1) uv: vec2, +}; + +struct VertOut { + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(in: VertIn) -> VertOut { + var out: VertOut; + out.clip_pos = u.mvp * vec4(in.pos, 1.0); + out.uv = in.uv; + return out; +} + +// ── Fragment stage ──────────────────────────────────────────────────────────── +@fragment +fn fs_main(in: VertOut) -> @location(0) vec4 { + let col = textureSample(img_texture, img_sampler, in.uv); + return vec4(col.rgb, col.a * img_params.opacity); +}