feat: raster image render pipeline (IMAGE command + GPU textured quad)

- Add IMAGE / IMAGEATTACH / IM command with async file-picker (rfd)
- src/scene/image_model.rs: ImageModel with RGBA pixel data + quad corners
- src/scene/pipeline/image_gpu.rs: ImageGpu with wgpu texture, sampler, opacity uniform
- src/scene/pipeline/mod.rs: image render pipeline (pass 2, between hatches and meshes)
- src/scene/render.rs: include images in Primitive, call upload_images in prepare
- src/scene/mod.rs: images HashMap, populate_images_from_document, cache on add_entity
- src/modules/home/draw/raster_image.rs: ImageCommand (two-click: origin + width, aspect-correct preview)
- src/io/mod.rs: pick_image_file() async helper (file dialog + dimension decode)
- app: ImagePick / ImagePickResult messages, populate on file open and undo/redo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-06 23:19:08 +03:00
commit 4312b6c3a5
39 changed files with 702 additions and 15 deletions

2
Cargo.lock generated
View file

@ -8,8 +8,10 @@ version = "0.1.3"
dependencies = [
"acadrust",
"bytemuck",
"flate2",
"glam 0.27.0",
"iced",
"image",
"open",
"printpdf",
"rfd",

View file

@ -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"] }

View file

@ -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();

View file

@ -35,8 +35,10 @@ impl H7CAD {
.filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some())
.collect::<HashSet<_>>();
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;

View file

@ -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 {

View file

@ -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 {

View file

@ -113,6 +113,26 @@ pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
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.

View file

@ -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![],
}
}

View file

@ -122,7 +122,8 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aci: 0,
key_vertices: vec![],
}
}

View file

@ -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![],
}
}

View file

@ -128,6 +128,7 @@ impl CadCommand for DimBaselineCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -125,6 +125,7 @@ impl CadCommand for DimContinueCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -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![],
}
}

View file

@ -110,7 +110,8 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aci: 0,
key_vertices: vec![],
}
}

View file

@ -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![],
}
}

View file

@ -109,6 +109,7 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aci: 0,
key_vertices: vec![],
}
}

View file

@ -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

View file

@ -83,6 +83,7 @@ impl CadCommand for ToleranceCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -131,6 +131,7 @@ impl CadCommand for AttdefCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -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![],
}
}

View file

@ -85,6 +85,7 @@ impl CadCommand for LineCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -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;

View file

@ -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<Vec3>,
}
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<WireModel> {
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)
}

View file

@ -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![],
})
}

View file

@ -68,6 +68,7 @@ impl CadCommand for RevCloudCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -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![],
})
}
}

View file

@ -73,6 +73,7 @@ impl CadCommand for AreaCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -68,6 +68,7 @@ impl CadCommand for DistCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -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

View file

@ -95,6 +95,7 @@ impl CadCommand for MviewCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -60,6 +60,7 @@ impl CadCommand for PlotWindowCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -66,6 +66,7 @@ impl CadCommand for ZoomWindowCommand {
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
}

View file

@ -159,6 +159,7 @@ pub fn apply_along(
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
})
.collect()

61
src/scene/image_model.rs Normal file
View file

@ -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<u8>,
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<Self> {
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<u8>, 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))
}

View file

@ -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<Handle, HatchModel>,
/// GPU render data for solid meshes (truck Shell/Solid tessellation).
pub meshes: HashMap<Handle, MeshModel>,
/// GPU render data for raster images (RasterImage entities), keyed by handle.
pub images: HashMap<Handle, ImageModel>,
/// 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();

View file

@ -0,0 +1,169 @@
// Image GPU buffers — renders raster images as textured quads.
//
// Group 1 bindings per image:
// binding 0 — texture_2d<f32> (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::<ImageVertex>() 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<Self> {
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(&params),
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,
})
}
}

View file

@ -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<u32>,
depth_view: wgpu::TextureView,
gpu_wires: Vec<WireGpu>,
gpu_hatches: Vec<HatchGpu>,
gpu_images: Vec<ImageGpu>,
gpu_meshes: Vec<MeshGpu>,
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"),

View file

@ -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<WireModel>,
pub(super) hatches: Vec<HatchModel>,
pub(super) images: Vec<ImageModel>,
pub(super) meshes: Vec<MeshModel>,
pub(super) uniforms: Uniforms,
/// Camera rotation matrix derived from the quaternion.
@ -59,6 +60,7 @@ impl<Msg: std::fmt::Debug + Clone> shader::Program<Msg> 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();

53
src/shaders/image.wgsl Normal file
View file

@ -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<f32>,
view: mat4x4<f32>,
width: f32,
height: f32,
cam_z: f32,
_pad: f32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
// Bind group 1: per-image texture + sampler
@group(1) @binding(0) var img_texture: texture_2d<f32>;
@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<uniform> img_params: ImageParams;
// Vertex stage
struct VertIn {
@location(0) pos: vec3<f32>,
@location(1) uv: vec2<f32>,
};
struct VertOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(in: VertIn) -> VertOut {
var out: VertOut;
out.clip_pos = u.mvp * vec4<f32>(in.pos, 1.0);
out.uv = in.uv;
return out;
}
// Fragment stage
@fragment
fn fs_main(in: VertOut) -> @location(0) vec4<f32> {
let col = textureSample(img_texture, img_sampler, in.uv);
return vec4<f32>(col.rgb, col.a * img_params.opacity);
}