Complete wipeout workflow and editing

This commit is contained in:
ramox81 2026-08-20 17:56:11 +03:00
commit b7819481f0
14 changed files with 513 additions and 339 deletions

4
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -515,6 +515,10 @@ inventory::submit!(crate::command::CommandRegistration {
"TEXTQLTY",
"SORTENTS",
"XCLIPFRAME",
"WIPEOUTFRAME",
"FRAMES0",
"FRAMES1",
"FRAMES2",
"HALOGAP",
"TRACEWID",
"SKETCHINC",

View file

@ -3,6 +3,9 @@ use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
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 <ByLayer|ByBlock|1-255|name> — 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::<i16>() {
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),

View file

@ -168,7 +168,21 @@ fn plot_scene_content(
Vec<crate::scene::model::hatch_model::HatchModel>,
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<String> = 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<String> = 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 {

View file

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

View file

@ -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<acadrust::types::Vector2> {
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<RenderEntity> {
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<GripDef> {
// 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, &current);
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::<f64>() else {
return;
};

View file

@ -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<DVec3>,
points: Vec<DVec3>,
plane: WorkingPlane,
selected_polyline: Option<Handle>,
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] <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] <No>:").into_owned()
}
}
}
fn options(&self) -> Vec<CmdOption> {
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<CmdResult> {
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<DVec3> {
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<EntityType> {
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<DVec3> {
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<EntityType> {
let points = match entity {
fn from_ocs(points: &[DVec3], normal: (f64, f64, f64)) -> Option<EntityType> {
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<DVec3> = polyline
.vertices
.iter()
@ -416,27 +559,15 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option<EntityType> {
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<DVec3> = polyline
.vertices
.iter()
@ -451,63 +582,13 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option<EntityType> {
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<DVec3> = 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<DVec3> = 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 ─────────────────────────────────

View file

@ -69,7 +69,7 @@ impl CadModule for InsertModule {
),
(
"FRAMES2",
"Frames & Print",
"Frames On, Not Plotted",
IconKind::Svg(include_bytes!(
"../../../assets/icons/underlay_frames.svg"
)),

View file

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