From b7819481f0df9cec8941fe510f4aaa3997278ab2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:56:11 +0300 Subject: [PATCH] Complete wipeout workflow and editing --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- src/app/command_driver.rs | 24 ++- src/app/commands/draw.rs | 6 +- src/app/commands/mod.rs | 4 + src/app/commands/styleprops.rs | 46 ++++ src/app/update/file.rs | 29 ++- src/command.rs | 6 +- src/entities/raster_image.rs | 354 +++++++++++++++---------------- src/modules/draw/draw/wipeout.rs | 353 ++++++++++++++++++------------ src/modules/insert/mod.rs | 2 +- src/scene/convert/tess.rs | 16 ++ 14 files changed, 514 insertions(+), 340 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86a9e334..9d55a792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0975677#0975677029f2b472759db00e9692421a5831ad00" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=6b6b50a#6b6b50ad56ca6184823769db6769cc15f5cbe50d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3d5de36#3d5de36cb5b391f17da924c5f80e34a0cdc1d948" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d674c55#d674c550cafda45dde9c96c9e13fc3fa632679d4" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3aaed7a5..fa42d884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3d5de36", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6b6b50a", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d674c55", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 5a540bdc..ec285f07 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6b6b50a", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6b6b50a", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a38821d6..cf58cf8e 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6b6b50a", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a3411a7d..2558022c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1014,7 +1014,10 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } - CmdResult::WipeoutFromPolyline(handle) => { + CmdResult::WipeoutFromPolyline { + handle, + erase_source, + } => { let wipeout = { let scene = &self.tabs[i].scene; scene @@ -1026,16 +1029,23 @@ impl OpenCADStudio { ) }; if let Some(wipeout) = wipeout { + if erase_source { + return self.apply_cmd_result(CmdResult::ReplaceMany( + vec![(handle, Vec::new())], + vec![wipeout], + )); + } return self.apply_cmd_result(CmdResult::CommitAndExit(wipeout)); } self.command_line.push_error( - "WIPEOUT Polyline: select a closed planar polyline with at least 3 vertices.", + "WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.", ); - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } + let command = + crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); + self.command_line.push_info( + &crate::command::CadCommand::prompt(&command), + ); + self.tabs[i].active_cmd = Some(Box::new(command)); } CmdResult::MviewSwitchLayout(layout) => { let task = self.on_layout_switch_preserving_command(layout); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index c2607381..051f21af 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -48,7 +48,11 @@ impl OpenCADStudio { let wo_cmd = match args.as_str() { "P" | "POLYLINE" => WipeoutCommand::new_polyline(), "R" | "RECTANGULAR" => WipeoutCommand::new_rectangular(), - _ => WipeoutCommand::new_polygonal(), + _ => WipeoutCommand::new_polygonal( + crate::modules::draw::draw::wipeout::wipeout_frame_mode( + &self.tabs[i].scene.document, + ), + ), }; self.command_line.push_info(&wo_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(wo_cmd)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 174653f8..d2262e62 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -515,6 +515,10 @@ inventory::submit!(crate::command::CommandRegistration { "TEXTQLTY", "SORTENTS", "XCLIPFRAME", + "WIPEOUTFRAME", + "FRAMES0", + "FRAMES1", + "FRAMES2", "HALOGAP", "TRACEWID", "SKETCHINC", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 5eba1ee6..516c75ff 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -3,6 +3,9 @@ use super::*; impl OpenCADStudio { pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option> { match cmd { + "FRAMES0" => return self.dispatch_styleprops("SETVAR WIPEOUTFRAME 0", i), + "FRAMES1" => return self.dispatch_styleprops("SETVAR WIPEOUTFRAME 1", i), + "FRAMES2" => return self.dispatch_styleprops("SETVAR WIPEOUTFRAME 2", i), // COLOR — the colour applied to new // objects (CECOLOR). Bare COLOR reports the current value. "COLOR" | "COLOUR" | "CECOLOR" | "DDCOLOR" => { @@ -920,6 +923,7 @@ impl OpenCADStudio { | "TEXTQLTY" | "SORTENTS" | "XCLIPFRAME" + | "WIPEOUTFRAME" | "HALOGAP" | "TRACEWID" | "SKETCHINC" @@ -948,6 +952,48 @@ impl OpenCADStudio { "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { + if name == "WIPEOUTFRAME" { + match &value { + Some(value) => match value.parse::() { + Ok(mode @ 0..=2) => { + self.push_undo_snapshot(i, "WIPEOUTFRAME"); + crate::modules::draw::draw::wipeout::set_wipeout_frame_mode( + &mut self.tabs[i].scene.document, + mode, + ); + let changes: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Wipeout(_)) + .then_some(( + entity.common().handle, + crate::scene::ChangeKind::Modified, + )) + }) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.command_line + .push_output(&format!("WIPEOUTFRAME = {mode}")); + } + _ => self.command_line.push_error( + "SETVAR: WIPEOUTFRAME requires 0, 1, or 2.", + ), + }, + None => { + let mode = crate::modules::draw::draw::wipeout::wipeout_frame_mode( + &self.tabs[i].scene.document, + ); + self.command_line.push_output(crate::tf!( + "Enter new value for WIPEOUTFRAME <{mode}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } // Parse a boolean given as 0/1 or ON/OFF. let parse_bool = |s: &str| match s.to_uppercase().as_str() { "1" | "ON" | "TRUE" => Some(true), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7283db73..d6eb6f58 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -168,7 +168,21 @@ fn plot_scene_content( Vec, crate::io::pdf_export::PlotGroupSplits, ) { - let (paper_wires, model_wires) = scene.plot_wire_groups(render_mode_override); + let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); + let hide_mask_frames = + crate::modules::draw::draw::wipeout::wipeout_frame_mode(&scene.document) == 2; + if hide_mask_frames { + let mask_handles: std::collections::HashSet = scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Wipeout(_)) + .then_some(entity.common().handle.value().to_string()) + }) + .collect(); + paper_wires.retain(|wire| !mask_handles.contains(&wire.name)); + model_wires.retain(|wire| !mask_handles.contains(&wire.name)); + } let paper_hatches = scene.paper_canvas_hatches().as_ref().clone(); let paper_wipeouts = scene.paper_canvas_wipeouts().as_ref().clone(); if scene.current_layout == "Model" { @@ -184,8 +198,19 @@ fn plot_scene_content( splits, ); } - let (model_pattern_wires, model_hatches, model_wipeouts) = + let (mut model_pattern_wires, model_hatches, model_wipeouts) = scene.viewport_plot_fills(); + if hide_mask_frames { + let mask_handles: std::collections::HashSet = scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Wipeout(_)) + .then_some(entity.common().handle.value().to_string()) + }) + .collect(); + model_pattern_wires.retain(|wire| !mask_handles.contains(&wire.name)); + } let (wires, hatches, wipeouts, splits) = if paper_space_last { let splits = crate::io::pdf_export::PlotGroupSplits { diff --git a/src/command.rs b/src/command.rs index 2faf989e..35f1b904 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1297,7 +1297,11 @@ pub enum CmdResult { boundary_handle: Handle, }, /// Create a wipeout from an existing closed polyline in the active space. - WipeoutFromPolyline(Handle), + /// `erase_source` controls whether the source boundary is consumed. + WipeoutFromPolyline { + handle: Handle, + erase_source: bool, + }, /// Temporarily switch between paper and Model while MVIEW defines a new /// model-space window, keeping the command active. MviewSwitchLayout(String), diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index be4437fb..95711d7b 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -7,6 +7,7 @@ use crate::entities::text_support::{resolve_text_style, text_local_bounds}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; +use crate::scene::model::wire_model::SnapHint; use crate::scene::text::lff; // ── Shared geometry helpers ─────────────────────────────────────────────────── @@ -416,62 +417,93 @@ impl Transformable for RasterImage { // ── Wipeout ─────────────────────────────────────────────────────────────────── +fn wipeout_is_polygonal(wipeout: &Wipeout) -> bool { + wipeout.clipping_enabled + && wipeout.clip_boundary_vertices.len() >= 3 + && matches!( + wipeout.clip_type, + acadrust::entities::WipeoutClipType::Polygonal + ) +} + +fn wipeout_clip_to_world(wipeout: &Wipeout, point: &acadrust::types::Vector2) -> [f64; 3] { + let x = point.x + wipeout.size.x * 0.5; + let y = wipeout.size.y * 0.5 - point.y; + [ + wipeout.insertion_point.x + wipeout.u_vector.x * x + wipeout.v_vector.x * y, + wipeout.insertion_point.y + wipeout.u_vector.y * x + wipeout.v_vector.y * y, + wipeout.insertion_point.z + wipeout.u_vector.z * x + wipeout.v_vector.z * y, + ] +} + +fn wipeout_boundary(wipeout: &Wipeout) -> Vec<[f64; 3]> { + if wipeout_is_polygonal(wipeout) { + wipeout + .clip_boundary_vertices + .iter() + .map(|point| wipeout_clip_to_world(wipeout, point)) + .collect() + } else { + image_corners( + &wipeout.insertion_point, + &wipeout.u_vector, + &wipeout.v_vector, + wipeout.size.x, + wipeout.size.y, + ) + .to_vec() + } +} + +fn wipeout_world_to_clip(wipeout: &Wipeout, world: [f64; 3]) -> Option { + let u = glam::DVec3::new( + wipeout.u_vector.x, + wipeout.u_vector.y, + wipeout.u_vector.z, + ); + let v = glam::DVec3::new( + wipeout.v_vector.x, + wipeout.v_vector.y, + wipeout.v_vector.z, + ); + let delta = glam::DVec3::from(world) + - glam::DVec3::new( + wipeout.insertion_point.x, + wipeout.insertion_point.y, + wipeout.insertion_point.z, + ); + let uu = u.dot(u); + let uv = u.dot(v); + let vv = v.dot(v); + let det = uu * vv - uv * uv; + if det.abs() <= 1e-18 { + return None; + } + let x = (delta.dot(u) * vv - delta.dot(v) * uv) / det; + let y = (delta.dot(v) * uu - delta.dot(u) * uv) / det; + Some(acadrust::types::Vector2::new( + x - wipeout.size.x * 0.5, + wipeout.size.y * 0.5 - y, + )) +} + impl RenderConvertible for Wipeout { fn to_render(&self, _document: &acadrust::CadDocument) -> Option { - let corners = image_corners( - &self.insertion_point, - &self.u_vector, - &self.v_vector, - self.size.x, - self.size.y, - ); - - // If clipping is enabled and there's a polygon boundary, show that. - let pts = if self.clipping_enabled - && self.clip_boundary_vertices.len() >= 3 - && matches!( - self.clip_type, - acadrust::entities::WipeoutClipType::Polygonal - ) { - // Clip vertices are stored in image-pixel space, centred on the - // image (range ±size/2). The image's bottom-left corner sits at - // `insertion_point`, the image-Y axis points DOWN (per DXF - // "v_vector points down the image"), so map: - // x_off = (clip.x + size.x/2) × u_vector - // y_off = (size.y/2 − clip.y) × v_vector ← y flipped - let ox = self.insertion_point.x; - let oy = self.insertion_point.y; - let oz = self.insertion_point.z; - let mut poly: Vec<[f64; 3]> = self - .clip_boundary_vertices - .iter() - .map(|v| { - let cx = v.x + self.size.x * 0.5; - let cy = self.size.y * 0.5 - v.y; - let wx = self.u_vector.x * cx + self.v_vector.x * cy; - let wy = self.u_vector.y * cx + self.v_vector.y * cy; - let wz = self.u_vector.z * cx + self.v_vector.z * cy; - [ox + wx, oy + wy, oz + wz] - }) - .collect(); - // Close the polygon. - if let Some(&first) = poly.first() { - poly.push(first); - } - poly - } else { - // Rectangular boundary — just the border, no diagonals (mask area). - image_wire(corners, false) - }; + let boundary = wipeout_boundary(self); + let mut pts = boundary.clone(); + if let Some(&first) = pts.first() { + pts.push(first); + } Some(RenderEntity { - // Interior pick surface — a wipeout reads as a solid patch, so a - // click anywhere on it should select it. - pick_tris: crate::entities::common::quad_pick_tris(&corners), + pick_tris: crate::entities::mesh::triangulate_planar(&boundary), object: RenderObject::Lines(pts), - snap_pts: vec![], + snap_pts: boundary + .iter() + .map(|point| (glam::DVec3::from(*point), SnapHint::Endpoint)) + .collect(), tangent_geoms: vec![], - key_vertices: corners.to_vec(), + key_vertices: boundary, fill_tris: vec![], }) } @@ -479,33 +511,15 @@ impl RenderConvertible for Wipeout { impl Grippable for Wipeout { fn grips(&self) -> Vec { - // If polygonal clipping is active, expose individual polygon vertices as grips. - let is_polygon = self.clipping_enabled - && self.clip_boundary_vertices.len() >= 3 - && matches!( - self.clip_type, - acadrust::entities::WipeoutClipType::Polygonal - ); - - if is_polygon { - let ox = self.insertion_point.x; - let oy = self.insertion_point.y; - let oz = self.insertion_point.z; - // Same image-pixel-space → WCS mapping as `to_render` so grips - // sit exactly on the rendered polygon vertices. - self.clip_boundary_vertices - .iter() + if wipeout_is_polygonal(self) { + wipeout_boundary(self) + .into_iter() .enumerate() - .map(|(i, v)| { - let cx = v.x + self.size.x * 0.5; - let cy = self.size.y * 0.5 - v.y; - let wx = self.u_vector.x * cx + self.v_vector.x * cy; - let wy = self.u_vector.y * cx + self.v_vector.y * cy; - let wz = self.u_vector.z * cx + self.v_vector.z * cy; + .map(|(i, point)| { if i == 0 { - square_grip(i, glam::DVec3::new(ox + wx, oy + wy, oz + wz)) + square_grip(i, glam::DVec3::from(point)) } else { - center_grip(i, glam::DVec3::new(ox + wx, oy + wy, oz + wz)) + center_grip(i, glam::DVec3::from(point)) } }) .collect() @@ -527,61 +541,84 @@ impl Grippable for Wipeout { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { - let is_polygon = self.clipping_enabled - && self.clip_boundary_vertices.len() >= 3 - && matches!( - self.clip_type, - acadrust::entities::WipeoutClipType::Polygonal - ); - - if is_polygon { - // Move the clicked polygon vertex in world space → back-project to pixel space. - if let Some(v) = self.clip_boundary_vertices.get_mut(grip_id) { - // Compute current world position of this vertex. - let ox = self.insertion_point.x; - let oy = self.insertion_point.y; - let oz = self.insertion_point.z; - let cur_wx = - ox + self.u_vector.x * v.x * self.size.x + self.v_vector.x * v.y * self.size.y; - let cur_wy = - oy + self.u_vector.y * v.x * self.size.x + self.v_vector.y * v.y * self.size.y; - let cur_wz = - oz + self.u_vector.z * v.x * self.size.x + self.v_vector.z * v.y * self.size.y; + if wipeout_is_polygonal(self) { + if let Some(current) = self.clip_boundary_vertices.get(grip_id).cloned() { + let current_world = wipeout_clip_to_world(self, ¤t); let new_w = match apply { GripApply::Translate(d) => [ - cur_wx + d.x as f64, - cur_wy + d.y as f64, - cur_wz + d.z as f64, + current_world[0] + d.x as f64, + current_world[1] + d.y as f64, + current_world[2] + d.z as f64, ], GripApply::Absolute(p) => [p.x as f64, p.y as f64, p.z as f64], }; - // Back-project: solve for pixel coords using u_vector and v_vector. - // In 2D (XY plane): new_w - insertion_point = u_vec * vx * sx + v_vec * vy * sy - let dx = new_w[0] - self.insertion_point.x; - let dy = new_w[1] - self.insertion_point.y; - let ux = self.u_vector.x * self.size.x; - let uy = self.u_vector.y * self.size.x; - let vx = self.v_vector.x * self.size.y; - let vy = self.v_vector.y * self.size.y; - let det = ux * vy - uy * vx; - if det.abs() > 1e-12 { - v.x = (dx * vy - dy * vx) / det; - v.y = (ux * dy - uy * dx) / det; + if let Some(clip) = wipeout_world_to_clip(self, new_w) { + self.clip_boundary_vertices[grip_id] = clip; } } - } else if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; - } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; - } + return; + } + + let corners = wipeout_boundary(self); + let Some(current) = corners.get(grip_id).copied() else { + return; + }; + let target = match apply { + GripApply::Translate(delta) => glam::DVec3::from(current) + delta, + GripApply::Absolute(point) => point, + }; + let u_hat = glam::DVec3::new(self.u_vector.x, self.u_vector.y, self.u_vector.z) + .normalize_or_zero(); + let v_hat = glam::DVec3::new(self.v_vector.x, self.v_vector.y, self.v_vector.z) + .normalize_or_zero(); + if u_hat == glam::DVec3::ZERO || v_hat == glam::DVec3::ZERO { + return; + } + let (width, height, insertion) = match grip_id { + 0 => { + let fixed = glam::DVec3::from(corners[2]); + let delta = fixed - target; + (delta.dot(u_hat), delta.dot(v_hat), target) } + 1 => { + let fixed = glam::DVec3::from(corners[3]); + let delta = target - fixed; + let width = delta.dot(u_hat); + let height = -delta.dot(v_hat); + (width, height, fixed - v_hat * height) + } + 2 => { + let fixed = glam::DVec3::from(corners[0]); + let delta = target - fixed; + (delta.dot(u_hat), delta.dot(v_hat), fixed) + } + 3 => { + let fixed = glam::DVec3::from(corners[1]); + let delta = target - fixed; + let width = -delta.dot(u_hat); + let height = delta.dot(v_hat); + (width, height, fixed - u_hat * width) + } + _ => return, + }; + if width.abs() > 1e-9 && height.abs() > 1e-9 { + self.insertion_point = acadrust::types::Vector3::new( + insertion.x, + insertion.y, + insertion.z, + ); + let sx = self.size.x.abs().max(1e-9); + let sy = self.size.y.abs().max(1e-9); + self.u_vector = acadrust::types::Vector3::new( + u_hat.x * width / sx, + u_hat.y * width / sx, + u_hat.z * width / sx, + ); + self.v_vector = acadrust::types::Vector3::new( + v_hat.x * height / sy, + v_hat.y * height / sy, + v_hat.z * height / sy, + ); } } } @@ -607,76 +644,23 @@ impl PropertyEditable for Wipeout { PropSection { title: t!("Misc").into_owned(), props: vec![ - Property { - label: t!("Show image").into_owned(), - field: "wo_show_image", - value: PropValue::BoolToggle { - field: "wo_show_image", - value: show_image, - }, - }, - Property { - label: t!("Show clipped").into_owned(), - field: "wo_show_clipped", - value: PropValue::BoolToggle { - field: "wo_show_clipped", - value: show_clipped, - }, - }, - Property { - label: t!("Background transparency").into_owned(), - field: "wo_bg_transparency", - value: PropValue::BoolToggle { - field: "wo_bg_transparency", - value: bg_transparency, - }, - }, + ro(t!("Show image").as_ref(), "wo_show_image", if show_image { t!("Yes") } else { t!("No") }), + ro(t!("Show clipped").as_ref(), "wo_show_clipped", if show_clipped { t!("Yes") } else { t!("No") }), + ro(t!("Background transparency").as_ref(), "wo_bg_transparency", if bg_transparency { t!("Yes") } else { t!("No") }), + ], + }, + PropSection { + title: t!("Image Adjust").into_owned(), + props: vec![ + ro(t!("Brightness").as_ref(), "wo_brightness", self.brightness.to_string()), + ro(t!("Contrast").as_ref(), "wo_contrast", self.contrast.to_string()), + ro(t!("Fade").as_ref(), "wo_fade", self.fade.to_string()), ], }, ] } fn apply_geom_prop(&mut self, field: &str, value: &str) { - match field { - "wo_show_image" => { - let on = if value == "toggle" { - !self.flags.contains(acadrust::entities::WipeoutDisplayFlags::SHOW_IMAGE) - } else { - value == "true" - }; - self.set_frame_visible(on); - return; - } - "wo_show_clipped" => { - let on = if value == "toggle" { - !self - .flags - .contains(acadrust::entities::WipeoutDisplayFlags::USE_CLIPPING_BOUNDARY) - } else { - value == "true" - }; - if on { - self.flags |= acadrust::entities::WipeoutDisplayFlags::USE_CLIPPING_BOUNDARY; - } else { - self.flags -= acadrust::entities::WipeoutDisplayFlags::USE_CLIPPING_BOUNDARY; - } - return; - } - "wo_bg_transparency" => { - let on = if value == "toggle" { - !self.flags.contains(acadrust::entities::WipeoutDisplayFlags::TRANSPARENCY_ON) - } else { - value == "true" - }; - if on { - self.flags |= acadrust::entities::WipeoutDisplayFlags::TRANSPARENCY_ON; - } else { - self.flags -= acadrust::entities::WipeoutDisplayFlags::TRANSPARENCY_ON; - } - return; - } - _ => {} - } let Ok(v) = value.trim().parse::() else { return; }; diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 7a0a3976..848f2a59 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -1,8 +1,9 @@ // WIPEOUT command — draw a polygonal mask or derive one from a closed polyline. use acadrust::entities::{Wipeout, WipeoutClipType}; +use acadrust::objects::{ObjectType, WipeoutVariables}; use acadrust::types::{Vector2, Vector3}; -use acadrust::{EntityType, Handle}; +use acadrust::{CadDocument, EntityType, Handle}; use glam::DVec3; use crate::t; @@ -26,6 +27,8 @@ pub struct WipeoutCommand { first: Option, points: Vec, plane: WorkingPlane, + selected_polyline: Option, + frame_mode: i16, } #[derive(Clone, Copy, PartialEq)] @@ -33,15 +36,19 @@ enum WipeoutMode { Draw, Polyline, Rectangular, + Frames, + ErasePolyline, } impl WipeoutCommand { - pub fn new_polygonal() -> Self { + pub fn new_polygonal(frame_mode: i16) -> Self { Self { mode: WipeoutMode::Draw, first: None, points: Vec::new(), plane: WorkingPlane::default(), + selected_polyline: None, + frame_mode: frame_mode.clamp(0, 2), } } @@ -51,6 +58,8 @@ impl WipeoutCommand { first: None, points: Vec::new(), plane: WorkingPlane::default(), + selected_polyline: None, + frame_mode: 1, } } @@ -61,6 +70,8 @@ impl WipeoutCommand { first: None, points: Vec::new(), plane: WorkingPlane::default(), + selected_polyline: None, + frame_mode: 1, } } @@ -94,7 +105,7 @@ impl CadCommand for WipeoutCommand { fn prompt(&self) -> String { match self.mode { WipeoutMode::Draw if self.points.is_empty() => { - t!("WIPEOUT Specify first point or [Polyline]:").into_owned() + t!("WIPEOUT Specify first point or [Frames/Polyline] :").into_owned() } WipeoutMode::Draw => { let n = self.points.len(); @@ -113,18 +124,38 @@ impl CadCommand for WipeoutCommand { WipeoutMode::Rectangular => { t!("WIPEOUT Rectangular Specify opposite corner:").into_owned() } + WipeoutMode::Frames => t!( + "WIPEOUT Frames Enter frame mode [Off/On/DisplayButNotPlot] <%{mode}>:", + mode = self.frame_mode + ) + .into_owned(), + WipeoutMode::ErasePolyline => { + t!("WIPEOUT Polyline Erase source polyline? [Yes/No] :").into_owned() + } } } fn options(&self) -> Vec { match self.mode { WipeoutMode::Draw if self.points.is_empty() => { - vec![CmdOption::new(t!("Polyline").as_ref(), "P")] + vec![ + CmdOption::new(t!("Frames").as_ref(), "F"), + CmdOption::new(t!("Polyline").as_ref(), "P"), + ] } WipeoutMode::Draw => vec![ CmdOption::new(t!("Undo").as_ref(), "U"), CmdOption::new(t!("Close").as_ref(), "C"), ], + WipeoutMode::Frames => vec![ + CmdOption::new(t!("Off").as_ref(), "OFF"), + CmdOption::new(t!("On").as_ref(), "ON"), + CmdOption::new(t!("Display but not plot").as_ref(), "D"), + ], + WipeoutMode::ErasePolyline => vec![ + CmdOption::new(t!("Yes").as_ref(), "Y"), + CmdOption::new(t!("No").as_ref(), "N"), + ], _ => Vec::new(), } } @@ -154,13 +185,23 @@ impl CadCommand for WipeoutCommand { CmdResult::NeedPoint } } - WipeoutMode::Polyline => CmdResult::NeedPoint, + WipeoutMode::Polyline | WipeoutMode::Frames | WipeoutMode::ErasePolyline => { + CmdResult::NeedPoint + } } } fn on_enter(&mut self) -> CmdResult { match self.mode { + WipeoutMode::Draw if self.points.is_empty() => { + self.mode = WipeoutMode::Polyline; + CmdResult::NeedPoint + } WipeoutMode::Draw if self.points.len() >= 3 => self.finish_draw(), + WipeoutMode::Frames => { + CmdResult::Dispatch(format!("WIPEOUTFRAME {}", self.frame_mode)) + } + WipeoutMode::ErasePolyline => self.finish_polyline(false), _ => CmdResult::Cancel, } } @@ -177,33 +218,55 @@ impl CadCommand for WipeoutCommand { if handle.is_null() { CmdResult::NeedPoint } else { - CmdResult::WipeoutFromPolyline(handle) + self.selected_polyline = Some(handle); + self.mode = WipeoutMode::ErasePolyline; + CmdResult::NeedPoint } } fn wants_text_input(&self) -> bool { - self.mode == WipeoutMode::Draw + matches!( + self.mode, + WipeoutMode::Draw | WipeoutMode::Frames | WipeoutMode::ErasePolyline + ) } fn point_step_accepts_keywords(&self) -> bool { - self.mode == WipeoutMode::Draw + matches!( + self.mode, + WipeoutMode::Draw | WipeoutMode::Frames | WipeoutMode::ErasePolyline + ) } fn on_text_input(&mut self, text: &str) -> Option { - if self.mode != WipeoutMode::Draw { - return None; - } - match text.trim().to_ascii_uppercase().as_str() { - "P" | "POLYLINE" if self.points.is_empty() => { - self.mode = WipeoutMode::Polyline; - Some(CmdResult::NeedPoint) - } - "U" | "UNDO" if !self.points.is_empty() => { - Some(self.undo_point()) - } - "C" | "CLOSE" if self.points.len() >= 3 => { - Some(self.finish_draw()) - } + let text = text.trim().to_ascii_uppercase(); + match self.mode { + WipeoutMode::Draw => match text.as_str() { + "F" | "FRAMES" if self.points.is_empty() => { + self.mode = WipeoutMode::Frames; + Some(CmdResult::NeedPoint) + } + "P" | "POLYLINE" if self.points.is_empty() => { + self.mode = WipeoutMode::Polyline; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if !self.points.is_empty() => Some(self.undo_point()), + "C" | "CLOSE" if self.points.len() >= 3 => Some(self.finish_draw()), + _ => None, + }, + WipeoutMode::Frames => match text.as_str() { + "0" | "OFF" => Some(CmdResult::Dispatch("WIPEOUTFRAME 0".into())), + "1" | "ON" => Some(CmdResult::Dispatch("WIPEOUTFRAME 1".into())), + "2" | "D" | "DISPLAYBUTNOTPLOT" => { + Some(CmdResult::Dispatch("WIPEOUTFRAME 2".into())) + } + _ => None, + }, + WipeoutMode::ErasePolyline => match text.as_str() { + "Y" | "YES" => Some(self.finish_polyline(true)), + "N" | "NO" => Some(self.finish_polyline(false)), + _ => None, + }, _ => None, } } @@ -264,11 +327,61 @@ impl CadCommand for WipeoutCommand { false, )) } - WipeoutMode::Polyline => None, + WipeoutMode::Polyline | WipeoutMode::Frames | WipeoutMode::ErasePolyline => None, } } } +impl WipeoutCommand { + fn finish_polyline(&self, erase_source: bool) -> CmdResult { + self.selected_polyline.map_or(CmdResult::Cancel, |handle| { + CmdResult::WipeoutFromPolyline { + handle, + erase_source, + } + }) + } +} + +pub(crate) fn wipeout_frame_mode(document: &CadDocument) -> i16 { + document + .objects + .values() + .find_map(|object| match object { + ObjectType::WipeoutVariables(value) => Some(value.display_frame), + _ => None, + }) + .unwrap_or(1) + .clamp(0, 2) +} + +pub(crate) fn set_wipeout_frame_mode(document: &mut CadDocument, mode: i16) { + let mode = mode.clamp(0, 2); + if let Some(value) = document.objects.values_mut().find_map(|object| match object { + ObjectType::WipeoutVariables(value) => Some(value), + _ => None, + }) { + value.display_frame = mode; + return; + } + + let owner = crate::scene::annotative::root_named_dict_handle(document); + let handle = document.allocate_handle(); + let mut value = WipeoutVariables::new(); + value.handle = handle; + value.owner = owner; + value.display_frame = mode; + document + .objects + .insert(handle, ObjectType::WipeoutVariables(value)); + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&owner) { + dictionary + .entries + .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_WIPEOUT_VARS")); + dictionary.add_entry("ACAD_WIPEOUT_VARS", handle); + } +} + fn make_rect_wipeout(first: DVec3, second: DVec3) -> EntityType { let corner1 = Vector3::new( first.x.min(second.x), @@ -300,11 +413,64 @@ fn clean_boundary(points: &[DVec3]) -> Vec { clean } +fn boundary_is_simple(points: &[DVec3]) -> bool { + fn orient(a: DVec3, b: DVec3, c: DVec3) -> f64 { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) + } + fn on_segment(a: DVec3, b: DVec3, p: DVec3) -> bool { + let eps = 1e-9; + orient(a, b, p).abs() <= eps + && p.x >= a.x.min(b.x) - eps + && p.x <= a.x.max(b.x) + eps + && p.y >= a.y.min(b.y) - eps + && p.y <= a.y.max(b.y) + eps + } + fn intersects(a: DVec3, b: DVec3, c: DVec3, d: DVec3) -> bool { + let ab_c = orient(a, b, c); + let ab_d = orient(a, b, d); + let cd_a = orient(c, d, a); + let cd_b = orient(c, d, b); + ((ab_c > 0.0 && ab_d < 0.0) || (ab_c < 0.0 && ab_d > 0.0)) + && ((cd_a > 0.0 && cd_b < 0.0) || (cd_a < 0.0 && cd_b > 0.0)) + || on_segment(a, b, c) + || on_segment(a, b, d) + || on_segment(c, d, a) + || on_segment(c, d, b) + } + + let count = points.len(); + for first in 0..count { + let first_next = (first + 1) % count; + for second in first + 1..count { + let second_next = (second + 1) % count; + if first == second + || first_next == second + || second_next == first + || (first == 0 && second_next == 0) + { + continue; + } + if intersects( + points[first], + points[first_next], + points[second], + points[second_next], + ) { + return false; + } + } + } + true +} + fn make_poly_wipeout(points: &[DVec3]) -> Option { let points = clean_boundary(points); if points.len() < 3 { return None; } + if !boundary_is_simple(&points) { + return None; + } let z = points[0].z; if points.iter().any(|point| (point.z - z).abs() > 1e-7) { return None; @@ -364,50 +530,27 @@ fn explicitly_closed(points: &[DVec3]) -> bool { points.len() >= 4 && same_point(points[0], *points.last().unwrap()) } -fn sample_2d_polyline( - vertices: &[([f64; 2], f64)], - elevation: f64, - normal: (f64, f64, f64), -) -> Vec { - if vertices.len() < 3 { - return Vec::new(); - } - let to_wcs = |point: [f64; 2]| { - let point = crate::scene::view::transform::ocs_point_to_wcs( - (point[0], point[1], elevation), - normal, - ); - DVec3::new(point.0, point.1, point.2) - }; - let mut points = vec![to_wcs(vertices[0].0)]; - for index in 0..vertices.len() { - let (start, bulge) = vertices[index]; - let end_index = (index + 1) % vertices.len(); - let end = vertices[end_index].0; - if let Some(arc) = - crate::entities::common::BulgeArc::from_bulge(start, end, bulge) - { - let steps = ((arc.sweep.abs() / std::f64::consts::TAU * 64.0).ceil() - as usize) - .clamp(4, 64); - for step in 1..=steps { - if end_index == 0 && step == steps { - break; - } - points.push(to_wcs(arc.sample(step as f64 / steps as f64))); - } - } else if end_index != 0 { - points.push(to_wcs(end)); - } - } - points -} - /// Build a wipeout boundary from a picked closed polyline without consuming -/// the source entity. Bulged 2D segments are sampled into the polygonal mask. +/// the source entity. Only straight, closed 2D polygonal boundaries qualify. pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { - let points = match entity { + fn from_ocs(points: &[DVec3], normal: (f64, f64, f64)) -> Option { + let origin = crate::scene::view::transform::ocs_point_to_wcs((0.0, 0.0, 0.0), normal); + let x = crate::scene::view::transform::ocs_point_to_wcs((1.0, 0.0, 0.0), normal); + let y = crate::scene::view::transform::ocs_point_to_wcs((0.0, 1.0, 0.0), normal); + let origin = DVec3::new(origin.0, origin.1, origin.2); + let plane = WorkingPlane::new( + origin, + DVec3::new(x.0, x.1, x.2) - origin, + DVec3::new(y.0, y.1, y.2) - origin, + ); + make_poly_wipeout(points).map(|entity| plane.place_entity(entity)) + } + + match entity { EntityType::LwPolyline(polyline) => { + if polyline.vertices.iter().any(|vertex| vertex.bulge.abs() > 1e-12) { + return None; + } let raw: Vec = polyline .vertices .iter() @@ -416,27 +559,15 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { if !polyline.is_closed && !explicitly_closed(&raw) { return None; } - let vertices: Vec<([f64; 2], f64)> = polyline - .vertices - .iter() - .map(|vertex| { - ( - [vertex.location.x, vertex.location.y], - vertex.bulge, - ) - }) - .collect(); - sample_2d_polyline( - &vertices, - polyline.elevation, - ( - polyline.normal.x, - polyline.normal.y, - polyline.normal.z, - ), + from_ocs( + &raw, + (polyline.normal.x, polyline.normal.y, polyline.normal.z), ) } EntityType::Polyline2D(polyline) => { + if polyline.vertices.iter().any(|vertex| vertex.bulge.abs() > 1e-12) { + return None; + } let raw: Vec = polyline .vertices .iter() @@ -451,63 +582,13 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { if !polyline.is_closed() && !explicitly_closed(&raw) { return None; } - let vertices: Vec<([f64; 2], f64)> = polyline - .vertices - .iter() - .map(|vertex| { - ( - [vertex.location.x, vertex.location.y], - vertex.bulge, - ) - }) - .collect(); - sample_2d_polyline( - &vertices, - polyline.elevation, - ( - polyline.normal.x, - polyline.normal.y, - polyline.normal.z, - ), + from_ocs( + &raw, + (polyline.normal.x, polyline.normal.y, polyline.normal.z), ) } - EntityType::Polyline(polyline) => { - let points: Vec = polyline - .vertices - .iter() - .map(|vertex| { - DVec3::new( - vertex.location.x, - vertex.location.y, - vertex.location.z, - ) - }) - .collect(); - if !polyline.is_closed() && !explicitly_closed(&points) { - return None; - } - points - } - EntityType::Polyline3D(polyline) => { - let points: Vec = polyline - .vertices - .iter() - .map(|vertex| { - DVec3::new( - vertex.position.x, - vertex.position.y, - vertex.position.z, - ) - }) - .collect(); - if !polyline.flags.closed && !explicitly_closed(&points) { - return None; - } - points - } _ => return None, - }; - make_poly_wipeout(&points) + } } // ── Autocomplete registry ───────────────────────────────── diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index d758a476..81d6c68b 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -69,7 +69,7 @@ impl CadModule for InsertModule { ), ( "FRAMES2", - "Frames & Print", + "Frames On, Not Plotted", IconKind::Svg(include_bytes!( "../../../assets/icons/underlay_frames.svg" )), diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 2d9e42c6..0b3a17a3 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1208,6 +1208,22 @@ pub(crate) fn tessellate_entity( if b.text_verts.is_empty() { set_wire_aabb(b, aabb); } + if matches!(e, EntityType::Wipeout(_)) { + b.depth_override = Some(0.5); + } + } + + // A hidden mask frame remains selectable and appears while selected, but + // contributes no visible line work during normal display. The interior + // pick triangles remain intact. + if matches!(e, EntityType::Wipeout(_)) + && crate::modules::draw::draw::wipeout::wipeout_frame_mode(document) == 0 + && !sel + { + for base in &mut bases { + base.points.clear(); + base.points_low.clear(); + } } // Complex linetypes (with embedded shapes / text) expand the *base*