Merge remote-tracking branch 'origin/main' into feat/viewport-dimensions

# Conflicts:
#	src/app/command_driver.rs
#	src/scene/mod.rs
This commit is contained in:
Christopher Overbeck committed 2026-09-15 13:53:12 -05:00
commit 369e0c9fc5
89 files changed
+27311 -11037

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ rustflags = [
[target.x86_64-pc-windows-gnu] [target.x86_64-pc-windows-gnu]
rustflags = [ rustflags = [
"-Clink-arg=-Wl,--stack,16777216", "-Clink-arg=-Wl,--stack,16777216",
"-Clink-arg=-Wl,--exclude-all-symbols",
] ]
[target.x86_64-pc-windows-msvc] [target.x86_64-pc-windows-msvc]
Generated
+4 -3
View File
@@ -83,7 +83,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.5.5" version = "0.5.5"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=4d7b359b0571f9b6e0f85a62a21c67508722b83e#4d7b359b0571f9b6e0f85a62a21c67508722b83e" source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"anyhow", "anyhow",
@@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
dependencies = [ dependencies = [
"acadrust", "acadrust",
"cavalier_contours", "cavalier_contours",
@@ -930,7 +930,7 @@ dependencies = [
[[package]] [[package]]
name = "cadkernel-constraints" name = "cadkernel-constraints"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
dependencies = [ dependencies = [
"nalgebra", "nalgebra",
] ]
@@ -4601,6 +4601,7 @@ dependencies = [
"base64", "base64",
"flate2", "flate2",
"getrandom 0.3.4", "getrandom 0.3.4",
"image",
"lopdf", "lopdf",
"serde", "serde",
"serde_derive", "serde_derive",
+5 -5
View File
@@ -32,9 +32,9 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17" rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] } clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11" env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f", features = ["acis", "offset"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f" } cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
@@ -60,7 +60,7 @@ fluent-syntax = "0.12"
naga = { version = "27", features = ["wgsl-in"] } naga = { version = "27", features = ["wgsl-in"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
@@ -85,7 +85,7 @@ meshopt = "0.6.2"
i18n-embed = { version = "0.16", features = ["desktop-requester"] } i18n-embed = { version = "0.16", features = ["desktop-requester"] }
rayon = "1" rayon = "1"
open = "5" open = "5"
printpdf = { version = "0.9.1", default-features = false } printpdf = { version = "0.9.1", default-features = false, features = ["images"] }
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 20h18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 6v11M12 10v7M17 4v13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M12 3v18" stroke="#ffffff" stroke-width="1.8" stroke-dasharray="2 2"/>
<path d="M6 7h12M8 12h8M4 17h16" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M4 3v18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 7h11M7 12h7M7 17h13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M20 3v18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M6 7h11M10 12h7M4 17h13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 4h18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 7v11M12 7v7M17 7v13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 12h18" stroke="#ffffff" stroke-width="1.8" stroke-dasharray="2 2"/>
<path d="M7 6v12M12 8v8M17 4v16" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+135 -38
View File
@@ -48,16 +48,20 @@ use acadrust::{CadDocument, DxfReader, DxfWriter, EntityType};
use glam::{DVec3, Mat4, Vec3}; use glam::{DVec3, Mat4, Vec3};
use iced::{Color, Point, Rectangle}; use iced::{Color, Point, Rectangle};
use OpenCADStudio::scene::parametric_constraints::{
ConstraintKind, ParametricRef, ParametricScope,
};
use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit}; use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit};
use OpenCADStudio::scene::pick::interaction_index::InteractionIndex; use OpenCADStudio::scene::pick::interaction_index::InteractionIndex;
use OpenCADStudio::scene::pick::selection_state::SelectionState; use OpenCADStudio::scene::pick::selection_state::SelectionState;
use OpenCADStudio::scene::pipeline::wire_arena::partition_wires; use OpenCADStudio::scene::pipeline::wire_arena::partition_wires;
use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
use OpenCADStudio::scene::view::camera::Camera; use OpenCADStudio::scene::view::camera::Camera;
use OpenCADStudio::scene::{ChangeKind, Scene}; use OpenCADStudio::scene::{ChangeKind, Scene};
use OpenCADStudio::snap::Snapper; use OpenCADStudio::snap::Snapper;
use OpenCADStudio::ui::icons::{self, CHECK}; use OpenCADStudio::ui::icons::{self, CHECK};
use OpenCADStudio::ui::overlay::{grid_segments, GridCanvasState, GridKey, GridParams, GridStyle, should_reuse}; use OpenCADStudio::ui::overlay::{
grid_segments, should_reuse, GridCanvasState, GridKey, GridParams, GridStyle,
};
use OpenCADStudio::ui::properties::LinetypeItem; use OpenCADStudio::ui::properties::LinetypeItem;
use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon};
@@ -109,8 +113,8 @@ impl Default for BenchmarkRunner {
impl BenchmarkRunner { impl BenchmarkRunner {
pub fn new() -> Self { pub fn new() -> Self {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let quick_mode = args.iter().any(|a| a == "--quick" || a == "-q") let quick_mode =
|| env::var("CAD_BENCH_QUICK").is_ok(); args.iter().any(|a| a == "--quick" || a == "-q") || env::var("CAD_BENCH_QUICK").is_ok();
let filter = args let filter = args
.windows(2) .windows(2)
@@ -220,22 +224,37 @@ impl BenchmarkRunner {
/// Print summary table to stdout and persist JSON report. /// Print summary table to stdout and persist JSON report.
pub fn finish(&self) { pub fn finish(&self) {
let baseline_map: HashMap<String, BenchmarkMetric> = if let Some(ref path) = self.baseline_path { let baseline_map: HashMap<String, BenchmarkMetric> =
if let Ok(content) = std::fs::read_to_string(path) { if let Some(ref path) = self.baseline_path {
if let Ok(report) = serde_json::from_str::<BenchmarkSuiteReport>(&content) { if let Ok(content) = std::fs::read_to_string(path) {
println!("\nLoaded baseline from '{}' ({} metrics)", path.display(), report.metrics.len()); if let Ok(report) = serde_json::from_str::<BenchmarkSuiteReport>(&content) {
report.metrics.into_iter().map(|m| (m.name.clone(), m)).collect() println!(
"\nLoaded baseline from '{}' ({} metrics)",
path.display(),
report.metrics.len()
);
report
.metrics
.into_iter()
.map(|m| (m.name.clone(), m))
.collect()
} else {
eprintln!(
"Warning: Failed to parse baseline JSON at '{}'",
path.display()
);
HashMap::new()
}
} else { } else {
eprintln!("Warning: Failed to parse baseline JSON at '{}'", path.display()); eprintln!(
"Warning: Could not read baseline file at '{}'",
path.display()
);
HashMap::new() HashMap::new()
} }
} else { } else {
eprintln!("Warning: Could not read baseline file at '{}'", path.display());
HashMap::new() HashMap::new()
} };
} else {
HashMap::new()
};
println!("\n========================================================================================================================"); println!("\n========================================================================================================================");
println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT "); println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT ");
@@ -301,7 +320,11 @@ impl BenchmarkRunner {
timestamp: chrono_now(), timestamp: chrono_now(),
os: env::consts::OS.to_string(), os: env::consts::OS.to_string(),
arch: env::consts::ARCH.to_string(), arch: env::consts::ARCH.to_string(),
profile: if cfg!(debug_assertions) { "debug".to_string() } else { "release".to_string() }, profile: if cfg!(debug_assertions) {
"debug".to_string()
} else {
"release".to_string()
},
is_quick_mode: self.quick_mode, is_quick_mode: self.quick_mode,
metrics: self.results.clone(), metrics: self.results.clone(),
}; };
@@ -311,9 +334,16 @@ impl BenchmarkRunner {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
if let Err(e) = std::fs::write(&self.output_path, json) { if let Err(e) = std::fs::write(&self.output_path, json) {
eprintln!("Error saving metrics to '{}': {}", self.output_path.display(), e); eprintln!(
"Error saving metrics to '{}': {}",
self.output_path.display(),
e
);
} else { } else {
println!("Saved complete performance metrics JSON to: {}\n", self.output_path.display()); println!(
"Saved complete performance metrics JSON to: {}\n",
self.output_path.display()
);
} }
} }
} }
@@ -381,9 +411,27 @@ fn bench_scene_entity_ingestion(runner: &mut BenchmarkRunner) {
let y = (i / 50) as f64 * 50.0 + 6000.0; let y = (i / 50) as f64 * 50.0 + 6000.0;
let mut pl = LwPolyline::new(); let mut pl = LwPolyline::new();
pl.vertices = vec![ pl.vertices = vec![
LwVertex { location: Vector2::new(x, y), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, LwVertex {
LwVertex { location: Vector2::new(x + 20.0, y), bulge: 0.5, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, location: Vector2::new(x, y),
LwVertex { location: Vector2::new(x + 20.0, y + 20.0), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 2 }, bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
},
LwVertex {
location: Vector2::new(x + 20.0, y),
bulge: 0.5,
start_width: 0.0,
end_width: 0.0,
vertex_id: 1,
},
LwVertex {
location: Vector2::new(x + 20.0, y + 20.0),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 2,
},
]; ];
scene.add_entity(EntityType::LwPolyline(pl)); scene.add_entity(EntityType::LwPolyline(pl));
} }
@@ -462,8 +510,20 @@ fn bench_analytical_tessellation_zoom(runner: &mut BenchmarkRunner) {
let mut pline = LwPolyline::new(); let mut pline = LwPolyline::new();
pline.is_closed = true; pline.is_closed = true;
pline.vertices = vec![ pline.vertices = vec![
LwVertex { location: Vector2::new(x, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, LwVertex {
LwVertex { location: Vector2::new(x + 30.0, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, location: Vector2::new(x, y),
bulge: 1.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
},
LwVertex {
location: Vector2::new(x + 30.0, y),
bulge: 1.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 1,
},
]; ];
scene.add_entity(EntityType::LwPolyline(pline)); scene.add_entity(EntityType::LwPolyline(pline));
} }
@@ -610,7 +670,12 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) {
let view_rot = Mat4::IDENTITY; let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(1000.0, 1000.0, 2000.0); let eye = DVec3::new(1000.0, 1000.0, 2000.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 1920.0,
height: 1080.0,
};
let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let n_queries = if runner.quick_mode { 200 } else { 1_000 };
let mut click_samples = Vec::with_capacity(5); let mut click_samples = Vec::with_capacity(5);
@@ -620,7 +685,15 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) {
for q in 0..n_queries { for q in 0..n_queries {
let cx = ((q * 37) % 1920) as f32; let cx = ((q * 37) % 1920) as f32;
let cy = ((q * 53) % 1080) as f32; let cy = ((q * 53) % 1080) as f32;
let hit = click_hit(Point::new(cx, cy), wires.as_slice(), view_rot, eye, bounds, false, 8.0); let hit = click_hit(
Point::new(cx, cy),
wires.as_slice(),
view_rot,
eye,
bounds,
false,
8.0,
);
black_box(hit); black_box(hit);
} }
let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64);
@@ -716,7 +789,12 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) {
let view_rot = Mat4::IDENTITY; let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(500.0, 500.0, 1000.0); let eye = DVec3::new(500.0, 500.0, 1000.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 1920.0,
height: 1080.0,
};
let grid_origin = Vec3::ZERO; let grid_origin = Vec3::ZERO;
let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z);
@@ -729,10 +807,7 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) {
let wx = (q as f64 * 7.3) % 2500.0; let wx = (q as f64 * 7.3) % 2500.0;
let wy = (q as f64 * 11.7) % 2500.0; let wy = (q as f64 * 11.7) % 2500.0;
let cursor_world = DVec3::new(wx, wy, 0.0); let cursor_world = DVec3::new(wx, wy, 0.0);
let cursor_screen = Point::new( let cursor_screen = Point::new(((q * 23) % 1920) as f32, ((q * 31) % 1080) as f32);
((q * 23) % 1920) as f32,
((q * 31) % 1080) as f32,
);
let hit = snapper.snap( let hit = snapper.snap(
cursor_world, cursor_world,
@@ -798,11 +873,23 @@ fn bench_geometric_constraint_solving(runner: &mut BenchmarkRunner) {
lines.push((h1, h2, h3)); lines.push((h1, h2, h3));
} }
let cs = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let cs = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
for &(h1, h2, h3) in &lines { for &(h1, h2, h3) in &lines {
cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h1), SketchRef::whole(h2)], None); cs.add(
cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h2), SketchRef::whole(h3)], None); ConstraintKind::Perpendicular,
cs.add(ConstraintKind::Parallel, vec![SketchRef::whole(h1), SketchRef::whole(h3)], None); vec![ParametricRef::whole(h1), ParametricRef::whole(h2)],
None,
);
cs.add(
ConstraintKind::Perpendicular,
vec![ParametricRef::whole(h2), ParametricRef::whole(h3)],
None,
);
cs.add(
ConstraintKind::Parallel,
vec![ParametricRef::whole(h1), ParametricRef::whole(h3)],
None,
);
} }
let target = lines[run_idx % lines.len()].0; let target = lines[run_idx % lines.len()].0;
@@ -858,7 +945,9 @@ fn bench_dxf_io_throughput(runner: &mut BenchmarkRunner) {
for _ in 0..runs { for _ in 0..runs {
let t0 = Instant::now(); let t0 = Instant::now();
let bytes = DxfWriter::new(&doc).write_to_vec().expect("DXF write succeeds"); let bytes = DxfWriter::new(&doc)
.write_to_vec()
.expect("DXF write succeeds");
write_samples.push(t0.elapsed().as_secs_f64() * 1000.0); write_samples.push(t0.elapsed().as_secs_f64() * 1000.0);
dxf_bytes = bytes; dxf_bytes = bytes;
} }
@@ -1095,8 +1184,12 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) {
// ── 1. Uncached grid geometry generation ── // ── 1. Uncached grid geometry generation ──
if runner.should_run("ui_grid_geometry_uncached") { if runner.should_run("ui_grid_geometry_uncached") {
for _ in 0..10 { for _ in 0..10 {
let _ = black_box(grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1)); let _ = black_box(grid_segments(
let _ = black_box(grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2)); view_rot1, eye1, bounds1, step1, origin1, axes1, limits1,
));
let _ = black_box(grid_segments(
view_rot2, eye2, bounds2, step2, origin2, axes2, limits2,
));
} }
let n = if runner.quick_mode { 20 } else { 100 }; let n = if runner.quick_mode { 20 } else { 100 };
@@ -1172,7 +1265,11 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) {
let t0 = Instant::now(); let t0 = Instant::now();
let mut hit_count = 0u32; let mut hit_count = 0u32;
for _ in 0..n { for _ in 0..n {
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); let key = GridKey::from_grids(
black_box(&grids),
black_box(canvas_bounds),
GridStyle::default(),
);
if should_reuse(state.key.borrow().as_ref(), &key) { if should_reuse(state.key.borrow().as_ref(), &key) {
hit_count += 1; hit_count += 1;
} }
+2 -2
View File
@@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed # Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine # `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply. # crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", optional = true, features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only). # Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true } interprocess = { version = "2", optional = true }
@@ -38,7 +38,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
cargo-lock = "11" cargo-lock = "11"
# acadrust is scanned at build time to generate the embedded type registry. # acadrust is scanned at build time to generate the embedded type registry.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
serde_json = "1" serde_json = "1"
+1 -1
View File
@@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
bincode = "1.3" bincode = "1.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
+1 -1
View File
@@ -10,4 +10,4 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
+1 -1
View File
@@ -12,4 +12,4 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e" } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" }
+11 -1
View File
@@ -112,13 +112,23 @@ codesign --verify --strict --verbose=2 "$APP"
echo "==> dmg" echo "==> dmg"
DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg"
rm -f "$DMG" rm -f "$DMG"
# Stage the app next to the install-location shortcut (#769).
STAGING="$DIST/dmg-staging"
rm -rf "$STAGING"
mkdir -p "$STAGING"
cp -R "$APP" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
if hdiutil create -volname "Open CAD Studio" -srcfolder "$APP" -ov -format UDZO "$DMG"; then if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDZO "$DMG"; then
break break
fi fi
rm -f "$DMG"
echo "hdiutil failed (attempt $i), retrying..." >&2 echo "hdiutil failed (attempt $i), retrying..." >&2
sleep 3 sleep 3
done done
rm -rf "$STAGING"
[ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; }
if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then
+77 -19
View File
@@ -18,7 +18,7 @@
use std::io::{BufRead, Write}; use std::io::{BufRead, Write};
use std::path::PathBuf; use std::path::PathBuf;
use serde_json::{Value, json}; use serde_json::{json, Value};
use super::OpenCADStudio; use super::OpenCADStudio;
@@ -317,8 +317,10 @@ impl OpenCADStudio {
let i = self.active_tab; let i = self.active_tab;
self.tabs[i].scene.clear(); self.tabs[i].scene.clear();
self.tabs[i].scene.document = doc; self.tabs[i].scene.document = doc;
self.tabs[i].scene.load_sketch_constraints_from_document();
self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].scene.load_named_parameters_from_document();
self.tabs[i]
.scene
.load_parametric_constraints_from_document();
self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from);
crate::app::style_ops::ensure_standard_styles( crate::app::style_ops::ensure_standard_styles(
&mut self.tabs[i].scene.document, &mut self.tabs[i].scene.document,
@@ -1399,7 +1401,8 @@ mod tests {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();
let stale = acadrust::Handle::from(9999); let stale = acadrust::Handle::from(9999);
app.tabs[app.active_tab].scene.solid_models.insert( app.tabs[app.active_tab].scene.solid_models.insert(
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), stale,
cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
); );
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
"ocs_automation_finalize_test_{}.dxf", "ocs_automation_finalize_test_{}.dxf",
@@ -1415,7 +1418,8 @@ mod tests {
let mut corrupt = acadrust::entities::Circle::new(); let mut corrupt = acadrust::entities::Circle::new();
corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0); corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0);
corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle
doc.add_entity(acadrust::EntityType::Circle(corrupt)).unwrap(); doc.add_entity(acadrust::EntityType::Circle(corrupt))
.unwrap();
let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version) let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version)
.expect("save a document containing a corrupt entity"); .expect("save a document containing a corrupt entity");
std::fs::write(&path, bytes).unwrap(); std::fs::write(&path, bytes).unwrap();
@@ -1423,19 +1427,29 @@ mod tests {
let p = path.to_string_lossy().replace('\\', "\\\\"); let p = path.to_string_lossy().replace('\\', "\\\\");
let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#)); let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#));
assert_eq!(result["ok"], true, "{}", result["error"]); assert_eq!(result["ok"], true, "{}", result["error"]);
assert_eq!(result["total"], 1, "the corrupt circle must not survive the open"); assert_eq!(
assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics"); result["total"], 1,
"the corrupt circle must not survive the open"
);
assert_eq!(
result["purged"], 1,
"the purge count must be reported, matching the UI open path's diagnostics"
);
let i = app.active_tab; let i = app.active_tab;
assert!(!app.tabs[i].scene.solid_models.contains_key(&stale)); assert!(!app.tabs[i].scene.solid_models.contains_key(&stale));
assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent()); assert_eq!(
app.tabs[i].scene.material_base_dir.as_deref(),
path.parent()
);
assert!( assert!(
app.tabs[i].scene.document.source_path.is_some(), app.tabs[i].scene.document.source_path.is_some(),
"automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)" "automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)"
); );
app.tabs[i].scene.solid_models.insert( app.tabs[i].scene.solid_models.insert(
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), stale,
cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
); );
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
assert!(app.tabs[i].scene.solid_models.is_empty()); assert!(app.tabs[i].scene.solid_models.is_empty());
@@ -1523,26 +1537,41 @@ mod tests {
// Start LINE // Start LINE
let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandInput("LINE".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Type M2P // Type M2P
let _ = app.update(Message::CommandInput("M2P".to_string())); let _ = app.update(Message::CommandInput("M2P".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("MTP")
);
assert!(app.tabs[0].suspended_cmd.is_some()); assert!(app.tabs[0].suspended_cmd.is_some());
assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Point 1: 0,0 // Point 1: 0,0
let _ = app.update(Message::CommandInput("0,0".to_string())); let _ = app.update(Message::CommandInput("0,0".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("MTP")
);
// Point 2: 10,20 // Point 2: 10,20
let _ = app.update(Message::CommandInput("10,20".to_string())); let _ = app.update(Message::CommandInput("10,20".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
// MTP should have finished and restored LINE, with midpoint (5, 10, 0) // MTP should have finished and restored LINE, with midpoint (5, 10, 0)
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
assert!(app.tabs[0].suspended_cmd.is_none()); assert!(app.tabs[0].suspended_cmd.is_none());
assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0)));
@@ -1561,7 +1590,10 @@ mod tests {
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
let _ = app.update(Message::SnapOverrideMtp); let _ = app.update(Message::SnapOverrideMtp);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("MTP")
);
assert_eq!( assert_eq!(
app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()),
Some("LINE") Some("LINE")
@@ -1577,17 +1609,26 @@ mod tests {
// Start LINE // Start LINE
let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandInput("LINE".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Type MTP // Type MTP
let _ = app.update(Message::CommandInput("MTP".to_string())); let _ = app.update(Message::CommandInput("MTP".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("MTP")
);
// Escape during MTP // Escape during MTP
let _ = app.update(Message::CommandEscape); let _ = app.update(Message::CommandEscape);
// Parent LINE must be restored, not cancelled! // Parent LINE must be restored, not cancelled!
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
assert!(app.tabs[0].suspended_cmd.is_none()); assert!(app.tabs[0].suspended_cmd.is_none());
// Escape again cancels LINE // Escape again cancels LINE
@@ -1605,7 +1646,10 @@ mod tests {
// Start LINE // Start LINE
let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandInput("LINE".to_string()));
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Simulate typing 'm', '2', 'p' character by character // Simulate typing 'm', '2', 'p' character by character
let _ = app.update(Message::CommandAppendChar("m".to_string())); let _ = app.update(Message::CommandAppendChar("m".to_string()));
@@ -1620,6 +1664,20 @@ mod tests {
// Submit triggers MTP // Submit triggers MTP
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("MTP")
);
}
#[test]
fn test_open_color_dropdown() {
use crate::app::Message;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let wid = app.main_window.unwrap_or_else(iced::window::Id::unique);
let _ = app.view(wid);
let _ = app.update(Message::ToggleRibbonDropdown("PROP_COLOR".to_string()));
let _ = app.view(wid);
} }
} }
+1867 -786
View File
File diff suppressed because it is too large. Load diff
+358 -221
View File
@@ -29,12 +29,7 @@ impl OpenCADStudio {
_ => None, _ => None,
}) })
.collect(); .collect();
let cmd_obj = MlineCommand::with_styles( let cmd_obj = MlineCommand::with_styles(styles, style_name, scale, justification);
styles,
style_name,
scale,
justification,
);
self.command_line.push_info(&cmd_obj.prompt()); self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
} }
@@ -133,25 +128,29 @@ impl OpenCADStudio {
if rest.is_empty() { if rest.is_empty() {
// List attributes. // List attributes.
if ins.attributes.is_empty() { if ins.attributes.is_empty() {
self.command_line.push_output(crate::tf!( self.command_line.push_output(
" Insert {:x}: no attributes.", crate::tf!(" Insert {:x}: no attributes.", sh.value())
sh.value() .as_ref(),
).as_ref()); );
} else { } else {
for attr in &ins.attributes { for attr in &ins.attributes {
self.command_line.push_output(crate::tf!( self.command_line.push_output(
" [{tag}] = {val}", crate::tf!(
tag = attr.tag, " [{tag}] = {val}",
val = attr.get_value() tag = attr.tag,
).as_ref()); val = attr.get_value()
)
.as_ref(),
);
} }
} }
} }
} }
} }
if !found_any { if !found_any {
self.command_line self.command_line.push_error(
.push_error(crate::t!("ATTEDIT: no Insert entities in selection.").as_ref()); crate::t!("ATTEDIT: no Insert entities in selection.").as_ref(),
);
} }
// If tag + value supplied, mutate attributes. // If tag + value supplied, mutate attributes.
if parts.len() == 2 && !parts[0].is_empty() { if parts.len() == 2 && !parts[0].is_empty() {
@@ -179,13 +178,17 @@ impl OpenCADStudio {
} }
if changed > 0 { if changed > 0 {
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
self.command_line.push_output(crate::tf!( self.command_line.push_output(
crate::tf!(
"ATTEDIT: updated {changed} attribute(s) [{tag_up}] = {new_val}." "ATTEDIT: updated {changed} attribute(s) [{tag_up}] = {new_val}."
).as_ref()); )
.as_ref(),
);
} else { } else {
self.command_line.push_error(crate::tf!( self.command_line.push_error(
"ATTEDIT: tag '{tag_up}' not found in selection." crate::tf!("ATTEDIT: tag '{tag_up}' not found in selection.")
).as_ref()); .as_ref(),
);
} }
} }
} }
@@ -243,9 +246,10 @@ impl OpenCADStudio {
} }
} }
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
self.command_line.push_output(crate::tf!( self.command_line.push_output(
"ATTDISP {sub}: {count} attribute definition(s) updated." crate::tf!("ATTDISP {sub}: {count} attribute definition(s) updated.")
).as_ref()); .as_ref(),
);
} }
_ => { _ => {
self.command_line self.command_line
@@ -388,8 +392,10 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
None => { None => {
self.command_line self.command_line.push_info(
.push_info(crate::t!("ARC Continue No previous line or arc to continue.").as_ref()); crate::t!("ARC Continue No previous line or arc to continue.")
.as_ref(),
);
} }
} }
} }
@@ -528,16 +534,26 @@ impl OpenCADStudio {
if count > 0 { if count > 0 {
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
} }
self.command_line self.command_line.push_output(&crate::tf!(
.push_output(&crate::tf!("CENTERRESET: {count} center object(s) updated.")); "CENTERRESET: {count} center object(s) updated."
));
} }
"CENTERREASSOCIATE" => { "CENTERREASSOCIATE" => {
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
let mark_targets: Vec<_> = handles.iter().copied().filter(|handle| { let mark_targets: Vec<_> = handles
let Some(acadrust::EntityType::Line(line)) = self.tabs[i].scene.document.get_entity(*handle) else { return false; }; .iter()
acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data).is_some() .copied()
}).collect(); .filter(|handle| {
let Some(acadrust::EntityType::Line(line)) =
self.tabs[i].scene.document.get_entity(*handle)
else {
return false;
};
acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data)
.is_some()
})
.collect();
if mark_targets.len() == 1 && handles.len() == 1 { if mark_targets.len() == 1 && handles.len() == 1 {
use crate::modules::draw::draw::dimcenter::CenterMarkReassociateCommand; use crate::modules::draw::draw::dimcenter::CenterMarkReassociateCommand;
let new_cmd = CenterMarkReassociateCommand::new(mark_targets[0]); let new_cmd = CenterMarkReassociateCommand::new(mark_targets[0]);
@@ -546,25 +562,35 @@ impl OpenCADStudio {
return Some(self.finish_dispatch(cmd)); return Some(self.finish_dispatch(cmd));
} }
self.push_undo_snapshot(i, "CENTERREASSOCIATE"); self.push_undo_snapshot(i, "CENTERREASSOCIATE");
let count = self.tabs[i].scene.set_centerline_association(&handles, true) let count = self.tabs[i]
+ self.tabs[i].scene.set_center_mark_association(&handles, true); .scene
.set_centerline_association(&handles, true)
+ self.tabs[i]
.scene
.set_center_mark_association(&handles, true);
if count > 0 { if count > 0 {
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
} }
self.command_line self.command_line.push_output(&crate::tf!(
.push_output(&crate::tf!("CENTERREASSOCIATE: {count} center object(s) associated.")); "CENTERREASSOCIATE: {count} center object(s) associated."
));
} }
"CENTERDISASSOCIATE" => { "CENTERDISASSOCIATE" => {
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
self.push_undo_snapshot(i, "CENTERDISASSOCIATE"); self.push_undo_snapshot(i, "CENTERDISASSOCIATE");
let count = self.tabs[i].scene.set_centerline_association(&handles, false) let count = self.tabs[i]
+ self.tabs[i].scene.set_center_mark_association(&handles, false); .scene
.set_centerline_association(&handles, false)
+ self.tabs[i]
.scene
.set_center_mark_association(&handles, false);
if count > 0 { if count > 0 {
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
} }
self.command_line self.command_line.push_output(&crate::tf!(
.push_output(&crate::tf!("CENTERDISASSOCIATE: {count} center object(s) detached.")); "CENTERDISASSOCIATE: {count} center object(s) detached."
));
} }
"DIMCENTER" => { "DIMCENTER" => {
@@ -602,15 +628,20 @@ impl OpenCADStudio {
self.command_line.push_info(&selection.prompt()); self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection)); self.tabs[i].active_cmd = Some(Box::new(selection));
} else { } else {
let replacements = self.tabs[i].scene.selected_entities().into_iter() let replacements = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
.filter_map(|(handle, entity)| ReverseCommand::reversed(entity) .filter_map(|(handle, entity)| {
.map(|reversed| (handle, vec![reversed]))) ReverseCommand::reversed(entity)
.map(|reversed| (handle, vec![reversed]))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !replacements.is_empty() { if !replacements.is_empty() {
return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany( return Some(self.apply_cmd_result(
replacements, Vec::new(), crate::command::CmdResult::ReplaceMany(replacements, Vec::new()),
))); ));
} }
} }
} }
@@ -766,9 +797,7 @@ impl OpenCADStudio {
glam::DVec3::from_array(storage.x_axis), glam::DVec3::from_array(storage.x_axis),
glam::DVec3::from_array(storage.y_axis), glam::DVec3::from_array(storage.y_axis),
); );
let boundary_sources = self.tabs[i] let boundary_sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6);
.scene
.boundary_sources_on_plane(plane, 1.0e-6);
let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6);
let selected = self.tabs[i] let selected = self.tabs[i]
.scene .scene
@@ -776,20 +805,14 @@ impl OpenCADStudio {
.into_iter() .into_iter()
.map(|(handle, _)| handle) .map(|(handle, _)| handle)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let inherited = selected let inherited = selected.iter().find_map(|handle| {
.iter() let model = self.tabs[i].scene.hatches.get(handle)?.clone();
.find_map(|handle| { let common = self.tabs[i].scene.document.get_entity(*handle)?.common();
let model = self.tabs[i].scene.hatches.get(handle)?.clone(); Some((model, common.color.clone(), common.transparency))
let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); });
Some((model, common.color.clone(), common.transparency)) let new_cmd =
}); HatchCommand::new(outlines, boundary_sources, selected, inherited, plane)
let new_cmd = HatchCommand::new( .with_origin(self.tabs[i].scene.document.hatch_origin());
outlines,
boundary_sources,
selected,
inherited,
plane,
).with_origin(self.tabs[i].scene.document.hatch_origin());
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
self.refresh_area_preview(i); self.refresh_area_preview(i);
@@ -804,11 +827,11 @@ impl OpenCADStudio {
if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() { if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() {
let entity = self.tabs[i].scene.document.get_entity(h); let entity = self.tabs[i].scene.document.get_entity(h);
let annotative = entity.is_some_and(|entity| { let annotative = entity.is_some_and(|entity| {
crate::scene::annotative::is_annotative( crate::scene::annotative::is_annotative(
&self.tabs[i].scene.document, &self.tabs[i].scene.document,
entity, entity,
) )
}); });
let (scale, angle) = match entity { let (scale, angle) = match entity {
Some(acadrust::EntityType::Hatch(hatch)) => ( Some(acadrust::EntityType::Hatch(hatch)) => (
hatch.pattern_scale as f32, hatch.pattern_scale as f32,
@@ -822,15 +845,28 @@ impl OpenCADStudio {
scale, scale,
angle, angle,
annotative, annotative,
).with_appearance(entity,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); )
.with_appearance(
entity,
self.tabs[i].scene.document.header.current_entity_color,
self.tabs[i].scene.document.current_entity_transparency(),
)
.with_origin(self.tabs[i].scene.document.hatch_origin());
self.command_line.push_info(&cmd.prompt()); self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} else { } else {
self.command_line self.command_line.push_error(
.push_error(crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref()); crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref(),
);
} }
} else { } else {
let cmd = HatcheditCommand::new().with_appearance(None,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); let cmd = HatcheditCommand::new()
.with_appearance(
None,
self.tabs[i].scene.document.header.current_entity_color,
self.tabs[i].scene.document.current_entity_transparency(),
)
.with_origin(self.tabs[i].scene.document.hatch_origin());
self.command_line.push_info(&cmd.prompt()); self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} }
@@ -854,9 +890,7 @@ impl OpenCADStudio {
} else { } else {
crate::command::WorkingPlane::default() crate::command::WorkingPlane::default()
}; };
let sources = self.tabs[i] let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6);
.scene
.boundary_sources_on_plane(plane, 1.0e-6);
let selected = self.tabs[i] let selected = self.tabs[i]
.scene .scene
.selected_entities() .selected_entities()
@@ -895,7 +929,8 @@ impl OpenCADStudio {
SplineCommand::control_vertices() SplineCommand::control_vertices()
} else { } else {
SplineCommand::new() SplineCommand::new()
}.with_document(&self.tabs[i].scene.document); }
.with_document(&self.tabs[i].scene.document);
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
@@ -935,8 +970,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} else { } else {
use crate::modules::draw::modify::mirror::MirrorCommand; use crate::modules::draw::modify::mirror::MirrorCommand;
let (wires, text_ghosts) = let (wires, text_ghosts) = self.tabs[i].scene.mirror_preview_parts(&handles);
self.tabs[i].scene.mirror_preview_parts(&handles);
let mirror_text = self.tabs[i].scene.document.header.mirror_text; let mirror_text = self.tabs[i].scene.document.header.mirror_text;
let new_cmd = MirrorCommand::new(handles, wires, text_ghosts, mirror_text); let new_cmd = MirrorCommand::new(handles, wires, text_ghosts, mirror_text);
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
@@ -986,10 +1020,10 @@ impl OpenCADStudio {
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else { } else {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let dimensional = cmd.starts_with("DC"); let dimensional = cmd.starts_with("DC");
let visible = !matches!(cmd, "GCHIDE" | "DCHIDE"); let visible = !matches!(cmd, "GCHIDE" | "DCHIDE");
let count = self.tabs[i].scene.set_sketch_constraint_visibility( let count = self.tabs[i].scene.set_parametric_constraint_visibility(
scope, scope,
Some(&handles), Some(&handles),
dimensional, dimensional,
@@ -1002,19 +1036,19 @@ impl OpenCADStudio {
} }
"GCRESET" => { "GCRESET" => {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let count = self.tabs[i] let count = self.tabs[i]
.scene .scene
.set_sketch_constraint_visibility(scope, None, false, true); .set_parametric_constraint_visibility(scope, None, false, true);
self.command_line self.command_line
.push_output(format!("{} constraint indicator(s) reset.", count).as_str()); .push_output(format!("{} constraint indicator(s) reset.", count).as_str());
} }
"GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => { "GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let dimensional = cmd.starts_with("DC"); let dimensional = cmd.starts_with("DC");
let visible = matches!(cmd, "GCSHOWALL" | "DCSHOWALL"); let visible = matches!(cmd, "GCSHOWALL" | "DCSHOWALL");
let count = self.tabs[i].scene.set_sketch_constraint_visibility( let count = self.tabs[i].scene.set_parametric_constraint_visibility(
scope, scope,
None, None,
dimensional, dimensional,
@@ -1032,11 +1066,8 @@ impl OpenCADStudio {
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else { } else {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let Some(before) = self.tabs[i] let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned()
.scene
.sketch_constraint_set(scope)
.cloned()
else { else {
self.command_line.push_output("No constraints found."); self.command_line.push_output("No constraints found.");
return None; return None;
@@ -1061,8 +1092,8 @@ impl OpenCADStudio {
let pending = self.begin_undo(i, "Delete constraints", handles.len(), true); let pending = self.begin_undo(i, "Delete constraints", handles.len(), true);
self.tabs[i] self.tabs[i]
.scene .scene
.record_undo_sketch_constraints_before(scope, before); .record_undo_parametric_constraints_before(scope, before);
let set = self.tabs[i].scene.sketch_constraint_set_mut(scope); let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
for id in &ids { for id in &ids {
set.remove(*id); set.remove(*id);
} }
@@ -1082,53 +1113,86 @@ impl OpenCADStudio {
} }
} }
"CONSTRAINTSETTINGS" => {
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
self.auto_constrain_selected_row = 0;
self.auto_constrain_distance_input =
format!("{}", self.auto_constrain_settings.distance_tolerance);
self.auto_constrain_angle_input =
format!("{}", self.auto_constrain_settings.angle_tolerance_deg);
self.active_modal = Some(crate::app::ModalKind::AutoConstrainSettings);
}
"AUTOCONSTRAIN" => { "AUTOCONSTRAIN" => {
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() { if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand; use crate::modules::draw::select::SelectObjectsCommand;
let sel = SelectObjectsCommand::new(cmd); let sel = SelectObjectsCommand::auto_constrain(cmd);
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else { } else {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let inferred = self.tabs[i] let inferred = self.tabs[i]
.scene .scene
.inferred_sketch_constraints(scope, &handles); .inferred_parametric_constraints(
scope,
&handles,
&self.auto_constrain_settings,
);
if inferred.is_empty() { if inferred.is_empty() {
self.command_line self.command_line.push_output(
.push_output("No supported geometric relations were found."); format!(
return None; "0 constraint(s) applied to {} object(s).",
} handles.len()
let before = self.tabs[i] )
.scene .as_str(),
.sketch_constraint_set(scope) );
.cloned() self.tabs[i].scene.deselect_all();
.unwrap_or_else(|| { self.refresh_selected_grips();
crate::scene::sketch_constraints::SketchConstraintSet::new(scope) } else {
}); let before = self.tabs[i]
let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); .scene
self.tabs[i] .parametric_constraint_set(scope)
.scene .cloned()
.record_undo_sketch_constraints_before(scope, before); .unwrap_or_else(|| {
let count = inferred.len(); crate::scene::parametric_constraints::ParametricConstraintSet::new(
for (kind, refs) in inferred { scope,
)
});
let pending = self.begin_undo(i, "Auto constrain", handles.len(), true);
self.tabs[i] self.tabs[i]
.scene .scene
.sketch_constraint_set_mut(scope) .record_undo_parametric_constraints_before(scope, before);
.add(kind, refs, None); let count = inferred.len();
} for (kind, refs) in inferred {
let changes: Vec<_> = handles self.tabs[i]
.iter() .scene
.copied() .parametric_constraint_set_mut(scope)
.map(|handle| (handle, crate::scene::ChangeKind::Modified)) .add(kind, refs, None);
.collect(); }
self.tabs[i].scene.bump_entities(&changes); let changes: Vec<_> = handles
self.tabs[i].dirty = true; .iter()
self.refresh_properties(); .copied()
self.command_line .map(|handle| (handle, crate::scene::ChangeKind::Modified))
.push_output(format!("{} constraint(s) applied.", count).as_str()); .collect();
if let Some(pd) = pending { self.tabs[i].scene.bump_entities_with_parametric_policy(
self.commit_undo_delta(i, pd); &changes,
&[],
self.constraint_solve_mode,
);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line.push_output(
format!(
"{} constraint(s) applied to {} object(s).",
count,
handles.len()
)
.as_str(),
);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} }
} }
} }
@@ -1142,14 +1206,13 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else { } else {
let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else { let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else {
self.command_line.push_output( self.command_line
"Select one open spline and one open target curve.", .push_output("Select one open spline and one open target curve.");
);
return None; return None;
}; };
use crate::command::CmdResult; use crate::command::CmdResult;
use crate::scene::sketch_constraints::ConstraintKind; use crate::scene::parametric_constraints::ConstraintKind;
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Smooth, kind: ConstraintKind::Smooth,
refs, refs,
driving_param: None, driving_param: None,
@@ -1166,7 +1229,7 @@ impl OpenCADStudio {
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else { } else {
let scope = self.tabs[i].current_sketch_scope(); let scope = self.tabs[i].current_parametric_scope();
let conversions: Vec<_> = handles let conversions: Vec<_> = handles
.iter() .iter()
.copied() .copied()
@@ -1179,23 +1242,24 @@ impl OpenCADStudio {
)?; )?;
self.tabs[i] self.tabs[i]
.scene .scene
.validate_sketch_constraint(kind, &refs, Some(&value)) .validate_parametric_constraint(kind, &refs, Some(&value))
.ok()?; .ok()?;
Some((handle, kind, refs, value)) Some((handle, kind, refs, value))
}) })
.collect(); .collect();
if conversions.is_empty() { if conversions.is_empty() {
self.command_line.push_output( self.command_line
"No supported associative dimensions were selected.", .push_output("No supported associative dimensions were selected.");
);
return None; return None;
} }
let before = self.tabs[i] let before = self.tabs[i]
.scene .scene
.sketch_constraint_set(scope) .parametric_constraint_set(scope)
.cloned() .cloned()
.unwrap_or_else(|| { .unwrap_or_else(|| {
crate::scene::sketch_constraints::SketchConstraintSet::new(scope) crate::scene::parametric_constraints::ParametricConstraintSet::new(
scope,
)
}); });
let mut touched: Vec<_> = conversions let mut touched: Vec<_> = conversions
.iter() .iter()
@@ -1215,12 +1279,13 @@ impl OpenCADStudio {
); );
self.tabs[i] self.tabs[i]
.scene .scene
.record_undo_sketch_constraints_before(scope, before); .record_undo_parametric_constraints_before(scope, before);
for (_, kind, refs, value) in conversions { for (_, kind, refs, value) in conversions {
self.tabs[i] self.tabs[i].scene.parametric_constraint_set_mut(scope).add(
.scene kind,
.sketch_constraint_set_mut(scope) refs,
.add(kind, refs, Some(value)); Some(value),
);
} }
self.tabs[i].scene.erase_entities(&dimensions); self.tabs[i].scene.erase_entities(&dimensions);
let changes: Vec<_> = touched let changes: Vec<_> = touched
@@ -1251,28 +1316,35 @@ impl OpenCADStudio {
.push_output("Select exactly one entity, then run this constraint again."); .push_output("Select exactly one entity, then run this constraint again.");
} else { } else {
use crate::command::CmdResult; use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
let (kind, label) = match cmd { let (kind, label) = match cmd {
"HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"),
"VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"),
_ => (ConstraintKind::Fixed, "Fixed constraint"), _ => (ConstraintKind::Fixed, "Fixed constraint"),
}; };
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind, kind,
refs: vec![SketchRef::whole(handles[0])], refs: vec![ParametricRef::whole(handles[0])],
driving_param: None, driving_param: None,
label, label,
})); }));
} }
} }
"CCONSTRAINT" => { "CCONSTRAINT" | "GCCOINCIDENT" => {
use crate::modules::parametric::CoincidentConstraintCommand; use crate::modules::parametric::CoincidentConstraintCommand;
let new_cmd = CoincidentConstraintCommand::new(); let new_cmd = CoincidentConstraintCommand::new();
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
"GEOMCONSTRAINT" => {
use crate::modules::parametric::GeomConstraintCommand;
let new_cmd = GeomConstraintCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"EDCONSTRAINT" => { "EDCONSTRAINT" => {
use crate::modules::parametric::EqualDistanceConstraintCommand; use crate::modules::parametric::EqualDistanceConstraintCommand;
let new_cmd = EqualDistanceConstraintCommand::new(); let new_cmd = EqualDistanceConstraintCommand::new();
@@ -1280,7 +1352,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT"
| "NRCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() { if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand; use crate::modules::draw::select::SelectObjectsCommand;
@@ -1293,18 +1366,23 @@ impl OpenCADStudio {
); );
} else { } else {
use crate::command::CmdResult; use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
let (kind, label) = match cmd { let (kind, label) = match cmd {
"PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"),
"QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), "QCONSTRAINT" => {
(ConstraintKind::Perpendicular, "Perpendicular constraint")
}
"TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"),
"LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"),
"NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"),
_ => (ConstraintKind::Equal, "Equal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"),
}; };
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind, kind,
refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])], refs: vec![
ParametricRef::whole(handles[0]),
ParametricRef::whole(handles[1]),
],
driving_param: None, driving_param: None,
label, label,
})); }));
@@ -1319,13 +1397,18 @@ impl OpenCADStudio {
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else if handles.len() != 2 { } else if handles.len() != 2 {
self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again."); self.command_line.push_output(
"Select exactly two circles/arcs, then run this constraint again.",
);
} else { } else {
use crate::command::CmdResult; use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Concentric, kind: ConstraintKind::Concentric,
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])], refs: vec![
ParametricRef::center(handles[0]),
ParametricRef::center(handles[1]),
],
driving_param: None, driving_param: None,
label: "Concentric constraint", label: "Concentric constraint",
})); }));
@@ -1345,10 +1428,14 @@ impl OpenCADStudio {
); );
} else { } else {
use crate::command::CmdResult; use crate::command::CmdResult;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Symmetric, kind: ConstraintKind::Symmetric,
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])], refs: vec![
ParametricRef::center(handles[0]),
ParametricRef::center(handles[1]),
ParametricRef::whole(handles[2]),
],
driving_param: None, driving_param: None,
label: "Symmetric constraint", label: "Symmetric constraint",
})); }));
@@ -1368,13 +1455,26 @@ impl OpenCADStudio {
); );
} else { } else {
use crate::modules::parametric::PointOnEntityConstraintCommand; use crate::modules::parametric::PointOnEntityConstraintCommand;
use crate::scene::sketch_constraints::ConstraintKind; use crate::scene::parametric_constraints::ConstraintKind;
let (name, kind, label) = match cmd { let (name, kind, label) = match cmd {
"CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"), "CPCONSTRAINT" => (
"MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"), "CPCONSTRAINT",
_ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"), ConstraintKind::CenterPoint,
"Center point constraint",
),
"MPCONSTRAINT" => (
"MPCONSTRAINT",
ConstraintKind::Midpoint,
"Midpoint constraint",
),
_ => (
"OCCONSTRAINT",
ConstraintKind::PointOnCurve,
"Point on curve constraint",
),
}; };
let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label); let new_cmd =
PointOnEntityConstraintCommand::new(name, kind, handles[0], label);
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
@@ -1389,8 +1489,9 @@ impl OpenCADStudio {
self.command_line.push_info(&sel.prompt()); self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel)); self.tabs[i].active_cmd = Some(Box::new(sel));
} else if handles.len() != 1 { } else if handles.len() != 1 {
self.command_line self.command_line.push_output(
.push_output("Select exactly one line or circle, then run this constraint again."); "Select exactly one line or circle, then run this constraint again.",
);
} else { } else {
use crate::modules::parametric::{DistanceConstraintCommand, DistanceMode}; use crate::modules::parametric::{DistanceConstraintCommand, DistanceMode};
let (command_name, mode) = match cmd { let (command_name, mode) = match cmd {
@@ -1412,7 +1513,9 @@ impl OpenCADStudio {
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
None => self.command_line.push_output("Select a line or a circle for a distance constraint."), None => self
.command_line
.push_output("Select a line or a circle for a distance constraint."),
} }
} }
} }
@@ -1445,7 +1548,9 @@ impl OpenCADStudio {
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
None => self.command_line.push_output("Select two lines for an angle constraint."), None => self
.command_line
.push_output("Select two lines for an angle constraint."),
} }
} }
} }
@@ -1457,8 +1562,7 @@ impl OpenCADStudio {
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
"BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" "BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" | "TORUS" => {
| "TORUS" => {
use crate::modules::model::primitive_cmd::PrimitiveCommand; use crate::modules::model::primitive_cmd::PrimitiveCommand;
let new_cmd = PrimitiveCommand::new(cmd); let new_cmd = PrimitiveCommand::new(cmd);
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
@@ -1625,54 +1729,69 @@ impl OpenCADStudio {
continue; continue;
}; };
let mut region = Region::new(); let mut region = Region::new();
region.point_of_reference = Vector3::new( region.point_of_reference =
plane.origin[0], Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
plane.origin[1],
plane.origin[2],
);
region.common.layer = self.tabs[i].active_layer.clone(); region.common.layer = self.tabs[i].active_layer.clone();
regions.push((region, body)); regions.push((region, body));
sources.push(*handle); sources.push(*handle);
} }
} }
// Open inputs must share one geometric plane, independently of the UCS. // Open inputs must share one geometric plane, independently of the UCS.
let open_curves: Vec<_> = self.tabs[i].scene.selected_entities().iter() let open_curves: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(handle, _)| !sources.contains(handle)) .filter(|(handle, _)| !sources.contains(handle))
.filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity)) .filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity))
.collect(); .collect();
let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6); let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6);
let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none(); let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none();
if let Some(plane) = open_plane { if let Some(plane) = open_plane {
let working_plane = crate::command::WorkingPlane::new( let working_plane = crate::command::WorkingPlane::new(
glam::DVec3::from_array(plane.origin), glam::DVec3::from_array(plane.origin),
glam::DVec3::from_array(plane.x_axis), glam::DVec3::from_array(plane.x_axis),
glam::DVec3::from_array(plane.y_axis), glam::DVec3::from_array(plane.y_axis),
);
let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene
.selected_entities().iter().map(|(handle, _)| *handle).collect();
let mut boundary_sources = self.tabs[i].scene
.boundary_sources_on_plane(working_plane, 1.0e-6);
boundary_sources.retain(|handle, _| {
selected_handles.contains(handle) && !sources.contains(handle)
});
for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) {
let paths = crate::scene::exact_hatch_paths(
std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6,
); );
let Some(path) = paths.first() else { continue; }; let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i]
let Some(curves) = path.edges.iter() .scene
.map(crate::entities::hatch::edge_curve).collect::<Option<Vec<_>>>() .selected_entities()
else { continue; }; .iter()
let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) .map(|(handle, _)| *handle)
else { continue; }; .collect();
let mut region = Region::new(); let mut boundary_sources = self.tabs[i]
region.point_of_reference = Vector3::new( .scene
plane.origin[0], plane.origin[1], plane.origin[2], .boundary_sources_on_plane(working_plane, 1.0e-6);
); boundary_sources.retain(|handle, _| {
region.common.layer = self.tabs[i].active_layer.clone(); selected_handles.contains(handle) && !sources.contains(handle)
regions.push((region, body)); });
sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) {
} let paths = crate::scene::exact_hatch_paths(
std::slice::from_ref(&ring),
&[true],
&boundary_sources,
1.0e-6,
);
let Some(path) = paths.first() else {
continue;
};
let Some(curves) = path
.edges
.iter()
.map(crate::entities::hatch::edge_curve)
.collect::<Option<Vec<_>>>()
else {
continue;
};
let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) else {
continue;
};
let mut region = Region::new();
region.point_of_reference =
Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]);
region.common.layer = self.tabs[i].active_layer.clone();
regions.push((region, body));
sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources));
}
} else if open_plane_rejected { } else if open_plane_rejected {
self.command_line.push_error( self.command_line.push_error(
"REGION: open objects must form coplanar, noncollinear boundaries.", "REGION: open objects must form coplanar, noncollinear boundaries.",
@@ -1720,8 +1839,10 @@ impl OpenCADStudio {
let sides = nums.get(2).map(|s| *s as usize).unwrap_or(4); let sides = nums.get(2).map(|s| *s as usize).unwrap_or(4);
return Some(self.solid_pyramid(nums[0], nums[1], sides)); return Some(self.solid_pyramid(nums[0], nums[1], sides));
} }
self.command_line self.command_line.push_info(
.push_info(crate::t!("Usage: PYRAMID <radius> <height> [sides] (default 4 sides)").as_ref()); crate::t!("Usage: PYRAMID <radius> <height> [sides] (default 4 sides)")
.as_ref(),
);
} }
// SECTION [X|Y|Z] <value> — draw the cross-section outline of the solid. // SECTION [X|Y|Z] <value> — draw the cross-section outline of the solid.
@@ -1756,7 +1877,10 @@ impl OpenCADStudio {
match parts.get(val_idx).and_then(|s| s.parse::<f64>().ok()) { match parts.get(val_idx).and_then(|s| s.parse::<f64>().ok()) {
Some(v) => return Some(self.solid_section(axis, v)), Some(v) => return Some(self.solid_section(axis, v)),
None => self.command_line.push_info( None => self.command_line.push_info(
crate::t!("Usage: SECTION [X|Y|Z] <value> (cross-sections the selected solid)").as_ref(), crate::t!(
"Usage: SECTION [X|Y|Z] <value> (cross-sections the selected solid)"
)
.as_ref(),
), ),
} }
} }
@@ -1909,7 +2033,8 @@ impl OpenCADStudio {
match angle { match angle {
Some(a) => return Some(self.solid_rotate3d(axis, a)), Some(a) => return Some(self.solid_rotate3d(axis, a)),
None => self.command_line.push_info( None => self.command_line.push_info(
crate::t!("Usage: 3DROTATE [X|Y|Z] <angle> (rotates the selected solid)").as_ref(), crate::t!("Usage: 3DROTATE [X|Y|Z] <angle> (rotates the selected solid)")
.as_ref(),
), ),
} }
} }
@@ -1951,11 +2076,12 @@ impl OpenCADStudio {
} }
}) })
}); });
let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { let (centre, radius) =
let min = glam::DVec3::from_array(min); bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| {
let max = glam::DVec3::from_array(max); let min = glam::DVec3::from_array(min);
((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) let max = glam::DVec3::from_array(max);
}); ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65)
});
(targets, centre, radius, scene.active_gaze_dir().as_dvec3()) (targets, centre, radius, scene.active_gaze_dir().as_dvec3())
}; };
let c = SliceCommand::new(targets, view_normal, centre, radius); let c = SliceCommand::new(targets, view_normal, centre, radius);
@@ -1990,7 +2116,10 @@ impl OpenCADStudio {
return Some(self.slice_selected(plane, Some(side))); return Some(self.slice_selected(plane, Some(side)));
} }
None => self.command_line.push_info( None => self.command_line.push_info(
crate::t!("Usage: SLICE [X|Y|Z] <value> [TOP|BOTTOM] (cuts the selected solid)").as_ref(), crate::t!(
"Usage: SLICE [X|Y|Z] <value> [TOP|BOTTOM] (cuts the selected solid)"
)
.as_ref(),
), ),
} }
} }
@@ -2006,8 +2135,7 @@ impl OpenCADStudio {
let styles = scene.document.text_styles.iter().cloned().collect(); let styles = scene.document.text_styles.iter().cloned().collect();
(defaults, styles, annotation_multiplier) (defaults, styles, annotation_multiplier)
}; };
let new_cmd = let new_cmd = TextCommand::with_defaults(defaults, styles, annotation_multiplier);
TextCommand::with_defaults(defaults, styles, annotation_multiplier);
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }
@@ -2092,9 +2220,15 @@ mod region_tests {
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
let i = app.active_tab; let i = app.active_tab;
let sources: Vec<_> = app.tabs[i].scene.document.entities() let sources: Vec<_> = app.tabs[i]
.scene
.document
.entities()
.map(|entity| { .map(|entity| {
(entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) (
entity.common().handle,
matches!(entity, acadrust::EntityType::Circle(_)),
)
}) })
.collect(); .collect();
assert_eq!(sources.len(), 2); assert_eq!(sources.len(), 2);
@@ -2110,7 +2244,10 @@ mod region_tests {
!delete_sources || !is_circle, !delete_sources || !is_circle,
); );
} }
let region_count = app.tabs[i].scene.document.entities() let region_count = app.tabs[i]
.scene
.document
.entities()
.filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) .filter(|entity| matches!(entity, acadrust::EntityType::Region(_)))
.count(); .count();
assert_eq!(region_count, 1); assert_eq!(region_count, 1);
+175
View File
@@ -1,6 +1,82 @@
use super::*; use super::*;
impl OpenCADStudio { impl OpenCADStudio {
/// Align every selected object or complete group by one edge or center.
fn align_selected_bounds(&mut self, i: usize, command: &str) {
use crate::command::EntityTransform;
use cadkernel::space::BoundsAlignment;
use glam::DVec3;
let (alignment, label) = match command {
"ALIGNLEFT" => (BoundsAlignment::Left, "Align Left"),
"ALIGNHCENTER" => (BoundsAlignment::HorizontalCenter, "Align Horizontal Centers"),
"ALIGNRIGHT" => (BoundsAlignment::Right, "Align Right"),
"ALIGNTOP" => (BoundsAlignment::Top, "Align Top"),
"ALIGNVCENTER" => (BoundsAlignment::VerticalCenter, "Align Vertical Centers"),
"ALIGNBOTTOM" => (BoundsAlignment::Bottom, "Align Bottom"),
_ => return,
};
let handles: Vec<_> = self.tabs[i]
.scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
if handles.len() < 2 {
self.command_line
.push_info(crate::t!("Select objects").as_ref());
return;
}
let bounds: Vec<_> = self.tabs[i]
.scene
.selected_object_units(&handles)
.into_iter()
.filter_map(|unit| {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for handle in &unit {
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
let bb = entity.as_entity().bounding_box();
min_x = min_x.min(bb.min.x);
min_y = min_y.min(bb.min.y);
max_x = max_x.max(bb.max.x);
max_y = max_y.max(bb.max.y);
}
let bounds = [min_x, min_y, max_x, max_y];
bounds
.iter()
.all(|value| value.is_finite())
.then_some((unit, bounds))
})
.collect();
if bounds.len() < 2 {
return;
}
let extents: Vec<_> = bounds.iter().map(|(_, bounds)| *bounds).collect();
let offsets = cadkernel::space::align_aabbs_2d(&extents, alignment)
.expect("validated at least two finite bounds");
let pending = self.begin_undo(i, label, handles.len(), true);
for ((unit, _), [dx, dy]) in bounds.iter().zip(offsets) {
self.tabs[i].scene.transform_entities(
unit,
&EntityTransform::Translate(DVec3::new(dx, dy, 0.0)),
);
}
self.tabs[i].dirty = true;
self.refresh_properties();
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
self.command_line.push_output(
crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref(),
);
}
pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> { pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd { match cmd {
"3DORBIT" => { "3DORBIT" => {
@@ -980,6 +1056,11 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} }
"ALIGNLEFT" | "ALIGNHCENTER" | "ALIGNRIGHT" | "ALIGNTOP" | "ALIGNVCENTER"
| "ALIGNBOTTOM" => {
self.align_selected_bounds(i, cmd);
}
"LENGTHEN" => { "LENGTHEN" => {
use crate::modules::draw::modify::lengthen::LengthenCommand; use crate::modules::draw::modify::lengthen::LengthenCommand;
let cmd = LengthenCommand::new(); let cmd = LengthenCommand::new();
@@ -2211,3 +2292,97 @@ mod flatten_tests {
); );
} }
} }
#[cfg(test)]
mod align_selected_bounds_tests {
use super::*;
use acadrust::entities::{EntityType, Line};
use acadrust::types::Vector3;
fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> acadrust::Handle {
app.tabs[app.active_tab]
.scene
.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x1, y1, 0.0),
Vector3::new(x2, y2, 0.0),
)))
}
fn line_start_x(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 {
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
Some(EntityType::Line(l)) => l.start.x.min(l.end.x),
other => panic!("expected a Line, got {other:?}"),
}
}
fn line_max_y(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 {
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
Some(EntityType::Line(l)) => l.start.y.max(l.end.y),
other => panic!("expected a Line, got {other:?}"),
}
}
/// A single selected object has no "other object" to align to — refuse
/// with an informational message rather than silently moving nothing
/// (or panicking on the empty-bounds case).
#[test]
fn refuses_with_fewer_than_two_selected_objects() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let a = add_line(&mut app, 0.0, 0.0, 2.0, 1.0);
app.tabs[app.active_tab].scene.select_entities(&[a]);
let i = app.active_tab;
app.dispatch_inquiry("ALIGNLEFT", i);
assert_eq!(line_start_x(&app, a), 0.0, "the lone object must not move");
}
/// ALIGNLEFT moves every selected object's left edge to the leftmost
/// edge already present in the selection; ALIGNTOP does the same for
/// the topmost edge. Both are undoable as one step.
#[test]
fn aligns_left_and_top_edges_and_is_undoable() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let left = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); // leftmost & topmost already
let right = add_line(&mut app, 5.0, -3.0, 8.0, -2.0); // further right and lower
app.tabs[app.active_tab].scene.select_entities(&[left, right]);
let i = app.active_tab;
app.dispatch_inquiry("ALIGNLEFT", i);
assert_eq!(line_start_x(&app, left), 0.0, "the already-leftmost object must not move");
assert_eq!(line_start_x(&app, right), 0.0, "the other object's left edge should meet it");
app.dispatch_inquiry("ALIGNTOP", i);
assert_eq!(line_max_y(&app, left), 1.0, "the already-topmost object must not move");
assert_eq!(line_max_y(&app, right), 1.0, "the other object's top edge should meet it");
app.undo_steps(1); // undo ALIGNTOP
assert_eq!(line_max_y(&app, right), -2.0, "undo should restore the pre-ALIGNTOP position");
app.undo_steps(1); // undo ALIGNLEFT
assert_eq!(line_start_x(&app, right), 5.0, "undo should restore the original geometry");
}
/// A complete, fully-selected group (`Scene::selected_object_units`)
/// moves as one rigid unit instead of each member line sliding to meet
/// the *other* members of its own group.
#[test]
fn a_complete_group_aligns_as_one_rigid_unit() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let a = add_line(&mut app, 5.0, 0.0, 6.0, 1.0);
let b = add_line(&mut app, 7.0, 0.0, 8.0, 1.0);
let other = add_line(&mut app, 0.0, 0.0, 1.0, 1.0);
app.tabs[app.active_tab].scene.create_group("pair".to_string(), vec![a, b]);
app.tabs[app.active_tab].scene.select_entities(&[a, b, other]);
let i = app.active_tab;
app.dispatch_inquiry("ALIGNLEFT", i);
// The group's own left edge (a's, at x=5) moves to meet `other`'s
// left edge (x=0): a shifts by -5, and b — sharing the same
// transform — must shift by exactly the same amount, not collapse
// onto `a`.
assert_eq!(line_start_x(&app, a), 0.0);
assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it");
}
}
+48 -1
View File
@@ -1046,6 +1046,9 @@ impl OpenCADStudio {
| "GRIPHOT" | "GRIPHOT"
| "GRIPHOVER" | "GRIPHOVER"
| "GRIPOBJLIMIT" | "GRIPOBJLIMIT"
| "CONSTRAINTSOLVEMODE"
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
) => ) =>
{ {
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -1068,7 +1071,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string()); let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" { if name.is_empty() || name == "?" {
self.command_line.push_info( self.command_line.push_info(
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
); );
} else { } else {
if name == "CETRANSPARENCY" { if name == "CETRANSPARENCY" {
@@ -1262,6 +1265,50 @@ impl OpenCADStudio {
} }
return Some(self.finish_dispatch(cmd)); return Some(self.finish_dispatch(cmd));
} }
if matches!(
name.as_str(),
"CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY"
) {
let current = match name.as_str() {
"CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode),
"CONSTRAINTINFER" => i16::from(self.constraint_infer),
"CONSTRAINTBARDISPLAY" => self.constraint_bar_display,
_ => unreachable!(),
};
let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 };
if let Some(value) = &value {
match value
.parse::<i16>()
.ok()
.filter(|value| (0..=maximum).contains(value))
{
Some(mode) => {
match name.as_str() {
"CONSTRAINTSOLVEMODE" => {
self.constraint_solve_mode = mode != 0
}
"CONSTRAINTINFER" => self.constraint_infer = mode != 0,
"CONSTRAINTBARDISPLAY" => {
self.constraint_bar_display = mode
}
_ => unreachable!(),
}
self.persist_settings_if_changed();
self.command_line
.push_output(&crate::tf!("{name} = {mode}"));
}
None => self.command_line.push_error(&crate::tf!(
"{name}: expected an integer from 0 to {maximum}."
)),
}
} else {
self.command_line.push_output(crate::tf!(
"Enter new value for {name} <{current}>:"
).as_ref());
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "DONUTID" | "DONUTOD") { if matches!(name.as_str(), "DONUTID" | "DONUTOD") {
let current = if name == "DONUTID" { let current = if name == "DONUTID" {
crate::modules::draw::defaults::get_donut_inner_diameter() crate::modules::draw::defaults::get_donut_inner_diameter()
+1
View File
@@ -52,6 +52,7 @@ fn listen(sender: mpsc::Sender<Envelope>) -> std::io::Result<()> {
} }
let mut file = options.open(&path)?; let mut file = options.open(&path)?;
write!(file, "{descriptor}")?; write!(file, "{descriptor}")?;
drop(file);
let clients = Arc::new(AtomicUsize::new(0)); let clients = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming().flatten() { for stream in listener.incoming().flatten() {
if clients.fetch_add(1, Ordering::SeqCst) >= 8 { if clients.fetch_add(1, Ordering::SeqCst) >= 8 {
+59 -65
View File
@@ -6,11 +6,11 @@ use crate::scene::pick::grip::GripEdit;
use crate::scene::GripDef; use crate::scene::GripDef;
use crate::scene::{ObjectIsolationState, Scene}; use crate::scene::{ObjectIsolationState, Scene};
use crate::snap::SnapResult; use crate::snap::SnapResult;
use crate::t;
use crate::ui::{LayerPanel, PropertiesPanel}; use crate::ui::{LayerPanel, PropertiesPanel};
use acadrust::tables::{normalize_name, Ucs}; use acadrust::tables::{normalize_name, Ucs};
use acadrust::{CadDocument, EntityType, Handle}; use acadrust::{CadDocument, EntityType, Handle};
use iced; use iced;
use crate::t;
use std::any::Any; use std::any::Any;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@@ -240,14 +240,18 @@ impl DocumentTab {
.and_then(|index| self.block_edits.get_mut(index)) .and_then(|index| self.block_edits.get_mut(index))
} }
/// The [`crate::scene::sketch_constraints::SketchScope`] a new persistent /// The [`crate::scene::parametric_constraints::ParametricScope`] a new persistent
/// constraint should attach to right now — whatever's actually being /// constraint should attach to right now — whatever's actually being
/// edited: the open block definition if a BEDIT session is active, model /// edited: the open block definition if a BEDIT session is active, model
/// space otherwise. /// space otherwise.
pub(super) fn current_sketch_scope(&self) -> crate::scene::sketch_constraints::SketchScope { pub(super) fn current_parametric_scope(
&self,
) -> crate::scene::parametric_constraints::ParametricScope {
match self.active_block_edit_session() { match self.active_block_edit_session() {
Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle), Some(session) => {
None => crate::scene::sketch_constraints::SketchScope::ModelSpace, crate::scene::parametric_constraints::ParametricScope::Block(session.br_handle)
}
None => crate::scene::parametric_constraints::ParametricScope::ModelSpace,
} }
} }
@@ -291,8 +295,7 @@ impl DocumentTab {
u.origin = h.model_space_ucs_origin; u.origin = h.model_space_ucs_origin;
u.x_axis = h.model_space_ucs_x_axis; u.x_axis = h.model_space_ucs_x_axis;
u.y_axis = h.model_space_ucs_y_axis; u.y_axis = h.model_space_ucs_y_axis;
if h.model_space_ucs_name.is_empty() if h.model_space_ucs_name.is_empty() && super::helpers::UcsXform::from_ucs(&u).is_identity()
&& super::helpers::UcsXform::from_ucs(&u).is_identity()
{ {
None None
} else { } else {
@@ -475,31 +478,30 @@ impl DocumentTab {
} }
} }
} else { } else {
let (origin, x_axis, y_axis, elevation, ortho, named, base) = let (origin, x_axis, y_axis, elevation, ortho, named, base) = match &self.active_ucs {
match &self.active_ucs { Some(ucs) => (
Some(ucs) => ( ucs.origin,
ucs.origin, ucs.x_axis,
ucs.x_axis, ucs.y_axis,
ucs.y_axis, ucs.elevation,
ucs.elevation, ucs.ortho_type,
ucs.ortho_type, if ucs.named_ucs_handle.is_valid() {
if ucs.named_ucs_handle.is_valid() { ucs.named_ucs_handle
ucs.named_ucs_handle } else {
} else { ucs.handle
ucs.handle },
}, ucs.base_ucs_handle,
ucs.base_ucs_handle, ),
), None => (
None => ( Vector3::ZERO,
Vector3::ZERO, Vector3::UNIT_X,
Vector3::UNIT_X, Vector3::UNIT_Y,
Vector3::UNIT_Y, 0.0,
0.0, 0,
0, Handle::NULL,
Handle::NULL, Handle::NULL,
Handle::NULL, ),
), };
};
for object in self.scene.document.objects.values_mut() { for object in self.scene.document.objects.values_mut() {
let acadrust::objects::ObjectType::Layout(layout) = object else { let acadrust::objects::ObjectType::Layout(layout) = object else {
continue; continue;
@@ -656,7 +658,7 @@ impl DocumentTab {
#[derive(Clone)] #[derive(Clone)]
pub(super) enum HistorySnapshot { pub(super) enum HistorySnapshot {
// Boxed: `DeltaSnapshot` grew past the other variants once // Boxed: `DeltaSnapshot` grew past the other variants once
// `sketch_constraints` (ERASE-undo fix) was added, and every undo/redo // `parametric_constraints` (ERASE-undo fix) was added, and every undo/redo
// stack slot costs as much as this enum's largest variant regardless of // stack slot costs as much as this enum's largest variant regardless of
// which one it actually holds. // which one it actually holds.
Delta(Box<DeltaSnapshot>), Delta(Box<DeltaSnapshot>),
@@ -687,41 +689,33 @@ impl HistorySnapshot {
) )
.saturating_add(d.selected_before.len().saturating_mul(16)) .saturating_add(d.selected_before.len().saturating_mul(16))
.saturating_add(d.selected_after.len().saturating_mul(16)) .saturating_add(d.selected_after.len().saturating_mul(16))
.saturating_add(d.active_layer.as_ref().map_or(0, |(before, after)| {
before.len().saturating_add(after.len())
}))
.saturating_add( .saturating_add(
d.active_layer d.parametric_constraints
.as_ref()
.map_or(0, |(before, after)| before.len().saturating_add(after.len())),
)
.saturating_add(
d.sketch_constraints
.iter() .iter()
.map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len())) .map(|entry| {
entry
.before
.constraints
.len()
.saturating_add(entry.after.constraints.len())
})
.sum::<usize>() .sum::<usize>()
.saturating_mul(96), .saturating_mul(96),
) )
.saturating_add( .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| {
d.named_parameters before.len().saturating_add(after.len()).saturating_mul(128)
.as_ref() }))
.map_or(0, |(before, after)| before.len().saturating_add(after.len()).saturating_mul(128)),
)
.saturating_add(d.label.len()), .saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v HistorySnapshot::ObjectVisibility(v) => v
.before .before
.hidden .hidden
.len() .len()
.saturating_add( .saturating_add(v.before.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len))
v.before
.keep
.as_ref()
.map_or(0, rustc_hash::FxHashSet::len),
)
.saturating_add(v.after.hidden.len()) .saturating_add(v.after.hidden.len())
.saturating_add( .saturating_add(v.after.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len))
v.after
.keep
.as_ref()
.map_or(0, rustc_hash::FxHashSet::len),
)
.saturating_add(v.selected_before.len()) .saturating_add(v.selected_before.len())
.saturating_add(v.selected_after.len()) .saturating_add(v.selected_after.len())
.saturating_mul(16) .saturating_mul(16)
@@ -760,12 +754,12 @@ pub(super) struct DeltaSnapshot {
/// Opposite non-entity document state. `apply_delta_state` swaps this with /// Opposite non-entity document state. `apply_delta_state` swaps this with
/// the live structure, so the same allocation shuttles between undo/redo. /// the live structure, so the same allocation shuttles between undo/redo.
pub(super) structure: Option<StructureSnapshot>, pub(super) structure: Option<StructureSnapshot>,
/// Sketch-constraint scopes this same command changed alongside its /// Parametric-constraint scopes this same command changed alongside its
/// entities (e.g. ERASE removing a constraint set's touched constraints) /// entities (e.g. ERASE removing a constraint set's touched constraints)
/// — `sketch_constraints` lives on `Scene`, not in `document`/ /// — `parametric_constraints` lives on `Scene`, not in `document`/
/// `document.objects`, so it needs its own channel here rather than /// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty. /// riding along with `entities`/`structure`. Almost always empty.
pub(super) sketch_constraints: Vec<SketchConstraintsEntryDelta>, pub(super) parametric_constraints: Vec<ParametricConstraintsEntryDelta>,
/// Drawing-wide parameter table changed by this transaction. /// Drawing-wide parameter table changed by this transaction.
pub(super) named_parameters: Option<( pub(super) named_parameters: Option<(
crate::scene::named_parameters::ParameterTable, crate::scene::named_parameters::ParameterTable,
@@ -831,13 +825,13 @@ pub(super) struct ObjectEntryDelta {
pub(super) after: Option<acadrust::objects::ObjectType>, pub(super) after: Option<acadrust::objects::ObjectType>,
} }
/// One sketch-constraint scope's before/after image within an entity delta. /// One parametric-constraint scope's before/after image within an entity delta.
/// This keeps constraint cleanup atomic with edits such as entity erasure. /// This keeps constraint cleanup atomic with edits such as entity erasure.
#[derive(Clone)] #[derive(Clone)]
pub(super) struct SketchConstraintsEntryDelta { pub(super) struct ParametricConstraintsEntryDelta {
pub(super) scope: crate::scene::sketch_constraints::SketchScope, pub(super) scope: crate::scene::parametric_constraints::ParametricScope,
pub(super) before: crate::scene::sketch_constraints::SketchConstraintSet, pub(super) before: crate::scene::parametric_constraints::ParametricConstraintSet,
pub(super) after: crate::scene::sketch_constraints::SketchConstraintSet, pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet,
} }
#[derive(Default)] #[derive(Default)]
+70 -59
View File
@@ -1,7 +1,8 @@
use super::{ use super::{
document::{ document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta, ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot,
TableEntryDelta,
}, },
OpenCADStudio, OpenCADStudio,
}; };
@@ -167,8 +168,7 @@ impl OpenCADStudio {
) { ) {
self.finish_pending_history(i); self.finish_pending_history(i);
let after = self.tabs[i].scene.object_isolation.clone(); let after = self.tabs[i].scene.object_isolation.clone();
let selected_after: Vec<Handle> = let selected_after: Vec<Handle> = self.tabs[i].scene.selected.iter().copied().collect();
self.tabs[i].scene.selected.iter().copied().collect();
if before == after && selected_before == selected_after { if before == after && selected_before == selected_after {
return; return;
} }
@@ -225,7 +225,7 @@ impl OpenCADStudio {
dirty_after: true, dirty_after: true,
active_layer: None, active_layer: None,
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: label.into(), label: label.into(),
}; };
@@ -233,9 +233,7 @@ impl OpenCADStudio {
} }
pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) { pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) {
let Some(HistorySnapshot::Delta(delta)) = let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else {
self.tabs[i].history.undo_stack.last_mut()
else {
return; return;
}; };
if let Some((_, _, after)) = delta if let Some((_, _, after)) = delta
@@ -254,9 +252,7 @@ impl OpenCADStudio {
let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let current_layout_after = self.tabs[i].scene.current_layout.clone(); let current_layout_after = self.tabs[i].scene.current_layout.clone();
let dirty_after = self.tabs[i].dirty; let dirty_after = self.tabs[i].dirty;
let Some(HistorySnapshot::Delta(delta)) = let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else {
self.tabs[i].history.undo_stack.last_mut()
else {
return; return;
}; };
let Some((_, _, entry_after)) = delta let Some((_, _, entry_after)) = delta
@@ -335,8 +331,9 @@ impl OpenCADStudio {
dirty_after, dirty_after,
active_layer: (pending.active_layer != active_layer_after) active_layer: (pending.active_layer != active_layer_after)
.then_some((pending.active_layer, active_layer_after)), .then_some((pending.active_layer, active_layer_after)),
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)), structure: structure_changed
sketch_constraints: Vec::new(), .then_some(StructureSnapshot::Full(pending.structure_before)),
parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -445,7 +442,7 @@ impl OpenCADStudio {
dirty_after: self.tabs[i].dirty, dirty_after: self.tabs[i].dirty,
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Layers(entries)), structure: Some(StructureSnapshot::Layers(entries)),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -502,7 +499,7 @@ impl OpenCADStudio {
dirty_after: self.tabs[i].dirty, dirty_after: self.tabs[i].dirty,
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::TextStyles(entries)), structure: Some(StructureSnapshot::TextStyles(entries)),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -559,7 +556,7 @@ impl OpenCADStudio {
dirty_after: self.tabs[i].dirty, dirty_after: self.tabs[i].dirty,
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::DimStyles(entries)), structure: Some(StructureSnapshot::DimStyles(entries)),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -624,7 +621,7 @@ impl OpenCADStudio {
dirty_after: self.tabs[i].dirty, dirty_after: self.tabs[i].dirty,
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Objects(entries)), structure: Some(StructureSnapshot::Objects(entries)),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -659,7 +656,7 @@ impl OpenCADStudio {
dim_names, dim_names,
object_handles, object_handles,
}), }),
sketch_constraints: Vec::new(), parametric_constraints: Vec::new(),
named_parameters: None, named_parameters: None,
label: "STYLE".to_string(), label: "STYLE".to_string(),
}; };
@@ -741,15 +738,16 @@ impl OpenCADStudio {
pending.label pending.label
); );
} }
let (entity_before, object_before, sketch_constraints_before, named_parameters_before) = let (entity_before, object_before, parametric_constraints_before, named_parameters_before) =
rec.into_recorded_images(); rec.into_recorded_images();
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> =
.into_iter() entity_before
.map(|(h, before)| { .into_iter()
let after = self.tabs[i].scene.document.get_entity_arc(h); .map(|(h, before)| {
(h, before, after) let after = self.tabs[i].scene.document.get_entity_arc(h);
}) (h, before, after)
.collect(); })
.collect();
let objects: Vec<ObjectEntryDelta> = object_before let objects: Vec<ObjectEntryDelta> = object_before
.into_iter() .into_iter()
.filter_map(|(handle, before)| { .filter_map(|(handle, before)| {
@@ -761,21 +759,28 @@ impl OpenCADStudio {
}) })
}) })
.collect(); .collect();
let sketch_constraints: Vec<SketchConstraintsEntryDelta> = sketch_constraints_before let parametric_constraints: Vec<ParametricConstraintsEntryDelta> =
.into_iter() parametric_constraints_before
.filter_map(|(scope, before)| { .into_iter()
let after = self.tabs[i] .filter_map(|(scope, before)| {
.scene let after = self.tabs[i]
.sketch_constraint_set(scope) .scene
.cloned() .parametric_constraint_set(scope)
.unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope)); .cloned()
(before.constraints != after.constraints).then_some(SketchConstraintsEntryDelta { .unwrap_or_else(|| {
scope, crate::scene::parametric_constraints::ParametricConstraintSet::new(
before, scope,
after, )
});
(before.constraints != after.constraints).then_some(
ParametricConstraintsEntryDelta {
scope,
before,
after,
},
)
}) })
}) .collect();
.collect();
let named_parameters = named_parameters_before.and_then(|before| { let named_parameters = named_parameters_before.and_then(|before| {
let after = self.tabs[i].scene.named_parameters().clone(); let after = self.tabs[i].scene.named_parameters().clone();
(before != after).then_some((before, after)) (before != after).then_some((before, after))
@@ -815,7 +820,7 @@ impl OpenCADStudio {
dirty_after, dirty_after,
active_layer: None, active_layer: None,
structure, structure,
sketch_constraints, parametric_constraints,
named_parameters, named_parameters,
label: pending.label, label: pending.label,
}; };
@@ -839,9 +844,10 @@ impl OpenCADStudio {
if let Some(structure) = d.structure.as_mut() { if let Some(structure) = d.structure.as_mut() {
match structure { match structure {
StructureSnapshot::Full(stored) => { StructureSnapshot::Full(stored) => {
let inverse = self.tabs[i].scene.document.swap_structure( let inverse = self.tabs[i]
std::mem::replace(stored, acadrust::CadDocument::new()), .scene
); .document
.swap_structure(std::mem::replace(stored, acadrust::CadDocument::new()));
*stored = inverse; *stored = inverse;
self.tabs[i].scene.invalidate_dependency_index(); self.tabs[i].scene.invalidate_dependency_index();
} }
@@ -945,9 +951,9 @@ impl OpenCADStudio {
} }
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
let scene = &mut self.tabs[i].scene; let scene = &mut self.tabs[i].scene;
for entry in &d.sketch_constraints { for entry in &d.parametric_constraints {
let value = if undo { &entry.before } else { &entry.after }; let value = if undo { &entry.before } else { &entry.after };
*scene.sketch_constraint_set_mut(entry.scope) = value.clone(); *scene.parametric_constraint_set_mut(entry.scope) = value.clone();
} }
if let Some((before, after)) = &d.named_parameters { if let Some((before, after)) = &d.named_parameters {
scene.named_parameters = if undo { before } else { after }.clone(); scene.named_parameters = if undo { before } else { after }.clone();
@@ -1070,6 +1076,7 @@ impl OpenCADStudio {
} }
} }
} }
scene.sync_native_parametric_graph();
scene.clear_preview_wire(); scene.clear_preview_wire();
} }
// UCS state is cached separately from its persisted header/viewport // UCS state is cached separately from its persisted header/viewport
@@ -1104,7 +1111,8 @@ impl OpenCADStudio {
let available = self.tabs[i].history.undo_stack.len(); let available = self.tabs[i].history.undo_stack.len();
let steps = steps.min(available); let steps = steps.min(available);
if steps == 0 { if steps == 0 {
self.command_line.push_info(crate::t!("Nothing to undo.").as_ref()); self.command_line
.push_info(crate::t!("Nothing to undo.").as_ref());
return; return;
} }
@@ -1127,14 +1135,15 @@ impl OpenCADStudio {
structure, structure,
StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) StructureSnapshot::Full(_) | StructureSnapshot::Layers(_)
) )
}) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { }) || d.active_layer.is_some()
before || d.entities.iter().any(|(_, before, after)| {
.as_deref() before
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
|| after
.as_deref() .as_deref()
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) .is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
}); || after
.as_deref()
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
});
had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full);
changes.extend(self.apply_delta_state(i, &mut d, true)); changes.extend(self.apply_delta_state(i, &mut d, true));
self.tabs[i] self.tabs[i]
@@ -1162,7 +1171,8 @@ impl OpenCADStudio {
let available = self.tabs[i].history.redo_stack.len(); let available = self.tabs[i].history.redo_stack.len();
let steps = steps.min(available); let steps = steps.min(available);
if steps == 0 { if steps == 0 {
self.command_line.push_info(crate::t!("Nothing to redo.").as_ref()); self.command_line
.push_info(crate::t!("Nothing to redo.").as_ref());
return; return;
} }
@@ -1182,14 +1192,15 @@ impl OpenCADStudio {
structure, structure,
StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) StructureSnapshot::Full(_) | StructureSnapshot::Layers(_)
) )
}) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { }) || d.active_layer.is_some()
before || d.entities.iter().any(|(_, before, after)| {
.as_deref() before
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
|| after
.as_deref() .as_deref()
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) .is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
}); || after
.as_deref()
.is_some_and(|entity| matches!(entity, EntityType::Viewport(_)))
});
had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full);
changes.extend(self.apply_delta_state(i, &mut d, false)); changes.extend(self.apply_delta_state(i, &mut d, false));
self.tabs[i] self.tabs[i]
+39 -13
View File
@@ -7,6 +7,7 @@ pub(crate) fn automation_action_names() -> &'static [&'static str] {
pub(crate) mod config; pub(crate) mod config;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use automation::{export_headless, serve}; pub use automation::{export_headless, serve};
mod annotation_data;
mod command_driver; mod command_driver;
pub(crate) mod commands; pub(crate) mod commands;
pub(crate) mod dim_viewport; pub(crate) mod dim_viewport;
@@ -31,7 +32,6 @@ mod startup;
mod style_ops; mod style_ops;
mod text_inline; mod text_inline;
mod tolerance_dialog; mod tolerance_dialog;
mod annotation_data;
mod update; mod update;
mod view; mod view;
mod visibility; mod visibility;
@@ -261,12 +261,12 @@ pub(crate) enum FindMatchKey {
} }
use crate::snap::Snapper; use crate::snap::Snapper;
use crate::ui::{CommandLine, Ribbon, StatusBar}; use crate::ui::{CommandLine, Ribbon, StatusBar};
use acadrust::CadDocument;
use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::CadDocument;
use iced::time::Instant; use iced::time::Instant;
use iced::window; use iced::window;
use iced::{Point, Task, Theme, mouse}; use iced::{mouse, Point, Task, Theme};
use std::sync::Arc; use std::sync::Arc;
pub(super) const POLY_START_DELAY_MS: u128 = 150; pub(super) const POLY_START_DELAY_MS: u128 = 150;
@@ -553,14 +553,18 @@ pub(super) struct OpenCADStudio {
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// When true (default), the app registers itself as a .dwg/.dxf/.bak file
/// handler on each launch. Toggle with the FILEASSOC command. /// handler on each launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool, pub file_assoc_enabled: bool,
/// When true, saving creates native constraint objects alongside the /// When true (default), a parametric constraint's viewport pill shows its
/// application's own persistence record. Existing native objects remain
/// synchronized regardless of this setting.
pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its
/// glyph plus a driven value or named-parameter name. When false, every /// glyph plus a driven value or named-parameter name. When false, every
/// pill shows only the glyph. /// pill shows only the glyph.
pub show_constraint_values: bool, pub show_constraint_values: bool,
pub auto_constrain_settings: settings::AutoConstrainSettings,
auto_constrain_saved: Option<settings::AutoConstrainSettings>,
auto_constrain_selected_row: usize,
auto_constrain_distance_input: String,
auto_constrain_angle_input: String,
pub constraint_solve_mode: bool,
pub constraint_infer: bool,
pub constraint_bar_display: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave. /// 0 disables autosave.
pub savetime_min: i32, pub savetime_min: i32,
@@ -1746,6 +1750,7 @@ pub enum ModalKind {
DrawingUnits, DrawingUnits,
GeometricTolerance, GeometricTolerance,
DraftingSettings, DraftingSettings,
AutoConstrainSettings,
LayerStateEditor, LayerStateEditor,
Plot, Plot,
PrintAll, PrintAll,
@@ -2138,8 +2143,6 @@ pub enum Message {
/// Register or unregister as the .dwg/.dxf handler, from Options. Same /// Register or unregister as the .dwg/.dxf handler, from Options. Same
/// setting the FILEASSOC command carries. /// setting the FILEASSOC command carries.
FileAssocChanged(bool), FileAssocChanged(bool),
/// Toggle writing native constraint objects on save.
WriteDwgNativeConstraintsChanged(bool),
/// Toggle showing driven values/named-parameter names on constraint /// Toggle showing driven values/named-parameter names on constraint
/// pills, from Options. See `show_constraint_values`'s doc comment. /// pills, from Options. See `show_constraint_values`'s doc comment.
ShowConstraintValuesChanged(bool), ShowConstraintValuesChanged(bool),
@@ -2567,9 +2570,9 @@ pub enum Message {
/// Cycle the coordinate readout mode ($COORDS): static → live → polar. /// Cycle the coordinate readout mode ($COORDS): static → live → polar.
CycleCoordsMode, CycleCoordsMode,
/// Removes one flagged redundant or conflicting constraint from the /// Removes one flagged redundant or conflicting constraint from the
/// current sketch scope. /// current parametric scope.
/// No-op if the scope currently has no flagged conflict. /// No-op if the scope currently has no flagged conflict.
ResolveOneSketchConflict, ResolveOneParametricConflict,
/// Toggle the status-bar customization menu open/closed. /// Toggle the status-bar customization menu open/closed.
ToggleStatusBarMenu, ToggleStatusBarMenu,
/// Close the status-bar customization menu. /// Close the status-bar customization menu.
@@ -2666,6 +2669,20 @@ pub enum Message {
DraftingSettingsClose, DraftingSettingsClose,
DraftingSettingsCloseDiscard, DraftingSettingsCloseDiscard,
DraftingSettingsCloseKeep, DraftingSettingsCloseKeep,
AutoConstrainSelectRow(usize),
AutoConstrainToggleKind(settings::AutoConstraintKind),
AutoConstrainMoveUp,
AutoConstrainMoveDown,
AutoConstrainSelectAll,
AutoConstrainClearAll,
AutoConstrainReset,
AutoConstrainToggleTangentPoint,
AutoConstrainTogglePerpendicularIntersection,
AutoConstrainDistanceChanged(String),
AutoConstrainAngleChanged(String),
AutoConstrainApply,
AutoConstrainOk,
AutoConstrainCancel,
/// Toggle a ribbon dropdown open/closed. /// Toggle a ribbon dropdown open/closed.
ToggleRibbonDropdown(String), ToggleRibbonDropdown(String),
/// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title).
@@ -2928,6 +2945,8 @@ pub enum Message {
/// A Constraints-section row was clicked: select every entity in the /// A Constraints-section row was clicked: select every entity in the
/// list (replacing the current selection) in the viewport. /// list (replacing the current selection) in the viewport.
PropConstraintLinkClick(Vec<acadrust::Handle>), PropConstraintLinkClick(Vec<acadrust::Handle>),
/// Remove one parametric constraint selected from Properties or the viewport.
PropConstraintDelete(crate::scene::parametric_constraints::ConstraintId),
// ── About window ──────────────────────────────────────────────────── // ── About window ────────────────────────────────────────────────────
AboutOpen, AboutOpen,
// ── Graphics warning ──────────────────────────────────────────────── // ── Graphics warning ────────────────────────────────────────────────
@@ -3656,8 +3675,15 @@ impl OpenCADStudio {
dimension_continue_mode: 1, dimension_continue_mode: 1,
backup_on_save: true, backup_on_save: true,
file_assoc_enabled: true, file_assoc_enabled: true,
write_dwg_native_constraints: false,
show_constraint_values: true, show_constraint_values: true,
auto_constrain_settings: settings::AutoConstrainSettings::default(),
auto_constrain_saved: None,
auto_constrain_selected_row: 0,
auto_constrain_distance_input: "0.05".to_string(),
auto_constrain_angle_input: "1".to_string(),
constraint_solve_mode: true,
constraint_infer: false,
constraint_bar_display: 3,
savetime_min: 10, savetime_min: 10,
default_bg_color: None, default_bg_color: None,
default_paper_bg_color: None, default_paper_bg_color: None,
+408 -339
View File
File diff suppressed because it is too large. Load diff
+127 -5
View File
@@ -11,6 +11,108 @@
use crate::snap::SnapType; use crate::snap::SnapType;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AutoConstraintKind {
Coincident,
Collinear,
Parallel,
Perpendicular,
Tangent,
Concentric,
Horizontal,
Vertical,
Equal,
}
impl AutoConstraintKind {
pub const ALL: [Self; 9] = [
Self::Coincident,
Self::Collinear,
Self::Parallel,
Self::Perpendicular,
Self::Tangent,
Self::Concentric,
Self::Horizontal,
Self::Vertical,
Self::Equal,
];
pub const fn label(self) -> &'static str {
match self {
Self::Coincident => "Coincident",
Self::Collinear => "Collinear",
Self::Parallel => "Parallel",
Self::Perpendicular => "Perpendicular",
Self::Tangent => "Tangent",
Self::Concentric => "Concentric",
Self::Horizontal => "Horizontal",
Self::Vertical => "Vertical",
Self::Equal => "Equal",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AutoConstrainSettings {
pub priority: Vec<AutoConstraintKind>,
pub enabled: Vec<AutoConstraintKind>,
pub tangent_must_share_point: bool,
pub perpendicular_must_intersect: bool,
pub distance_tolerance: f64,
pub angle_tolerance_deg: f64,
}
impl Default for AutoConstrainSettings {
fn default() -> Self {
Self {
priority: AutoConstraintKind::ALL.to_vec(),
// Keep Equal available without creating redundant relations
// between equal-length segments by default.
enabled: AutoConstraintKind::ALL
.into_iter()
.filter(|kind| *kind != AutoConstraintKind::Equal)
.collect(),
tangent_must_share_point: true,
perpendicular_must_intersect: true,
distance_tolerance: 0.05,
angle_tolerance_deg: 1.0,
}
}
}
impl AutoConstrainSettings {
pub fn sanitize(&mut self) {
let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len());
for kind in self
.priority
.iter()
.copied()
.chain(AutoConstraintKind::ALL)
{
if !priority.contains(&kind) {
priority.push(kind);
}
}
self.priority = priority;
self.enabled
.retain(|kind| AutoConstraintKind::ALL.contains(kind));
self.enabled.sort_by_key(|kind| {
self.priority
.iter()
.position(|candidate| candidate == kind)
.unwrap_or(usize::MAX)
});
self.enabled.dedup();
if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 {
self.distance_tolerance = 0.05;
}
if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 {
self.angle_tolerance_deg = 1.0;
}
}
}
/// Cursor shown over the drawing viewport. /// Cursor shown over the drawing viewport.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CursorType { pub enum CursorType {
@@ -283,16 +385,25 @@ pub struct UserSettings {
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
/// handler on every launch. Toggle with the FILEASSOC command. /// handler on every launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool, pub file_assoc_enabled: bool,
/// When true, saving also writes sketch constraints as native drawing
/// objects alongside the application's own persistence record.
#[serde(default)]
pub write_dwg_native_constraints: bool,
/// When true (default), a sketch constraint's viewport pill shows its /// When true (default), a sketch constraint's viewport pill shows its
/// glyph plus a driven value or named-parameter name. When false, every /// glyph plus a driven value or named-parameter name. When false, every
/// pill shows just the bare glyph, so the value/name text doesn't cover /// pill shows just the bare glyph, so the value/name text doesn't cover
/// canvas detail on a dense sketch. /// canvas detail on a dense sketch.
#[serde(default = "default_show_constraint_values")] #[serde(default = "default_show_constraint_values")]
pub show_constraint_values: bool, pub show_constraint_values: bool,
/// Inference types, priority, intersection rules, and tolerances used by
/// the Auto Constrain command.
#[serde(default)]
pub auto_constrain: AutoConstrainSettings,
/// Keep existing geometry size while solving after a constraint edit.
#[serde(default = "default_constraint_solve_mode")]
pub constraint_solve_mode: bool,
/// Apply eligible geometric constraints while creating geometry.
#[serde(default)]
pub constraint_infer: bool,
/// Constraint bar display bit mask: 1 after applying, 2 on selection.
#[serde(default = "default_constraint_bar_display")]
pub constraint_bar_display: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave. /// 0 disables autosave.
pub savetime_min: i32, pub savetime_min: i32,
@@ -363,6 +474,14 @@ fn default_show_constraint_values() -> bool {
true true
} }
fn default_constraint_solve_mode() -> bool {
true
}
fn default_constraint_bar_display() -> i16 {
3
}
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error> fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
@@ -418,8 +537,11 @@ impl Default for UserSettings {
textfill: true, textfill: true,
backup_on_save: true, backup_on_save: true,
file_assoc_enabled: true, file_assoc_enabled: true,
write_dwg_native_constraints: false,
show_constraint_values: true, show_constraint_values: true,
auto_constrain: AutoConstrainSettings::default(),
constraint_solve_mode: true,
constraint_infer: false,
constraint_bar_display: 3,
savetime_min: 10, savetime_min: 10,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true, pick_add: true,
+44 -5
View File
@@ -1999,10 +1999,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
} }
pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task<Message> { pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
self.ribbon.prop_color_palette_open = false; self.ribbon.prop_color_palette_open = false;
self.ribbon.close_dropdown(); self.ribbon.close_dropdown();
let handles = self.property_target_handles(i); self.note_recent_color(color);
let handles = self.property_target_handles(i);
if handles.is_empty() { if handles.is_empty() {
if self.has_property_selection(i) { if self.has_property_selection(i) {
return Task::none(); return Task::none();
@@ -3247,6 +3248,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].properties.active_field = None; self.tabs[i].properties.active_field = None;
let handles = self.property_target_handles(i); let handles = self.property_target_handles(i);
if !handles.is_empty() { if !handles.is_empty() {
let driven_marker = match field {
"start_x" | "start_y" | "start_z" => Some(0),
"end_x" | "end_y" | "end_z" => Some(1),
"center_x" | "center_y" | "center_z" => Some(-3),
_ => None,
};
let retained_originals: Vec<_> = if self.constraint_solve_mode
&& driven_marker.is_some()
{
handles
.iter()
.filter_map(|handle| {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.cloned()
.map(|entity| (*handle, entity))
})
.collect()
} else {
Vec::new()
};
let evaluates_expression = self.tabs[i] let evaluates_expression = self.tabs[i]
.properties .properties
.sections .sections
@@ -3563,7 +3587,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
} }
} }
} }
self.invalidate_property_targets(i, &handles); let driven_refs: Vec<_> = driven_marker
.into_iter()
.flat_map(|marker| {
handles.iter().copied().map(move |handle| {
crate::scene::parametric_constraints::ParametricRef::point(
handle, marker,
)
})
})
.collect();
self.invalidate_property_targets_with_originals(
i,
&handles,
&driven_refs,
&retained_originals,
);
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
self.refresh_properties(); self.refresh_properties();
} }
+1016 -1243
View File
File diff suppressed because it is too large. Load diff
+674 -502
View File
File diff suppressed because it is too large. Load diff
+657 -553
View File
File diff suppressed because it is too large. Load diff
+1156 -1264
View File
File diff suppressed because it is too large. Load diff
+17 -3
View File
@@ -25,6 +25,7 @@ impl OpenCADStudio {
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"),
Some(K::Plot) => crate::tr!("modal", "plot"), Some(K::Plot) => crate::tr!("modal", "plot"),
Some(K::PrintAll) => t!("Print All").into_owned(), Some(K::PrintAll) => t!("Print All").into_owned(),
@@ -201,7 +202,6 @@ impl OpenCADStudio {
crate::ui::window::options::view_window( crate::ui::window::options::view_window(
&self.default_save_format, &self.default_save_format,
self.file_assoc_enabled, self.file_assoc_enabled,
self.write_dwg_native_constraints,
self.show_constraint_values, self.show_constraint_values,
&self.ui_theme, &self.ui_theme,
&self.theme_color_inputs, &self.theme_color_inputs,
@@ -292,8 +292,8 @@ impl OpenCADStudio {
let confirm = self.drafting_settings_close_confirm; let confirm = self.drafting_settings_close_confirm;
sized_flow( sized_flow(
ex, ex,
700, 780,
480, 500,
|flow| { |flow| {
if let Some(state) = state { if let Some(state) = state {
crate::ui::window::drafting_settings::view_window( crate::ui::window::drafting_settings::view_window(
@@ -308,6 +308,20 @@ impl OpenCADStudio {
}, },
) )
} }
super::super::ModalKind::AutoConstrainSettings => sized_flow(
ex,
620,
610,
|flow| {
crate::ui::window::auto_constrain_settings::view_window(
&self.auto_constrain_settings,
self.auto_constrain_selected_row,
&self.auto_constrain_distance_input,
&self.auto_constrain_angle_input,
flow,
)
},
),
super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| {
crate::ui::window::find_replace::view_window( crate::ui::window::find_replace::view_window(
&self.find_replace.search, &self.find_replace.search,
+10
View File
@@ -1114,6 +1114,7 @@ pub(super) fn viewport_context_menu_overlay(
bottom_inset: f32, bottom_inset: f32,
has_cmd: bool, has_cmd: bool,
has_selection: bool, has_selection: bool,
selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
isolation_active: bool, isolation_active: bool,
last_cmds: Vec<String>, last_cmds: Vec<String>,
draworder_open: bool, draworder_open: bool,
@@ -1250,6 +1251,15 @@ pub(super) fn viewport_context_menu_overlay(
t!("Invert Selection").into_owned(), t!("Invert Selection").into_owned(),
Message::InvertSelection, Message::InvertSelection,
)); ));
} else if let Some(id) = selected_constraint {
// A selected constraint-glyph pill gets its own minimal menu —
// none of the entity-selection actions above (Move/Copy/Isolate/
// Select Similar) apply to it.
items.push(item(
t!("Delete").into_owned(),
Message::PropConstraintDelete(id),
));
items.push(sep());
} }
if isolation_active { if isolation_active {
items.push(item( items.push(item(
+155 -64
View File
@@ -25,10 +25,15 @@ pub enum HatchEditOperation {
style: Option<acadrust::entities::HatchStyleType>, style: Option<acadrust::entities::HatchStyleType>,
annotative: Option<bool>, annotative: Option<bool>,
}, },
RecreateBoundary { associate: bool, region: bool }, RecreateBoundary {
associate: bool,
region: bool,
},
BeginAssociate, BeginAssociate,
AssociatePaths(Vec<acadrust::entities::BoundaryPath>), AssociatePaths(Vec<acadrust::entities::BoundaryPath>),
DrawOrderBoundary { above: bool }, DrawOrderBoundary {
above: bool,
},
Separate, Separate,
AddBoundaries(Vec<Handle>), AddBoundaries(Vec<Handle>),
RemoveBoundaries(Vec<Handle>), RemoveBoundaries(Vec<Handle>),
@@ -102,8 +107,7 @@ impl WorkingPlane {
pub fn angle(self, from: DVec3, to: DVec3) -> Option<f64> { pub fn angle(self, from: DVec3, to: DVec3) -> Option<f64> {
let direction = self.vector_to_local(to - from); let direction = self.vector_to_local(to - from);
(direction.x.hypot(direction.y) > f64::EPSILON) (direction.x.hypot(direction.y) > f64::EPSILON).then(|| direction.y.atan2(direction.x))
.then(|| direction.y.atan2(direction.x))
} }
pub fn to_world_transform(self) -> acadrust::types::Transform { pub fn to_world_transform(self) -> acadrust::types::Transform {
@@ -493,16 +497,18 @@ impl CadCommand for UserRegCommand {
fn prompt(&self) -> String { fn prompt(&self) -> String {
match self.slot { match self.slot {
None => crate::t!("%{name} which register? [1-5]:", name = self.name) None => crate::t!("%{name} which register? [1-5]:", name = self.name).into_owned(),
.into_owned(), Some(n) => {
Some(n) => crate::t!("%{name}%{slot} new value:", name = self.name, slot = n) crate::t!("%{name}%{slot} new value:", name = self.name, slot = n).into_owned()
.into_owned(), }
} }
} }
fn options(&self) -> Vec<CmdOption> { fn options(&self) -> Vec<CmdOption> {
match self.slot { match self.slot {
None => (1..=5).map(|n| CmdOption::new(&n.to_string(), &n.to_string())).collect(), None => (1..=5)
.map(|n| CmdOption::new(&n.to_string(), &n.to_string()))
.collect(),
Some(_) => Vec::new(), Some(_) => Vec::new(),
} }
} }
@@ -593,9 +599,10 @@ fn match_cmd_option<'a>(
return None; return None;
} }
// 1. Exact match on keyword or label (case-insensitive) // 1. Exact match on keyword or label (case-insensitive)
if let Some(opt) = options.iter().find(|(label, k, _)| { if let Some(opt) = options
k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) .iter()
}) { .find(|(label, k, _)| k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t))
{
return Some(opt); return Some(opt);
} }
// 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT")
@@ -650,8 +657,7 @@ impl CadCommand for KeywordCommand {
// Consumed inputs that keep prompting return `Some(NeedPoint)` — // Consumed inputs that keep prompting return `Some(NeedPoint)` —
// `None` would hand the same text to the command a second time. // `None` would hand the same text to the command a second time.
None => { None => {
let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else {
else {
// Unknown verb — keep prompting rather than dispatch garbage. // Unknown verb — keep prompting rather than dispatch garbage.
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);
}; };
@@ -809,7 +815,7 @@ impl CadCommand for Mid2PointCommand {
WireModel::CYAN, WireModel::CYAN,
false, false,
)); ));
// Midpoint marker (AutoCAD style triangle glyph) // Midpoint marker (triangle glyph)
let mid = (first + pt) * 0.5; let mid = (first + pt) * 0.5;
let dist = (pt - first).length(); let dist = (pt - first).length();
let s = (dist * 0.02).clamp(0.5, 10.0); let s = (dist * 0.02).clamp(0.5, 10.0);
@@ -834,7 +840,6 @@ inventory::submit!(CommandRegistration {
names: &["MTP", "M2P"], names: &["MTP", "M2P"],
}); });
/// Generic interactive front-end for a keyword command that operates on the /// Generic interactive front-end for a keyword command that operates on the
/// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing
/// is selected when it starts it first gathers a selection (Enter confirms), /// is selected when it starts it first gathers a selection (Enter confirms),
@@ -942,8 +947,7 @@ impl CadCommand for SelectThenKeywordCommand {
} }
} }
None => { None => {
let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else {
else {
// Unknown verb — consumed, keep prompting (`None` would // Unknown verb — consumed, keep prompting (`None` would
// feed the same text to the command a second time). // feed the same text to the command a second time).
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);
@@ -1233,6 +1237,17 @@ pub enum ExtrudeExtent {
Path(Handle), Path(Handle),
} }
/// One ordered selection used by the Coincident command. A click can name
/// either a constraint point on an entity or the entity's whole curve. Typed
/// coordinates have no entity handle and are resolved by the host after the
/// command returns.
#[derive(Clone, Copy, Debug)]
pub struct CoincidentPick {
pub handle: Option<Handle>,
pub point: DVec3,
pub whole_curve: bool,
}
/// Construction options shared by SWEEP creation and its live preview. /// Construction options shared by SWEEP creation and its live preview.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct SweepOptions { pub struct SweepOptions {
@@ -1405,7 +1420,10 @@ pub enum CmdResult {
/// Copy selected entities with a transform; command stays active for more copies. /// Copy selected entities with a transform; command stays active for more copies.
CopySelected(Vec<Handle>, EntityTransform), CopySelected(Vec<Handle>, EntityTransform),
/// Store selected entities in the shared clipboard; command stays active. /// Store selected entities in the shared clipboard; command stays active.
CopyToClipboard { handles: Vec<Handle>, base: DVec3 }, CopyToClipboard {
handles: Vec<Handle>,
base: DVec3,
},
/// Commit a hatch fill (stored in Scene::hatches, not the DXF document). /// Commit a hatch fill (stored in Scene::hatches, not the DXF document).
CommitHatch(HatchModel), CommitHatch(HatchModel),
/// Commit a hatch with the selected hatch's entity colour and transparency. /// Commit a hatch with the selected hatch's entity colour and transparency.
@@ -1430,22 +1448,25 @@ pub enum CmdResult {
/// Erase `handle` and replace with new entities; command stays active. /// Erase `handle` and replace with new entities; command stays active.
ReplaceEntity(Handle, Vec<EntityType>), ReplaceEntity(Handle, Vec<EntityType>),
/// Update one entity in place, preserve its handle, and end the command. /// Update one entity in place, preserve its handle, and end the command.
UpdateEntityAndFinish { handle: Handle, entity: EntityType }, UpdateEntityAndFinish {
handle: Handle,
entity: EntityType,
},
/// Replace / delete multiple entities and add new ones; command ends. /// Replace / delete multiple entities and add new ones; command ends.
/// Each pair: (handle_to_erase, replacement_entities) — empty vec = delete only. /// Each pair: (handle_to_erase, replacement_entities) — empty vec = delete only.
ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>), ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>),
/// Replace several entities as one undo step while keeping the command active. /// Replace several entities as one undo step while keeping the command active.
ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>), ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>),
/// Add a persistent parametric constraint to the current scope's /// Add a persistent parametric constraint to the current scope's
/// `SketchConstraintSet` and trigger its /// `ParametricConstraintSet` and trigger its
/// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`,
/// nothing here is geometry to add or replace directly — the host adds /// nothing here is geometry to add or replace directly — the host adds
/// the constraint record, then re-solves through `Scene::bump_entities`'s /// the constraint record, then re-solves through `Scene::bump_entities`'s
/// existing chain (`refresh_sketch_constraints`) the same way any later /// existing chain (`refresh_parametric_constraints`) the same way any later
/// edit to these entities will. /// edit to these entities will.
AddSketchConstraint { AddParametricConstraint {
kind: crate::scene::sketch_constraints::ConstraintKind, kind: crate::scene::parametric_constraints::ConstraintKind,
refs: Vec<crate::scene::sketch_constraints::SketchRef>, refs: Vec<crate::scene::parametric_constraints::ParametricRef>,
/// The typed target for a dimensional kind (Distance/Angle/Radius) — /// The typed target for a dimensional kind (Distance/Angle/Radius) —
/// a literal number, or a named-parameter reference recognized by /// a literal number, or a named-parameter reference recognized by
/// `DistanceConstraintCommand`/`AngleConstraintCommand:: /// `DistanceConstraintCommand`/`AngleConstraintCommand::
@@ -1456,19 +1477,28 @@ pub enum CmdResult {
/// Undo-history label, e.g. `"Horizontal constraint"`. /// Undo-history label, e.g. `"Horizontal constraint"`.
label: &'static str, label: &'static str,
}, },
/// Adds a Coincident constraint between the sub-entity points nearest /// Opens the Auto Constrain settings dialog from the selection prompt.
/// the two picked positions. The host resolves the raw points because OpenAutoConstrainSettings,
/// `CadCommand` has no document access. /// Adds an ordered Coincident relation. Point/point selections create a
/// Coincident constraint; point/curve selections create the corresponding
/// point-on-curve relation while retaining Coincident command semantics.
AddCoincidentConstraint { AddCoincidentConstraint {
point_a: DVec3, first: CoincidentPick,
point_b: DVec3, second: CoincidentPick,
/// Keep the first curve active and accept another point.
multiple: bool,
label: &'static str, label: &'static str,
}, },
/// Apply only Coincident relations that already exist geometrically in
/// the selected set.
AddAutoCoincidentConstraints {
handles: Vec<Handle>,
},
/// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint
/// (`crate::modules::parametric::point_on_entity`) between one /// (`crate::modules::parametric::point_on_entity`) between one
/// picked point and `target`, a whole entity selected before the tool /// picked point and `target`, a whole entity selected before the tool
/// ran. Like `AddCoincidentConstraint`, the host resolves `point` via /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via
/// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no /// `parametric_constraints::nearest_parametric_point` (a `CadCommand` has no
/// document access) and, for `CenterPoint` specifically, addresses /// document access) and, for `CenterPoint` specifically, addresses
/// `target` via its center marker rather than as a whole entity — see /// `target` via its center marker rather than as a whole entity — see
/// `ConstraintKind::CenterPoint`'s own doc comment for why it's the /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the
@@ -1477,14 +1507,14 @@ pub enum CmdResult {
AddPointOnEntityConstraint { AddPointOnEntityConstraint {
point: DVec3, point: DVec3,
target: Handle, target: Handle,
kind: crate::scene::sketch_constraints::ConstraintKind, kind: crate::scene::parametric_constraints::ConstraintKind,
label: &'static str, label: &'static str,
}, },
/// Add a persistent `EqualDistance` constraint /// Add a persistent `EqualDistance` constraint
/// (`crate::modules::parametric::equal_distance`): the distance /// (`crate::modules::parametric::equal_distance`): the distance
/// between `points[0]`/`points[1]` equals the distance between /// between `points[0]`/`points[1]` equals the distance between
/// `points[2]`/`points[3]`. The host resolves each point via /// `points[2]`/`points[3]`. The host resolves each point via
/// `sketch_constraints::nearest_sketch_point`, same reasoning as /// `parametric_constraints::nearest_parametric_point`, same reasoning as
/// `AddCoincidentConstraint`. /// `AddCoincidentConstraint`.
AddEqualDistanceConstraint { AddEqualDistanceConstraint {
points: [DVec3; 4], points: [DVec3; 4],
@@ -1516,7 +1546,10 @@ pub enum CmdResult {
/// it owns the document needed for the table-style lookup — honors /// it owns the document needed for the table-style lookup — honors
/// content locks, and launches the cell editor, re-prompting the /// content locks, and launches the cell editor, re-prompting the
/// command when the pick misses a cell. /// command when the pick misses a cell.
EditTableCell { handle: Handle, point: DVec3 }, EditTableCell {
handle: Handle,
point: DVec3,
},
/// Cancel because the active drawing space changed. Cleanup is identical /// Cancel because the active drawing space changed. Cleanup is identical
/// to `Cancel`, but the host reports the context change explicitly. /// to `Cancel`, but the host reports the context change explicitly.
CancelForSpaceChange, CancelForSpaceChange,
@@ -1539,13 +1572,24 @@ pub enum CmdResult {
/// `Relaunch` it does not touch the selection. /// `Relaunch` it does not touch the selection.
Dispatch(String), Dispatch(String),
/// Move `dest` entities to the layer of the `src` entity; end command. /// Move `dest` entities to the layer of the `src` entity; end command.
MatchEntityLayer { dest: Vec<Handle>, src: Handle }, MatchEntityLayer {
dest: Vec<Handle>,
src: Handle,
},
/// Copy all visual properties (layer/color/linetype/lineweight) from `src` to `dest`; end command. /// Copy all visual properties (layer/color/linetype/lineweight) from `src` to `dest`; end command.
MatchProperties { dest: Vec<Handle>, src: Handle }, MatchProperties {
dest: Vec<Handle>,
src: Handle,
},
/// Create a named group from the given entity handles; end command. /// Create a named group from the given entity handles; end command.
CreateGroup { handles: Vec<Handle>, name: String }, CreateGroup {
handles: Vec<Handle>,
name: String,
},
/// Dissolve all groups that contain any of the given handles; end command. /// Dissolve all groups that contain any of the given handles; end command.
DeleteGroups { handles: Vec<Handle> }, DeleteGroups {
handles: Vec<Handle>,
},
/// Freeze or thaw layers by name in the given viewport; command stays active. /// Freeze or thaw layers by name in the given viewport; command stays active.
VpLayerUpdate { VpLayerUpdate {
vp_handle: Handle, vp_handle: Handle,
@@ -1553,9 +1597,14 @@ pub enum CmdResult {
thaw: Vec<String>, thaw: Vec<String>,
}, },
/// Paste clipboard entities translated so their centroid lands at `base_pt`; end command. /// Paste clipboard entities translated so their centroid lands at `base_pt`; end command.
PasteClipboard { base_pt: DVec3 }, PasteClipboard {
base_pt: DVec3,
},
/// Zoom the model-space camera to fit the given corner points; end command. /// Zoom the model-space camera to fit the given corner points; end command.
ZoomToWindow { p1: DVec3, p2: DVec3 }, ZoomToWindow {
p1: DVec3,
p2: DVec3,
},
/// Print a measurement result to the command line and end the command. /// Print a measurement result to the command line and end the command.
Measurement(String), Measurement(String),
/// Print a measurement result and keep the command active. /// Print a measurement result and keep the command active.
@@ -1567,20 +1616,36 @@ pub enum CmdResult {
/// Clear the current selection and keep the command active at its updated step. /// Clear the current selection and keep the command active at its updated step.
DeselectAndContinue, DeselectAndContinue,
/// Break `handle` at points `p1` and `p2`; replace with computed fragments. /// Break `handle` at points `p1` and `p2`; replace with computed fragments.
BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, BreakEntity {
handle: Handle,
p1: DVec3,
p2: DVec3,
},
/// Attempt to join the given entities into fewer merged entities. /// Attempt to join the given entities into fewer merged entities.
JoinEntities(Vec<Handle>), JoinEntities(Vec<Handle>),
/// Join candidates into an explicitly selected source. /// Join candidates into an explicitly selected source.
JoinToSource { source: Handle, handles: Vec<Handle> }, JoinToSource {
source: Handle,
handles: Vec<Handle>,
},
/// Apply a polyline-edit operation to one entity; keep command active. /// Apply a polyline-edit operation to one entity; keep command active.
PeditOp { PeditOp {
handle: Handle, handle: Handle,
op: crate::modules::draw::modify::pedit::PeditOp, op: crate::modules::draw::modify::pedit::PeditOp,
}, },
/// Place Point entities at N equal intervals along the entity. /// Place Point entities at N equal intervals along the entity.
DivideEntity { handle: Handle, n: usize, marker: Option<CurveMarker> }, DivideEntity {
handle: Handle,
n: usize,
marker: Option<CurveMarker>,
},
/// Place Point entities at `segment_length` intervals along the entity. /// Place Point entities at `segment_length` intervals along the entity.
MeasureEntity { handle: Handle, segment_length: f64, pick_point: DVec3, marker: Option<CurveMarker> }, MeasureEntity {
handle: Handle,
segment_length: f64,
pick_point: DVec3,
marker: Option<CurveMarker>,
},
/// Extend/trim a Line or Arc by the given mode; end command. /// Extend/trim a Line or Arc by the given mode; end command.
LengthenEntity { LengthenEntity {
handle: Handle, handle: Handle,
@@ -1596,7 +1661,10 @@ pub enum CmdResult {
scale: f64, scale: f64,
}, },
/// Set the plot window on the active layout's PlotSettings. /// Set the plot window on the active layout's PlotSettings.
SetPlotWindow { p1: DVec3, p2: DVec3 }, SetPlotWindow {
p1: DVec3,
p2: DVec3,
},
/// Create a paper-space viewport. `preserve_view` keeps an explicitly /// Create a paper-space viewport. `preserve_view` keeps an explicitly
/// selected/defined view instead of applying the normal model-extents fit. /// selected/defined view instead of applying the normal model-extents fit.
MviewCreate { MviewCreate {
@@ -1623,7 +1691,10 @@ pub enum CmdResult {
/// Quick-print the bounding box of the given selected entities to a PDF. /// Quick-print the bounding box of the given selected entities to a PDF.
QuickPrint(Vec<Handle>), QuickPrint(Vec<Handle>),
/// Replace the text content of a Text/MText entity in-place. /// Replace the text content of a Text/MText entity in-place.
DdeditEntity { handle: Handle, new_text: String }, DdeditEntity {
handle: Handle,
new_text: String,
},
/// Apply one DIMEDIT operation to every selected dimension. /// Apply one DIMEDIT operation to every selected dimension.
EditDimensions { EditDimensions {
handles: Vec<Handle>, handles: Vec<Handle>,
@@ -1631,7 +1702,9 @@ pub enum CmdResult {
}, },
/// Open the in-place editor (plain box or rich MText editor, per type) for /// Open the in-place editor (plain box or rich MText editor, per type) for
/// a text-bearing entity picked by a command such as DDEDIT. /// a text-bearing entity picked by a command such as DDEDIT.
EditTextEntity { handle: Handle }, EditTextEntity {
handle: Handle,
},
/// Open the in-place MText editor (formatting toolbar + multi-line text /// Open the in-place MText editor (formatting toolbar + multi-line text
/// area with live viewport preview). `handle` is `Some` when editing an /// area with live viewport preview). `handle` is `Some` when editing an
/// existing MText, `None` when creating a new one at `pos`. /// existing MText, `None` when creating a new one at `pos`.
@@ -1784,17 +1857,18 @@ pub enum CmdResult {
/// INSERT landed on a block that has AttributeDefinitions. /// INSERT landed on a block that has AttributeDefinitions.
/// The host should look up the attdefs for `block_name` from the document /// The host should look up the attdefs for `block_name` from the document
/// and call `attreq_set_attdefs()` on the command, then loop on text input. /// and call `attreq_set_attdefs()` on the command, then loop on text input.
AttreqNeeded { block_name: String }, AttreqNeeded {
block_name: String,
},
/// Add a command-owned "live" entity to the document mid-command and hand /// Add a command-owned "live" entity to the document mid-command and hand
/// its assigned handle back to the active command via `set_live_handle()`. /// its assigned handle back to the active command via `set_live_handle()`.
/// One undo snapshot is pushed here, so the whole in-progress object reverts /// One undo snapshot is pushed here, so the whole in-progress object reverts
/// as a single unit. The command stays active. Used by PLINE so the partial /// as a single unit. The command stays active. Used by PLINE so the partial
/// polyline is a real, snappable entity while later vertices are placed. /// polyline is a real, snappable entity while later vertices are placed.
CommitLiveEntity(EntityType), CommitLiveEntity(EntityType),
/// Replace the geometry of the live entity `handle` in place — preserving /// Replace the geometry of the live entity `handle` in place while
/// its layer — without pushing a new undo snapshot. When `finish` is true /// preserving its document identity and common display properties. No new
/// the command also exits (the entity is already committed, so no separate /// undo snapshot is pushed. When `finish` is true the command also exits.
/// commit is needed).
UpdateLiveEntity { UpdateLiveEntity {
handle: Handle, handle: Handle,
entity: EntityType, entity: EntityType,
@@ -1808,7 +1882,9 @@ pub enum CmdResult {
/// PLINE's Undo popping back below the two vertices an entity needs. /// PLINE's Undo popping back below the two vertices an entity needs.
RemoveLiveEntity(Handle), RemoveLiveEntity(Handle),
/// Suspends command execution, moves it to suspended_cmd, and opens the text editor for the given handle. /// Suspends command execution, moves it to suspended_cmd, and opens the text editor for the given handle.
SuspendForTextEdit { handle: Handle }, SuspendForTextEdit {
handle: Handle,
},
/// Requests a standard document-level undo while keeping the command active. /// Requests a standard document-level undo while keeping the command active.
UndoDocument, UndoDocument,
/// Sets the TEXTEDITMODE system variable and ends the command. /// Sets the TEXTEDITMODE system variable and ends the command.
@@ -2048,7 +2124,6 @@ pub struct CurveMarker {
pub plane: WorkingPlane, pub plane: WorkingPlane,
} }
/// Current screen projection and configured aperture for point-feature picking. /// Current screen projection and configured aperture for point-feature picking.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct PointPickContext { pub struct PointPickContext {
@@ -2060,10 +2135,16 @@ pub struct PointPickContext {
pub trait CadCommand: Send { pub trait CadCommand: Send {
/// Preserve source appearance for commands that extract existing entities. /// Preserve source appearance for commands that extract existing entities.
fn preserve_commit_style(&self) -> bool { false } fn preserve_commit_style(&self) -> bool {
fn nested_copy_bind_setting(&self) -> Option<bool> { None } false
}
fn nested_copy_bind_setting(&self) -> Option<bool> {
None
}
/// Symbol localization is committed together with the extracted entities. /// Symbol localization is committed together with the extracted entities.
fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None } fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> {
None
}
/// Keep the layer already carried by entities committed by this command /// Keep the layer already carried by entities committed by this command
/// instead of replacing it with the current drawing layer. /// instead of replacing it with the current drawing layer.
fn preserve_commit_layer(&self) -> bool { fn preserve_commit_layer(&self) -> bool {
@@ -2131,7 +2212,9 @@ pub trait CadCommand: Send {
fn on_point(&mut self, pt: DVec3) -> CmdResult; fn on_point(&mut self, pt: DVec3) -> CmdResult;
/// Opt in only while a point input selects an existing point feature. /// Opt in only while a point input selects an existing point feature.
fn wants_point_pick_context(&self) -> bool { false } fn wants_point_pick_context(&self) -> bool {
false
}
/// Refreshed for each point input so zoom and viewport changes are reflected. /// Refreshed for each point input so zoom and viewport changes are reflected.
fn set_point_pick_context(&mut self, _context: Option<PointPickContext>) {} fn set_point_pick_context(&mut self, _context: Option<PointPickContext>) {}
@@ -2211,7 +2294,10 @@ pub trait CadCommand: Send {
} }
fn on_deferred_entity_hover( fn on_deferred_entity_hover(
&mut self, _scene: &Scene, _handle: Option<Handle>, _point: DVec3, &mut self,
_scene: &Scene,
_handle: Option<Handle>,
_point: DVec3,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
Vec::new() Vec::new()
} }
@@ -2425,7 +2511,9 @@ pub trait CadCommand: Send {
} }
/// Exclude locked-layer entities from injected selection geometry. /// Exclude locked-layer entities from injected selection geometry.
fn selection_entities_exclude_locked(&self) -> bool { false } fn selection_entities_exclude_locked(&self) -> bool {
false
}
fn inject_selection_entities(&mut self, _entities: Vec<SelectionEntity>) {} fn inject_selection_entities(&mut self, _entities: Vec<SelectionEntity>) {}
@@ -2433,9 +2521,7 @@ pub trait CadCommand: Send {
None None
} }
fn hatch_preview_models( fn hatch_preview_models(&self) -> Option<Vec<crate::scene::model::hatch_model::HatchModel>> {
&self,
) -> Option<Vec<crate::scene::model::hatch_model::HatchModel>> {
None None
} }
@@ -2622,6 +2708,8 @@ mod constraint_registry_tests {
let names = all_registered_command_names(); let names = all_registered_command_names();
for id in [ for id in [
"CCONSTRAINT", "CCONSTRAINT",
"GCCOINCIDENT",
"GEOMCONSTRAINT",
"EDCONSTRAINT", "EDCONSTRAINT",
"CPCONSTRAINT", "CPCONSTRAINT",
"MPCONSTRAINT", "MPCONSTRAINT",
@@ -2640,7 +2728,10 @@ mod constraint_registry_tests {
"DCONSTRAINT", "DCONSTRAINT",
"ACONSTRAINT", "ACONSTRAINT",
] { ] {
assert!(names.contains(&id), "{id} is missing from the command registry"); assert!(
names.contains(&id),
"{id} is missing from the command registry"
);
} }
} }
} }
+139
View File
@@ -3505,6 +3505,26 @@ fn tessellate_dimension_inner(
return wires; return wires;
} }
} }
if let Some((local, normal)) = linear_dimension_in_ocs(dim) {
let mut wires = tessellate_dimension_inner(
document,
handle,
&local,
selected,
entity_color,
line_weight_px,
anno_scale,
selected_set,
active_viewport,
bg_color,
None,
world_per_pixel,
);
for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal);
}
return wires;
}
let name = handle.value().to_string(); let name = handle.value().to_string();
// (Baked-block fast path moved up into scene::tessellate_entity so the // (Baked-block fast path moved up into scene::tessellate_entity so the
// recursive call goes through the LOD ladder, not the kernel path.) // recursive call goes through the LOD ladder, not the kernel path.)
@@ -5300,6 +5320,50 @@ pub(crate) fn large_radial_dimension_in_ocs(
Some((local, normal)) Some((local, normal))
} }
/// A linear or aligned dimension in a plane off world Z, moved into its own
/// OCS. Their builders read the offset and extension directions in XY, so
/// they draw it there and the result is mapped back onto the plane. `None`
/// for a dimension in world XY or of another kind.
pub(crate) fn linear_dimension_in_ocs(dimension: &Dimension) -> Option<(Dimension, Vector3)> {
let length = dimension.base().normal.length();
if !length.is_finite() || length <= 1.0e-12 {
return None;
}
let normal = dimension.base().normal / length;
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
return None;
}
let normal_tuple = (normal.x, normal.y, normal.z);
let to_ocs = |point: Vector3| {
let point = crate::scene::view::transform::wcs_point_to_ocs(
(point.x, point.y, point.z),
normal_tuple,
);
Vector3::new(point.0, point.1, point.2)
};
let mut local = dimension.clone();
match &mut local {
Dimension::Linear(d) => {
d.first_point = to_ocs(d.first_point);
d.second_point = to_ocs(d.second_point);
d.definition_point = to_ocs(d.definition_point);
d.base.definition_point = d.definition_point;
d.base.text_middle_point = to_ocs(d.base.text_middle_point);
d.base.normal = Vector3::UNIT_Z;
}
Dimension::Aligned(d) => {
d.first_point = to_ocs(d.first_point);
d.second_point = to_ocs(d.second_point);
d.definition_point = to_ocs(d.definition_point);
d.base.definition_point = d.definition_point;
d.base.text_middle_point = to_ocs(d.base.text_middle_point);
d.base.normal = Vector3::UNIT_Z;
}
_ => return None,
}
Some((local, normal))
}
fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
let normal_tuple = (normal.x, normal.y, normal.z); let normal_tuple = (normal.x, normal.y, normal.z);
let map = |x: f64, y: f64, z: f64| { let map = |x: f64, y: f64, z: f64| {
@@ -7768,4 +7832,79 @@ mod linear_transform_tests {
); );
assert_measures_ten(&dim); assert_measures_ten(&dim);
} }
/// Every stroke point the dimension draws, with its low residual added.
fn stroke_points(dim: &Dimension) -> Vec<DVec3> {
let document = CadDocument::new();
let wires = dim.tessellate(
&document,
Handle::new(0x40),
false,
[1.0; 4],
1.0,
1.0,
&rustc_hash::FxHashSet::default(),
None,
[0.0, 0.0, 0.0, 1.0],
None,
None,
);
let mut points = Vec::new();
for wire in &wires {
for (index, high) in wire.points.iter().enumerate() {
if !high[0].is_finite() {
continue;
}
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
points.push(DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
));
}
}
points
}
/// Turned 90° about X into the XZ plane, the dimension has to draw the
/// same strokes turned with it.
fn assert_draws_turned_with_its_plane(flat: Dimension) {
let turn = glam::DQuat::from_axis_angle(DVec3::X, FRAC_PI_2);
let expected: Vec<DVec3> = stroke_points(&flat).into_iter().map(|p| turn * p).collect();
assert!(!expected.is_empty(), "the dimension draws no strokes");
let mut turned = flat;
apply_transform(
&mut turned,
&EntityTransform::Rotate {
center: DVec3::ZERO,
axis: DVec3::X,
angle_rad: FRAC_PI_2,
},
);
let drawn = stroke_points(&turned);
assert_eq!(drawn.len(), expected.len(), "stroke point count");
for (index, (got, want)) in drawn.iter().zip(&expected).enumerate() {
assert!(
got.abs_diff_eq(*want, 1e-3),
"point {index}: expected {want:?}, got {got:?}"
);
}
}
/// A linear dimension in a working plane off world Z draws in that plane.
/// Its offset used to be read in world XY only, so once it ran along Z the
/// dimension line fell onto the measured points and the extension lines
/// stuck out along world Y.
#[test]
fn a_linear_dimension_off_world_z_draws_in_its_own_plane() {
assert_draws_turned_with_its_plane(horizontal());
}
/// An aligned dimension draws through the same builder and plane.
#[test]
fn an_aligned_dimension_off_world_z_draws_in_its_own_plane() {
let mut d = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(6.0, 8.0, 0.0));
d.definition_point = Vector3::new(2.0, 11.0, 0.0);
assert_draws_turned_with_its_plane(Dimension::Aligned(d));
}
} }
+16
View File
@@ -214,6 +214,9 @@ fn properties(line: &Line) -> Vec<PropSection> {
ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")), ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")),
ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())),
ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")), ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")),
edit(t!("Normal X").as_ref(), "normal_x", line.normal.x),
edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y),
edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z),
], ],
}] }]
} }
@@ -286,6 +289,19 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) {
"end_x" => line.end.x = v, "end_x" => line.end.x = v,
"end_y" => line.end.y = v, "end_y" => line.end.y = v,
"end_z" => line.end.z = v, "end_z" => line.end.z = v,
"normal_x" | "normal_y" | "normal_z" => {
let mut normal = glam::DVec3::new(line.normal.x, line.normal.y, line.normal.z);
match field {
"normal_x" => normal.x = v,
"normal_y" => normal.y = v,
"normal_z" => normal.z = v,
_ => unreachable!(),
}
if normal.is_finite() && normal.length_squared() > 1.0e-18 {
let normal = normal.normalize();
line.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
}
}
_ => {} _ => {}
} }
} }
+280 -175
View File
@@ -13,6 +13,7 @@ use crate::scene::model::hatch_model::HatchModel;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::scene::model::hatch_model::HatchPattern; use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::WireModel; use crate::scene::WireModel;
use crate::scene::model::image_model::ImageModel;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use printpdf::{ use printpdf::{
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
@@ -37,25 +38,18 @@ impl std::ops::Deref for PlotWire {
} }
} }
/// Decoded image geometry plus inherited block/viewport clip boundaries.
#[derive(Clone, Debug)]
pub struct PlotImage {
pub image: ImageModel,
pub clips: Vec<Vec<[f64; 2]>>,
}
// The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via // The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via
// lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web // lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web
// build gets these stubs so the call sites still compile. // build gets these stubs so the call sites still compile.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn export_pdf( pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> {
_wires: &[PlotWire],
_hatches: &[HatchModel],
_wipeouts: &[HatchModel],
_paper_w: f64,
_paper_h: f64,
_offset_x: f64,
_offset_y: f64,
_rotation_deg: i32,
_scale: f32,
_clip: Option<(f32, f32, f32, f32)>,
_path: &Path,
_plot_style: Option<&PlotStyleTable>,
_options: PdfPlotOptions,
) -> Result<(), String> {
Err("PDF export is not available in the web version.".into()) Err("PDF export is not available in the web version.".into())
} }
@@ -94,7 +88,6 @@ pub struct PdfPlotOptions {
pub transparency: bool, pub transparency: bool,
pub stamp: bool, pub stamp: bool,
pub merge_lines: bool, pub merge_lines: bool,
pub group_splits: PlotGroupSplits,
} }
/// End indexes of the first paper/model render group in each flat input list. /// End indexes of the first paper/model render group in each flat input list.
@@ -103,16 +96,26 @@ pub struct PlotGroupSplits {
pub wires: usize, pub wires: usize,
pub hatches: usize, pub hatches: usize,
pub wipeouts: usize, pub wipeouts: usize,
pub images: usize,
} }
/// Owned render data for one page in a multi-page PDF. #[derive(Default)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct PlotContent {
pub struct PdfPageInput {
pub wires: std::sync::Arc<Vec<PlotWire>>, pub wires: std::sync::Arc<Vec<PlotWire>>,
pub hatches: Vec<HatchModel>, pub hatches: Vec<HatchModel>,
pub wipeouts: Vec<HatchModel>, pub wipeouts: Vec<HatchModel>,
pub images: Vec<PlotImage>,
pub group_splits: PlotGroupSplits,
}
/// Owned geometry and settings for one PDF page.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub struct PdfPageInput {
pub content: PlotContent,
/// Page dimensions in mm, after any 90/270-degree rotation.
pub paper_w: f64, pub paper_w: f64,
pub paper_h: f64, pub paper_h: f64,
/// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates.
pub offset_x: f64, pub offset_x: f64,
pub offset_y: f64, pub offset_y: f64,
pub rotation_deg: i32, pub rotation_deg: i32,
@@ -130,50 +133,16 @@ impl Default for PdfPlotOptions {
transparency: false, transparency: false,
stamp: false, stamp: false,
merge_lines: false, merge_lines: false,
group_splits: PlotGroupSplits::default(),
} }
} }
} }
// ── Public entry point ──────────────────────────────────────────────────── // ── Public entry point ────────────────────────────────────────────────────
/// Export `wires` to a PDF file. /// Export one page to a PDF file.
///
/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller).
/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing
/// origin maps to the bottom-left corner of the page.
/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn export_pdf( pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> {
wires: &[PlotWire], export_pdf_pages(std::slice::from_ref(page), path, None)
hatches: &[HatchModel],
wipeouts: &[HatchModel],
paper_w: f64,
paper_h: f64,
offset_x: f64,
offset_y: f64,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
path: &Path,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
) -> Result<(), String> {
let bytes = build_pdf(
wires,
hatches,
wipeouts,
paper_w as f32,
paper_h as f32,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
plot_style,
options,
);
write_pdf_atomically(path, &bytes)
} }
/// Export several independently sized pages into one PDF file. /// Export several independently sized pages into one PDF file.
@@ -186,7 +155,7 @@ pub fn export_pdf_pages(
if pages.is_empty() { if pages.is_empty() {
return Err("No pages were selected.".into()); return Err("No pages were selected.".into());
} }
let bytes = build_pdf_pages(pages, plot_style); let bytes = build_pdf_pages(pages, plot_style)?;
write_pdf_atomically(path, &bytes) write_pdf_atomically(path, &bytes)
} }
@@ -205,6 +174,202 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(not(target_arch = "wasm32"))]
fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: rings
.into_iter()
.map(|ring| PolygonRing {
points: ring
.into_iter()
.map(|[x, y]| LinePoint {
p: Point { x: Pt(x), y: Pt(y) },
bezier: false,
})
.collect(),
})
.collect(),
mode: PaintMode::Clip,
winding_order: WindingOrder::EvenOdd,
},
});
}
#[cfg(not(target_arch = "wasm32"))]
fn emit_image(
doc: &mut PdfDocument,
resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
ops: &mut Vec<Op>,
plot: &PlotImage,
ox: f64,
oy: f64,
options: PdfPlotOptions,
) -> Result<(), String> {
let image = &plot.image;
let expected_byte_count = (image.width as usize)
.checked_mul(image.height as usize)
.and_then(|n| n.checked_mul(4));
if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) {
return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into());
}
if image.verts.len() < 3 || image.verts.len() % 3 != 0 {
return Err("Cannot plot bitmap: incomplete triangle geometry.".into());
}
if options.transparency && image.opacity <= 0.0 {
return Ok(());
}
let to_page = |high: [f32; 3], low: [f32; 3]| {
[
((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32,
((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32,
]
};
let corners: [[f32; 2]; 4] =
std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i]));
if !corners.iter().flatten().all(|v| v.is_finite())
|| !image.verts.iter().all(|v| {
to_page(v.pos, v.pos_low)
.iter()
.chain(&v.uv)
.all(|c| c.is_finite())
})
|| !plot
.clips
.iter()
.all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite()))
|| !image.opacity.is_finite()
{
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
}
let key = (
std::sync::Arc::as_ptr(&image.pixels) as usize,
image.width,
image.height,
);
let id = resources
.entry(key)
.or_insert_with(|| {
let id = printpdf::XObjectId::new();
// add_image clones its argument; the public map accepts owned pixels.
doc.resources.xobjects.map.insert(
id.clone(),
printpdf::XObject::Image(printpdf::RawImage {
pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()),
width: image.width as usize,
height: image.height as usize,
data_format: printpdf::RawImageFormat::RGBA8,
tag: Vec::new(),
}),
);
id
})
.clone();
ops.push(Op::SaveGraphicsState);
if options.transparency && image.opacity < 1.0 {
// printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside
// this image's saved state so the nonstroking alpha is correct.
let alpha = image.opacity.clamp(0.0, 1.0);
let gs = doc.add_graphics_state(
ExtendedGraphicsState::default()
.with_current_fill_alpha(alpha)
.with_current_stroke_alpha(alpha),
);
ops.push(Op::LoadGraphicsState { gs });
}
for clip in &plot.clips {
image_clip(
ops,
vec![clip
.iter()
.map(|p| {
[
((p[0] + ox) * MM_TO_PT as f64) as f32,
((p[1] + oy) * MM_TO_PT as f64) as f32,
]
})
.collect()],
);
}
let draw = |ops: &mut Vec<Op>, matrix: [f32; 6]| {
ops.push(Op::SetTransformationMatrix {
matrix: printpdf::CurTransMat::Raw(matrix),
});
ops.push(Op::UseXobject {
id: id.clone(),
// Cancel printpdf's pixel-size transform: matrix maps the PDF
// image's unit square directly onto the CAD quad in page points.
transform: printpdf::XObjectTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / image.width as f32),
scale_y: Some(1.0 / image.height as f32),
..Default::default()
},
});
};
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
let affine = (0..2).all(|axis| {
(corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs()
<= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0)
});
if affine {
// A single draw preserves intrinsic IMAGE clipping without overlapping image draws.
image_clip(
ops,
image
.verts
.chunks_exact(3)
.map(|tri| {
tri.iter()
.map(|vertex| to_page(vertex.pos, vertex.pos_low))
.collect()
})
.collect(),
);
draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]);
} else {
// PDF transforms are affine; perspective sampling is approximate.
for triangle in image.verts.chunks_exact(3) {
let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low));
let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]);
let matrix = image_triangle_matrix(page, uv)
.filter(|matrix| matrix.iter().all(|v| v.is_finite()))
.ok_or("Cannot plot bitmap: degenerate texture mapping.")?;
ops.push(Op::SaveGraphicsState);
image_clip(ops, vec![page.to_vec()]);
draw(ops, matrix);
ops.push(Op::RestoreGraphicsState);
}
}
ops.push(Op::RestoreGraphicsState);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> {
let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = u[0] * v[1] - v[0] * u[1];
if determinant.abs() < 1e-12 {
return None;
}
let axes: [[f32; 2]; 2] = std::array::from_fn(|i| {
[
((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant,
(u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant,
]
});
Some([
axes[0][0],
axes[1][0],
axes[0][1],
axes[1][1],
p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1],
p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1],
])
}
/// Show a parented PDF save-file dialog and return the chosen path. /// Show a parented PDF save-file dialog and return the chosen path.
/// ///
/// The parent comes from `iced::window::run`, keeping the portal request tied /// The parent comes from `iced::window::run`, keeping the portal request tied
@@ -230,83 +395,32 @@ pub fn pick_pdf_path_owned(
// ── PDF builder ─────────────────────────────────────────────────────────── // ── PDF builder ───────────────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn build_pdf( fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
wires: &[PlotWire],
hatches: &[HatchModel],
wipeouts: &[HatchModel],
paper_w: f32,
paper_h: f32,
// Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6
// where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself
// already quantised before it can cancel the coordinate it is meant to cancel.
ox: f64,
oy: f64,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
) -> Vec<u8> {
let mut doc = PdfDocument::new("Open CAD Studio Export"); let mut doc = PdfDocument::new("Open CAD Studio Export");
append_pdf_page( // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
&mut doc, // Allocation addresses cannot be reused by another source during this export.
wires, let mut image_resources = std::collections::HashMap::new();
hatches, for (index, page) in pages.iter().enumerate() {
wipeouts, append_pdf_page(&mut doc, &mut image_resources, page, plot_style)
paper_w, .map_err(|error| format!("Page {}: {error}", index + 1))?;
paper_h,
ox,
oy,
rotation_deg,
scale,
clip,
plot_style,
options,
);
let mut warnings = Vec::new();
doc.save(&PdfSaveOptions::default(), &mut warnings)
}
#[cfg(not(target_arch = "wasm32"))]
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Vec<u8> {
let mut doc = PdfDocument::new("Open CAD Studio Export");
for page in pages {
append_pdf_page(
&mut doc,
&page.wires,
&page.hatches,
&page.wipeouts,
page.paper_w as f32,
page.paper_h as f32,
page.offset_x,
page.offset_y,
page.rotation_deg,
page.scale,
page.clip,
page.plot_style.as_ref().or(plot_style),
page.options,
);
} }
let mut warnings = Vec::new(); let mut warnings = Vec::new();
doc.save(&PdfSaveOptions::default(), &mut warnings) Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn append_pdf_page( fn append_pdf_page(
doc: &mut PdfDocument, doc: &mut PdfDocument,
wires: &[PlotWire], image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
hatches: &[HatchModel], page: &PdfPageInput,
wipeouts: &[HatchModel], fallback_plot_style: Option<&PlotStyleTable>,
paper_w: f32, ) -> Result<(), String> {
paper_h: f32, let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content;
ox: f64, let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
oy: f64, let (ox, oy) = (page.offset_x, page.offset_y);
rotation_deg: i32, let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip);
scale: f32, let plot_style = page.plot_style.as_ref().or(fallback_plot_style);
clip: Option<(f32, f32, f32, f32)>, let options = page.options;
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
) {
let mut ops: Vec<Op> = Vec::new(); let mut ops: Vec<Op> = Vec::new();
// White page background. // White page background.
@@ -411,23 +525,26 @@ fn append_pdf_page(
let (first_wires, second_wires) = let (first_wires, second_wires) =
wires.split_at(options.group_splits.wires.min(wires.len())); wires.split_at(group_splits.wires.min(wires.len()));
let (first_hatches, second_hatches) = let (first_hatches, second_hatches) =
hatches.split_at(options.group_splits.hatches.min(hatches.len())); hatches.split_at(group_splits.hatches.min(hatches.len()));
let (first_wipeouts, second_wipeouts) = let (first_wipeouts, second_wipeouts) =
wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len())); wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len()));
for (wires, hatches, wipeouts) in [ let (first_images, second_images) =
(first_wires, first_hatches, first_wipeouts), images.split_at(group_splits.images.min(images.len()));
(second_wires, second_hatches, second_wipeouts), for (wires, hatches, wipeouts, images) in [
(first_wires, first_hatches, first_wipeouts, first_images),
(second_wires, second_hatches, second_wipeouts, second_images),
] { ] {
enum DrawItem<'a> { enum DrawItem<'a> {
WireFill(&'a PlotWire), WireFill(&'a PlotWire),
Hatch(&'a HatchModel), Hatch(&'a HatchModel),
Image(&'a PlotImage),
Wire(&'a PlotWire), Wire(&'a PlotWire),
Text(&'a PlotWire), Text(&'a PlotWire),
} }
let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len()); let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len());
let mut sequence = 0usize; let mut sequence = 0usize;
for wire in wires { for wire in wires {
if !wire.fill_tris.is_empty() { if !wire.fill_tris.is_empty() {
@@ -445,6 +562,10 @@ fn append_pdf_page(
draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch))); draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch)));
sequence += 1; sequence += 1;
} }
for image in images {
draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image)));
sequence += 1;
}
draw_items.sort_by(|a, b| { draw_items.sort_by(|a, b| {
a.0.total_cmp(&b.0) a.0.total_cmp(&b.0)
.then_with(|| a.1.cmp(&b.1)) .then_with(|| a.1.cmp(&b.1))
@@ -508,6 +629,10 @@ fn append_pdf_page(
last_dash = None; last_dash = None;
continue; continue;
} }
DrawItem::Image(image) => {
emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?;
continue;
}
DrawItem::Wire(wire) => wire, DrawItem::Wire(wire) => wire,
}; };
let [mut r, mut g, mut b, a] = wire.color; let [mut r, mut g, mut b, a] = wire.color;
@@ -722,6 +847,7 @@ fn append_pdf_page(
let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops); let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops);
doc.pages.push(page); doc.pages.push(page);
Ok(())
} }
/// Build a PDF dash array (in points) from a WireModel linetype pattern. /// Build a PDF dash array (in points) from a WireModel linetype pattern.
@@ -1517,6 +1643,21 @@ fn emit_text(
mod tests { mod tests {
use super::*; use super::*;
fn test_page(wires: Vec<PlotWire>) -> PdfPageInput {
PdfPageInput {
content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() },
paper_w: 210.0,
paper_h: 297.0,
offset_x: 0.0,
offset_y: 0.0,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: PdfPlotOptions::default(),
plot_style: None,
}
}
#[test] #[test]
fn atomic_write_replaces_an_existing_pdf() { fn atomic_write_replaces_an_existing_pdf() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
@@ -1546,20 +1687,10 @@ mod tests {
), ),
draw_depth: 0.0, draw_depth: 0.0,
}; };
let bytes = build_pdf( let mut page = test_page(vec![w]);
&[w], page.scale = 2.0;
&[], page.clip = Some((10.0, 10.0, 100.0, 100.0));
&[], let bytes = build_pdf_pages(&[page], None).unwrap();
210.0,
297.0,
0.0,
0.0,
0,
2.0,
Some((10.0, 10.0, 100.0, 100.0)),
None,
PdfPlotOptions::default(),
);
// A valid PDF is produced (starts with the PDF header) and is non-trivial. // A valid PDF is produced (starts with the PDF header) and is non-trivial.
assert!(bytes.starts_with(b"%PDF"), "not a PDF"); assert!(bytes.starts_with(b"%PDF"), "not a PDF");
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len()); assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
@@ -1594,34 +1725,8 @@ mod tests {
let mut blank = wire.clone(); let mut blank = wire.clone();
blank.wire.text_verts.clear(); blank.wire.text_verts.clear();
let with_text = build_pdf( let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
&[wire], let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap();
&[],
&[],
210.0,
297.0,
0.0,
0.0,
0,
1.0,
None,
None,
PdfPlotOptions::default(),
);
let no_text = build_pdf(
&[blank],
&[],
&[],
210.0,
297.0,
0.0,
0.0,
0,
1.0,
None,
None,
PdfPlotOptions::default(),
);
assert!(with_text.starts_with(b"%PDF")); assert!(with_text.starts_with(b"%PDF"));
assert!( assert!(
with_text.len() > no_text.len(), with_text.len() > no_text.len(),
+24 -8
View File
@@ -357,13 +357,8 @@ impl PlotStyleTable {
let description = self.description.replace(['\r', '\n'], " "); let description = self.description.replace(['\r', '\n'], " ");
s.push_str(&format!("description=\"{description}\n")); s.push_str(&format!("description=\"{description}\n"));
s.push_str("aci_table_available=TRUE\n"); s.push_str("aci_table_available=TRUE\n");
// The factor scales every lineweight when applied, so write it in full, // The factor scales every lineweight when applied, so write it in full.
// keeping the decimal point on a whole value. s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor)));
let mut scale_factor = self.scale_factor.to_string();
if scale_factor.chars().all(|c| c.is_ascii_digit() || c == '-') {
scale_factor.push_str(".0");
}
s.push_str(&format!("scale_factor={scale_factor}\n"));
s.push_str(&format!( s.push_str(&format!(
"apply_factor={}\n", "apply_factor={}\n",
if self.apply_factor { "TRUE" } else { "FALSE" } if self.apply_factor { "TRUE" } else { "FALSE" }
@@ -420,7 +415,7 @@ impl PlotStyleTable {
} }
s.push_str("}\ncustom_lineweight_table{\n"); s.push_str("}\ncustom_lineweight_table{\n");
for (index, weight) in self.lineweights.iter().enumerate() { for (index, weight) in self.lineweights.iter().enumerate() {
s.push_str(&format!(" {index}={weight:.2}\n")); s.push_str(&format!(" {index}={}\n", full_real(*weight)));
} }
s.push_str("}\n"); s.push_str("}\n");
s s
@@ -512,6 +507,15 @@ fn adler32(bytes: &[u8]) -> u32 {
(b << 16) | a (b << 16) | a
} }
/// A real number written in full, keeping the decimal point on a whole value.
fn full_real(value: f32) -> String {
let mut text = value.to_string();
if text.chars().all(|c| c.is_ascii_digit() || c == '-') {
text.push_str(".0");
}
text
}
fn packed_rgb([r, g, b]: [u8; 3]) -> i32 { fn packed_rgb([r, g, b]: [u8; 3]) -> i32 {
u32::from_be_bytes([0xC2, r, g, b]) as i32 u32::from_be_bytes([0xC2, r, g, b]) as i32
} }
@@ -780,4 +784,16 @@ mod round_trip_tests {
assert!(table.to_text().contains("\nscale_factor=1.0\n")); assert!(table.to_text().contains("\nscale_factor=1.0\n"));
assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0); assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0);
} }
/// Saving keeps each custom lineweight as the table holds it. They used to
/// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one.
#[test]
fn custom_lineweights_survive_a_save() {
let mut table = PlotStyleTable::identity("pens.ctb");
table.lineweights[1] = 0.035;
table.aci_entries[1].lineweight = 1;
let reloaded = saved_and_reloaded(&table);
assert_eq!(reloaded.lineweights, table.lineweights);
assert_eq!(reloaded.resolve_lineweight(1), Some(0.035));
}
} }
+140 -52
View File
@@ -9,9 +9,6 @@
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::io::pdf_export; use crate::io::pdf_export;
use crate::io::plot_style::PlotStyleTable;
use crate::scene::model::hatch_model::HatchModel;
use crate::io::pdf_export::PlotWire;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf {
@@ -50,8 +47,6 @@ pub struct PrintOptions {
/// setting wins, so the field is legitimately unread there. /// setting wins, so the field is legitimately unread there.
#[cfg_attr(target_os = "windows", allow(dead_code))] #[cfg_attr(target_os = "windows", allow(dead_code))]
pub quality: Option<String>, pub quality: Option<String>,
/// Controls applied while building the intermediate PDF.
pub render: crate::io::pdf_export::PdfPlotOptions,
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -60,19 +55,8 @@ pub fn list_printers() -> Vec<String> {
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
#[allow(clippy::too_many_arguments)]
pub async fn print_wires_with( pub async fn print_wires_with(
_wires: std::sync::Arc<Vec<PlotWire>>, _page: crate::io::pdf_export::PdfPageInput,
_hatches: Vec<HatchModel>,
_wipeouts: Vec<HatchModel>,
_paper_w: f64,
_paper_h: f64,
_offset_x: f64,
_offset_y: f64,
_rotation_deg: i32,
_scale: f32,
_clip: Option<(f32, f32, f32, f32)>,
_plot_style: Option<PlotStyleTable>,
_opts: PrintOptions, _opts: PrintOptions,
) -> Result<String, String> { ) -> Result<String, String> {
Err("Printing is not available in the web version.".into()) Err("Printing is not available in the web version.".into())
@@ -89,8 +73,7 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu
} }
/// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; /// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`;
/// Windows returns an empty list (the "printto" dispatch targets a named /// Windows queries the spooler's cached local and connected printer list.
/// printer directly and the system default is always available).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn list_printers() -> Vec<String> { pub fn list_printers() -> Vec<String> {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
@@ -111,7 +94,140 @@ pub fn list_printers() -> Vec<String> {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
Vec::new() windows_printers().unwrap_or_else(|error| {
eprintln!("Could not enumerate printers: {error}");
Vec::new()
})
}
}
#[cfg(target_os = "windows")]
fn windows_printers() -> std::io::Result<Vec<String>> {
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
use windows_sys::Win32::Graphics::Printing::{
EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL,
};
let mut bytes_needed = 0;
let mut printer_count = 0;
let mut buffer = Vec::<usize>::new();
// The printer list can grow between the size query and the data query.
for _ in 0..4 {
let buffer_bytes = u32::try_from(std::mem::size_of_val(buffer.as_slice()))
.map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?;
let data = if buffer.is_empty() {
std::ptr::null_mut()
} else {
buffer.as_mut_ptr().cast::<u8>()
};
// SAFETY: data is null for the size query, otherwise it points to an
// aligned, writable allocation of buffer_bytes bytes.
let success = unsafe {
EnumPrintersW(
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
std::ptr::null(),
4,
data,
buffer_bytes,
&mut bytes_needed,
&mut printer_count,
)
};
if success != 0 {
return windows_printer_names(&buffer, printer_count as usize);
}
let error = unsafe { GetLastError() };
if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes {
return Err(std::io::Error::from_raw_os_error(error as i32));
}
buffer.resize(
(bytes_needed as usize).div_ceil(std::mem::size_of::<usize>()),
0,
);
}
Err(std::io::Error::from_raw_os_error(
ERROR_INSUFFICIENT_BUFFER as i32,
))
}
#[cfg(target_os = "windows")]
fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Result<Vec<String>> {
use std::io::{Error, ErrorKind};
use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W;
const {
assert!(std::mem::align_of::<usize>() >= std::mem::align_of::<PRINTER_INFO_4W>());
}
let buffer_bytes = std::mem::size_of_val(buffer);
if printer_count > buffer_bytes / std::mem::size_of::<PRINTER_INFO_4W>() {
return Err(Error::from(ErrorKind::InvalidData));
}
// SAFETY: the initialized buffer is aligned and large enough for these
// records. Raw pointer fields are validated before reading any names.
let records = unsafe {
std::slice::from_raw_parts(buffer.as_ptr().cast::<PRINTER_INFO_4W>(), printer_count)
};
let mut names = Vec::with_capacity(printer_count);
for record in records {
if record.pPrinterName.is_null() {
continue;
}
let offset = (record.pPrinterName as usize)
.checked_sub(buffer.as_ptr() as usize)
.filter(|offset| *offset < buffer_bytes && offset % 2 == 0)
.ok_or(Error::from(ErrorKind::InvalidData))?;
// SAFETY: offset is UTF-16 aligned and the slice ends within buffer.
let utf16 = unsafe {
std::slice::from_raw_parts(
buffer.as_ptr().cast::<u8>().add(offset).cast::<u16>(),
(buffer_bytes - offset) / 2,
)
};
let length = utf16
.iter()
.position(|&unit| unit == 0)
.ok_or(Error::from(ErrorKind::InvalidData))?;
if length != 0 {
names.push(String::from_utf16_lossy(&utf16[..length]));
}
}
names.sort();
names.dedup();
Ok(names)
}
#[cfg(all(test, target_os = "windows"))]
mod printer_buffer_tests {
use super::windows_printer_names;
use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W;
#[test]
fn printer_names_stay_within_the_returned_buffer() {
assert!(windows_printer_names(&[], 0).unwrap().is_empty());
assert!(windows_printer_names(&[], 1).is_err());
let mut buffer = vec![0usize; 16];
let name_offset = std::mem::size_of::<PRINTER_INFO_4W>();
let name: Vec<u16> = "Printer \u{03b1}\0".encode_utf16().collect();
// SAFETY: the aligned allocation holds the record and UTF-16 name.
unsafe {
let record = buffer.as_mut_ptr().cast::<PRINTER_INFO_4W>();
let name_ptr = buffer
.as_mut_ptr()
.cast::<u8>()
.add(name_offset)
.cast::<u16>();
std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len());
(*record).pPrinterName = name_ptr;
assert_eq!(
windows_printer_names(&buffer, 1).unwrap(),
["Printer \u{03b1}"]
);
(*record).pPrinterName = buffer.as_mut_ptr().cast::<u16>().wrapping_sub(1);
assert!(windows_printer_names(&buffer, 1).is_err());
(*record).pPrinterName = name_ptr;
}
buffer[name_offset / std::mem::size_of::<usize>()..].fill(usize::MAX);
assert!(windows_printer_names(&buffer, 1).is_err());
} }
} }
@@ -172,46 +288,18 @@ pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> {
Err("Printer properties are not available in the web version.".into()) Err("Printer properties are not available in the web version.".into())
} }
/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, /// Render a page and send it to the selected printer.
/// grayscale, quality, DPI).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
pub async fn print_wires_with( pub async fn print_wires_with(
wires: std::sync::Arc<Vec<PlotWire>>, page: crate::io::pdf_export::PdfPageInput,
hatches: Vec<HatchModel>,
wipeouts: Vec<HatchModel>,
paper_w: f64,
paper_h: f64,
offset_x: f64,
offset_y: f64,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
plot_style: Option<PlotStyleTable>,
opts: PrintOptions, opts: PrintOptions,
) -> Result<String, String> { ) -> Result<String, String> {
let tmp_path = temp_pdf_path("print"); let tmp_path = temp_pdf_path("print");
pdf_export::export_pdf( pdf_export::export_pdf(&page, &tmp_path)?;
&wires,
&hatches,
&wipeouts,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
&tmp_path,
plot_style.as_ref(),
opts.render,
)?;
dispatch_to_printer_opts(&tmp_path, &opts) dispatch_to_printer_opts(&tmp_path, &opts)
} }
/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for /// Send an already-rendered PDF to the selected printer.
/// clipped window plots, whose PDF is built with a scale + clip the plain
/// `print_wires_with` path doesn't expose.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> { pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
dispatch_to_printer_opts(path, opts) dispatch_to_printer_opts(path, opts)
+39 -1
View File
@@ -42,7 +42,7 @@ impl CadModule for AnnotateModule {
} }
fn ribbon_groups(&self) -> &[RibbonGroup] { fn ribbon_groups(&self) -> &[RibbonGroup] {
use crate::modules::draw::draw::{revcloud, wipeout}; use crate::modules::draw::draw::{centerline, dimcenter, revcloud, wipeout};
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new(); static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| { GROUPS.get_or_init(|| {
@@ -148,6 +148,44 @@ impl CadModule for AnnotateModule {
}, },
], ],
}, },
// ── Centerlines ───────────────────────────────────────────────
RibbonGroup {
title: "Centerlines",
tools: vec![
RibbonItem::LargeTool(dimcenter::tool()),
RibbonItem::LargeTool(centerline::tool()),
RibbonItem::Dropdown {
id: "ANNOTATE_CENTER_ASSOCIATIVITY",
icon: crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
items: vec![
(
"CENTERREASSOCIATE",
"Reassociate Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
(
"CENTERDISASSOCIATE",
"Disassociate Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
(
"CENTERRESET",
"Reset Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
],
default: "CENTERREASSOCIATE",
},
],
},
// ── Leaders ────────────────────────────────────────────────── // ── Leaders ──────────────────────────────────────────────────
RibbonGroup { RibbonGroup {
title: "Leaders", title: "Leaders",
+8
View File
@@ -304,6 +304,14 @@ impl AlignCommand {
} }
inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand
// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: one-shot
// bounding-box alignment, dispatched directly (no CadCommand of their own) by
// `OpenCADStudio::align_selected_bounds` in `src/app/commands/inquiry.rs`.
inventory::submit!(crate::command::CommandRegistration {
names: &[
"ALIGNLEFT", "ALIGNHCENTER", "ALIGNRIGHT", "ALIGNTOP", "ALIGNVCENTER", "ALIGNBOTTOM",
],
});
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+65
View File
@@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
.collect(); .collect();
} }
} }
if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) {
let (x_axis, y_axis) =
crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z));
let plane = WorkingPlane::new(
DVec3::ZERO,
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
);
return explode_dimension(&local, doc)
.into_iter()
.map(|entity| plane.place_entity(entity))
.collect();
}
let base = dim.base(); let base = dim.base();
let met = dim_metrics(dim, doc); let met = dim_metrics(dim, doc);
@@ -1659,6 +1672,58 @@ mod tests {
assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}");
} }
/// The line segments EXPLODE turns the dimension into.
fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> {
let point = |v: Vector3| DVec3::new(v.x, v.y, v.z);
explode_dimension(dim, doc)
.into_iter()
.filter_map(|entity| match entity {
EntityType::Line(line) => Some((point(line.start), point(line.end))),
_ => None,
})
.collect()
}
/// EXPLODE of a linear dimension in a working plane off world Z leaves
/// its lines in that plane. Turned 90° about X, the dimension has to
/// explode into the same lines turned with it; its offset, now along Z,
/// used to be read in world XY only.
#[test]
fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() {
use crate::command::EntityTransform;
let doc = CadDocument::new();
let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0));
d.definition_point = Vector3::new(0.0, 5.0, 0.0);
let flat = Dimension::Linear(d);
let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2);
let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc)
.into_iter()
.map(|(a, b)| (turn * a, turn * b))
.collect();
assert!(!expected.is_empty(), "the dimension explodes into no lines");
let mut turned = EntityType::Dimension(flat);
crate::scene::view::dispatch::apply_transform(
&mut turned,
&EntityTransform::Rotate {
center: DVec3::ZERO,
axis: DVec3::X,
angle_rad: std::f64::consts::FRAC_PI_2,
},
);
let EntityType::Dimension(turned) = turned else {
panic!("dimension expected");
};
let lines = exploded_segments(&turned, &doc);
assert_eq!(lines.len(), expected.len(), "line count");
for (index, (got, want)) in lines.iter().zip(&expected).enumerate() {
assert!(
got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9),
"line {index}: expected {want:?}, got {got:?}"
);
}
}
// An angular dimension must bake its swept ARC (not just two rays), else a // An angular dimension must bake its swept ARC (not just two rays), else a
// saved+reloaded angular dim collapses to a V. The block should carry the // saved+reloaded angular dim collapses to a V. The block should carry the
// two extension lines plus many arc chords. // two extension lines plus many arc chords.
+35 -2
View File
@@ -34,6 +34,8 @@ pub struct SelectObjectsCommand {
/// Whether the polygon being picked takes what it merely touches. Ignored /// Whether the polygon being picked takes what it merely touches. Ignored
/// by a fence, which has no inside to speak of. /// by a fence, which has no inside to speak of.
pick_crossing: bool, pick_crossing: bool,
/// Add the command-specific Settings keyword used by Auto Constrain.
auto_constrain_settings: bool,
} }
impl SelectObjectsCommand { impl SelectObjectsCommand {
@@ -47,6 +49,7 @@ impl SelectObjectsCommand {
show_options: true, show_options: true,
pick: None, pick: None,
pick_crossing: true, pick_crossing: true,
auto_constrain_settings: false,
} }
} }
@@ -61,6 +64,7 @@ impl SelectObjectsCommand {
show_options: false, show_options: false,
pick: None, pick: None,
pick_crossing: true, pick_crossing: true,
auto_constrain_settings: false,
} }
} }
@@ -75,8 +79,15 @@ impl SelectObjectsCommand {
show_options: false, show_options: false,
pick: None, pick: None,
pick_crossing: true, pick_crossing: true,
auto_constrain_settings: false,
} }
} }
pub fn auto_constrain(pending_cmd: &str) -> Self {
let mut command = Self::new(pending_cmd);
command.auto_constrain_settings = true;
command
}
} }
impl CadCommand for SelectObjectsCommand { impl CadCommand for SelectObjectsCommand {
@@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand {
.into_owned(); .into_owned();
} }
if self.commit_on_enter && !self.handles.is_empty() { if self.commit_on_enter && !self.handles.is_empty() {
if self.auto_constrain_settings {
return t!(
"%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):",
cmd = self.prompt_cmd,
count = self.handles.len()
)
.into_owned();
}
t!( t!(
"%{cmd} Select objects (%{count} selected, Enter to apply):", "%{cmd} Select objects (%{count} selected, Enter to apply):",
cmd = self.prompt_cmd, cmd = self.prompt_cmd,
@@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand {
) )
.into_owned() .into_owned()
} else { } else {
if self.auto_constrain_settings {
return t!(
"%{cmd} Select objects or [Settings]:",
cmd = self.prompt_cmd
)
.into_owned();
}
t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned()
} }
} }
@@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand {
// the on-screen keyboard-less case needs. // the on-screen keyboard-less case needs.
fn options(&self) -> Vec<CmdOption> { fn options(&self) -> Vec<CmdOption> {
if self.commit_on_enter && self.show_options { if self.commit_on_enter && self.show_options {
vec![ if self.auto_constrain_settings {
return vec![CmdOption::new(t!("Settings").as_ref(), "S")];
}
let options = vec![
CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Window").as_ref(), "W"),
CmdOption::new(t!("Crossing").as_ref(), "C"), CmdOption::new(t!("Crossing").as_ref(), "C"),
CmdOption::new(t!("Fence").as_ref(), "F"), CmdOption::new(t!("Fence").as_ref(), "F"),
@@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand {
CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Remove").as_ref(), "R"),
CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Previous").as_ref(), "P"),
CmdOption::new(t!("Last").as_ref(), "L"), CmdOption::new(t!("Last").as_ref(), "L"),
] ];
options
} else { } else {
Vec::new() Vec::new()
} }
@@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand {
return None; return None;
} }
match text.trim().to_uppercase().as_str() { match text.trim().to_uppercase().as_str() {
"S" | "SETTINGS" if self.auto_constrain_settings => {
Some(CmdResult::OpenAutoConstrainSettings)
}
"F" | "FENCE" => { "F" | "FENCE" => {
self.pick = Some(FencePick::fence()); self.pick = Some(FencePick::fence());
Some(CmdResult::NeedPoint) Some(CmdResult::NeedPoint)
+304 -29
View File
@@ -1,20 +1,10 @@
//! CCONSTRAINT — manual Coincident constraint input. //! Interactive Coincident input: ordered point/curve picks, repeated
//! unlike the rest of this module's plain select-and-click constraints, //! point-to-curve placement, and Coincident-only automatic inference.
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc
//! center), not the whole entity, so it needs two point picks instead of a
//! selection.
//!
//! A `CadCommand` has no document access (see `value.rs`'s module doc
//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this
//! command only accumulates the two raw points `on_point` receives and hands
//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves
//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`)
//! and adds the constraint, the same split `ReassociateCenterMark` already
//! uses for a similar reason.
use acadrust::{EntityType, Handle};
use glam::DVec3; use glam::DVec3;
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub mod coincident_tool { pub mod coincident_tool {
@@ -31,45 +21,272 @@ pub mod coincident_tool {
} }
} }
#[derive(Default)] #[derive(Clone)]
enum Step {
First,
FirstObject,
Second(CoincidentPick),
PointsOnCurve {
curve: CoincidentPick,
multiple: bool,
},
AutoConstrain {
handles: Vec<Handle>,
},
}
pub struct CoincidentConstraintCommand { pub struct CoincidentConstraintCommand {
first: Option<DVec3>, step: Step,
picked_entity: Option<EntityType>,
} }
impl CoincidentConstraintCommand { impl CoincidentConstraintCommand {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self {
step: Step::First,
picked_entity: None,
}
}
fn point_pick(handle: Option<Handle>, point: DVec3) -> CoincidentPick {
CoincidentPick {
handle,
point,
whole_curve: false,
}
}
fn curve_pick(handle: Handle, point: DVec3) -> CoincidentPick {
CoincidentPick {
handle: Some(handle),
point,
whole_curve: true,
}
}
fn picked_constraint_point(entity: &EntityType, point: DVec3) -> bool {
crate::scene::parametric_constraints::is_parametric_point_near(
entity,
acadrust::types::Vector3::new(point.x, point.y, point.z),
)
}
fn valid_curve(entity: &EntityType) -> bool {
matches!(
entity,
EntityType::Line(_)
| EntityType::LwPolyline(_)
| EntityType::Polyline2D(_)
| EntityType::Circle(_)
| EntityType::Arc(_)
| EntityType::Ellipse(_)
| EntityType::Spline(_)
)
} }
} }
impl CadCommand for CoincidentConstraintCommand { impl CadCommand for CoincidentConstraintCommand {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"CCONSTRAINT" "GCCOINCIDENT"
} }
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.first.is_none() { match self.step.clone() {
"COINCIDENT Specify first point:".to_string() Step::First => {
} else { "COINCIDENT Select first point or [Object/Autoconstrain] <Object>:".to_string()
"COINCIDENT Specify second point:".to_string() }
Step::FirstObject => "COINCIDENT Select object:".to_string(),
Step::Second(_) => "COINCIDENT Select second point or object:".to_string(),
Step::PointsOnCurve { multiple: false, .. } => {
"COINCIDENT Select point or [Multiple]:".to_string()
}
Step::PointsOnCurve { multiple: true, .. } => {
"COINCIDENT Select points to coincide with the first object (Enter = done):"
.to_string()
}
Step::AutoConstrain { handles } if handles.is_empty() => {
"COINCIDENT Select objects:".to_string()
}
Step::AutoConstrain { handles } => format!(
"COINCIDENT Select objects ({} selected, Enter = apply):",
handles.len()
),
} }
} }
fn on_point(&mut self, pt: DVec3) -> CmdResult { fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.first { match &self.step {
None => { Step::First => {
self.first = Some(pt); self.step = Step::Second(Self::point_pick(None, pt));
CmdResult::NeedPoint CmdResult::NeedPoint
} }
Some(first) => CmdResult::AddCoincidentConstraint { Step::FirstObject => CmdResult::NeedPoint,
point_a: first, Step::Second(first) => CmdResult::AddCoincidentConstraint {
point_b: pt, first: *first,
second: Self::point_pick(None, pt),
multiple: false,
label: "Coincident constraint", label: "Coincident constraint",
}, },
Step::PointsOnCurve { curve, multiple } => CmdResult::AddCoincidentConstraint {
first: *curve,
second: Self::point_pick(None, pt),
multiple: *multiple,
label: "Coincident constraint",
},
Step::AutoConstrain { .. } => CmdResult::NeedPoint,
} }
} }
fn options(&self) -> Vec<CmdOption> {
match self.step.clone() {
Step::First => vec![
CmdOption::new("Object", "O"),
CmdOption::new("Autoconstrain", "A"),
],
Step::PointsOnCurve { multiple: false, .. } => vec![
CmdOption::new("Point", "P"),
CmdOption::new("Multiple", "M"),
],
Step::PointsOnCurve { multiple: true, .. } => vec![CmdOption::enter("Done")],
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
!matches!(&self.step, Step::AutoConstrain { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text
.trim()
.trim_start_matches('_')
.to_ascii_uppercase();
match (&mut self.step, keyword.as_str()) {
(Step::First, "O" | "OBJECT") => {
self.step = Step::FirstObject;
Some(CmdResult::NeedPoint)
}
(Step::First, "A" | "AUTOCONSTRAIN") => {
self.step = Step::AutoConstrain { handles: Vec::new() };
Some(CmdResult::NeedPoint)
}
(Step::PointsOnCurve { multiple, .. }, "M" | "MULTIPLE") => {
*multiple = true;
Some(CmdResult::NeedPoint)
}
(Step::PointsOnCurve { .. }, "P" | "POINT") => Some(CmdResult::NeedPoint),
_ => None,
}
}
fn needs_entity_pick(&self) -> bool {
!matches!(&self.step, Step::AutoConstrain { .. })
}
fn entity_pick_accepts_points(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked_entity = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
let is_point = self
.picked_entity
.as_ref()
.is_some_and(|entity| Self::picked_constraint_point(entity, point));
let is_curve = self
.picked_entity
.as_ref()
.is_some_and(Self::valid_curve);
self.picked_entity = None;
match self.step.clone() {
Step::FirstObject if is_curve => {
self.step = Step::PointsOnCurve {
curve: Self::curve_pick(handle, point),
multiple: false,
};
CmdResult::NeedPoint
}
Step::First if is_point => {
self.step = Step::Second(Self::point_pick(Some(handle), point));
CmdResult::NeedPoint
}
Step::First if is_curve => {
self.step = Step::PointsOnCurve {
curve: Self::curve_pick(handle, point),
multiple: false,
};
CmdResult::NeedPoint
}
Step::Second(first) if is_point => CmdResult::AddCoincidentConstraint {
first,
second: Self::point_pick(Some(handle), point),
multiple: false,
label: "Coincident constraint",
},
Step::Second(first) if is_curve => CmdResult::AddCoincidentConstraint {
first,
second: Self::curve_pick(handle, point),
multiple: false,
label: "Coincident constraint",
},
Step::PointsOnCurve { curve, multiple } if is_point => {
CmdResult::AddCoincidentConstraint {
first: curve,
second: Self::point_pick(Some(handle), point),
multiple,
label: "Coincident constraint",
}
}
_ => CmdResult::NeedPoint,
}
}
fn is_selection_gathering(&self) -> bool {
matches!(&self.step, Step::AutoConstrain { .. })
}
fn selection_forces_add(&self) -> bool {
true
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
if let Step::AutoConstrain { handles: selected } = &mut self.step {
*selected = handles;
}
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult { fn on_enter(&mut self) -> CmdResult {
if matches!(&self.step, Step::First) {
self.step = Step::FirstObject;
return CmdResult::NeedPoint;
}
if let Step::AutoConstrain { handles } = &self.step {
if !handles.is_empty() {
return CmdResult::AddAutoCoincidentConstraints {
handles: handles.clone(),
};
}
}
CmdResult::Cancel CmdResult::Cancel
} }
@@ -80,5 +297,63 @@ impl CadCommand for CoincidentConstraintCommand {
// ── Autocomplete registry ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration {
names: &["CCONSTRAINT"] names: &["CCONSTRAINT", "GCCOINCIDENT"]
}); });
#[cfg(test)]
mod tests {
use super::*;
use acadrust::types::Vector3;
#[test]
fn multiple_keeps_the_first_curve_for_repeated_points() {
let mut command = CoincidentConstraintCommand::new();
assert!(matches!(
command.on_text_input("Object"),
Some(CmdResult::NeedPoint)
));
command.inject_picked_entity(EntityType::Line(
acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X),
));
let handle = Handle::new(7);
assert!(matches!(
command.on_entity_pick(handle, DVec3::ZERO),
CmdResult::NeedPoint
));
assert!(matches!(
command.on_text_input("Multiple"),
Some(CmdResult::NeedPoint)
));
assert!(matches!(
command.on_point(DVec3::X),
CmdResult::AddCoincidentConstraint {
first: CoincidentPick {
handle: Some(found),
whole_curve: true,
..
},
multiple: true,
..
} if found == handle
));
}
#[test]
fn automatic_mode_returns_the_completed_selection() {
let mut command = CoincidentConstraintCommand::new();
assert!(matches!(
command.on_text_input("Autoconstrain"),
Some(CmdResult::NeedPoint)
));
let handles = vec![Handle::new(3), Handle::new(5)];
assert!(matches!(
command.on_selection_complete(handles.clone()),
CmdResult::NeedPoint
));
assert!(matches!(
command.on_enter(),
CmdResult::AddAutoCoincidentConstraints { handles: found } if found == handles
));
}
}
+105
View File
@@ -0,0 +1,105 @@
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult};
/// Command-line front end for the standard geometric-constraint family.
/// Each choice is handed to the existing focused command so ribbon and typed
/// entry use one implementation.
pub struct GeomConstraintCommand;
impl GeomConstraintCommand {
pub fn new() -> Self {
Self
}
fn dispatch(keyword: &str) -> Option<&'static str> {
Some(match keyword {
"H" | "HORIZONTAL" => "HCONSTRAINT",
"V" | "VERTICAL" => "VCONSTRAINT",
"P" | "PERPENDICULAR" => "QCONSTRAINT",
"PA" | "PARALLEL" => "PCONSTRAINT",
"T" | "TANGENT" => "TCONSTRAINT",
"SM" | "SMOOTH" => "SMOOTHCONSTRAINT",
"C" | "COINCIDENT" => "GCCOINCIDENT",
"CON" | "CONCENTRIC" => "NCONSTRAINT",
"COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT",
"SY" | "SYMMETRIC" => "SYCONSTRAINT",
"E" | "EQUAL" => "ECONSTRAINT",
"F" | "FIX" => "FXCONSTRAINT",
_ => return None,
})
}
}
impl CadCommand for GeomConstraintCommand {
fn name(&self) -> &'static str {
"GEOMCONSTRAINT"
}
fn prompt(&self) -> String {
"GEOMCONSTRAINT Enter constraint type [Horizontal/Vertical/Perpendicular/Parallel/Tangent/Smooth/Coincident/Concentric/Collinear/Symmetric/Equal/Fix] <Coincident>:".to_string()
}
fn options(&self) -> Vec<CmdOption> {
vec![
CmdOption::new("Horizontal", "H"),
CmdOption::new("Vertical", "V"),
CmdOption::new("Perpendicular", "P"),
CmdOption::new("Parallel", "PA"),
CmdOption::new("Tangent", "T"),
CmdOption::new("Smooth", "SM"),
CmdOption::new("Coincident", "C"),
CmdOption::new("Concentric", "CON"),
CmdOption::new("Collinear", "COL"),
CmdOption::new("Symmetric", "SY"),
CmdOption::new("Equal", "E"),
CmdOption::new("Fix", "F"),
]
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text
.trim()
.trim_start_matches('_')
.to_ascii_uppercase();
Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string()))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch("GCCOINCIDENT".to_string())
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["GEOMCONSTRAINT"]
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chooser_dispatches_an_existing_constraint_command() {
let mut command = GeomConstraintCommand::new();
assert!(matches!(
command.on_text_input("Parallel"),
Some(CmdResult::Dispatch(found)) if found == "PCONSTRAINT"
));
assert!(matches!(
command.on_enter(),
CmdResult::Dispatch(found) if found == "GCCOINCIDENT"
));
}
}
+3 -1
View File
@@ -4,11 +4,13 @@
mod coincident; mod coincident;
mod equal_distance; mod equal_distance;
mod geom_constraint;
mod point_on_entity; mod point_on_entity;
mod tools; mod tools;
mod value; mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
pub use geom_constraint::GeomConstraintCommand;
pub use point_on_entity::{ pub use point_on_entity::{
center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand,
}; };
@@ -128,7 +130,7 @@ impl CadModule for ParametricModule {
inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration {
names: &[ names: &[
"AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET",
"GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL",
"DCCONVERT", "DELCONSTRAINT", "DCCONVERT", "DELCONSTRAINT",
] ]
+2 -2
View File
@@ -7,7 +7,7 @@
//! endpoint) so it doesn't need interactive picking; the point side does //! endpoint) so it doesn't need interactive picking; the point side does
//! (which of a line's two endpoints, or some other addressable point), so //! (which of a line's two endpoints, or some other addressable point), so
//! this command accumulates just that one point pick and hands it back for //! this command accumulates just that one point pick and hands it back for
//! the host to resolve via `nearest_sketch_point` — the same split //! the host to resolve via `nearest_parametric_point` — the same split
//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` //! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand`
//! has no document access). //! has no document access).
@@ -15,7 +15,7 @@ use glam::DVec3;
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::sketch_constraints::ConstraintKind; use crate::scene::parametric_constraints::ConstraintKind;
use acadrust::types::Handle; use acadrust::types::Handle;
pub mod center_point_tool { pub mod center_point_tool {
+4 -10
View File
@@ -64,9 +64,7 @@ pub mod equal {
ToolDef { ToolDef {
id: "ECONSTRAINT", id: "ECONSTRAINT",
label: "Equal", label: "Equal",
icon: IconKind::Svg(include_bytes!( icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/equal.svg")),
"../../../assets/icons/constrain/equal.svg"
)),
event: ModuleEvent::Command("ECONSTRAINT".to_string()), event: ModuleEvent::Command("ECONSTRAINT".to_string()),
} }
} }
@@ -105,7 +103,7 @@ pub mod concentric {
/// "the line passes through the circle's center" (a circle's radius is /// "the line passes through the circle's center" (a circle's radius is
/// always normal to its own tangent), so it's built from the same /// always normal to its own tangent), so it's built from the same
/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line /// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line
/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only /// case (`parametric_solve.rs`). Distinct from `Perpendicular`, which only
/// covers line-to-line. /// covers line-to-line.
pub mod normal { pub mod normal {
use super::*; use super::*;
@@ -113,9 +111,7 @@ pub mod normal {
ToolDef { ToolDef {
id: "NRCONSTRAINT", id: "NRCONSTRAINT",
label: "Normal", label: "Normal",
icon: IconKind::Svg(include_bytes!( icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/normal.svg")),
"../../../assets/icons/constrain/normal.svg"
)),
event: ModuleEvent::Command("NRCONSTRAINT".to_string()), event: ModuleEvent::Command("NRCONSTRAINT".to_string()),
} }
} }
@@ -141,9 +137,7 @@ pub mod fixed {
ToolDef { ToolDef {
id: "FXCONSTRAINT", id: "FXCONSTRAINT",
label: "Fixed", label: "Fixed",
icon: IconKind::Svg(include_bytes!( icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")),
"../../../assets/icons/constrain/fixed.svg"
)),
event: ModuleEvent::Command("FXCONSTRAINT".to_string()), event: ModuleEvent::Command("FXCONSTRAINT".to_string()),
} }
} }
+140 -155
View File
@@ -4,8 +4,8 @@
//! //!
//! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only //! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! gets `&mut self` — no document access — so it can't add a //! gets `&mut self` — no document access — so it can't add a
//! `SketchConstraint` to the scene directly; instead these commands hand the //! `ParametricConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which //! typed value back as `CmdResult::AddParametricConstraint`, and the host (which
//! does have `&mut Scene`) adds the record and solves it via the same //! does have `&mut Scene`) adds the record and solves it via the same
//! `Scene::bump_entities` path any later edit to these entities will use too. //! `Scene::bump_entities` path any later edit to these entities will use too.
//! `default_value` (what the prompt shows in `<...>`, and what a bare Enter //! `default_value` (what the prompt shows in `<...>`, and what a bare Enter
@@ -19,7 +19,7 @@ use glam::DVec3;
use crate::command::{CadCommand, CmdResult, InputKind}; use crate::command::{CadCommand, CmdResult, InputKind};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::named_parameters::DrivingValue; use crate::scene::named_parameters::DrivingValue;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
use crate::scene::Scene; use crate::scene::Scene;
/// Recognizes a typed prompt token as either a numeric literal or a /// Recognizes a typed prompt token as either a numeric literal or a
@@ -28,7 +28,7 @@ use crate::scene::Scene;
/// ::new`, which already has `&Scene`) since `on_text_input` itself has no /// ::new`, which already has `&Scene`) since `on_text_input` itself has no
/// document access — the same constraint `default_value` already works /// document access — the same constraint `default_value` already works
/// around. Only *existence* is checked here; the actual value is resolved /// around. Only *existence* is checked here; the actual value is resolved
/// fresh at solve time (`sketch_solve::build_constraint`), consistent with /// fresh at solve time (`parametric_solve::build_constraint`), consistent with
/// this project's "no incremental/cached resolution" approach throughout. /// this project's "no incremental/cached resolution" approach throughout.
fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> { fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> {
let text = text.trim(); let text = text.trim();
@@ -64,17 +64,70 @@ pub mod dimensional_tools {
use super::*; use super::*;
fn command(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { fn command(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef { id, label, icon: IconKind::Svg(icon), event: ModuleEvent::Command(id.to_string()) } ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
} }
pub fn linear() -> ToolDef { command("DCLINEAR", "Linear", include_bytes!("../../../assets/icons/dim_linear.svg")) } pub fn linear() -> ToolDef {
pub fn horizontal() -> ToolDef { command("DCHORIZONTAL", "Horizontal", include_bytes!("../../../assets/icons/constrain/distance_x.svg")) } command(
pub fn vertical() -> ToolDef { command("DCVERTICAL", "Vertical", include_bytes!("../../../assets/icons/constrain/distance_y.svg")) } "DCLINEAR",
pub fn aligned() -> ToolDef { command("DCALIGNED", "Aligned", include_bytes!("../../../assets/icons/dim_aligned.svg")) } "Linear",
pub fn angular() -> ToolDef { command("DCANGULAR", "Angular", include_bytes!("../../../assets/icons/dim_angular.svg")) } include_bytes!("../../../assets/icons/dim_linear.svg"),
pub fn radius() -> ToolDef { command("DCRADIUS", "Radius", include_bytes!("../../../assets/icons/dim_radius.svg")) } )
pub fn diameter() -> ToolDef { command("DCDIAMETER", "Diameter", include_bytes!("../../../assets/icons/dim_diameter.svg")) } }
pub fn convert() -> ToolDef { command("DCCONVERT", "Convert", include_bytes!("../../../assets/icons/constrain/convert.svg")) } pub fn horizontal() -> ToolDef {
command(
"DCHORIZONTAL",
"Horizontal",
include_bytes!("../../../assets/icons/constrain/distance_x.svg"),
)
}
pub fn vertical() -> ToolDef {
command(
"DCVERTICAL",
"Vertical",
include_bytes!("../../../assets/icons/constrain/distance_y.svg"),
)
}
pub fn aligned() -> ToolDef {
command(
"DCALIGNED",
"Aligned",
include_bytes!("../../../assets/icons/dim_aligned.svg"),
)
}
pub fn angular() -> ToolDef {
command(
"DCANGULAR",
"Angular",
include_bytes!("../../../assets/icons/dim_angular.svg"),
)
}
pub fn radius() -> ToolDef {
command(
"DCRADIUS",
"Radius",
include_bytes!("../../../assets/icons/dim_radius.svg"),
)
}
pub fn diameter() -> ToolDef {
command(
"DCDIAMETER",
"Diameter",
include_bytes!("../../../assets/icons/dim_diameter.svg"),
)
}
pub fn convert() -> ToolDef {
command(
"DCCONVERT",
"Convert",
include_bytes!("../../../assets/icons/constrain/convert.svg"),
)
}
} }
pub mod angle_tool { pub mod angle_tool {
@@ -83,9 +136,7 @@ pub mod angle_tool {
ToolDef { ToolDef {
id: "ACONSTRAINT", id: "ACONSTRAINT",
label: "Angle", label: "Angle",
icon: IconKind::Svg(include_bytes!( icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/angle.svg")),
"../../../assets/icons/constrain/angle.svg"
)),
event: ModuleEvent::Command("ACONSTRAINT".to_string()), event: ModuleEvent::Command("ACONSTRAINT".to_string()),
} }
} }
@@ -104,14 +155,10 @@ pub enum DistanceMode {
X, X,
Y, Y,
Diameter, Diameter,
/// Arc length — only offered when `is_arc` (not a plain Circle, which
/// has no "arc length" distinct from its circumference).
ArcLength,
} }
/// Constrains a single line's length (or its X/Y-only component) between /// Constrains a single line's length (or its X/Y-only component) between
/// its two endpoints, or a circle/arc's radius (or diameter, or — for an /// its two endpoints, or a circle/arc's radius or diameter, to a typed target value.
/// arc specifically — its swept arc length), to a typed target value.
pub struct DistanceConstraintCommand { pub struct DistanceConstraintCommand {
command_name: &'static str, command_name: &'static str,
handle: Handle, handle: Handle,
@@ -119,16 +166,10 @@ pub struct DistanceConstraintCommand {
/// (Distance/DistanceX/DistanceY) — decided once at construction from /// (Distance/DistanceX/DistanceY) — decided once at construction from
/// the entity's type. /// the entity's type.
is_circle: bool, is_circle: bool,
/// Whether `handle` is specifically an Arc (as opposed to a full
/// Circle) — gates the `[Arclength]` prompt option.
is_arc: bool,
mode: DistanceMode, mode: DistanceMode,
default_value: f64, default_value: f64,
default_x: f64, default_x: f64,
default_y: f64, default_y: f64,
/// The entity's current arc length, for `ArcLength` mode's `<default>`
/// — meaningless (left at 0) unless `is_arc`.
default_arc_length: f64,
/// Snapshot of `Scene::named_parameters`' names at construction time — /// Snapshot of `Scene::named_parameters`' names at construction time —
/// see `parse_driving_value`'s doc comment for why a snapshot. /// see `parse_driving_value`'s doc comment for why a snapshot.
known_param_names: Vec<String>, known_param_names: Vec<String>,
@@ -147,17 +188,16 @@ impl DistanceConstraintCommand {
requested_mode: DistanceMode, requested_mode: DistanceMode,
) -> Option<Self> { ) -> Option<Self> {
let entity = scene.document.get_entity(handle)?; let entity = scene.document.get_entity(handle)?;
let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) = let (is_circle, default_value, default_x, default_y) = match entity {
match entity { EntityType::Line(l) => {
EntityType::Line(l) => { let dx = l.end.x - l.start.x;
let dx = l.end.x - l.start.x; let dy = l.end.y - l.start.y;
let dy = l.end.y - l.start.y; (false, (dx * dx + dy * dy).sqrt(), dx, dy)
(false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0) }
} EntityType::Circle(c) => (true, c.radius, 0.0, 0.0),
EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0), EntityType::Arc(a) => (true, a.radius, 0.0, 0.0),
EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()), _ => return None,
_ => return None, };
};
let known_param_names = scene let known_param_names = scene
.named_parameters() .named_parameters()
.iter() .iter()
@@ -165,7 +205,11 @@ impl DistanceConstraintCommand {
.collect(); .collect();
let mode = match requested_mode { let mode = match requested_mode {
DistanceMode::Linear if !is_circle => { DistanceMode::Linear if !is_circle => {
if default_x.abs() >= default_y.abs() { DistanceMode::X } else { DistanceMode::Y } if default_x.abs() >= default_y.abs() {
DistanceMode::X
} else {
DistanceMode::Y
}
} }
DistanceMode::Aligned if !is_circle => DistanceMode::Aligned, DistanceMode::Aligned if !is_circle => DistanceMode::Aligned,
DistanceMode::Radius if is_circle => DistanceMode::Radius, DistanceMode::Radius if is_circle => DistanceMode::Radius,
@@ -178,12 +222,10 @@ impl DistanceConstraintCommand {
command_name, command_name,
handle, handle,
is_circle, is_circle,
is_arc,
mode, mode,
default_value, default_value,
default_x, default_x,
default_y, default_y,
default_arc_length,
known_param_names, known_param_names,
}) })
} }
@@ -198,46 +240,41 @@ impl DistanceConstraintCommand {
let (kind, refs, label) = match (self.is_circle, self.mode) { let (kind, refs, label) = match (self.is_circle, self.mode) {
(true, DistanceMode::Diameter) => ( (true, DistanceMode::Diameter) => (
ConstraintKind::Diameter, ConstraintKind::Diameter,
vec![SketchRef::whole(self.handle)], vec![ParametricRef::whole(self.handle)],
"Diameter constraint", "Diameter constraint",
), ),
(true, DistanceMode::ArcLength) if self.is_arc => (
ConstraintKind::ArcLength,
vec![SketchRef::whole(self.handle)],
"Arc length constraint",
),
(true, DistanceMode::Auto | DistanceMode::Radius) => ( (true, DistanceMode::Auto | DistanceMode::Radius) => (
ConstraintKind::Radius, ConstraintKind::Radius,
vec![SketchRef::whole(self.handle)], vec![ParametricRef::whole(self.handle)],
"Radius constraint", "Radius constraint",
), ),
(false, DistanceMode::X) => ( (false, DistanceMode::X) => (
ConstraintKind::DistanceX, ConstraintKind::DistanceX,
vec![ vec![
SketchRef::point(self.handle, 0), ParametricRef::point(self.handle, 0),
SketchRef::point(self.handle, 1), ParametricRef::point(self.handle, 1),
], ],
"DistanceX constraint", "DistanceX constraint",
), ),
(false, DistanceMode::Y) => ( (false, DistanceMode::Y) => (
ConstraintKind::DistanceY, ConstraintKind::DistanceY,
vec![ vec![
SketchRef::point(self.handle, 0), ParametricRef::point(self.handle, 0),
SketchRef::point(self.handle, 1), ParametricRef::point(self.handle, 1),
], ],
"DistanceY constraint", "DistanceY constraint",
), ),
(false, DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear) => ( (false, DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear) => (
ConstraintKind::Distance, ConstraintKind::Distance,
vec![ vec![
SketchRef::point(self.handle, 0), ParametricRef::point(self.handle, 0),
SketchRef::point(self.handle, 1), ParametricRef::point(self.handle, 1),
], ],
"Distance constraint", "Distance constraint",
), ),
_ => return None, _ => return None,
}; };
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddParametricConstraint {
kind, kind,
refs, refs,
driving_param: Some(target), driving_param: Some(target),
@@ -257,16 +294,6 @@ impl CadCommand for DistanceConstraintCommand {
"Specify diameter <{:.4}> or [Radius]: ", "Specify diameter <{:.4}> or [Radius]: ",
self.default_value * 2.0 self.default_value * 2.0
), ),
(true, DistanceMode::ArcLength) if self.is_arc => {
format!(
"Specify arc length <{:.4}> or [Radius]: ",
self.default_arc_length
)
}
(true, DistanceMode::Auto | DistanceMode::Radius) if self.is_arc => format!(
"Specify distance <{:.4}> or [Diameter/Arclength]: ",
self.default_value
),
(true, DistanceMode::Auto | DistanceMode::Radius) => format!( (true, DistanceMode::Auto | DistanceMode::Radius) => format!(
"Specify distance <{:.4}> or [Diameter]: ", "Specify distance <{:.4}> or [Diameter]: ",
self.default_value self.default_value
@@ -292,16 +319,24 @@ impl CadCommand for DistanceConstraintCommand {
fn on_enter(&mut self) -> CmdResult { fn on_enter(&mut self) -> CmdResult {
let default = match self.mode { let default = match self.mode {
DistanceMode::Diameter => self.default_value * 2.0, DistanceMode::Diameter => self.default_value * 2.0,
DistanceMode::ArcLength => self.default_arc_length,
DistanceMode::X => self.default_x, DistanceMode::X => self.default_x,
DistanceMode::Y => self.default_y, DistanceMode::Y => self.default_y,
DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear | DistanceMode::Radius => self.default_value, DistanceMode::Auto
| DistanceMode::Aligned
| DistanceMode::Linear
| DistanceMode::Radius => self.default_value,
}; };
self.build(DrivingValue::Literal(default)) self.build(DrivingValue::Literal(default))
.unwrap_or(CmdResult::Cancel) .unwrap_or(CmdResult::Cancel)
} }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// An existing parameter name wins over a mode keyword with the same spelling.
if let Some(value @ DrivingValue::Named(_)) =
parse_driving_value(text, &self.known_param_names)
{
return self.build(value);
}
// Mode-switch keywords re-prompt instead of building — consumed // Mode-switch keywords re-prompt instead of building — consumed
// inputs return `Some(NeedPoint)` so the driver doesn't re-offer // inputs return `Some(NeedPoint)` so the driver doesn't re-offer
// the same token a second time (matches e.g. `trim.rs`'s // the same token a second time (matches e.g. `trim.rs`'s
@@ -314,10 +349,6 @@ impl CadCommand for DistanceConstraintCommand {
self.mode = DistanceMode::Diameter; self.mode = DistanceMode::Diameter;
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);
} }
(true, "A" | "ARCLENGTH") if self.is_arc => {
self.mode = DistanceMode::ArcLength;
return Some(CmdResult::NeedPoint);
}
(true, "R" | "RADIUS") => { (true, "R" | "RADIUS") => {
self.mode = DistanceMode::Radius; self.mode = DistanceMode::Radius;
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);
@@ -390,11 +421,11 @@ impl AngleConstraintCommand {
if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) { if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) {
return None; return None;
} }
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Angle, kind: ConstraintKind::Angle,
refs: vec![ refs: vec![
SketchRef::whole(self.fixed_handle), ParametricRef::whole(self.fixed_handle),
SketchRef::whole(self.moving_handle), ParametricRef::whole(self.moving_handle),
], ],
driving_param: Some(target), driving_param: Some(target),
label: "Angle constraint", label: "Angle constraint",
@@ -437,8 +468,15 @@ impl CadCommand for AngleConstraintCommand {
// ── Autocomplete registry ───────────────────────────────── // ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration {
names: &[ names: &[
"DCONSTRAINT", "ACONSTRAINT", "DCLINEAR", "DCHORIZONTAL", "DCVERTICAL", "DCONSTRAINT",
"DCALIGNED", "DCANGULAR", "DCRADIUS", "DCDIAMETER", "ACONSTRAINT",
"DCLINEAR",
"DCHORIZONTAL",
"DCVERTICAL",
"DCALIGNED",
"DCANGULAR",
"DCRADIUS",
"DCDIAMETER",
] ]
}); });
@@ -509,17 +547,6 @@ mod tests {
)) ))
} }
fn add_arc(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Arc(
acadrust::entities::Arc::from_center_radius_angles(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
0.0,
std::f64::consts::FRAC_PI_2,
),
))
}
#[test] #[test]
fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() {
let mut scene = Scene::new(); let mut scene = Scene::new();
@@ -537,7 +564,7 @@ mod tests {
); );
match cmd.on_text_input("10") { match cmd.on_text_input("10") {
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddParametricConstraint {
kind, kind,
driving_param: Some(DrivingValue::Literal(v)), driving_param: Some(DrivingValue::Literal(v)),
.. ..
@@ -545,7 +572,7 @@ mod tests {
assert_eq!(kind, ConstraintKind::DistanceX); assert_eq!(kind, ConstraintKind::DistanceX);
assert_eq!(v, 10.0); assert_eq!(v, 10.0);
} }
_ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), _ => panic!("expected an AddParametricConstraint{{DistanceX}}, got a different result"),
} }
} }
@@ -558,7 +585,7 @@ mod tests {
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
assert!(matches!( assert!(matches!(
cmd.on_text_input("-3"), cmd.on_text_input("-3"),
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::DistanceX, kind: ConstraintKind::DistanceX,
driving_param: Some(DrivingValue::Literal(-3.0)), driving_param: Some(DrivingValue::Literal(-3.0)),
.. ..
@@ -576,7 +603,7 @@ mod tests {
assert!(cmd.prompt().contains("<8.0000>")); assert!(cmd.prompt().contains("<8.0000>"));
assert!(matches!( assert!(matches!(
cmd.on_enter(), cmd.on_enter(),
CmdResult::AddSketchConstraint { CmdResult::AddParametricConstraint {
kind: ConstraintKind::DistanceY, kind: ConstraintKind::DistanceY,
driving_param: Some(DrivingValue::Literal(8.0)), driving_param: Some(DrivingValue::Literal(8.0)),
.. ..
@@ -584,6 +611,24 @@ mod tests {
)); ));
} }
#[test]
fn a_parameter_named_like_a_mode_keyword_remains_usable() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
scene.named_parameters_mut().set("r", "5").unwrap();
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(matches!(
cmd.on_text_input("R"),
Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Radius,
driving_param: Some(DrivingValue::Named(name)),
..
}) if name == "r"
));
}
#[test] #[test]
fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { fn ordinary_distances_reject_nonpositive_and_nonfinite_values() {
let mut scene = Scene::new(); let mut scene = Scene::new();
@@ -609,7 +654,7 @@ mod tests {
); );
match cmd.on_text_input("16") { match cmd.on_text_input("16") {
Some(CmdResult::AddSketchConstraint { Some(CmdResult::AddParametricConstraint {
kind, kind,
driving_param: Some(DrivingValue::Literal(v)), driving_param: Some(DrivingValue::Literal(v)),
.. ..
@@ -617,7 +662,7 @@ mod tests {
assert_eq!(kind, ConstraintKind::Diameter); assert_eq!(kind, ConstraintKind::Diameter);
assert_eq!(v, 16.0); assert_eq!(v, 16.0);
} }
_ => panic!("expected an AddSketchConstraint{{Diameter}}"), _ => panic!("expected an AddParametricConstraint{{Diameter}}"),
} }
} }
@@ -629,70 +674,10 @@ mod tests {
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
match cmd.on_text_input("5") { match cmd.on_text_input("5") {
Some(CmdResult::AddSketchConstraint { kind, .. }) => { Some(CmdResult::AddParametricConstraint { kind, .. }) => {
assert_eq!(kind, ConstraintKind::Radius) assert_eq!(kind, ConstraintKind::Radius)
} }
_ => panic!("expected an AddSketchConstraint{{Radius}}"), _ => panic!("expected an AddParametricConstraint{{Radius}}"),
} }
} }
#[test]
fn a_keyword_switches_an_arcs_command_to_arc_length_mode() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint)));
assert!(
cmd.prompt().contains("arc length"),
"prompt should reflect ArcLength mode: {}",
cmd.prompt()
);
match cmd.on_text_input("8") {
Some(CmdResult::AddSketchConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
}) => {
assert_eq!(kind, ConstraintKind::ArcLength);
assert_eq!(v, 8.0);
}
_ => panic!("expected an AddSketchConstraint{{ArcLength}}"),
}
}
#[test]
fn arc_length_keyword_is_not_offered_for_a_plain_circle() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(
!cmd.prompt().contains("Arclength"),
"a plain circle has no arc length option: {}",
cmd.prompt()
);
// "A" isn't a recognized keyword here and doesn't parse as a
// number or a named parameter either, so it's rejected outright.
assert!(cmd.on_text_input("A").is_none());
}
#[test]
fn arc_length_default_is_the_arcs_current_swept_length() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
cmd.on_text_input("A");
let expected = 3.0 * std::f64::consts::FRAC_PI_2;
assert!(
cmd.prompt().contains(&format!("{expected:.4}")),
"prompt should default to the arc's current length: {}",
cmd.prompt()
);
}
} }
+1 -10
View File
@@ -32,7 +32,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned();
let mut section = PropSection { let section = PropSection {
title: t!("General").into_owned(), title: t!("General").into_owned(),
props: vec![ props: vec![
Property { Property {
@@ -88,15 +88,6 @@ pub fn general_section(entity: &EntityType) -> PropSection {
], ],
}; };
// Thickness (DXF 39) is a General-group property, but only the entity
// types that carry an extrusion thickness expose it (line, circle, arc,
// polyline, text, 2D solid, …). Show it right after Hyperlink for those.
if let Some(t) = crate::scene::view::dispatch::entity_thickness(entity) {
section
.props
.push(crate::entities::common::edit_prop(t!("Thickness").as_ref(), "thickness", t));
}
section section
} }
+129 -127
View File
@@ -1,13 +1,13 @@
use acadrust::entities::Dimension; use acadrust::entities::Dimension;
use acadrust::objects::{ use acadrust::objects::{
AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject,
AssociativeObject, ObjectType, ObjectType,
}; };
use acadrust::types::{Handle, Vector3}; use acadrust::types::{Handle, Vector3};
use acadrust::EntityType; use acadrust::EntityType;
use cadkernel::geom2d::{ use cadkernel::geom2d::{
angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, BulgeArc,
BulgeArc, Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN,
}; };
use cadkernel::space::Plane; use cadkernel::space::Plane;
use std::f64::consts::TAU; use std::f64::consts::TAU;
@@ -25,8 +25,7 @@ pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 {
} }
fn polyline_arc_segment_from_point_marker(marker: i32) -> Option<i32> { fn polyline_arc_segment_from_point_marker(marker: i32) -> Option<i32> {
(marker <= POLYLINE_ARC_POINT_MARKER_BASE) (marker <= POLYLINE_ARC_POINT_MARKER_BASE).then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker)
.then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker)
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -158,11 +157,13 @@ pub(crate) fn radial_source_at(
entity: &EntityType, entity: &EntityType,
point: Vector3, point: Vector3,
) -> Option<RadialSourceGeometry> { ) -> Option<RadialSourceGeometry> {
radial_candidates(entity).into_iter().min_by(|first, second| { radial_candidates(entity)
first .into_iter()
.distance_squared_to(dpoint(point)) .min_by(|first, second| {
.total_cmp(&second.distance_squared_to(dpoint(point))) first
}) .distance_squared_to(dpoint(point))
.total_cmp(&second.distance_squared_to(dpoint(point)))
})
} }
fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option<RadialSourceGeometry> { fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option<RadialSourceGeometry> {
@@ -177,14 +178,16 @@ fn radial_source_matching(
radius: f64, radius: f64,
chord: Vector3, chord: Vector3,
) -> Option<RadialSourceGeometry> { ) -> Option<RadialSourceGeometry> {
radial_candidates(entity).into_iter().min_by(|first, second| { radial_candidates(entity)
let score = |candidate: &RadialSourceGeometry| { .into_iter()
point_distance_squared(candidate.center_world(), center) .min_by(|first, second| {
+ (candidate.radius - radius).powi(2) let score = |candidate: &RadialSourceGeometry| {
+ candidate.distance_squared_to(dpoint(chord)) * 1e-6 point_distance_squared(candidate.center_world(), center)
}; + (candidate.radius - radius).powi(2)
score(first).total_cmp(&score(second)) + candidate.distance_squared_to(dpoint(chord)) * 1e-6
}) };
score(first).total_cmp(&score(second))
})
} }
fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { fn point_distance_squared(first: Vector3, second: Vector3) -> f64 {
@@ -235,9 +238,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option<Vector3> {
EntityType::LwPolyline(polyline) => { EntityType::LwPolyline(polyline) => {
let count = polyline.vertices.len(); let count = polyline.vertices.len();
let first = *polyline.vertices.get(segment)?; let first = *polyline.vertices.get(segment)?;
let second = *polyline let second =
.vertices *polyline
.get(next_segment_index(count, polyline.is_closed, segment)?)?; .vertices
.get(next_segment_index(count, polyline.is_closed, segment)?)?;
bulge_center_world( bulge_center_world(
[first.location.x, first.location.y], [first.location.x, first.location.y],
[second.location.x, second.location.y], [second.location.x, second.location.y],
@@ -249,9 +253,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option<Vector3> {
EntityType::Polyline2D(polyline) => { EntityType::Polyline2D(polyline) => {
let count = polyline.vertices.len(); let count = polyline.vertices.len();
let first = polyline.vertices.get(segment)?; let first = polyline.vertices.get(segment)?;
let second = polyline let second =
.vertices polyline
.get(next_segment_index(count, polyline.is_closed(), segment)?)?; .vertices
.get(next_segment_index(count, polyline.is_closed(), segment)?)?;
bulge_center_world( bulge_center_world(
[first.location.x, first.location.y], [first.location.x, first.location.y],
[second.location.x, second.location.y], [second.location.x, second.location.y],
@@ -267,20 +272,24 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option<Vector3> {
/// Ordered, named points for an entity (line start/end, polyline vertices, /// Ordered, named points for an entity (line start/end, polyline vertices,
/// ...), indexed by the same non-negative GsMarker convention /// ...), indexed by the same non-negative GsMarker convention
/// `AssocDimensionReference::main_gs_marker` uses. Promoted to /// `AssocDimensionReference::main_gs_marker` uses. Promoted to
/// `pub(crate)` so `sketch::constraint_set` can address constraint /// `pub(crate)` so the parametric solver can address constraint
/// endpoints the same way associative dimensions already address theirs, /// endpoints the same way associative dimensions already address theirs,
/// rather than inventing a second sub-element scheme. /// rather than inventing a second sub-element scheme.
pub(crate) fn source_points(entity: &EntityType) -> Vec<Vector3> { pub(crate) fn source_points(entity: &EntityType) -> Vec<Vector3> {
match entity { match entity {
EntityType::Line(line) => vec![line.start, line.end], EntityType::Line(line) => vec![line.start, line.end],
EntityType::Ray(ray) => vec![ray.base_point],
EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()],
EntityType::Circle(_) => Vec::new(), EntityType::Circle(_) => Vec::new(),
EntityType::Spline(spline) => crate::entities::spline::nurbs3(spline) EntityType::Spline(spline) => crate::entities::spline::nurbs3(spline)
.map(|curve| { .map(|curve| {
let (start, end) = curve.domain(); let (start, end) = curve.domain();
[curve.point_at_knot(start), curve.point_at_knot(end)].into_iter() [curve.point_at_knot(start), curve.point_at_knot(end)]
.map(|point| Vector3::new(point[0], point[1], point[2])).collect() .into_iter()
}).unwrap_or_default(), .map(|point| Vector3::new(point[0], point[1], point[2]))
.collect()
})
.unwrap_or_default(),
EntityType::LwPolyline(polyline) => polyline EntityType::LwPolyline(polyline) => polyline
.vertices .vertices
.iter() .iter()
@@ -345,8 +354,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option<i32> {
.into_iter() .into_iter()
.enumerate() .enumerate()
.min_by(|(_, first), (_, second)| { .min_by(|(_, first), (_, second)| {
point_distance_squared(*first, point) point_distance_squared(*first, point).total_cmp(&point_distance_squared(*second, point))
.total_cmp(&point_distance_squared(*second, point))
}) })
.map(|(index, _)| index as i32) .map(|(index, _)| index as i32)
} }
@@ -360,9 +368,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
return Some(radial.point_at_angle(angle)); return Some(radial.point_at_angle(angle));
} }
if let Some(segment) = if let Some(segment) = polyline_arc_segment_from_point_marker(reference.main_gs_marker) {
polyline_arc_segment_from_point_marker(reference.main_gs_marker)
{
let radial = radial_source_for_marker(entity, segment)?; let radial = radial_source_for_marker(entity, segment)?;
let sweep = positive_sweep(radial.start_angle, radial.end_angle); let sweep = positive_sweep(radial.start_angle, radial.end_angle);
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
@@ -381,9 +387,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti
} }
if reference.main_gs_marker == -2 { if reference.main_gs_marker == -2 {
return match entity { return match entity {
EntityType::Circle(circle) => { EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)),
Some(circle.point_at_angle_wcs(reference.osnap_distance))
}
EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)),
_ => None, _ => None,
}; };
@@ -399,14 +403,17 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti
); );
let curve = circle_curve(circle); let curve = circle_curve(circle);
let stored_parameter = curve.parameter_at([stored.0, stored.1]) * TAU; let stored_parameter = curve.parameter_at([stored.0, stored.1]) * TAU;
let parameter = if reference.osnap_distance.abs() > 1e-12 let parameter = if reference.osnap_distance.abs() > 1e-12 || stored_parameter.abs() <= 1e-12
|| stored_parameter.abs() <= 1e-12
{ {
reference.osnap_distance reference.osnap_distance
} else { } else {
stored_parameter stored_parameter
}; };
let parameter = if parameter.is_finite() { parameter } else { 0.0 }; let parameter = if parameter.is_finite() {
parameter
} else {
0.0
};
let point = curve.point_at(parameter / TAU); let point = curve.point_at(parameter / TAU);
return Some(ocs_point( return Some(ocs_point(
point[0], point[0],
@@ -494,27 +501,13 @@ fn signed_angle_delta(value: f64) -> f64 {
(value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI (value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
} }
fn angle_about_plane( fn angle_about_plane(plane: Plane, center: Vector3, point: Vector3) -> f64 {
plane: Plane, let delta = [point.x - center.x, point.y - center.y, point.z - center.z];
center: Vector3, let dot = |axis: [f64; 3]| delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2];
point: Vector3,
) -> f64 {
let delta = [
point.x - center.x,
point.y - center.y,
point.z - center.z,
];
let dot = |axis: [f64; 3]| {
delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]
};
dot(plane.y_axis).atan2(dot(plane.x_axis)) dot(plane.y_axis).atan2(dot(plane.x_axis))
} }
fn point_on_radial_circle( fn point_on_radial_circle(radial: RadialSourceGeometry, radius: f64, angle: f64) -> Vector3 {
radial: RadialSourceGeometry,
radius: f64,
angle: f64,
) -> Vector3 {
vector3(radial.plane.point_at([ vector3(radial.plane.point_at([
radial.center[0] + radius * angle.cos(), radial.center[0] + radius * angle.cos(),
radial.center[1] + radius * angle.sin(), radial.center[1] + radius * angle.sin(),
@@ -522,9 +515,7 @@ fn point_on_radial_circle(
} }
fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane { fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane {
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(( let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z));
normal.x, normal.y, normal.z,
));
Plane::from_axes( Plane::from_axes(
[origin.x, origin.y, origin.z], [origin.x, origin.y, origin.z],
[x_axis.0, x_axis.1, x_axis.2], [x_axis.0, x_axis.1, x_axis.2],
@@ -574,35 +565,52 @@ pub(crate) fn constraint_from_associative_dimension(
document: &acadrust::CadDocument, document: &acadrust::CadDocument,
handle: Handle, handle: Handle,
) -> Option<( ) -> Option<(
super::sketch_constraints::ConstraintKind, super::parametric_constraints::ConstraintKind,
Vec<super::sketch_constraints::SketchRef>, Vec<super::parametric_constraints::ParametricRef>,
super::named_parameters::DrivingValue, super::named_parameters::DrivingValue,
)> { )> {
use super::named_parameters::DrivingValue; use super::named_parameters::DrivingValue;
use super::sketch_constraints::{ConstraintKind, SketchRef}; use super::parametric_constraints::{ConstraintKind, ParametricRef};
let EntityType::Dimension(dimension) = document.get_entity(handle)? else { return None }; let EntityType::Dimension(dimension) = document.get_entity(handle)? else {
return None;
};
let association = document.objects.values().find_map(|object| { let association = document.objects.values().find_map(|object| {
let ObjectType::Associative(object) = object else { return None }; let ObjectType::Associative(object) = object else {
let AssociativeData::DimensionAssociation(association) = &object.data else { return None }; return None;
};
let AssociativeData::DimensionAssociation(association) = &object.data else {
return None;
};
(association.dimension == handle && association.associativity != 0).then_some(association) (association.dimension == handle && association.associativity != 0).then_some(association)
})?; })?;
let point = |index: usize| { let point = |index: usize| {
let reference = association.references.get(index)?.first()?; let reference = association.references.get(index)?.first()?;
Some(SketchRef::point(*reference.xrefs.first()?, reference.main_gs_marker)) Some(ParametricRef::point(
*reference.xrefs.first()?,
reference.main_gs_marker,
))
}; };
let whole = |index: usize| { let whole = |index: usize| {
let reference = association.references.get(index)?.first()?; let reference = association.references.get(index)?.first()?;
Some(SketchRef::whole(*reference.xrefs.first()?)) Some(ParametricRef::whole(*reference.xrefs.first()?))
}; };
let measurement = DrivingValue::Literal(dimension.measurement()); let measurement = DrivingValue::Literal(dimension.measurement());
match dimension { match dimension {
Dimension::Aligned(_) => Some((ConstraintKind::Distance, vec![point(0)?, point(1)?], measurement)), Dimension::Aligned(_) => Some((
ConstraintKind::Distance,
vec![point(0)?, point(1)?],
measurement,
)),
Dimension::Linear(linear) => { Dimension::Linear(linear) => {
let direction = [linear.rotation.cos().abs(), linear.rotation.sin().abs()]; let direction = [linear.rotation.cos().abs(), linear.rotation.sin().abs()];
let kind = if direction[1] <= 1e-9 { ConstraintKind::DistanceX } let kind = if direction[1] <= 1e-9 {
else if direction[0] <= 1e-9 { ConstraintKind::DistanceY } ConstraintKind::DistanceX
else { return None }; } else if direction[0] <= 1e-9 {
ConstraintKind::DistanceY
} else {
return None;
};
Some((kind, vec![point(0)?, point(1)?], measurement)) Some((kind, vec![point(0)?, point(1)?], measurement))
} }
Dimension::Radius(_) | Dimension::LargeRadial(_) => { Dimension::Radius(_) | Dimension::LargeRadial(_) => {
@@ -610,16 +618,24 @@ pub(crate) fn constraint_from_associative_dimension(
} }
Dimension::Diameter(_) => Some((ConstraintKind::Diameter, vec![whole(0)?], measurement)), Dimension::Diameter(_) => Some((ConstraintKind::Diameter, vec![whole(0)?], measurement)),
Dimension::Angular2Ln(_) => { Dimension::Angular2Ln(_) => {
let mut sources: Vec<Handle> = association.references.iter().flatten() let mut sources: Vec<Handle> = association
.filter_map(|reference| reference.xrefs.first().copied()).collect(); .references
.iter()
.flatten()
.filter_map(|reference| reference.xrefs.first().copied())
.collect();
sources.sort_unstable(); sources.sort_unstable();
sources.dedup(); sources.dedup();
let [first, second] = sources.as_slice() else { return None }; let [first, second] = sources.as_slice() else {
Some((ConstraintKind::Angle, return None;
vec![SketchRef::whole(*first), SketchRef::whole(*second)], measurement)) };
Some((
ConstraintKind::Angle,
vec![ParametricRef::whole(*first), ParametricRef::whole(*second)],
measurement,
))
} }
Dimension::Arc(_) => Some((ConstraintKind::ArcLength, vec![whole(0)?], measurement)), Dimension::Arc(_) | Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None,
Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None,
} }
} }
@@ -716,8 +732,7 @@ impl Scene {
dimension: Handle, dimension: Handle,
sources: Vec<Option<DimensionAssociationSource>>, sources: Vec<Option<DimensionAssociationSource>>,
) { ) {
let Some(EntityType::Dimension(dimension_entity)) = let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension)
self.document.get_entity(dimension)
else { else {
return; return;
}; };
@@ -803,22 +818,20 @@ impl Scene {
return; return;
} }
let reference = |source: Handle, let reference =
marker: i32, |source: Handle, marker: i32, parameter: f64, osnap_type: u8, point: Vector3| {
parameter: f64, AssocDimensionReference {
osnap_type: u8, class_name: "AcDbOsnapPointRef".to_string(),
point: Vector3| AssocDimensionReference { osnap_type,
class_name: "AcDbOsnapPointRef".to_string(), xrefs: vec![source],
osnap_type, main_subent_type: 1,
xrefs: vec![source], main_gs_marker: marker,
main_subent_type: 1, osnap_distance: parameter,
main_gs_marker: marker, osnap_point: point,
osnap_distance: parameter, ..AssocDimensionReference::default()
osnap_point: point, }
..AssocDimensionReference::default() };
}; let mut references: [Vec<AssocDimensionReference>; 4] = std::array::from_fn(|_| Vec::new());
let mut references: [Vec<AssocDimensionReference>; 4] =
std::array::from_fn(|_| Vec::new());
let mut associativity = 0; let mut associativity = 0;
for (index, resolved) in resolved.into_iter().enumerate() { for (index, resolved) in resolved.into_iter().enumerate() {
if index >= references.len() { if index >= references.len() {
@@ -873,12 +886,8 @@ impl Scene {
} }
} }
pub(crate) fn infer_dimension_sources( pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec<Option<Handle>> {
&self, let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else {
dimension: Handle,
) -> Vec<Option<Handle>> {
let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension)
else {
return Vec::new(); return Vec::new();
}; };
let radial_data = match entity { let radial_data = match entity {
@@ -906,18 +915,11 @@ impl Scene {
.entities() .entities()
.filter(|candidate| candidate.common().handle != dimension) .filter(|candidate| candidate.common().handle != dimension)
.filter_map(|candidate| { .filter_map(|candidate| {
let radial = radial_source_matching( let radial = radial_source_matching(candidate, center, radius, chord)?;
candidate,
center,
radius,
chord,
)?;
let center_error = point_distance_squared(radial.center_world(), center); let center_error = point_distance_squared(radial.center_world(), center);
let radius_error = (radial.radius - radius).powi(2); let radius_error = (radial.radius - radius).powi(2);
(center_error + radius_error <= tolerance * tolerance).then_some(( (center_error + radius_error <= tolerance * tolerance)
center_error + radius_error, .then_some((center_error + radius_error, candidate.common().handle))
candidate.common().handle,
))
}) })
.min_by(|first, second| first.0.total_cmp(&second.0)) .min_by(|first, second| first.0.total_cmp(&second.0))
.map(|(_, handle)| handle); .map(|(_, handle)| handle);
@@ -946,8 +948,7 @@ impl Scene {
&mut self, &mut self,
changes: &[(Handle, ChangeKind)], changes: &[(Handle, ChangeKind)],
) -> Vec<(Handle, ChangeKind)> { ) -> Vec<(Handle, ChangeKind)> {
let changed: rustc_hash::FxHashSet<_> = let changed: rustc_hash::FxHashSet<_> = changes.iter().map(|(handle, _)| *handle).collect();
changes.iter().map(|(handle, _)| *handle).collect();
if changed.is_empty() { if changed.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -966,7 +967,12 @@ impl Scene {
.references .references
.iter() .iter()
.flatten() .flatten()
.any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) .any(|reference| {
reference
.xrefs
.iter()
.any(|handle| changed.contains(handle))
})
.then_some(association.clone()) .then_some(association.clone())
}) })
.collect(); .collect();
@@ -989,9 +995,7 @@ impl Scene {
let entity = self.document.get_entity(source)?; let entity = self.document.get_entity(source)?;
let segment = match reference.main_gs_marker { let segment = match reference.main_gs_marker {
-3 => 0, -3 => 0,
POLYLINE_ARC_CENTER_MARKER => { POLYLINE_ARC_CENTER_MARKER => reference.osnap_distance.round().max(0.0) as i32,
reference.osnap_distance.round().max(0.0) as i32
}
_ => return None, _ => return None,
}; };
radial_source_for_marker(entity, segment) radial_source_for_marker(entity, segment)
@@ -1144,10 +1148,7 @@ impl Scene {
let old_dim_radius = old_center.distance(&old_definition); let old_dim_radius = old_center.distance(&old_definition);
let radial_offset = old_dim_radius - old_source_radius; let radial_offset = old_dim_radius - old_source_radius;
let old_mid = arc.arc_start_parameter let old_mid = arc.arc_start_parameter
+ positive_sweep( + positive_sweep(arc.arc_start_parameter, arc.arc_end_parameter) * 0.5;
arc.arc_start_parameter,
arc.arc_end_parameter,
) * 0.5;
let old_definition_angle = let old_definition_angle =
angle_about_plane(old_plane, old_center, old_definition); angle_about_plane(old_plane, old_center, old_definition);
let definition_angle_offset = let definition_angle_offset =
@@ -1208,8 +1209,7 @@ impl Scene {
arc.base.insertion_point = definition; arc.base.insertion_point = definition;
} }
if arc.has_leader { if arc.has_leader {
arc.first_leader_point = arc.first_leader_point = point_on_radial_circle(radial, dim_radius, middle);
point_on_radial_circle(radial, dim_radius, middle);
arc.second_leader_point = second_leader; arc.second_leader_point = second_leader;
} }
arc.base.actual_measurement = arc.measurement(); arc.base.actual_measurement = arc.measurement();
@@ -1228,7 +1228,9 @@ impl Scene {
fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 {
let plane = plane_from_normal(linear.first_point, linear.base.normal); let plane = plane_from_normal(linear.first_point, linear.base.normal);
let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]); let first = plane
.project(dpoint(linear.first_point))
.unwrap_or([0.0; 2]);
let second = plane.project(dpoint(linear.second_point)).unwrap_or(first); let second = plane.project(dpoint(linear.second_point)).unwrap_or(first);
let axis = [linear.rotation.cos(), linear.rotation.sin()]; let axis = [linear.rotation.cos(), linear.rotation.sin()];
((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs()
File diff suppressed because it is too large. Load diff
+231 -229
View File
@@ -146,8 +146,7 @@ impl Scene {
fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool {
use acadrust::entities::ExtendedEntityData; use acadrust::entities::ExtendedEntityData;
use acadrust::objects::{ use acadrust::objects::{
ClassObject, ClassObjectData, SectionManager, SectionSettings, ClassObject, ClassObjectData, SectionManager, SectionSettings, SectionTypeSettings,
SectionTypeSettings,
}; };
let EntityType::Extended(extended) = entity else { let EntityType::Extended(extended) = entity else {
@@ -169,16 +168,15 @@ impl Scene {
); );
if !valid_settings { if !valid_settings {
let settings_handle = self.document.allocate_handle(); let settings_handle = self.document.allocate_handle();
let mut settings = ClassObject::new(ClassObjectData::SectionSettings( let mut settings =
SectionSettings { ClassObject::new(ClassObjectData::SectionSettings(SectionSettings {
current_type: 4, current_type: 4,
types: vec![SectionTypeSettings { types: vec![SectionTypeSettings {
section_type: 4, section_type: 4,
generation: 17, generation: 17,
..SectionTypeSettings::default() ..SectionTypeSettings::default()
}], }],
}, }));
));
settings.handle = settings_handle; settings.handle = settings_handle;
settings.owner = entity_handle; settings.owner = entity_handle;
self.document self.document
@@ -223,9 +221,9 @@ impl Scene {
if let Some(ObjectType::Dictionary(dictionary)) = if let Some(ObjectType::Dictionary(dictionary)) =
self.document.objects.get_mut(&root) self.document.objects.get_mut(&root)
{ {
dictionary.entries.retain(|(name, _)| { dictionary
!name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER") .entries
}); .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER"));
dictionary.add_entry("ACAD_SECTION_MANAGER", handle); dictionary.add_entry("ACAD_SECTION_MANAGER", handle);
} }
handle handle
@@ -256,10 +254,7 @@ impl Scene {
let mut needs_geometry_bump = false; let mut needs_geometry_bump = false;
for entity in entities { for entity in entities {
let affects_blocks = matches!( let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_));
&entity,
EntityType::Block(_) | EntityType::BlockEnd(_)
);
let handle = self.add_entity_internal(entity, false); let handle = self.add_entity_internal(entity, false);
handles.push(handle); handles.push(handle);
if handle.is_null() { if handle.is_null() {
@@ -285,10 +280,7 @@ impl Scene {
// Only block sentinels mutate a block definition and require rebuilding // Only block sentinels mutate a block definition and require rebuilding
// the block cache. A top-level INSERT merely references an existing // the block cache. A top-level INSERT merely references an existing
// definition, so adding it can patch just that new render handle. // definition, so adding it can patch just that new render handle.
let affects_blocks = matches!( let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_));
&entity,
EntityType::Block(_) | EntityType::BlockEnd(_)
);
// INSERT invalidates rendered block instances, but it does not mutate // INSERT invalidates rendered block instances, but it does not mutate
// the referenced block definition. Only block sentinels require a // the referenced block definition. Only block sentinels require a
// structure image for undo; ordinary owner membership is intrinsic add // structure image for undo; ordinary owner membership is intrinsic add
@@ -363,8 +355,9 @@ impl Scene {
// Delta-undo poison inputs (captured before the mutations below): an // Delta-undo poison inputs (captured before the mutations below): an
// add that also creates a new layer, adds a block, or inserts an image // add that also creates a new layer, adds a block, or inserts an image
// definition mutates non-entity state a pure-entity delta can't undo. // definition mutates non-entity state a pure-entity delta can't undo.
let creates_layer = let creates_layer = self.is_recording_undo()
self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer); && !layer.trim().is_empty()
&& !self.document.layers.contains(&layer);
self.ensure_layer(&layer); self.ensure_layer(&layer);
let app_ids: Vec<String> = entity let app_ids: Vec<String> = entity
.common() .common()
@@ -374,9 +367,9 @@ impl Scene {
.map(|record| record.application_name.clone()) .map(|record| record.application_name.clone())
.collect(); .collect();
let creates_app_id = self.is_recording_undo() let creates_app_id = self.is_recording_undo()
&& app_ids.iter().any(|name| { && app_ids
!name.trim().is_empty() && !self.document.app_ids.contains(name) .iter()
}); .any(|name| !name.trim().is_empty() && !self.document.app_ids.contains(name));
for name in &app_ids { for name in &app_ids {
self.ensure_app_id(name); self.ensure_app_id(name);
} }
@@ -412,14 +405,13 @@ impl Scene {
if let Some(mut model) = mesh_seed { if let Some(mut model) = mesh_seed {
if let Some(entity) = self.document.get_entity(handle) { if let Some(entity) = self.document.get_entity(handle) {
let color = self.render_style(entity).0; let color = self.render_style(entity).0;
let material = let material = crate::scene::model::material_model::resolve_material_with_base(
crate::scene::model::material_model::resolve_material_with_base( &self.document,
&self.document, entity,
entity, color,
color, None,
None, self.material_base_dir.as_deref(),
self.material_base_dir.as_deref(), );
);
material.apply_to_with_face_overrides( material.apply_to_with_face_overrides(
&mut model, &mut model,
&self.document, &self.document,
@@ -539,13 +531,8 @@ impl Scene {
// Replacing (or becoming) a block sentinel forces a full block-cache // Replacing (or becoming) a block sentinel forces a full block-cache
// rebuild. INSERT edits (including retargeting to another existing // rebuild. INSERT edits (including retargeting to another existing
// definition) only change that top-level render handle. // definition) only change that top-level render handle.
let affects_blocks = matches!( let affects_blocks = matches!(existing, EntityType::Block(_) | EntityType::BlockEnd(_))
existing, || matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_));
EntityType::Block(_) | EntityType::BlockEnd(_)
) || matches!(
&entity,
EntityType::Block(_) | EntityType::BlockEnd(_)
);
// A plugin edit may retarget the entity to a novel layer; register it // A plugin edit may retarget the entity to a novel layer; register it
// so the edited entity keeps that layer on save instead of collapsing // so the edited entity keeps that layer on save instead of collapsing
@@ -627,14 +614,13 @@ impl Scene {
if let Some(mut model) = mesh_seed { if let Some(mut model) = mesh_seed {
if let Some(entity) = self.document.get_entity(handle) { if let Some(entity) = self.document.get_entity(handle) {
let color = self.render_style(entity).0; let color = self.render_style(entity).0;
let material = let material = crate::scene::model::material_model::resolve_material_with_base(
crate::scene::model::material_model::resolve_material_with_base( &self.document,
&self.document, entity,
entity, color,
color, None,
None, self.material_base_dir.as_deref(),
self.material_base_dir.as_deref(), );
);
material.apply_to_with_face_overrides( material.apply_to_with_face_overrides(
&mut model, &mut model,
&self.document, &self.document,
@@ -770,15 +756,14 @@ impl Scene {
_ => None, _ => None,
}) })
.collect(); .collect();
let entries: Vec<(Handle, std::sync::Arc<EntityType>, [f32; 4], bool)> = let entries: Vec<(Handle, std::sync::Arc<EntityType>, [f32; 4], bool)> = mesh_entities
mesh_entities .into_iter()
.into_iter() .map(|(handle, entity)| {
.map(|(handle, entity)| { let color = self.render_style(entity.as_ref()).0;
let color = self.render_style(entity.as_ref()).0; let top_level = layout_blocks.contains(&entity.common().owner_handle);
let top_level = layout_blocks.contains(&entity.common().owner_handle); (handle, entity, color, top_level)
(handle, entity, color, top_level) })
}) .collect();
.collect();
let facet_res = self.document.header.facet_resolution; let facet_res = self.document.header.facet_resolution;
let chordal_deflection = let chordal_deflection =
crate::entities::solid3d::display_deflection(&self.document.header, facet_res); crate::entities::solid3d::display_deflection(&self.document.header, facet_res);
@@ -1119,16 +1104,13 @@ impl Scene {
self.belongs_to_visible_block(handle, c.owner_handle, target_block) self.belongs_to_visible_block(handle, c.owner_handle, target_block)
}) })
.flat_map(|(&handle, model)| { .flat_map(|(&handle, model)| {
let contextual = self let contextual = self.document.get_entity(handle).map(|entity| {
.document crate::scene::annotative::entity_for_annotation_context(
.get_entity(handle) &self.document,
.map(|entity| { entity,
crate::scene::annotative::entity_for_annotation_context( annotation_scale_handle,
&self.document, )
entity, });
annotation_scale_handle,
)
});
let entity = contextual.as_deref(); let entity = contextual.as_deref();
let mut m = match entity { let mut m = match entity {
Some(EntityType::Hatch(dxf)) Some(EntityType::Hatch(dxf))
@@ -1196,10 +1178,7 @@ impl Scene {
), ),
index, index,
), ),
other => ( other => (crate::scene::convert::tess_util::aci_to_rgba(&other), 0),
crate::scene::convert::tess_util::aci_to_rgba(&other),
0,
),
}; };
b.color = bg_color; b.color = bg_color;
b.aci = bg_aci; b.aci = bg_aci;
@@ -1218,9 +1197,7 @@ impl Scene {
m.scale = dxf.pattern_scale as f32; m.scale = dxf.pattern_scale as f32;
} }
model::hatch_model::HatchPattern::Gradient { model::hatch_model::HatchPattern::Gradient {
angle_deg, angle_deg, shift, ..
shift,
..
} => { } => {
*angle_deg = dxf.gradient_color.angle.to_degrees() as f32; *angle_deg = dxf.gradient_color.angle.to_degrees() as f32;
*shift = dxf.gradient_color.shift as f32; *shift = dxf.gradient_color.shift as f32;
@@ -1351,7 +1328,10 @@ impl Scene {
.iter() .iter()
.copied() .copied()
.filter(|&handle| { .filter(|&handle| {
matches!(self.document.get_entity(handle), Some(EntityType::Insert(_))) matches!(
self.document.get_entity(handle),
Some(EntityType::Insert(_))
)
}) })
.collect(); .collect();
if targets.is_empty() { if targets.is_empty() {
@@ -1421,8 +1401,7 @@ impl Scene {
return true; return true;
} }
if self.object_isolation.hides(common.handle) if self.object_isolation.hides(common.handle)
|| (!include_preview_hidden || (!include_preview_hidden && self.preview_hidden.contains(&common.handle))
&& self.preview_hidden.contains(&common.handle))
{ {
return false; return false;
} }
@@ -1462,10 +1441,7 @@ impl Scene {
} }
let style = context.style_for(&self.document, entity); let style = context.style_for(&self.document, entity);
let preserve_white_mask = source_hatch.is_solid let preserve_white_mask = source_hatch.is_solid
&& matches!( && matches!(source_hatch.common.color, acadrust::types::Color::Index(7));
source_hatch.common.color,
acadrust::types::Color::Index(7)
);
let color = if preserve_white_mask { let color = if preserve_white_mask {
style.0 style.0
} else { } else {
@@ -1504,9 +1480,15 @@ impl Scene {
} }
let matrix = &context.transform.matrix.m; let matrix = &context.transform.matrix.m;
let linear = [ let linear = [
matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), matrix[0][0].to_bits(),
matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), matrix[0][1].to_bits(),
matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), matrix[0][2].to_bits(),
matrix[1][0].to_bits(),
matrix[1][1].to_bits(),
matrix[1][2].to_bits(),
matrix[2][0].to_bits(),
matrix[2][1].to_bits(),
matrix[2][2].to_bits(),
]; ];
let key = ( let key = (
source_hatch.common.handle.value(), source_hatch.common.handle.value(),
@@ -1525,12 +1507,10 @@ impl Scene {
let source_id = *hatch_sources let source_id = *hatch_sources
.entry(key) .entry(key)
.or_insert_with(crate::scene::model::instance_model::next_source_id); .or_insert_with(crate::scene::model::instance_model::next_source_id);
model.render_instance = Some( model.render_instance = Some(crate::scene::model::instance_model::RenderInstance {
crate::scene::model::instance_model::RenderInstance { source_id,
source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]],
translation: [matrix[0][3], matrix[1][3], matrix[2][3]], });
},
);
models.push(model); models.push(model);
}, },
); );
@@ -1598,48 +1578,37 @@ impl Scene {
}; };
let mut wipeout = source.clone(); let mut wipeout = source.clone();
if context.is_instanced() { if context.is_instanced() {
wipeout.insertion_point = wipeout.insertion_point = context.transform.apply(source.insertion_point);
context.transform.apply(source.insertion_point); wipeout.u_vector = context.transform.apply_rotation(source.u_vector);
wipeout.u_vector = wipeout.v_vector = context.transform.apply_rotation(source.v_vector);
context.transform.apply_rotation(source.u_vector);
wipeout.v_vector =
context.transform.apply_rotation(source.v_vector);
} }
let Some(mut fill_plane) = Self::wipeout_fill_plane(&wipeout) else { let Some(mut fill_plane) = Self::wipeout_fill_plane(&wipeout) else {
return; return;
}; };
let (world_origin, mut boundary) = let (world_origin, mut boundary) = Self::wipeout_boundary_2d(&wipeout);
Self::wipeout_boundary_2d(&wipeout);
for clip in &context.clips { for clip in &context.clips {
let clip: Vec<[f32; 2]> = clip let clip: Vec<[f32; 2]> = clip
.iter() .iter()
.map(|point| [point[0] as f32, point[1] as f32]) .map(|point| [point[0] as f32, point[1] as f32])
.collect(); .collect();
boundary = pick::xclip::clip_hatch_boundary( boundary = pick::xclip::clip_hatch_boundary(&boundary, world_origin, &clip);
&boundary,
world_origin,
&clip,
);
} }
if boundary.len() < 3 { if boundary.len() < 3 {
return; return;
} }
if !context.clips.is_empty() { if !context.clips.is_empty() {
let Some(local) = Self::wipeout_boundary_at_xy( let Some(local) =
fill_plane.0, Self::wipeout_boundary_at_xy(fill_plane.0, world_origin, &boundary)
world_origin, else {
&boundary,
) else {
return; return;
}; };
fill_plane.1 = local; fill_plane.1 = local;
} }
let selection_handle = let selection_handle = if tint_insert_selection && context.is_instanced() {
if tint_insert_selection && context.is_instanced() { context.root_handle
context.root_handle } else {
} else { source.common.handle
source.common.handle };
};
let color = if self.selected.contains(&selection_handle) { let color = if self.selected.contains(&selection_handle) {
[0.15, 0.55, 1.00, 0.35] [0.15, 0.55, 1.00, 0.35]
} else { } else {
@@ -1648,9 +1617,15 @@ impl Scene {
let render_instance = if context.is_instanced() { let render_instance = if context.is_instanced() {
let matrix = &context.transform.matrix.m; let matrix = &context.transform.matrix.m;
let linear = [ let linear = [
matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), matrix[0][0].to_bits(),
matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), matrix[0][1].to_bits(),
matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), matrix[0][2].to_bits(),
matrix[1][0].to_bits(),
matrix[1][1].to_bits(),
matrix[1][2].to_bits(),
matrix[2][0].to_bits(),
matrix[2][1].to_bits(),
matrix[2][2].to_bits(),
]; ];
let key = ( let key = (
source.common.handle.value(), source.common.handle.value(),
@@ -1661,9 +1636,9 @@ impl Scene {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
color.map(f32::to_bits), color.map(f32::to_bits),
); );
let source_id = *wipeout_sources.entry(key).or_insert_with( let source_id = *wipeout_sources
crate::scene::model::instance_model::next_source_id, .entry(key)
); .or_insert_with(crate::scene::model::instance_model::next_source_id);
Some(crate::scene::model::instance_model::RenderInstance { Some(crate::scene::model::instance_model::RenderInstance {
source_id, source_id,
translation: [matrix[0][3], matrix[1][3], matrix[2][3]], translation: [matrix[0][3], matrix[1][3], matrix[2][3]],
@@ -1692,8 +1667,7 @@ impl Scene {
angle_offset: 0.0, angle_offset: 0.0,
scale: 1.0, scale: 1.0,
world_origin, world_origin,
draw_depth: context draw_depth: context.draw_depth(source.common.handle, depth_map.as_ref()),
.draw_depth(source.common.handle, depth_map.as_ref()),
}); });
}, },
); );
@@ -1708,7 +1682,11 @@ impl Scene {
) -> ([f64; 2], Vec<[f32; 2]>) { ) -> ([f64; 2], Vec<[f32; 2]>) {
let origin = [wo.insertion_point.x, wo.insertion_point.y]; let origin = [wo.insertion_point.x, wo.insertion_point.y];
let plane = cadkernel::space::Plane::from_axes( let plane = cadkernel::space::Plane::from_axes(
[wo.insertion_point.x, wo.insertion_point.y, wo.insertion_point.z], [
wo.insertion_point.x,
wo.insertion_point.y,
wo.insertion_point.z,
],
[wo.u_vector.x, wo.u_vector.y, wo.u_vector.z], [wo.u_vector.x, wo.u_vector.y, wo.u_vector.z],
[wo.v_vector.x, wo.v_vector.y, wo.v_vector.z], [wo.v_vector.x, wo.v_vector.y, wo.v_vector.z],
); );
@@ -1744,12 +1722,7 @@ impl Scene {
let mut clip: Vec<[f64; 2]> = wo let mut clip: Vec<[f64; 2]> = wo
.clip_boundary_vertices .clip_boundary_vertices
.iter() .iter()
.map(|point| { .map(|point| [point.x + wo.size.x * 0.5, wo.size.y * 0.5 - point.y])
[
point.x + wo.size.x * 0.5,
wo.size.y * 0.5 - point.y,
]
})
.collect(); .collect();
if clip.last() != clip.first() { if clip.last() != clip.first() {
clip.push(clip[0]); clip.push(clip[0]);
@@ -1793,11 +1766,7 @@ impl Scene {
world_origin: [f64; 2], world_origin: [f64; 2],
boundary: &[[f32; 2]], boundary: &[[f32; 2]],
) -> Option<Vec<[f32; 2]>> { ) -> Option<Vec<[f32; 2]>> {
let plane = cadkernel::space::Plane::from_axes( let plane = cadkernel::space::Plane::from_axes(plane.origin, plane.x_axis, plane.y_axis);
plane.origin,
plane.x_axis,
plane.y_axis,
);
boundary boundary
.iter() .iter()
.map(|point| { .map(|point| {
@@ -1815,10 +1784,7 @@ impl Scene {
.collect() .collect()
} }
pub(crate) fn hatch_model_from_dxf( pub(crate) fn hatch_model_from_dxf(dxf: &DxfHatch, color: [f32; 4]) -> Option<HatchModel> {
dxf: &DxfHatch,
color: [f32; 4],
) -> Option<HatchModel> {
let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z); let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z);
// Build the boundary in f64 first so the precision-preserving // Build the boundary in f64 first so the precision-preserving
// origin computation below sees full WCS precision. We only cast // origin computation below sees full WCS precision. We only cast
@@ -1849,9 +1815,7 @@ impl Scene {
let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new(); let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new();
for edge in &path.edges { for edge in &path.edges {
if let Some(curve) = crate::entities::hatch::edge_curve(edge) { if let Some(curve) = crate::entities::hatch::edge_curve(edge) {
edge_polys.push( edge_polys.push(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE));
curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE),
);
} }
} }
let mut local_ring = chain_path_edges(edge_polys); let mut local_ring = chain_path_edges(edge_polys);
@@ -1870,7 +1834,7 @@ impl Scene {
.map(|point| to_xy(point[0], point[1])) .map(|point| to_xy(point[0], point[1]))
.collect(); .collect();
rings.push(ring); rings.push(ring);
local_rings.push(local_ring); local_rings.push((local_ring, path));
ring_sources.push(path.boundary_handles.clone()); ring_sources.push(path.boundary_handles.clone());
} }
@@ -1883,7 +1847,8 @@ impl Scene {
let mut local_boundary = Vec::new(); let mut local_boundary = Vec::new();
let mut boundary_exterior = Vec::new(); let mut boundary_exterior = Vec::new();
let mut boundary_sources = Vec::new(); let mut boundary_sources = Vec::new();
for (((ring, local_ring), sources), depth) in rings let mut spline_paths = Vec::new();
for (((ring, (local_ring, path)), sources), depth) in rings
.into_iter() .into_iter()
.zip(local_rings) .zip(local_rings)
.zip(ring_sources) .zip(ring_sources)
@@ -1901,12 +1866,14 @@ impl Scene {
boundary.push([f64::NAN, f64::NAN]); boundary.push([f64::NAN, f64::NAN]);
local_boundary.push([f32::NAN, f32::NAN]); local_boundary.push([f32::NAN, f32::NAN]);
} }
let range = boundary.len()..boundary.len() + ring.len();
boundary.extend(ring); boundary.extend(ring);
local_boundary.extend( local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32]));
local_ring if path.edges.iter().any(|edge| {
.into_iter() matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))
.map(|[x, y]| [x as f32, y as f32]), }) {
); spline_paths.push((path, local_ring, range));
}
boundary_exterior.push(depth == 0); boundary_exterior.push(depth == 0);
boundary_sources.push(sources); boundary_sources.push(sources);
} }
@@ -1941,18 +1908,19 @@ impl Scene {
// lines. The stored offset is the authoritative world-unit spacing, so // lines. The stored offset is the authoritative world-unit spacing, so
// the resulting families are already final: no pattern_scale / angle // the resulting families are already final: no pattern_scale / angle
// is re-applied (see `prebaked` below and the HatchModel fields). // is re-applied (see `prebaked` below and the HatchModel fields).
let prebaked = !dxf.is_solid let prebaked =
&& !dxf.gradient_color.is_enabled() !dxf.is_solid && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty();
&& !dxf.pattern.lines.is_empty();
// The gradient's first stop is the fill's start colour (not the // The gradient's first stop is the fill's start colour (not the
// entity colour); capture it so the HatchModel draws stop-0 → stop-1. // entity colour); capture it so the HatchModel draws stop-0 → stop-1.
let mut gradient_color1: Option<[f32; 4]> = None; let mut gradient_color1: Option<[f32; 4]> = None;
let mut pattern = if dxf.gradient_color.is_enabled() { let mut pattern = if dxf.gradient_color.is_enabled() {
let stop = |i: usize| { let stop = |i: usize| {
dxf.gradient_color.colors.get(i).and_then(|e| e.color.rgb()).map( dxf.gradient_color
|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0], .colors
) .get(i)
.and_then(|e| e.color.rgb())
.map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
}; };
gradient_color1 = stop(0); gradient_color1 = stop(0);
let color1 = gradient_color1.unwrap_or(color); let color1 = gradient_color1.unwrap_or(color);
@@ -1975,7 +1943,11 @@ impl Scene {
model::hatch_model::HatchPattern::Solid model::hatch_model::HatchPattern::Solid
} else if prebaked { } else if prebaked {
model::hatch_model::HatchPattern::Pattern( model::hatch_model::HatchPattern::Pattern(
dxf.pattern.lines.iter().map(family_from_stored_line).collect(), dxf.pattern
.lines
.iter()
.map(family_from_stored_line)
.collect(),
) )
} else { } else {
let pat_name = &dxf.pattern.name; let pat_name = &dxf.pattern.name;
@@ -2038,10 +2010,18 @@ impl Scene {
let mut max_y = f64::NEG_INFINITY; let mut max_y = f64::NEG_INFINITY;
for &[x, y] in &boundary { for &[x, y] in &boundary {
if x.is_finite() && y.is_finite() { if x.is_finite() && y.is_finite() {
if x < min_x { min_x = x; } if x < min_x {
if y < min_y { min_y = y; } min_x = x;
if x > max_x { max_x = x; } }
if y > max_y { max_y = y; } if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
} }
} }
let world_origin = if min_x.is_finite() && min_y.is_finite() { let world_origin = if min_x.is_finite() && min_y.is_finite() {
@@ -2095,6 +2075,26 @@ impl Scene {
} }
} }
} }
let project = |point: [f64; 2]| {
let [x, y] = to_xy(point[0], point[1]);
[(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64]
};
for (path, ring, range) in spline_paths.into_iter().rev() {
if range.end > boundary.len() {
continue;
}
let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect();
if let Some(refined) =
cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project)
{
if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS {
continue;
}
boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1])));
local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32]));
}
}
let boundary_f32: Vec<[f32; 2]> = boundary let boundary_f32: Vec<[f32; 2]> = boundary
.iter() .iter()
.map(|&[x, y]| { .map(|&[x, y]| {
@@ -2129,8 +2129,16 @@ impl Scene {
color: gradient_color1.unwrap_or(color), color: gradient_color1.unwrap_or(color),
aci: 0, aci: 0,
line_weight_px: 1.0, line_weight_px: 1.0,
angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, angle_offset: if prebaked {
scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, 0.0
} else {
dxf.pattern_angle as f32
},
scale: if prebaked {
1.0
} else {
dxf.pattern_scale as f32
},
world_origin, world_origin,
draw_depth: 0.0, draw_depth: 0.0,
}) })
@@ -2168,16 +2176,13 @@ impl Scene {
/// at load, so a pattern-scale / background / boundary edit stays /// at load, so a pattern-scale / background / boundary edit stays
/// invisible until the cached model is refreshed (#415). /// invisible until the cached model is refreshed (#415).
pub fn refresh_fill_model(&mut self, handle: Handle) { pub fn refresh_fill_model(&mut self, handle: Handle) {
let contextual = self let contextual = self.document.get_entity(handle).map(|entity| {
.document crate::scene::annotative::entity_for_annotation_context(
.get_entity(handle) &self.document,
.map(|entity| { entity,
crate::scene::annotative::entity_for_annotation_context( self.displayed_annotation_scale_handle(),
&self.document, )
entity, });
self.displayed_annotation_scale_handle(),
)
});
let new_model = match contextual.as_deref() { let new_model = match contextual.as_deref() {
Some(EntityType::Hatch(dxf)) => { Some(EntityType::Hatch(dxf)) => {
let color = convert::tess_util::aci_to_rgba(&dxf.common.color); let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
@@ -2213,7 +2218,7 @@ impl Scene {
e, e,
self.displayed_annotation_scale_handle(), self.displayed_annotation_scale_handle(),
) )
.into_owned(), .into_owned(),
)), )),
EntityType::Solid(s) => Some((s.common.handle, e.clone())), EntityType::Solid(s) => Some((s.common.handle, e.clone())),
_ => None, _ => None,
@@ -2315,7 +2320,8 @@ impl Scene {
// Incremental (post-xref) pass: leave already-tessellated // Incremental (post-xref) pass: leave already-tessellated
// host solids untouched, only build the newly merged ones. // host solids untouched, only build the newly merged ones.
if incremental if incremental
&& (self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle)) && (self.meshes.contains_key(&handle)
|| self.block_meshes.contains_key(&handle))
{ {
return None; return None;
} }
@@ -2363,7 +2369,11 @@ impl Scene {
&self.document, &self.document,
&entity, &entity,
); );
let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; let mesh = if top_level {
offset_mesh_lod_set(mesh)
} else {
mesh
};
(handle, mesh, top_level) (handle, mesh, top_level)
}) })
}) })
@@ -2478,18 +2488,18 @@ impl Scene {
} else { } else {
// Otherwise reconstruct every ring from render offsets. // Otherwise reconstruct every ring from render offsets.
let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() {
let [wx, wy] = model.world_origin; let [wx, wy] = model.world_origin;
model model
.boundary .boundary
.iter() .iter()
.map(|&[x, y]| { .map(|&[x, y]| {
if x.is_finite() && y.is_finite() { if x.is_finite() && y.is_finite() {
[x as f64 + wx, y as f64 + wy] [x as f64 + wx, y as f64 + wy]
} else { } else {
[f64::NAN, f64::NAN] [f64::NAN, f64::NAN]
} }
}) })
.collect() .collect()
} else { } else {
Vec::new() Vec::new()
}; };
@@ -2502,31 +2512,31 @@ impl Scene {
let mut first = true; let mut first = true;
let mut ring_index = 0usize; let mut ring_index = 0usize;
let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool, index: usize| { let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool, index: usize| {
if !r.is_empty() { if !r.is_empty() {
let edge = PolylineEdge::new(std::mem::take(r), true); let edge = PolylineEdge::new(std::mem::take(r), true);
let handles: Vec<_> = model let handles: Vec<_> = model
.boundary_sources .boundary_sources
.as_deref() .as_deref()
.and_then(|sources| sources.get(index)) .and_then(|sources| sources.get(index))
.into_iter() .into_iter()
.flatten() .flatten()
.copied() .copied()
.filter(|handle| handle.is_valid()) .filter(|handle| handle.is_valid())
.collect(); .collect();
let mut bits = 0; let mut bits = 0;
if is_outer { if is_outer {
bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits();
bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits();
}
let mut path = BoundaryPath::with_flags(
acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits),
);
path.add_edge(BoundaryEdge::Polyline(edge));
for handle in handles {
path.add_boundary_handle(handle);
}
dxf.paths.push(path);
} }
let mut path = BoundaryPath::with_flags(
acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits),
);
path.add_edge(BoundaryEdge::Polyline(edge));
for handle in handles {
path.add_boundary_handle(handle);
}
dxf.paths.push(path);
}
}; };
for &[x, y] in wcs { for &[x, y] in wcs {
if x.is_finite() && y.is_finite() { if x.is_finite() && y.is_finite() {
@@ -2580,28 +2590,20 @@ impl Scene {
let family_angle = (family.angle_deg as f64).to_radians(); let family_angle = (family.angle_deg as f64).to_radians();
let angle = family_angle + rotation; let angle = family_angle + rotation;
let (family_sin, family_cos) = family_angle.sin_cos(); let (family_sin, family_cos) = family_angle.sin_cos();
let local_offset_x = family.dx as f64 * family_cos let local_offset_x = family.dx as f64 * family_cos - family.dy as f64 * family_sin;
- family.dy as f64 * family_sin; let local_offset_y = family.dx as f64 * family_sin + family.dy as f64 * family_cos;
let local_offset_y = family.dx as f64 * family_sin
+ family.dy as f64 * family_cos;
let base_x = family.x0 as f64 * pattern_scale; let base_x = family.x0 as f64 * pattern_scale;
let base_y = family.y0 as f64 * pattern_scale; let base_y = family.y0 as f64 * pattern_scale;
pattern.lines.push(acadrust::entities::HatchPatternLine { pattern.lines.push(acadrust::entities::HatchPatternLine {
angle, angle,
base_point: Vector2::new( base_point: Vector2::new(
pattern_origin[0] pattern_origin[0] + base_x * rotation_cos - base_y * rotation_sin,
+ base_x * rotation_cos pattern_origin[1] + base_x * rotation_sin + base_y * rotation_cos,
- base_y * rotation_sin,
pattern_origin[1]
+ base_x * rotation_sin
+ base_y * rotation_cos,
), ),
offset: Vector2::new( offset: Vector2::new(
(local_offset_x * rotation_cos (local_offset_x * rotation_cos - local_offset_y * rotation_sin)
- local_offset_y * rotation_sin)
* pattern_scale, * pattern_scale,
(local_offset_x * rotation_sin (local_offset_x * rotation_sin + local_offset_y * rotation_cos)
+ local_offset_y * rotation_cos)
* pattern_scale, * pattern_scale,
), ),
dash_lengths: family dash_lengths: family
@@ -2642,13 +2644,13 @@ impl Scene {
dxf.gradient_color.is_single_color = false; dxf.gradient_color.is_single_color = false;
// Linear has no INV name in the standard set: persist an inverted // Linear has no INV name in the standard set: persist an inverted
// linear by swapping the colour stops instead. // linear by swapping the colour stops instead.
let (c0, c1) = let (c0, c1) = if *invert
if *invert && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear)
{ {
(*color2, model.color) (*color2, model.color)
} else { } else {
(model.color, *color2) (model.color, *color2)
}; };
dxf.gradient_color.colors = vec![ dxf.gradient_color.colors = vec![
acadrust::entities::hatch::GradientColorEntry { acadrust::entities::hatch::GradientColorEntry {
value: 0.0, value: 0.0,
@@ -2703,7 +2705,7 @@ impl Scene {
// resetting them, a "new"/"open" (e.g. via automation, which reuses // resetting them, a "new"/"open" (e.g. via automation, which reuses
// this same reset) would leak the previous document's constraints // this same reset) would leak the previous document's constraints
// and named parameters into the fresh one. // and named parameters into the fresh one.
self.sketch_constraints.clear(); self.parametric_constraints.clear();
self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
self.bump_geometry(); self.bump_geometry();
} }
+63
View File
@@ -203,6 +203,42 @@ impl Scene {
self.bump_selection_set(); self.bump_selection_set();
} }
} }
/// Partition selected entity handles into visual objects for operations
/// such as edge alignment. A selectable CAD group is one object and must
/// receive one shared transform; treating its member lines independently
/// can collapse a rectangle or triangle onto its own edge.
pub fn selected_object_units(&self, handles: &[Handle]) -> Vec<Vec<Handle>> {
let selected: HashSet<Handle> = handles.iter().copied().collect();
let mut claimed = HashSet::default();
let mut units = Vec::new();
for group in self.groups().filter(|group| group.selectable) {
let members: Vec<_> = group
.entities
.iter()
.copied()
.filter(|handle| selected.contains(handle))
.collect();
if !members.is_empty()
&& members.iter().all(|handle| !claimed.contains(handle))
&& group
.entities
.iter()
.all(|handle| selected.contains(handle))
{
claimed.extend(members.iter().copied());
units.push(members);
}
}
units.extend(
handles
.iter()
.copied()
.filter(|handle| !claimed.contains(handle))
.map(|handle| vec![handle]),
);
units
}
} }
#[cfg(test)] #[cfg(test)]
@@ -247,4 +283,31 @@ mod group_expansion_tests {
assert_eq!(both, [a, b, c, d, e].into_iter().collect::<HashSet<_>>()); assert_eq!(both, [a, b, c, d, e].into_iter().collect::<HashSet<_>>());
assert!(!both.contains(&lonely)); assert!(!both.contains(&lonely));
} }
/// `selected_object_units` is `Scene::selected_object_units`'s
/// counterpart for operations like edge alignment (`ALIGNTOP`, etc.):
/// a complete, fully-selected group collapses to one unit sharing a
/// single transform, so aligning a triangle's edge to another object
/// moves the whole triangle rather than collapsing its own lines onto
/// each other.
#[test]
fn selected_object_units_keep_a_complete_group_together() {
use acadrust::entities::{EntityType, Line};
use acadrust::types::Vector3;
let mut scene = Scene::new();
let mut line = |x: f64| {
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 0.0, 0.0),
)))
};
let (a, b, c, standalone) = (line(0.0), line(1.0), line(2.0), line(10.0));
scene.create_group("triangle".into(), vec![a, b, c]);
let units = scene.selected_object_units(&[a, b, c, standalone]);
assert_eq!(units.len(), 2);
assert!(units.iter().any(|unit| unit == &vec![a, b, c]));
assert!(units.iter().any(|unit| unit == &vec![standalone]));
}
} }
+2
View File
@@ -0,0 +1,2 @@
#[cfg(test)]
mod tests;
+236
View File
@@ -0,0 +1,236 @@
use crate::scene::Scene;
use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge};
use acadrust::types::{Vector2, Vector3};
use cadkernel::geom2d::{refine_spline_boundary, triangulate_rings, Curve, NurbsCurve};
use cadkernel::tessellation::DEFAULT_ANGLE;
fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve {
let line = |a: [f64; 2], b: [f64; 2]| {
[
a,
std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0),
std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0),
b,
]
};
let [a, b, c, d, e, f] = [
[-1.0, -1.0],
[0.0, -1.0],
[0.0, 1.0],
[clearance, 0.999],
[-0.5, 1.5],
[-1.0, 1.5],
];
// The returning cubic passes close to the vertical side b--c.
let spans = [
line(a, b),
line(b, c),
line(c, d),
[d, [clearance, 1.1], [-0.3, 1.5], e],
line(e, f),
line(f, a),
];
let mut controls = vec![a];
for span in spans {
controls.extend_from_slice(&span[1..]);
}
for p in &mut controls {
for i in 0..2 {
p[i] += offset[i];
}
}
let mut knots = vec![0.0; 4];
for i in 1..6 {
knots.extend([i as f64; 3]);
}
knots.extend([6.0; 4]);
NurbsCurve::new(3, controls, knots, None).unwrap()
}
fn edge(spline: NurbsCurve) -> BoundaryEdge {
BoundaryEdge::Spline(SplineEdge {
degree: spline.degree() as i32,
rational: false,
periodic: false,
knots: spline.knots().to_vec(),
control_points: spline
.control_points()
.iter()
.map(|p| Vector3::new(p[0], p[1], 0.0))
.collect(),
fit_points: Vec::new(),
start_tangent: Vector2::new(0.0, 0.0),
end_tangent: Vector2::new(0.0, 0.0),
})
}
fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath {
let points = [a, [b[0], a[1]], b, [a[0], b[1]]]
.map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1]));
let mut path = BoundaryPath::new();
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(
points.to_vec(),
true,
)));
path
}
#[test]
fn narrow_fill_preserves_corners_holes_and_shared_origin() {
let curve = narrow(0.00001, [0.0; 2]);
let area: f64 = (0..6)
.map(|i| {
Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap())
.enclosed_area()
})
.sum();
for (offset, shuffled, multiple) in [
([0.0; 2], false, false),
([1e6, -1e6], false, false),
([0.0; 2], true, true),
([1e6, -1e6], true, true),
] {
let spline = narrow(0.00001, offset);
let mut path = BoundaryPath::new();
path.edges = if shuffled {
let (a, b) = spline.split_at(0.5).unwrap();
vec![edge(b), edge(a.reversed())]
} else {
vec![edge(spline.clone())]
};
let mut hatch = Hatch::new();
hatch.is_solid = true;
hatch.paths.push(path);
if multiple {
hatch.paths.extend([
rectangle([-0.8, -0.8], [-0.2, -0.2], offset),
rectangle([-0.6, -0.6], [-0.4, -0.4], offset),
rectangle([1.0, -1.0], [2.0, 2.5], offset),
]);
}
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
if multiple {
assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]);
}
let project = |p: [f64; 2]| {
[
(p[0] - model.world_origin[0]) as f32,
(p[1] - model.world_origin[1]) as f32,
]
};
let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE);
let original: Vec<_> = original
.into_iter()
.map(|p| project(p).map(f64::from))
.collect();
assert!(triangulate_rings(&[original]).1.is_empty());
for i in 0..=6 {
assert!(model
.boundary
.contains(&project(spline.point_at(i as f64 / 6.0))));
}
assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths);
let (vertices, indices) = model.fill_mesh();
assert!(!indices.is_empty());
let mut filled_area = 0.0;
for triangle in indices.chunks_exact(3) {
let [a, b, c] = [triangle[0], triangle[1], triangle[2]]
.map(|i| vertices[i as usize].map(f64::from));
let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
assert!(twice_area > 0.0);
filled_area += twice_area * 0.5;
if multiple {
let p =
[0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]);
let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi);
assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4));
}
}
let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 };
assert!(
(filled_area - expected).abs() < 0.001,
"{filled_area} vs {expected}"
);
}
}
#[test]
fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() {
for clearance in [0.01, 0.000001, 0.000002, 0.000005] {
let spline = narrow(clearance, [0.0; 2]);
let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE);
let mut path = BoundaryPath::new();
path.edges.push(edge(spline));
let mut hatch = Hatch::new();
hatch.is_solid = true;
hatch.paths.push(path);
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
let expected: Vec<_> = original
.iter()
.map(|p| {
[
(p[0] - model.world_origin[0]) as f32,
(p[1] - model.world_origin[1]) as f32,
]
})
.collect();
assert_eq!(model.boundary.as_ref(), &expected);
assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01);
}
let curve = Curve::Nurbs(narrow(0.01, [0.0; 2]));
assert!(refine_spline_boundary(
&curve.tessellate_angle(DEFAULT_ANGLE),
|| panic!("valid path resampled"),
|p| p
)
.is_none());
assert!(refine_spline_boundary(
&vec![[0.0; 2]; 513],
|| panic!("size cap ignored"),
|p| p
)
.is_none());
}
#[test]
fn recovery_respects_the_total_hatch_vertex_cap() {
let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2]));
let original = curve.tessellate_angle(DEFAULT_ANGLE);
let Curve::Nurbs(spline) = curve.clone() else {
unreachable!()
};
let mut path = BoundaryPath::new();
path.edges.push(edge(spline));
let mut hatch = Hatch::new();
hatch.is_solid = true;
hatch.paths.push(path);
let count = 16_384 - original.len() - 2;
let points = (0..count)
.map(|i| {
let angle = std::f64::consts::TAU * i as f64 / count as f64;
Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin())
})
.collect();
let mut other = BoundaryPath::new();
other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true)));
hatch.paths.push(other);
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
let project = |p: [f64; 2]| {
[
(p[0] - model.world_origin[0]) as f32 as f64,
(p[1] - model.world_origin[1]) as f32 as f64,
]
};
assert!(
refine_spline_boundary(&original, || vec![curve], project)
.unwrap()
.len()
> original.len()
);
assert_eq!(model.boundary.len(), 16_384);
let expected: Vec<_> = original
.into_iter()
.map(|p| project(p).map(|v| v as f32))
.collect();
assert_eq!(&model.boundary[..expected.len()], expected.as_slice());
}
+735 -625
View File
File diff suppressed because it is too large. Load diff
+19 -9
View File
@@ -24,10 +24,7 @@ pub enum PropValue {
/// Editable text plus a dropdown of existing options (block reference /// Editable text plus a dropdown of existing options (block reference
/// Name row): picking an option re-points the reference, submitting a /// Name row): picking an option re-points the reference, submitting a
/// new name renames the definition. /// new name renames the definition.
EditChoice { EditChoice { value: String, options: Vec<String> },
value: String,
options: Vec<String>,
},
/// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker. /// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker.
ColorChoice(AcadColor), ColorChoice(AcadColor),
/// Color-book color with its file-provided display name. /// Color-book color with its file-provided display name.
@@ -51,7 +48,10 @@ pub enum PropValue {
BoolToggle { field: &'static str, value: bool }, BoolToggle { field: &'static str, value: bool },
/// A 0-based index navigated with ◀ / ▶ buttons (e.g. a polyline's Current /// A 0-based index navigated with ◀ / ▶ buttons (e.g. a polyline's Current
/// Vertex). `display` is the label shown between the arrows (e.g. "2 / 7"). /// Vertex). `display` is the label shown between the arrows (e.g. "2 / 7").
Stepper { field: &'static str, display: String }, Stepper {
field: &'static str,
display: String,
},
/// Hatch pattern name — rendered as a combo_box from the catalog. /// Hatch pattern name — rendered as a combo_box from the catalog.
HatchPatternChoice(String), HatchPatternChoice(String),
/// Block attribute value keyed by its (dynamic, runtime) tag — rendered as /// Block attribute value keyed by its (dynamic, runtime) tag — rendered as
@@ -59,11 +59,16 @@ pub enum PropValue {
/// tag carried here, not the row's `&'static str` field. /// tag carried here, not the row's `&'static str` field.
AttrText { tag: String, value: String }, AttrText { tag: String, value: String },
/// A clickable link to one or more entities — the Constraints section's /// A clickable link to one or more entities — the Constraints section's
/// row for one persistent sketch constraint. Clicking it selects every /// row for one parametric constraint. Clicking it selects every
/// entity in `handles` in the viewport. `conflicting` mirrors /// entity in `handles` in the viewport. `conflicting` mirrors
/// `SketchConstraintSet::conflicts`, tinting the row the same danger /// `ParametricConstraintSet::conflicts`, tinting the row the same danger
/// color the constraint's glyph pill already uses. /// color the constraint's glyph pill already uses. `id` routes the row's
EntityLink { handles: Vec<Handle>, conflicting: bool }, /// delete action back to that constraint in the active scope.
EntityLink {
id: crate::scene::parametric_constraints::ConstraintId,
handles: Vec<Handle>,
conflicting: bool,
},
/// One row of the document-wide named-parameter table (Parameters /// One row of the document-wide named-parameter table (Parameters
/// section, shown when nothing is selected). `index` is the row's /// section, shown when nothing is selected). `index` is the row's
/// position in `ParameterTable::iter()` order — stable across edits to /// position in `ParameterTable::iter()` order — stable across edits to
@@ -80,6 +85,11 @@ pub enum PropValue {
}, },
/// The trailing "+ Add parameter" row in the Parameters section. /// The trailing "+ Add parameter" row in the Parameters section.
ParamAddRow, ParamAddRow,
/// The Parameters section's leading header row (no-selection page only):
/// a global on/off toggle for whether any constraint pill in the
/// viewport shows its driven value/parameter-name text. Mirrors
/// `App::show_constraint_values`.
ParamsVisibilityToggle(bool),
} }
/// A single property row in the Properties panel. /// A single property row in the Properties panel.
+65 -93
View File
@@ -9,9 +9,7 @@ struct TextOrient {
x_scale: f64, x_scale: f64,
} }
fn inverse_affine( fn inverse_affine(transform: &acadrust::types::Transform) -> Option<acadrust::types::Transform> {
transform: &acadrust::types::Transform,
) -> Option<acadrust::types::Transform> {
use acadrust::types::{Matrix3, Matrix4, Transform, Vector3}; use acadrust::types::{Matrix3, Matrix4, Transform, Vector3};
let matrix = &transform.matrix.m; let matrix = &transform.matrix.m;
let linear = Matrix3::from_rows( let linear = Matrix3::from_rows(
@@ -248,9 +246,7 @@ impl Scene {
let image_bearing = self.document.get_entity(h).is_some_and(|entity| { let image_bearing = self.document.get_entity(h).is_some_and(|entity| {
matches!( matches!(
entity, entity,
EntityType::RasterImage(_) EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_)
| EntityType::Ole2Frame(_)
| EntityType::Underlay(_)
) )
}); });
if image_bearing { if image_bearing {
@@ -301,10 +297,7 @@ impl Scene {
/// Entities in anonymous dimension blocks that visually belong to /// Entities in anonymous dimension blocks that visually belong to
/// dimensions inside `block_record`. They must follow a block-coordinate /// dimensions inside `block_record`. They must follow a block-coordinate
/// reframe and be included in BEDIT's discard snapshot. /// reframe and be included in BEDIT's discard snapshot.
pub(crate) fn block_definition_dependent_handles( pub(crate) fn block_definition_dependent_handles(&self, block_record: Handle) -> Vec<Handle> {
&self,
block_record: Handle,
) -> Vec<Handle> {
let Some(record) = self let Some(record) = self
.document .document
.block_records .block_records
@@ -367,17 +360,16 @@ impl Scene {
let owned: HashSet<Handle> = handles.iter().copied().collect(); let owned: HashSet<Handle> = handles.iter().copied().collect();
let transform = EntityTransform::Affine(*local_from_old); let transform = EntityTransform::Affine(*local_from_old);
let matrix = &local_from_old.matrix.m; let matrix = &local_from_old.matrix.m;
let dependent_transform = let dependent_transform = EntityTransform::Affine(acadrust::types::Transform::from_matrix(
EntityTransform::Affine(acadrust::types::Transform::from_matrix( acadrust::types::Matrix4 {
acadrust::types::Matrix4 { m: [
m: [ [matrix[0][0], matrix[0][1], matrix[0][2], 0.0],
[matrix[0][0], matrix[0][1], matrix[0][2], 0.0], [matrix[1][0], matrix[1][1], matrix[1][2], 0.0],
[matrix[1][0], matrix[1][1], matrix[1][2], 0.0], [matrix[2][0], matrix[2][1], matrix[2][2], 0.0],
[matrix[2][0], matrix[2][1], matrix[2][2], 0.0], [0.0, 0.0, 0.0, 1.0],
[0.0, 0.0, 0.0, 1.0], ],
], },
}, ));
));
let mut changed = Vec::new(); let mut changed = Vec::new();
for handle in handles { for handle in handles {
@@ -596,10 +588,7 @@ impl Scene {
let entity = self.document.get_entity(h)?.clone(); let entity = self.document.get_entity(h)?.clone();
let annotation_scales = if leader_pair_handles.contains(&h) { let annotation_scales = if leader_pair_handles.contains(&h) {
crate::scene::annotative::annotation_scale_handles_for_entity( crate::scene::annotative::annotation_scale_handles_for_entity(&self.document, h)
&self.document,
h,
)
} else { } else {
Vec::new() Vec::new()
}; };
@@ -691,8 +680,8 @@ impl Scene {
if let Some(model) = new_model { if let Some(model) = new_model {
self.hatches.insert(h, model); self.hatches.insert(h, model);
} }
let rebuilt_history = self.copy_solid_history(src_handle, h) let rebuilt_history =
&& self.transform_solid_history(h, t); self.copy_solid_history(src_handle, h) && self.transform_solid_history(h, t);
if !rebuilt_history if !rebuilt_history
&& self.document.get_entity(h).is_some_and(|entity| { && self.document.get_entity(h).is_some_and(|entity| {
matches!( matches!(
@@ -717,8 +706,7 @@ impl Scene {
let leader_links: Vec<(Handle, Handle)> = handle_map let leader_links: Vec<(Handle, Handle)> = handle_map
.iter() .iter()
.filter_map(|(&source_handle, &copied_handle)| { .filter_map(|(&source_handle, &copied_handle)| {
let EntityType::Leader(source_leader) = let EntityType::Leader(source_leader) = self.document.get_entity(source_handle)?
self.document.get_entity(source_handle)?
else { else {
return None; return None;
}; };
@@ -733,9 +721,7 @@ impl Scene {
.collect(); .collect();
for (leader_handle, annotation_handle) in leader_links { for (leader_handle, annotation_handle) in leader_links {
if let Some(EntityType::Leader(leader)) = if let Some(EntityType::Leader(leader)) = self.document.get_entity_mut(leader_handle) {
self.document.get_entity_mut(leader_handle)
{
leader.annotation_handle = annotation_handle; leader.annotation_handle = annotation_handle;
} }
@@ -753,7 +739,7 @@ impl Scene {
.collect(); .collect();
self.bump_entities(&changes); self.bump_entities(&changes);
// Constraints wholly within the duplicated selection follow it. // Constraints wholly within the duplicated selection follow it.
self.duplicate_sketch_constraints_for(&handle_map); self.duplicate_parametric_constraints_for(&handle_map);
self.refresh_meshes_for_handles(&refresh_solid_handles); self.refresh_meshes_for_handles(&refresh_solid_handles);
new_handles new_handles
} }
@@ -840,11 +826,7 @@ impl Scene {
let Some(reference) = reference else { let Some(reference) = reference else {
return; return;
}; };
let point = acadrust::types::Vector3::new( let point = acadrust::types::Vector3::new(reference.x, reference.y, reference.z);
reference.x,
reference.y,
reference.z,
);
match self.document.get_entity_mut(handle) { match self.document.get_entity_mut(handle) {
Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point, Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point,
Some(EntityType::Surface(entity)) => entity.point_of_reference = point, Some(EntityType::Surface(entity)) => entity.point_of_reference = point,
@@ -928,7 +910,9 @@ impl Scene {
(EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => { (EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => {
let EntityType::Surface(surface) = let EntityType::Surface(surface) =
crate::scene::model::loft_command_model::surface_entity(value) crate::scene::model::loft_command_model::surface_entity(value)
else { return None; }; else {
return None;
};
Some(Some(surface.surface_data)) Some(Some(surface.surface_data))
} }
(EntityType::Solid3D(_), _) => Some(None), (EntityType::Solid3D(_), _) => Some(None),
@@ -953,9 +937,11 @@ impl Scene {
let Some(display) = self.prepare_solid_model_display(handle, &body) else { let Some(display) = self.prepare_solid_model_display(handle, &body) else {
return false; return false;
}; };
if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) if matches!(
| acadrust::objects::SolidHistoryOperation::Extrusion(_)) &operation,
&& !display.0.complete acadrust::objects::SolidHistoryOperation::Loft(_)
| acadrust::objects::SolidHistoryOperation::Extrusion(_)
) && !display.0.complete
{ {
return false; return false;
} }
@@ -970,7 +956,8 @@ impl Scene {
match self.document.get_entity_mut(handle) { match self.document.get_entity_mut(handle) {
Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document),
Some(EntityType::Surface(entity)) => { Some(EntityType::Surface(entity)) => {
entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); entity.acis_data =
acadrust::entities::AcisData::from_sat(&document.to_sat_string());
if let Some(data) = surface_data { if let Some(data) = surface_data {
if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) {
entity.kind = acadrust::entities::SurfaceKind::Extruded; entity.kind = acadrust::entities::SurfaceKind::Extruded;
@@ -1001,16 +988,19 @@ impl Scene {
let Some(display) = self.prepare_solid_model_display(handle, &body) else { let Some(display) = self.prepare_solid_model_display(handle, &body) else {
return false; return false;
}; };
if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) if matches!(
| acadrust::objects::SolidHistoryOperation::Extrusion(_)) &operation,
&& !display.0.complete acadrust::objects::SolidHistoryOperation::Loft(_)
| acadrust::objects::SolidHistoryOperation::Extrusion(_)
) && !display.0.complete
{ {
return false; return false;
} }
match self.document.get_entity_mut(handle) { match self.document.get_entity_mut(handle) {
Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document),
Some(EntityType::Surface(entity)) => { Some(EntityType::Surface(entity)) => {
entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); entity.acis_data =
acadrust::entities::AcisData::from_sat(&document.to_sat_string());
if let Some(data) = surface_data { if let Some(data) = surface_data {
if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) {
entity.kind = acadrust::entities::SurfaceKind::Extruded; entity.kind = acadrust::entities::SurfaceKind::Extruded;
@@ -1062,24 +1052,22 @@ impl Scene {
true true
} }
fn transform_solid_history( fn transform_solid_history(&mut self, handle: Handle, transform: &EntityTransform) -> bool {
&mut self,
handle: Handle,
transform: &EntityTransform,
) -> bool {
let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else {
return false; return false;
}; };
if !crate::scene::model::solid_history::transform_operation( if !crate::scene::model::solid_history::transform_operation(&mut operation, transform) {
&mut operation,
transform,
) {
return false; return false;
} }
if let EntityTransform::Translate(delta) = transform { if let EntityTransform::Translate(delta) = transform {
let surface_data = match (&operation, self.document.get_entity(handle)) { let surface_data = match (&operation, self.document.get_entity(handle)) {
(acadrust::objects::SolidHistoryOperation::Extrusion(value), Some(EntityType::Surface(_))) => { (
let Some(data) = crate::scene::model::solid_history::extrusion_surface_data(value) else { acadrust::objects::SolidHistoryOperation::Extrusion(value),
Some(EntityType::Surface(_)),
) => {
let Some(data) =
crate::scene::model::solid_history::extrusion_surface_data(value)
else {
return false; return false;
}; };
Some(data) Some(data)
@@ -1104,12 +1092,7 @@ impl Scene {
self.rebuild_solid_history(handle, operation) self.rebuild_solid_history(handle, operation)
} }
pub fn apply_solid_history_choice( pub fn apply_solid_history_choice(&mut self, handle: Handle, field: &str, value: &str) -> bool {
&mut self,
handle: Handle,
field: &str,
value: &str,
) -> bool {
self.record_solid_history_before(handle); self.record_solid_history_before(handle);
let mut created = false; let mut created = false;
if self.document.solid_history_graph(handle).is_none() { if self.document.solid_history_graph(handle).is_none() {
@@ -1137,7 +1120,10 @@ impl Scene {
_ => return false, _ => return false,
}; };
if create { if create {
if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_))) { if !matches!(
self.document.get_entity(handle),
Some(EntityType::Solid3D(_))
) {
return false; return false;
} }
self.restore_solid_models(&[handle]); self.restore_solid_models(&[handle]);
@@ -1195,13 +1181,9 @@ impl Scene {
return false; return false;
}; };
self.restore_solid_models(&[handle]); self.restore_solid_models(&[handle]);
let Some(definition) = self let Some(definition) = self.solid_models.get(&handle).and_then(|body| {
.solid_models crate::scene::model::solid_history::fillet_radius_grip(body, radius)
.get(&handle) }) else {
.and_then(|body| {
crate::scene::model::solid_history::fillet_radius_grip(body, radius)
})
else {
return false; return false;
}; };
let Some(axis) = definition.axis else { let Some(axis) = definition.axis else {
@@ -1247,8 +1229,7 @@ impl Scene {
let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else {
return false; return false;
}; };
let distance = if grip_id let distance = if grip_id == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1
== crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1
{ {
&mut value.base_distance &mut value.base_distance
} else { } else {
@@ -1260,11 +1241,8 @@ impl Scene {
} }
return self.preview_solid_history(handle, operation); return self.preview_solid_history(handle, operation);
} }
if !crate::scene::model::solid_history::apply_primitive_grip( if !crate::scene::model::solid_history::apply_primitive_grip(&mut operation, grip_id, apply)
&mut operation, {
grip_id,
apply,
) {
return false; return false;
} }
self.preview_solid_history(handle, operation) self.preview_solid_history(handle, operation)
@@ -1276,12 +1254,10 @@ impl Scene {
field: &str, field: &str,
value: &str, value: &str,
) -> bool { ) -> bool {
let Some(mut operation) = let Some(mut operation) = crate::scene::model::solid_history::primitive_property_operation(
crate::scene::model::solid_history::primitive_property_operation( &self.document,
&self.document, handle,
handle, ) else {
)
else {
return false; return false;
}; };
if !crate::scene::model::solid_history::apply_primitive_property( if !crate::scene::model::solid_history::apply_primitive_property(
@@ -1407,7 +1383,8 @@ impl Scene {
}; };
let n = leader.vertices.len(); let n = leader.vertices.len();
if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() { if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null()
{
return None; return None;
} }
@@ -1451,10 +1428,7 @@ impl Scene {
if self.sync_displayed_annotation_context(annotation_handle) { if self.sync_displayed_annotation_context(annotation_handle) {
self.poison_undo_recording(); self.poison_undo_recording();
} }
self.bump_entities(&[( self.bump_entities(&[(annotation_handle, crate::scene::ChangeKind::Modified)]);
annotation_handle,
crate::scene::ChangeKind::Modified,
)]);
} }
} }
} }
@@ -1510,9 +1484,7 @@ impl Scene {
let image_bearing = self.document.get_entity(handle).is_some_and(|entity| { let image_bearing = self.document.get_entity(handle).is_some_and(|entity| {
matches!( matches!(
entity, entity,
EntityType::RasterImage(_) EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_)
| EntityType::Ole2Frame(_)
| EntityType::Underlay(_)
) )
}); });
+6 -21
View File
@@ -2,14 +2,13 @@
//! Formulas are parsed locally so dependency cycles can be rejected before evaluation. //! Formulas are parsed locally so dependency cycles can be rejected before evaluation.
use super::Scene; use super::Scene;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to /// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to
/// another parameter by name) and [`Expr::Call`] (a function call). Only the /// another parameter by name) and [`Expr::Call`] (a function call). Only the
/// small builtin set in `call_builtin` is accepted. /// small builtin set in `call_builtin` is accepted.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub enum Expr { pub enum Expr {
Number(f64), Number(f64),
Ref(String), Ref(String),
@@ -57,7 +56,7 @@ impl Expr {
/// stages, the UI) rather than panicking — matching this codebase's /// stages, the UI) rather than panicking — matching this codebase's
/// established pattern of isolating a bad derived value instead of letting /// established pattern of isolating a bad derived value instead of letting
/// it take down an unrelated computation (e.g. dangling-constraint handling /// it take down an unrelated computation (e.g. dangling-constraint handling
/// in `sketch_solve.rs`). /// in `parametric_solve.rs`).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ParamError { pub enum ParamError {
/// A formula's text doesn't parse. The `String` is a short, human- /// A formula's text doesn't parse. The `String` is a short, human-
@@ -131,7 +130,7 @@ pub fn is_valid_name(name: &str) -> bool {
} }
/// A dimensional constraint's literal or named driving value. /// A dimensional constraint's literal or named driving value.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub enum DrivingValue { pub enum DrivingValue {
Literal(f64), Literal(f64),
Named(String), Named(String),
@@ -458,7 +457,7 @@ pub fn parse(source: &str) -> Result<Expr, ParamError> {
/// One named parameter: a name plus its formula, both the original typed /// One named parameter: a name plus its formula, both the original typed
/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] /// text (`source`, for redisplay/re-editing) and the parsed [`Expr`]
/// (`expr`, for evaluation). /// (`expr`, for evaluation).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct Parameter { pub struct Parameter {
pub name: String, pub name: String,
pub source: String, pub source: String,
@@ -468,11 +467,11 @@ pub struct Parameter {
/// Every named parameter for one document. /// Every named parameter for one document.
/// ///
/// `parameters` is intentionally not `pub` (unlike the sibling /// `parameters` is intentionally not `pub` (unlike the sibling
/// `SketchConstraintSet::constraints`): this table's entire value is that /// `ParametricConstraintSet::constraints`): this table's entire value is that
/// every stored formula is already known to be parse-valid and cycle-free, /// every stored formula is already known to be parse-valid and cycle-free,
/// an invariant only `set`/`remove` maintain. A direct external push could /// an invariant only `set`/`remove` maintain. A direct external push could
/// silently violate it. /// silently violate it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct ParameterTable { pub struct ParameterTable {
parameters: Vec<Parameter>, parameters: Vec<Parameter>,
} }
@@ -932,20 +931,6 @@ mod tests {
assert_eq!(table.resolve("a"), Ok(2.0)); assert_eq!(table.resolve("a"), Ok(2.0));
} }
#[test]
fn table_round_trips_through_bincode() {
let mut table = ParameterTable::new();
table.set("hole_dia", "5").unwrap();
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
let bytes = bincode::serialize(&table).expect("serialize");
let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("hole_spacing"), Ok(11.5));
assert_eq!(restored.get("hole_dia").unwrap().source, "5");
}
#[test] #[test]
fn iter_preserves_insertion_order_across_redefinitions() { fn iter_preserves_insertion_order_across_redefinitions() {
let mut table = ParameterTable::new(); let mut table = ParameterTable::new();
-210
View File
@@ -1,210 +0,0 @@
//! Persists the drawing-wide named-parameter table in a versioned XRecord.
use super::named_parameters::ParameterTable;
use super::Scene;
use bincode::Options;
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG
/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX`
/// bytes, so a table past a handful of parameters must be split across
/// multiple same-code `310` entries on write and concatenated back on read.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
/// Prefixed onto the serialized blob so a future schema change can be
/// detected and gracefully skipped rather than silently misreading bytes —
/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the
/// migration path itself is still undesigned.
const FORMAT_VERSION: u8 = 1;
fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
let body = bincode::serialize(table).ok()?;
if body.len() >= MAX_RECORD_BYTES {
return None;
}
bytes.extend(body);
Some(bytes)
}
fn decode(bytes: &[u8]) -> Option<ParameterTable> {
let (&version, body) = bytes.split_first()?;
if version != FORMAT_VERSION {
return None;
}
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_RECORD_BYTES as u64)
.deserialize(body)
.ok()
}
impl Scene {
/// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS`
/// XRecord, ready for whatever save call happens next to serialize
/// `self.document` as-is. Called from the same save entry points as
/// `materialize_sketch_constraints_for_save`
/// (`OpenCADStudio::prepare_native_save`, the wasm save path).
///
/// An empty table is skipped rather than writing an empty XRecord — an
/// unconstrained-by-parameters drawing (the common case, at least until
/// this feature is used) should not gain persisted-but-empty
/// bookkeeping on every save.
pub(crate) fn materialize_named_parameters_for_save(&mut self) {
if self.named_parameters.is_empty() {
return;
}
let Some(bytes) = encode(&self.named_parameters) else {
return;
};
let owner = self.document.header.model_space_block_handle;
if owner.is_null() {
return;
}
self.document.ensure_xrecord(owner, XRECORD_KEY);
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
// Overwrite, not append: a resave must replace the prior blob,
// not accumulate more Chunk entries every time.
record.entries.clear();
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
record.entries.push(acadrust::objects::XRecordEntry::new(
310,
acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
));
}
}
}
/// Populates `named_parameters` from the just-loaded `self.document`'s
/// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a
/// document open installs its `CadDocument` into this `Scene`, the same
/// call sites `load_sketch_constraints_from_document` already has
/// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"`
/// ops). Replaces whatever was already in `named_parameters` (a fresh
/// table for a real open; `"new"`'s blank document has nothing to find
/// anyway).
pub(crate) fn load_named_parameters_from_document(&mut self) {
self.named_parameters = ParameterTable::new();
let owner = self.document.header.model_space_block_handle;
if owner.is_null() {
return;
}
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else {
return;
};
// Concatenate every Chunk entry in order, not just the first — see
// `MAX_CHUNK_BYTES`'s doc comment.
let mut bytes = Vec::new();
for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
bytes.clear();
break;
}
bytes.extend_from_slice(chunk);
}
}
if bytes.is_empty() {
return;
}
if let Some(table) = decode(&bytes) {
self.named_parameters = table;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_parameter_table_survives_materialize_and_reload() {
let mut scene = Scene::new();
scene.named_parameters_mut().set("hole_dia", "5").unwrap();
scene
.named_parameters_mut()
.set("hole_spacing", "2 * hole_dia + 1.5")
.unwrap();
scene.materialize_named_parameters_for_save();
// Simulate the load-time side of a save/reload round trip: drop the
// in-memory table (a fresh open starts empty) and repopulate purely
// from what materialize just wrote into `document.objects`.
scene.named_parameters = ParameterTable::new();
scene.load_named_parameters_from_document();
assert_eq!(scene.named_parameters().len(), 2);
assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5));
assert_eq!(
scene.named_parameters().get("hole_dia").unwrap().source,
"5"
);
}
/// The above test only proves `materialize`/`load` agree with each
/// other on the same in-memory `CadDocument` — this drives a real
/// `save_to_bytes`/`load_bytes` round trip (the same primitives
/// `OpenCADStudio::on_file_opened`'s native save/open path uses),
/// through both supported formats, mirroring
/// `sketch_persist`'s own real-bytes tests.
fn full_bytes_roundtrip(ext: &str) -> ParameterTable {
let mut scene = Scene::new();
scene
.named_parameters_mut()
.set("plate_len", "42.5")
.unwrap();
scene
.named_parameters_mut()
.set("plate_width", "plate_len / 2")
.unwrap();
scene.materialize_named_parameters_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let mut reloaded_scene = Scene::new();
reloaded_scene.document =
crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes)
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_named_parameters_from_document();
reloaded_scene.named_parameters
}
#[test]
fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dxf");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
}
#[test]
fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dwg");
assert_eq!(restored.len(), 2);
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
}
#[test]
fn an_empty_table_is_not_materialized() {
let mut scene = Scene::new();
scene.materialize_named_parameters_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
"an empty table should not create an XRecord"
);
}
#[test]
fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap());
assert!(
decode(&bytes).is_none(),
"a future/unknown format version must be rejected, not misread"
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+56 -3
View File
@@ -386,12 +386,12 @@ impl Scene {
/// needs the equivalent paper-space geometry explicitly. /// needs the equivalent paper-space geometry explicitly.
pub fn viewport_plot_fills( pub fn viewport_plot_fills(
&self, &self,
) -> (Vec<(WireModel, f32)>, Vec<HatchModel>, Vec<HatchModel>) { ) -> (Vec<(WireModel, f32)>, Vec<HatchModel>, Vec<HatchModel>, Vec<crate::io::pdf_export::PlotImage>) {
use acadrust::entities::Viewport; use acadrust::entities::Viewport;
use model::hatch_model::HatchPattern; use model::hatch_model::HatchPattern;
if self.current_layout == "Model" { if self.current_layout == "Model" {
return (Vec::new(), Vec::new(), Vec::new()); return (Vec::new(), Vec::new(), Vec::new(), Vec::new());
} }
let paper_block = self.current_layout_block_handle(); let paper_block = self.current_layout_block_handle();
let model_block = self.model_space_block_handle(); let model_block = self.model_space_block_handle();
@@ -411,6 +411,7 @@ impl Scene {
let mut pattern_wires = Vec::new(); let mut pattern_wires = Vec::new();
let mut projected_hatches = Vec::new(); let mut projected_hatches = Vec::new();
let mut projected_wipeouts = Vec::new(); let mut projected_wipeouts = Vec::new();
let mut projected_images = Vec::new();
for viewport in viewports { for viewport in viewports {
let Some(camera) = self.camera_for_viewport(viewport.common.handle) else { let Some(camera) = self.camera_for_viewport(viewport.common.handle) else {
@@ -463,6 +464,58 @@ impl Scene {
let frozen: rustc_hash::FxHashSet<Handle> = let frozen: rustc_hash::FxHashSet<Handle> =
viewport.frozen_layers.iter().copied().collect(); viewport.frozen_layers.iter().copied().collect();
let mut viewport_clips = vec![vec![
[xmin as f64, ymin as f64], [xmax as f64, ymin as f64],
[xmax as f64, ymax as f64], [xmin as f64, ymax as f64],
]];
if !self.images.is_empty() && !viewport.clip_boundary_handle.is_null() {
let boundary = self.clip_boundary_polygon(
viewport.clip_boundary_handle, viewport.center.z as f32);
if boundary.len() >= 3 {
viewport_clips.push(boundary.iter().map(|p| [p[0] as f64, p[1] as f64]).collect());
}
}
for mut plot in self.placed_images(model_block, Some(&frozen),
self.viewport_scale_handle(viewport.common.handle),
self.annotation_all_visible(), Some(viewport.common.handle), true)
{
let mut valid = true;
let mut project_point = |high: &mut [f32; 3], low: &mut [f32; 3]| {
let point = std::array::from_fn(|i| high[i] as f64 + low[i] as f64);
if let Some([x, y]) = project_3d(point) {
*high = [x, y, 0.0];
*low = [0.0; 3];
} else {
valid = false;
}
};
for (high, low) in plot.image.corners.iter_mut().zip(&mut plot.image.corners_low) {
project_point(high, low);
}
for vertex in &mut plot.image.verts {
project_point(&mut vertex.pos, &mut vertex.pos_low);
}
for clip in &mut plot.clips {
for point in clip {
if let Some([x, y]) = project(point[0], point[1]) {
*point = [x as f64, y as f64];
} else {
valid = false;
}
}
}
if !valid {
continue;
}
if [(0, xmin, xmax), (1, ymin, ymax)].iter().any(|&(axis, min, max)| {
plot.image.verts.iter().all(|v| v.pos[axis] < min)
|| plot.image.verts.iter().all(|v| v.pos[axis] > max)
}) {
continue;
}
plot.clips.extend(viewport_clips.iter().cloned());
projected_images.push(plot);
}
let hatches = self.plot_hatches_for_block( let hatches = self.plot_hatches_for_block(
model_block, model_block,
Some(&frozen), Some(&frozen),
@@ -536,7 +589,7 @@ impl Scene {
} }
} }
(pattern_wires, projected_hatches, projected_wipeouts) (pattern_wires, projected_hatches, projected_wipeouts, projected_images)
} }
/// A content viewport's clip boundary, projected into that viewport's /// A content viewport's clip boundary, projected into that viewport's
+9 -2
View File
@@ -72,6 +72,7 @@ impl Scene {
if handles.is_empty() { if handles.is_empty() {
return; return;
} }
self.selected_constraint = None;
let expanded = self.expanded_with_leaders(handles); let expanded = self.expanded_with_leaders(handles);
let mut changed = false; let mut changed = false;
for handle in expanded { for handle in expanded {
@@ -111,6 +112,7 @@ impl Scene {
expanded expanded
} }
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
self.selected_constraint = None;
let handles = self.handles_expanded_for_leader_annotations(&[handle]); let handles = self.handles_expanded_for_leader_annotations(&[handle]);
let mut changed = false; let mut changed = false;
@@ -133,6 +135,7 @@ impl Scene {
} }
pub fn deselect_all(&mut self) { pub fn deselect_all(&mut self) {
self.selected_constraint = None;
if self.selected.is_empty() { if self.selected.is_empty() {
return; return;
} }
@@ -215,6 +218,7 @@ impl Scene {
/// only when its contents actually changed. History/file/command paths must /// only when its contents actually changed. History/file/command paths must
/// use this instead of assigning `selected` directly. /// use this instead of assigning `selected` directly.
pub(crate) fn replace_selection(&mut self, selected: HashSet<Handle>) { pub(crate) fn replace_selection(&mut self, selected: HashSet<Handle>) {
self.selected_constraint = None;
let handles: Vec<Handle> = selected.iter().copied().collect(); let handles: Vec<Handle> = selected.iter().copied().collect();
let selected: HashSet<Handle> = self let selected: HashSet<Handle> = self
.handles_expanded_for_leader_annotations(&handles) .handles_expanded_for_leader_annotations(&handles)
@@ -750,7 +754,8 @@ impl Scene {
| PropValue::FieldLwVaries { .. } | PropValue::FieldLwVaries { .. }
| PropValue::EntityLink { .. } | PropValue::EntityLink { .. }
| PropValue::ParamRow { .. } | PropValue::ParamRow { .. }
| PropValue::ParamAddRow => continue, | PropValue::ParamAddRow
| PropValue::ParamsVisibilityToggle(_) => continue,
} }
}; };
out.push(choice(prop.field, prop.label, editor)); out.push(choice(prop.field, prop.label, editor));
@@ -887,7 +892,8 @@ impl Scene {
| PropValue::FieldLwVaries { .. } | PropValue::FieldLwVaries { .. }
| PropValue::EntityLink { .. } | PropValue::EntityLink { .. }
| PropValue::ParamRow { .. } | PropValue::ParamRow { .. }
| PropValue::ParamAddRow => return None, | PropValue::ParamAddRow
| PropValue::ParamsVisibilityToggle(_) => return None,
}) })
} }
} }
@@ -1001,6 +1007,7 @@ impl Scene {
self.hover_highlight = None; self.hover_highlight = None;
hover_changed = true; hover_changed = true;
} }
hover_changed |= self.constraint_hover_highlights.remove(&h);
self.hatches.remove(&h); self.hatches.remove(&h);
self.images.remove(&h); self.images.remove(&h);
self.meshes.remove(&h); self.meshes.remove(&h);
File diff suppressed because it is too large. Load diff
-391
View File
@@ -1,391 +0,0 @@
//! Persists each sketch constraint set in a versioned, chunked XRecord.
use super::sketch_constraints::SketchConstraintSet;
use super::Scene;
use acadrust::{CadDocument, Handle};
use bincode::Options;
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
/// The vendored DWG writer's `XRecordValue::Chunk` encoder packs a single
/// `u8` length prefix per entry and silently truncates anything longer
/// (`cadcodec`'s `io/dwg/dwg_stream_writers/object_writer/objects.rs`,
/// `encode_xrecord_entries`) — unlike the DXF writer, which has no such
/// limit. A constraint set past a handful of entries serializes past 255
/// bytes easily, so a single `XRecordEntry` silently loses the tail and
/// `bincode::deserialize` then fails on the truncated bytes, dropping the
/// *entire* scope's constraints on the next DWG load — reproduced and
/// root-caused live (not from a change log): a 5-entry set round-tripped
/// clean, a 6-entry set of the same shapes lost everything. Splitting the
/// blob across as many same-code `310` entries as needed (below) works
/// around it without touching the vendored crate — `CadDocument::xrecord`'s
/// `entries: Vec<XRecordEntry>` already supports repeating a code.
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
/// Removes a named XRecord from `owner`'s extension dictionary, if present.
/// The dictionary itself is left in place even if now empty — other code may
/// already reference it via `xdictionary_handle`, and an empty extension
/// dictionary is harmless bookkeeping, unlike a stale XRecord that would
/// resurrect deleted data on the next load.
fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) {
let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else {
return;
};
let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) =
document.objects.get_mut(&dictionary_handle)
else {
return;
};
let Some(index) = dictionary
.entries
.iter()
.position(|(name, _)| name.eq_ignore_ascii_case(key))
else {
return;
};
let (_, record_handle) = dictionary.entries.remove(index);
document.objects.remove(&record_handle);
}
/// Serialized schema version. Unknown versions are skipped without affecting
/// the rest of the drawing.
const FORMAT_VERSION: u8 = 2;
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
let body = bincode::serialize(set).ok()?;
if body.len() >= MAX_RECORD_BYTES {
return None;
}
bytes.extend(body);
Some(bytes)
}
fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
let (&version, body) = bytes.split_first()?;
if version != FORMAT_VERSION {
return None;
}
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_RECORD_BYTES as u64)
.deserialize(body)
.ok()
}
impl Scene {
/// Writes every current `sketch_constraints` entry into its scope's
/// owner-handle XRecord, ready for whatever save call happens next to
/// serialize `self.document` as-is. Called right before a native/web
/// save (`OpenCADStudio::prepare_native_save` and the wasm save path)
/// — see those call sites' doc comments for why "right before save"
/// rather than keeping the XRecord resident and live-updated (design
/// doc §5.2's "live" model, deferred: it would also let constraint-set
/// edits ride the ordinary object-delta undo path per §5.1(b), but this
/// lazy model was the doc's own recommended first step).
///
/// A scope with an empty constraint set writes no XRecord — an
/// unconstrained drawing (the common case) should not gain
/// persisted-but-empty bookkeeping on every save. If a *previous* save
/// already materialized a non-empty XRecord for that scope, the now-empty
/// set actively removes it instead: leaving the stale blob in place would
/// resurrect the deleted constraints the next time the document loads.
pub(crate) fn materialize_sketch_constraints_for_save(&mut self) {
for index in 0..self.sketch_constraints.len() {
let set = &self.sketch_constraints[index];
if set.constraints.is_empty() {
let owner = set.scope.owner_handle(&self.document);
if !owner.is_null() {
remove_xrecord(&mut self.document, owner, XRECORD_KEY);
}
continue;
}
let owner = set.scope.owner_handle(&self.document);
if owner.is_null() {
continue;
}
let Some(bytes) = encode(set) else { continue };
self.document.ensure_xrecord(owner, XRECORD_KEY);
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
// Overwrite, not append: a resave must replace the prior
// blob, not accumulate more Chunk entries every time.
record.entries.clear();
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
record.entries.push(acadrust::objects::XRecordEntry::new(
310,
acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
));
}
}
}
}
/// Populates `sketch_constraints` from every `OCS_SKETCH_CONSTRAINTS`
/// XRecord found in the just-loaded `self.document` — called right
/// after a document open installs its `CadDocument` into this `Scene`
/// (`OpenCADStudio::on_file_opened` and the automation `"open"`/`"new"`
/// ops). Every `BlockRecord` (model space, paper space, and named
/// block definitions) is a candidate owner; most will have no such
/// XRecord and are skipped cheaply. Replaces whatever was already in
/// `sketch_constraints` (a fresh/empty `Vec` for a real open; `"new"`'s
/// blank document has nothing to find anyway).
pub(crate) fn load_sketch_constraints_from_document(&mut self) {
self.sketch_constraints.clear();
let owners: Vec<acadrust::Handle> = self
.document
.block_records
.iter()
.map(|record| record.handle)
.collect();
for owner in owners {
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else {
continue;
};
// Concatenate every Chunk entry in order, not just the first —
// a set materialized past `MAX_CHUNK_BYTES` spans more than one
// same-code `310` entry (see that constant's doc comment).
let mut bytes = Vec::new();
for entry in &record.entries {
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
bytes.clear();
break;
}
bytes.extend_from_slice(chunk);
}
}
if bytes.is_empty() {
continue;
}
if let Some(set) = decode(&bytes) {
self.sketch_constraints.push(set);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
use super::*;
use acadrust::entities::EntityType;
use acadrust::types::Vector3;
#[test]
fn a_model_space_constraint_set_survives_materialize_and_reload() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
// Simulate the load-time side of a save/reload round trip: drop the
// in-memory sets (a fresh open starts with none) and repopulate
// purely from what materialize just wrote into `document.objects`.
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
let set = scene
.sketch_constraint_set(SketchScope::ModelSpace)
.expect("constraint set should have been reloaded");
assert_eq!(set.constraints.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal);
assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]);
}
/// The above test only proves `materialize`/`load` agree with each
/// other on the same in-memory `CadDocument` — it never actually
/// serializes anything. This drives a real `save_to_bytes`/`load_bytes`
/// round trip (the same primitives `OpenCADStudio::on_file_opened`'s
/// native save/open path uses), through both supported formats, closing
/// the gap stage 2's spike proved for the raw XRecord mechanism up to
/// this stage's actual app-level wiring.
fn full_bytes_roundtrip(ext: &str) -> SketchConstraintSet {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
)));
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(20.0, 5.0, 0.0),
)));
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Coincident,
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
None,
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::whole(b)],
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)),
);
scene.materialize_sketch_constraints_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let mut reloaded_scene = Scene::new();
reloaded_scene.document = crate::io::load_bytes(&format!("stage4_roundtrip.{ext}"), bytes)
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_sketch_constraints_from_document();
reloaded_scene
.sketch_constraint_set(SketchScope::ModelSpace)
.cloned()
.unwrap_or_else(|| {
panic!("no ModelSpace constraint set survived the {ext} save/load round trip")
})
}
#[test]
fn a_constraint_set_survives_a_real_dxf_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dxf");
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(
restored.constraints[1].driving_param,
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))
);
}
#[test]
fn a_constraint_set_survives_a_real_dwg_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dwg");
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(
restored.constraints[1].driving_param,
Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))
);
}
#[test]
fn a_block_scoped_constraint_set_survives_materialize_and_reload() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
// `create_block_from_entities` returns the placed INSERT's handle,
// not the BlockRecord's own handle — `SketchScope::Block` needs the
// latter (it's what `BlockEditSession::br_handle` and
// `SketchScope::owner_handle` both mean by "a block definition's
// handle"), so look it up from the table by name instead of using
// the return value directly.
let _insert_handle = scene
.create_block_from_entities(
&[a],
"OCS_STAGE4_TEST_BLOCK",
&acadrust::types::Transform::identity(),
&acadrust::types::Transform::identity(),
)
.expect("create a block definition to own the constraint set");
let br_handle = scene
.document
.block_records
.get("OCS_STAGE4_TEST_BLOCK")
.expect("block record should exist")
.handle;
scene
.sketch_constraint_set_mut(SketchScope::Block(br_handle))
.add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
let set = scene
.sketch_constraint_set(SketchScope::Block(br_handle))
.expect("block-scoped constraint set should have been reloaded");
assert_eq!(set.constraints.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Vertical);
}
#[test]
fn an_empty_constraint_set_is_not_materialized() {
// A scope touched (e.g. `sketch_constraint_set_mut` called once,
// then every constraint removed) but left with zero constraints
// should not persist an empty XRecord.
let mut scene = Scene::new();
let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
"an empty constraint set should not create an XRecord"
);
}
#[test]
fn clearing_a_previously_materialized_set_removes_its_stale_xrecord() {
// Regression for the audit-flagged case `an_empty_constraint_set_is_not_materialized`
// doesn't cover: a scope that already had a *non-empty* XRecord from a
// prior save must have that XRecord actively removed once its
// in-memory set goes back to empty — otherwise the stale blob
// survives the save and resurrects the deleted constraints on the
// next load.
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_some(),
"sanity: the first save should have materialized an XRecord"
);
// Clear every constraint (mirrors deleting the constrained entity, or
// removing the last constraint by hand) and save again.
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.constraints
.clear();
scene.materialize_sketch_constraints_for_save();
assert!(
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
"the stale XRecord from the earlier non-empty save must be removed, not left behind"
);
// And the load path must agree: nothing resurrects on reload.
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
assert!(
scene
.sketch_constraint_set(SketchScope::ModelSpace)
.is_none(),
"no constraints should come back after the set was cleared and re-saved"
);
}
#[test]
fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(
bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(),
);
assert!(
decode(&bytes).is_none(),
"a future/unknown format version must be rejected, not misread"
);
}
}
File diff suppressed because it is too large. Load diff
+44 -3
View File
@@ -80,6 +80,25 @@ pub fn properties_sectioned(
} else { } else {
sections.extend(groups); sections.extend(groups);
} }
if let Some(thickness) = entity_thickness(entity) {
let already_present = sections.iter().flat_map(|section| &section.props).any(|property| {
property.field == "thickness"
|| property.field == "sl_thickness"
|| property.label == crate::t!("Thickness").as_ref()
});
if !already_present {
if let Some(geometry) = sections
.iter_mut()
.find(|section| section.title == crate::t!("Geometry").as_ref())
{
geometry.props.push(crate::entities::common::edit_prop(
crate::t!("Thickness").as_ref(),
"thickness",
thickness,
));
}
}
}
sections sections
} }
@@ -252,9 +271,8 @@ pub fn set_entity_xdata(
} }
/// The extrusion thickness (DXF 39) of the entities that carry one, or `None` /// The extrusion thickness (DXF 39) of the entities that carry one, or `None`
/// for entity types that have none. Thickness is a per-entity field but is /// for entity types that have none. The Properties panel uses this to append
/// surfaced in the General group (as in a standard properties palette), so /// the shared field to each entity's Geometry group.
/// this bridges the two.
pub fn entity_thickness(entity: &EntityType) -> Option<f64> { pub fn entity_thickness(entity: &EntityType) -> Option<f64> {
Some(match entity { Some(match entity {
EntityType::Arc(e) => e.thickness, EntityType::Arc(e) => e.thickness,
@@ -382,3 +400,26 @@ pub fn grip_menu_point_value(
pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) { pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) {
EntityTypeOps::apply_transform(entity, t); EntityTypeOps::apply_transform(entity, t);
} }
#[cfg(test)]
mod tests {
use super::*;
use acadrust::types::Vector3;
#[test]
fn line_thickness_follows_the_entity_geometry_rows() {
let mut line = acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X);
line.thickness = 2.5;
let sections = properties_sectioned(Handle::NULL, &EntityType::Line(line), &[]);
assert!(!sections[0]
.props
.iter()
.any(|property| property.field == "thickness"));
let geometry = sections
.iter()
.find(|section| section.props.iter().any(|property| property.field == "start_x"))
.unwrap();
assert_eq!(geometry.props.last().unwrap().field, "thickness");
}
}
+4 -1
View File
@@ -3692,7 +3692,10 @@ impl Scene {
&self, &self,
inst: &ViewportInstance, inst: &ViewportInstance,
) -> Arc<Vec<WireModel>> { ) -> Arc<Vec<WireModel>> {
if self.selected.is_empty() && self.hover_highlight.is_none() { if self.selected.is_empty()
&& self.hover_highlight.is_none()
&& self.constraint_hover_highlights.is_empty()
{
return Arc::new(Vec::new()); return Arc::new(Vec::new());
} }
+124 -24
View File
@@ -84,6 +84,23 @@ fn strip_option_listing(s: &str) -> String {
out.replace(" :", ":").trim().to_string() out.replace(" :", ":").trim().to_string()
} }
// Font swaps must preserve U+276F, U+24D8, and U+2715.
const COMMAND_PREFIX: &str = "❯ ";
const INFO_PREFIX: &str = "ⓘ ";
const ERROR_PREFIX: &str = "✕ ";
fn format_info(msg: &str) -> String {
if msg.starts_with(INFO_PREFIX) {
msg.to_string()
} else {
format!("{INFO_PREFIX}{msg}")
}
}
fn format_error(msg: &str) -> String {
format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase())
}
const MAX_HISTORY: usize = 64; const MAX_HISTORY: usize = 64;
#[derive(Clone)] #[derive(Clone)]
@@ -335,7 +352,10 @@ impl CommandLine {
} }
pub fn push_command(&mut self, cmd: &str) { pub fn push_command(&mut self, cmd: &str) {
self.push(EntryKind::Command, format!("{} {cmd}", t!("Command:"))); self.push(
EntryKind::Command,
format!("{COMMAND_PREFIX}{} {cmd}", t!("Command:")),
);
} }
pub fn push_output(&mut self, msg: &str) { pub fn push_output(&mut self, msg: &str) {
self.push(EntryKind::Output, msg.to_string()); self.push(EntryKind::Output, msg.to_string());
@@ -343,7 +363,7 @@ impl CommandLine {
pub fn push_error(&mut self, msg: &str) { pub fn push_error(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1); self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned()); self.last_error = Some(msg.to_owned());
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid"))); self.push(EntryKind::Error, format_error(msg));
} }
/// Append an error unless it is already the latest history line. Repeated /// Append an error unless it is already the latest history line. Repeated
/// retry failures should refresh the concise message, not flood history /// retry failures should refresh the concise message, not flood history
@@ -352,7 +372,7 @@ impl CommandLine {
pub fn push_error_once(&mut self, msg: &str) { pub fn push_error_once(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1); self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned()); self.last_error = Some(msg.to_owned());
let text = format!("*{}* {msg}", t!("Invalid")); let text = format_error(msg);
if let Some(last) = self if let Some(last) = self
.history .history
.last_mut() .last_mut()
@@ -372,12 +392,7 @@ impl CommandLine {
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning"))); self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning")));
} }
pub fn push_info(&mut self, msg: &str) { pub fn push_info(&mut self, msg: &str) {
let text = if msg.starts_with("i ") { self.push(EntryKind::Info, format_info(msg));
msg.to_string()
} else {
format!("i {msg}")
};
self.push(EntryKind::Info, text);
} }
fn push(&mut self, kind: EntryKind, text: String) { fn push(&mut self, kind: EntryKind, text: String) {
self.history.push(HistoryEntry { self.history.push(HistoryEntry {
@@ -407,11 +422,7 @@ impl CommandLine {
e.created_at = Instant::now(); e.created_at = Instant::now();
} }
if let Some(p) = &prompt { if let Some(p) = &prompt {
let formatted = if p.starts_with("i ") { let formatted = format_info(p);
p.clone()
} else {
format!("i {p}")
};
// Reuse the prompt line dispatch/step-transition just pushed; // Reuse the prompt line dispatch/step-transition just pushed;
// otherwise add it. // otherwise add it.
if self.history.last().map(|e| &e.text) != Some(&formatted) { if self.history.last().map(|e| &e.text) != Some(&formatted) {
@@ -583,13 +594,18 @@ impl CommandLine {
.iter() .iter()
.fold(column![].spacing(0), |col, entry| { .fold(column![].spacing(0), |col, entry| {
let kind = entry.kind.clone(); let kind = entry.kind.clone();
let is_error = kind == EntryKind::Error;
let entry_text = |value: String| { let entry_text = |value: String| {
text(value).size(11).style({ let mut txt = text(value).size(11).style({
let kind = kind.clone(); let kind = kind.clone();
move |theme: &Theme| iced::widget::text::Style { move |theme: &Theme| iced::widget::text::Style {
color: Some(history_color(theme, &kind)), color: Some(history_color(theme, &kind)),
} }
}) });
if is_error {
txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold));
}
txt
}; };
// The current step's prompt is the single pinned line. When the // The current step's prompt is the single pinned line. When the
// step offers options, render them as clickable buttons inline // step offers options, render them as clickable buttons inline
@@ -1041,13 +1057,19 @@ fn history_highlight_format(
) -> iced::advanced::text::highlighter::Format<iced::Font> { ) -> iced::advanced::text::highlighter::Format<iced::Font> {
iced::advanced::text::highlighter::Format { iced::advanced::text::highlighter::Format {
color: Some(history_color(theme, kind)), color: Some(history_color(theme, kind)),
font: None, font: match kind {
EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)),
_ => None,
},
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{mcp_status, ranked_matches, CommandLine}; use super::{
format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind,
ERROR_PREFIX, INFO_PREFIX,
};
use crate::t; use crate::t;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
@@ -1121,13 +1143,81 @@ mod tests {
assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}"); assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}");
} }
#[test]
fn history_markers_and_copy_text_are_clean() {
let mut line = CommandLine::new();
line.clear_history();
let command = "LINE";
let info = "Object selected.";
let error = "Unable to save: file is in use.";
let output = "12.5, 7.0\nraw output";
line.push_command(command);
line.push_info(info);
line.push_error(error);
line.push_output(output);
let expected = vec![
format!("❯ {} {command}", t!("Command:")),
format!("{INFO_PREFIX}{info}"),
format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()),
output.to_string(),
];
let actual = line
.history
.iter()
.map(|entry| entry.text.as_str())
.collect::<Vec<_>>();
assert_eq!(
actual,
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
assert_eq!(
line.history
.iter()
.map(|entry| entry.kind.clone())
.collect::<Vec<_>>(),
vec![
EntryKind::Command,
EntryKind::Info,
EntryKind::Error,
EntryKind::Output,
]
);
let plain = line.history_plain_text();
assert!(!plain.contains('*'));
assert_eq!(plain, expected.join("\n"));
assert_eq!(line.history[3].text.as_bytes(), output.as_bytes());
}
#[test]
fn error_history_uses_bold_highlight_format() {
let theme = iced::Theme::Light;
let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold);
assert_eq!(
history_highlight_format(&EntryKind::Error, &theme).font,
Some(bold)
);
for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] {
assert_eq!(history_highlight_format(&kind, &theme).font, None);
}
}
#[test] #[test]
fn issue_498_repeated_save_error_is_not_duplicated() { fn issue_498_repeated_save_error_is_not_duplicated() {
let mut line = CommandLine::new(); let mut line = CommandLine::new();
let initial_len = line.history.len(); let initial_len = line.history.len();
line.push_error_once(t!("Unable to save: file is in use.").as_ref()); let message = t!("Unable to save: file is in use.");
line.push_error_once(t!("Unable to save: file is in use.").as_ref()); let expected_error = format_error(message.as_ref());
line.push_error_once(message.as_ref());
line.push_error_once(message.as_ref());
assert_eq!(line.history.len(), initial_len + 1); assert_eq!(line.history.len(), initial_len + 1);
assert_eq!(
line.history.last().map(|entry| entry.text.as_str()),
Some(expected_error.as_str())
);
assert!(!line.history_plain_text().contains('*'));
} }
#[test] #[test]
@@ -1193,8 +1283,14 @@ mod tests {
fn info_entries_carry_prefix_marker() { fn info_entries_carry_prefix_marker() {
let mut line = CommandLine::new(); let mut line = CommandLine::new();
line.push_info("Object selected."); line.push_info("Object selected.");
assert_eq!(line.history.last().map(|e| e.text.as_str()), Some("i Object selected.")); assert_eq!(
assert_eq!(line.history.last().map(|e| &e.kind), Some(&super::EntryKind::Info)); line.history.last().map(|entry| entry.text.as_str()),
Some("ⓘ Object selected.")
);
assert_eq!(
line.history.last().map(|entry| &entry.kind),
Some(&EntryKind::Info)
);
} }
#[test] #[test]
@@ -1204,9 +1300,13 @@ mod tests {
line.push_info("Specify first point:"); line.push_info("Specify first point:");
assert_eq!(line.history.len(), initial_len + 1); assert_eq!(line.history.len(), initial_len + 1);
line.set_step_prompt(Some("Specify first point:".to_string())); line.set_step_prompt(Some("Specify first point:".to_string()));
assert_eq!(line.history.len(), initial_len + 1, "prompt must reuse push_info entry without duplicating"); assert_eq!(
line.history.len(),
initial_len + 1,
"prompt must reuse push_info entry without duplicating"
);
let last = line.history.last().unwrap(); let last = line.history.last().unwrap();
assert!(last.pinned); assert!(last.pinned);
assert_eq!(last.text, "i Specify first point:"); assert_eq!(last.text, "ⓘ Specify first point:");
} }
} }
+132 -17
View File
@@ -27,6 +27,7 @@ const CONSTRAINT_GLYPH_PAD_X: f32 = 7.0;
const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0; const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0;
const CONSTRAINT_GLYPH_GAP: f32 = 6.0; const CONSTRAINT_GLYPH_GAP: f32 = 6.0;
const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0; const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0;
const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0;
fn constraint_glyph_size(label: &str) -> Size { fn constraint_glyph_size(label: &str) -> Size {
let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62 let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62
@@ -35,6 +36,22 @@ fn constraint_glyph_size(label: &str) -> Size {
Size::new(w, h) Size::new(w, h)
} }
fn draw_tangent_constraint_glyph(
frame: &mut canvas::Frame,
center: Point,
color: Color,
) {
let radius = 4.5;
let circle_center = Point::new(center.x - 0.3, center.y + 2.75);
let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2;
let contact = Point::new(circle_center.x - diagonal, circle_center.y - diagonal);
let tangent_end = Point::new(contact.x + 8.0, contact.y - 8.0);
let stroke = canvas::Stroke::default().with_color(color).with_width(1.35);
frame.stroke(&canvas::Path::circle(circle_center, radius), stroke.clone());
frame.stroke(&canvas::Path::line(contact, tangent_end), stroke);
}
fn constraint_glyph_box( fn constraint_glyph_box(
anchor: Point, anchor: Point,
outward: [f32; 2], outward: [f32; 2],
@@ -94,6 +111,32 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec<f
offsets offsets
} }
/// Hit-tests screen point `p` against the same glyph-pill layout `draw`
/// renders — reusing `constraint_glyph_offsets`/`constraint_glyph_box` so
/// the clickable area can never drift from what's actually drawn, including
/// the tangential fan-out applied when several glyphs share one anchor.
/// Returns the index into `glyphs` of the topmost (last-drawn) match.
/// `Scene::constraint_glyph_hit` maps the index back to a constraint id.
pub(crate) fn constraint_glyph_hit_test(
glyphs: &[(Point, [f32; 2], String, bool)],
p: Point,
) -> Option<usize> {
let offsets = constraint_glyph_offsets(glyphs);
glyphs
.iter()
.zip(offsets)
.enumerate()
.rev()
.find_map(|(index, ((anchor, outward, label, _), tangent_offset))| {
let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset);
let within = p.x >= top_left.x
&& p.x <= top_left.x + size.width
&& p.y >= top_left.y
&& p.y <= top_left.y + size.height;
within.then_some(index)
})
}
/// Convert CURSORSIZE to a screen-space arm length while keeping the original /// Convert CURSORSIZE to a screen-space arm length while keeping the original
/// 60 px cursor at the default value and the full-viewport result at 100. /// 60 px cursor at the default value and the full-viewport result at 100.
pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 {
@@ -723,7 +766,7 @@ pub fn selection_overlay<'a>(
crosshair_bg: [f32; 4], crosshair_bg: [f32; 4],
crosshair: CrosshairOptions, crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions, selection_visual: SelectionVisualOptions,
constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec<Point>)>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
canvas(SelectionCanvas { canvas(SelectionCanvas {
selection, selection,
@@ -806,8 +849,10 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4], crosshair_bg: [f32; 4],
crosshair: CrosshairOptions, crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions, selection_visual: SelectionVisualOptions,
/// Constraint glyph anchor, outward screen direction, label, and conflict state. /// Constraint glyph anchor, outward screen direction, label, conflict
constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, /// state, whether the pill itself is the current click-to-select target,
/// and the points to mark while the pill is hovered.
constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec<Point>)>,
} }
fn draw_grip_marker( fn draw_grip_marker(
@@ -989,6 +1034,18 @@ impl canvas::Program<Message> for SelectionCanvas {
} }
} }
} }
if let Some(pos) = cursor.position_in(bounds) {
let glyphs: Vec<_> = self
.constraint_glyphs
.iter()
.map(|(anchor, outward, label, conflict, _, _)| {
(*anchor, *outward, label.clone(), *conflict)
})
.collect();
if constraint_glyph_hit_test(&glyphs, pos).is_some() {
return mouse::Interaction::Pointer;
}
}
// The resize cursor over a divider is supplied by the input pane_grid // The resize cursor over a divider is supplied by the input pane_grid
// layered above this overlay; `draw` only suppresses the CAD crosshair // layered above this overlay; `draw` only suppresses the CAD crosshair
// there (see `divider_under`). // there (see `divider_under`).
@@ -1703,9 +1760,22 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&b1, stroke.clone()); frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke); frame.stroke(&b2, stroke);
} }
// Constraint glyphs are visual-only and have no hit testing. // Hit testing shares this layout math with the scene projection.
if !self.constraint_glyphs.is_empty() { if !self.constraint_glyphs.is_empty() {
let offsets = constraint_glyph_offsets(&self.constraint_glyphs); // `constraint_glyph_offsets` only needs the anchor/outward/label/
// conflict quadruple it was written against; project away the
// trailing display fields rather than widen its signature.
let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self
.constraint_glyphs
.iter()
.map(|(anchor, outward, label, is_conflicting, _, _)| {
(*anchor, *outward, label.clone(), *is_conflicting)
})
.collect();
let offsets = constraint_glyph_offsets(&glyphs_for_offsets);
let hovered = cursor
.position_in(bounds)
.and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point));
let normal_bg = theme.palette().primary.base.color; let normal_bg = theme.palette().primary.base.color;
let normal_fg = theme.palette().primary.base.text; let normal_fg = theme.palette().primary.base.text;
// A redundant or conflicting constraint gets the danger palette // A redundant or conflicting constraint gets the danger palette
@@ -1714,7 +1784,8 @@ impl canvas::Program<Message> for SelectionCanvas {
// would show, surfaced right on the geometry. // would show, surfaced right on the geometry.
let conflict_bg = theme.palette().danger.base.color; let conflict_bg = theme.palette().danger.base.color;
let conflict_fg = theme.palette().danger.base.text; let conflict_fg = theme.palette().danger.base.text;
for ((anchor, outward, label, is_conflicting), tangent_offset) in let selected_ring = theme.palette().primary.strong.color;
for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in
self.constraint_glyphs.iter().zip(offsets) self.constraint_glyphs.iter().zip(offsets)
{ {
if !anchor.x.is_finite() || !anchor.y.is_finite() { if !anchor.x.is_finite() || !anchor.y.is_finite() {
@@ -1729,17 +1800,58 @@ impl canvas::Program<Message> for SelectionCanvas {
(size.height * 0.5).into(), (size.height * 0.5).into(),
); );
frame.fill(&pill, bg); frame.fill(&pill, bg);
frame.fill_text(canvas::Text { if *is_selected {
content: label.clone(), frame.stroke(
position: Point::new( &pill,
top_left.x + CONSTRAINT_GLYPH_PAD_X, canvas::Stroke::default().with_color(selected_ring).with_width(2.0),
top_left.y + CONSTRAINT_GLYPH_PAD_Y, );
), }
color: fg, let glyph_center = Point::new(
size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), top_left.x + size.width * 0.5,
shaping: iced::advanced::text::Shaping::Advanced, top_left.y + size.height * 0.5,
..Default::default() );
}); if label == "T" {
draw_tangent_constraint_glyph(&mut frame, glyph_center, fg);
} else {
frame.fill_text(canvas::Text {
content: label.clone(),
position: glyph_center,
color: fg,
size: iced::Pixels(CONSTRAINT_GLYPH_SIZE),
align_x: iced::alignment::Horizontal::Center.into(),
align_y: iced::alignment::Vertical::Center,
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
}
}
if let Some(index) = hovered {
let red = Color::from_rgb(1.0, 0.0, 0.0);
let stroke = canvas::Stroke::default().with_color(red).with_width(1.5);
for point in &self.constraint_glyphs[index].5 {
let first = canvas::Path::line(
Point::new(
point.x - CONSTRAINT_HOVER_MARKER_RADIUS,
point.y - CONSTRAINT_HOVER_MARKER_RADIUS,
),
Point::new(
point.x + CONSTRAINT_HOVER_MARKER_RADIUS,
point.y + CONSTRAINT_HOVER_MARKER_RADIUS,
),
);
let second = canvas::Path::line(
Point::new(
point.x - CONSTRAINT_HOVER_MARKER_RADIUS,
point.y + CONSTRAINT_HOVER_MARKER_RADIUS,
),
Point::new(
point.x + CONSTRAINT_HOVER_MARKER_RADIUS,
point.y - CONSTRAINT_HOVER_MARKER_RADIUS,
),
);
frame.stroke(&first, stroke.clone());
frame.stroke(&second, stroke.clone());
}
} }
} }
// Small cross at each acquired tracking point. // Small cross at each acquired tracking point.
@@ -3424,6 +3536,9 @@ mod constraint_glyph_tests {
assert!( assert!(
(anchor.y - (left.y + right_size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6 (anchor.y - (left.y + right_size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6
); );
let click = Point::new(left.x + left_size.width * 0.5, left.y + left_size.height * 0.5);
assert_eq!(constraint_glyph_hit_test(&glyphs, click), Some(0));
} }
} }
+54 -5
View File
@@ -865,13 +865,14 @@ impl PropertiesPanel {
self.render_hatch_pattern_row(label, current) self.render_hatch_pattern_row(label, current)
} }
PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), PropValue::AttrText { tag, value } => self.render_attr_row(tag, value),
PropValue::EntityLink { handles, conflicting } => { PropValue::EntityLink { id, handles, conflicting } => {
render_entity_link_row(label, handles.clone(), *conflicting) render_entity_link_row(label, *id, handles.clone(), *conflicting)
} }
PropValue::ParamRow { index, name, formula, resolved } => { PropValue::ParamRow { index, name, formula, resolved } => {
self.render_param_row(*index, name, formula, resolved) self.render_param_row(*index, name, formula, resolved)
} }
PropValue::ParamAddRow => render_param_add_row(), PropValue::ParamAddRow => render_param_add_row(),
PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value),
} }
} }
@@ -1933,8 +1934,13 @@ fn render_ro_with_tooltip_row<'a>(
/// field, so this doesn't use the usual `prop_row_widget` label|value split. /// field, so this doesn't use the usual `prop_row_widget` label|value split.
/// Clicking it selects every entity in `handles`; a conflicting/redundant /// Clicking it selects every entity in `handles`; a conflicting/redundant
/// constraint (mirrors the viewport glyph pill's own color cue) tints red. /// constraint (mirrors the viewport glyph pill's own color cue) tints red.
fn render_entity_link_row<'a>(label: &'a str, handles: Vec<Handle>, conflicting: bool) -> Element<'a, Message> { fn render_entity_link_row<'a>(
let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) label: &'a str,
id: crate::scene::parametric_constraints::ConstraintId,
handles: Vec<Handle>,
conflicting: bool,
) -> Element<'a, Message> {
let link = button(text(label).size(FONT_SZ).width(Length::Fill))
.on_press(Message::PropConstraintLinkClick(handles)) .on_press(Message::PropConstraintLinkClick(handles))
.style(move |theme: &Theme, status| { .style(move |theme: &Theme, status| {
let palette = theme.palette(); let palette = theme.palette();
@@ -1955,7 +1961,13 @@ fn render_entity_link_row<'a>(label: &'a str, handles: Vec<Handle>, conflicting:
}) })
.padding([3, 8]) .padding([3, 8])
.width(Length::Fill); .width(Length::Fill);
container(btn).width(Length::Fill).into() let delete = button(text("\u{2715}").size(FONT_SZ))
.on_press(Message::PropConstraintDelete(id))
.style(button::text)
.padding([2, 6]);
container(row![link, delete].spacing(2).align_y(iced::Center))
.width(Length::Fill)
.into()
} }
// ── Parameters section: "+ Add parameter" row ────────────────────────────── // ── Parameters section: "+ Add parameter" row ──────────────────────────────
@@ -1973,6 +1985,43 @@ fn render_param_add_row<'a>() -> Element<'a, Message> {
container(btn).width(Length::Fill).into() container(btn).width(Length::Fill).into()
} }
// ── Parameters section: leading global visibility toggle ───────────────────
/// The Parameters section's leading header row (no-selection page): a
/// global on/off toggle for whether any constraint pill in the viewport
/// shows its driven value/parameter-name text — lives next to the
/// named-parameter table it governs.
fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> {
let btn_label = if value { t!("On") } else { t!("Off") }.into_owned();
let btn = button(
row![
crate::ui::icons::semantic(crate::ui::icons::layer_visible(value), 13.0),
text(btn_label).size(FONT_SZ),
]
.spacing(6)
.align_y(iced::Center),
)
.on_press(Message::ShowConstraintValuesChanged(!value))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => palette.background.weak,
_ => palette.background.base,
};
button::Style {
background: Some(Background::Color(pair.color)),
border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() },
text_color: pair.text,
..Default::default()
}
})
.padding([4, 8])
.width(Length::Fill);
container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center))
.width(Length::Fill)
.into()
}
/// Build a label | widget property row. /// Build a label | widget property row.
fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> {
prop_row_with_active(label, widget, false) prop_row_with_active(label, widget, false)
+333
View File
@@ -0,0 +1,333 @@
//! Object Color Dropdown panel.
//!
//! Provides a 5-section flyout ribbon color control:
//! 1. ByLayer and ByBlock logical color entries
//! 2. 45-swatch Quick Pick Grid (5 rows × 9 columns)
//! 3. Standard Index Colors (ACI 1–9)
//! 4. Recent Colors row (up to 9 swatches)
//! 5. "Select Color..." action to open the full Select Color dialog
use super::widgets::{muted_text_style, popup_panel_style, popup_row_style};
use crate::app::Message;
use crate::t;
use crate::ui::properties::acad_color_display;
use acadrust::types::Color as AcadColor;
use iced::widget::{button, column, container, row, text, Space};
use iced::{Background, Border, Element, Length, Padding, Theme};
pub const PANEL_W: f32 = 202.0;
const SWATCH_SIZE: f32 = 18.0;
const SWATCH_GAP: f32 = 2.0;
/// Curated 45-swatch quick pick grid measured from the AutoCAD ribbon control.
pub const QUICK_PICK_GRID: [[(u8, u8, u8); 9]; 5] = [
[
(0x00, 0x00, 0x00),
(0xF8, 0xD7, 0x31),
(0xCD, 0x69, 0x28),
(0x99, 0x1B, 0x1E),
(0x64, 0x21, 0x65),
(0x29, 0x31, 0x89),
(0x22, 0x34, 0x6E),
(0x13, 0x67, 0x34),
(0x72, 0x74, 0x30),
],
[
(0x63, 0x64, 0x66),
(0xF1, 0xEB, 0x1F),
(0xF2, 0x67, 0x22),
(0xCD, 0x20, 0x27),
(0x93, 0x27, 0x8F),
(0x45, 0x54, 0xA5),
(0x10, 0x56, 0x89),
(0x13, 0x9B, 0x48),
(0x99, 0x9B, 0x37),
],
[
(0x93, 0x95, 0x98),
(0xF4, 0xEE, 0x51),
(0xF8, 0x99, 0x1E),
(0xED, 0x1F, 0x24),
(0xA9, 0x53, 0xA0),
(0x5E, 0x67, 0xAF),
(0x27, 0x76, 0xBB),
(0x58, 0xBA, 0x48),
(0xC8, 0xC9, 0x2D),
],
[
(0xC7, 0xC8, 0xCA),
(0xF7, 0xF2, 0x81),
(0xFE, 0xCC, 0x66),
(0xF2, 0x71, 0x72),
(0xD1, 0x9C, 0xC7),
(0x98, 0x98, 0xCC),
(0x7A, 0xAF, 0xDF),
(0xAF, 0xD9, 0xAA),
(0xE5, 0xE8, 0x78),
],
[
(0xFF, 0xFF, 0xFF),
(0xF8, 0xF6, 0xB0),
(0xFE, 0xEA, 0xB9),
(0xF7, 0xAB, 0xAE),
(0xDD, 0xB3, 0xD4),
(0xCE, 0xCC, 0xE6),
(0xAD, 0xDD, 0xF7),
(0xD7, 0xEB, 0xD2),
(0xED, 0xEE, 0x99),
],
];
/// The standard ACI 1..=9 index colors pulled directly from the ACI color table.
#[allow(dead_code)]
pub fn aci_1_9() -> [(u8, u8, u8); 9] {
std::array::from_fn(|i| {
acadrust::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128))
})
}
fn swatch_btn<'a>(color: AcadColor, cur_color: AcadColor) -> Element<'a, Message> {
let (bg, _) = acad_color_display(color);
let selected = color == cur_color;
button(
Space::new()
.width(Length::Fixed(SWATCH_SIZE))
.height(Length::Fixed(SWATCH_SIZE)),
)
.on_press(Message::RibbonColorChanged(color))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: Some(Background::Color(bg)),
border: Border {
color: if selected {
palette.primary.base.color
} else if hovered {
palette.background.base.text
} else {
palette.background.neutral.color
},
width: if selected {
2.0
} else if hovered {
1.5
} else {
1.0
},
radius: 1.0.into(),
},
..Default::default()
}
})
.padding(0)
.into()
}
fn placeholder_swatch<'a>() -> Element<'a, Message> {
container(
Space::new()
.width(Length::Fixed(SWATCH_SIZE))
.height(Length::Fixed(SWATCH_SIZE)),
)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color.scale_alpha(0.5))),
border: Border {
color: palette.background.neutral.color.scale_alpha(0.5),
width: 1.0,
radius: 1.0.into(),
},
..Default::default()
}
})
.into()
}
fn divider<'a>() -> Element<'a, Message> {
container(
Space::new()
.width(Length::Fill)
.height(Length::Fixed(1.0)),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.into()
}
fn logical_row<'a>(
color: AcadColor,
cur_color: AcadColor,
label: &'static str,
) -> Element<'a, Message> {
let (bg, _) = acad_color_display(color);
let selected = color == cur_color;
let swatch = container(
Space::new()
.width(Length::Fixed(SWATCH_SIZE))
.height(Length::Fixed(SWATCH_SIZE)),
)
.style(move |theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(bg)),
border: Border {
color: if selected {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: if selected { 2.0 } else { 1.0 },
radius: 1.0.into(),
},
..Default::default()
}
});
button(
row![
swatch,
text(t!(label)).size(11),
]
.spacing(8)
.align_y(iced::Center),
)
.on_press(Message::RibbonColorChanged(color))
.style(popup_row_style)
.padding([3, 4])
.width(Length::Fill)
.into()
}
fn more_colors_row<'a>(active_color: AcadColor) -> Element<'a, Message> {
button(
row![text(t!("Select Color...")).size(11)]
.align_y(iced::Center),
)
.on_press(Message::OpenColorWindow(
crate::app::ColorPickTarget::Ribbon,
active_color,
))
.style(popup_row_style)
.padding([4, 4])
.width(Length::Fill)
.into()
}
/// Builds the complete 5-section color dropdown panel element.
pub fn color_dropdown_panel<'a>(
active_color: AcadColor,
recent_colors: &[AcadColor],
) -> Element<'a, Message> {
// 1. ByLayer and ByBlock
let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer");
let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock");
let section1 = column![by_layer, by_block].spacing(2);
// 2. 45-swatch Quick Pick Grid (5 rows × 9 columns)
let mut grid_rows = column![].spacing(SWATCH_GAP);
for row_colors in QUICK_PICK_GRID {
let mut r = row![].spacing(SWATCH_GAP);
for (red, green, blue) in row_colors {
let color = AcadColor::Rgb {
r: red,
g: green,
b: blue,
};
r = r.push(swatch_btn(color, active_color));
}
grid_rows = grid_rows.push(r);
}
// 3. Standard Index Colors (ACI 1..=9)
let index_header = text(t!("Index Color")).size(10).style(muted_text_style);
let mut standard_row = row![].spacing(SWATCH_GAP);
for idx in 1u8..=9 {
let color = AcadColor::Index(idx);
standard_row = standard_row.push(swatch_btn(color, active_color));
}
let section3 = column![index_header, standard_row].spacing(4);
// 4. Recent Colors row (up to 9 swatches; pad with placeholders if fewer)
let recent_header = text(t!("Recent Colors")).size(10).style(muted_text_style);
let mut recent_row = row![].spacing(SWATCH_GAP);
let mut count = 0;
for &color in recent_colors.iter().take(9) {
recent_row = recent_row.push(swatch_btn(color, active_color));
count += 1;
}
while count < 9 {
recent_row = recent_row.push(placeholder_swatch());
count += 1;
}
let section4 = column![recent_header, recent_row].spacing(4);
// 5. Select Color...
let section5 = more_colors_row(active_color);
let content = column![
section1,
divider(),
grid_rows,
divider(),
section3,
divider(),
section4,
divider(),
section5,
]
.spacing(6);
container(content)
.padding(Padding {
top: 6.0,
bottom: 6.0,
left: 12.0,
right: 12.0,
})
.width(Length::Fixed(PANEL_W))
.style(popup_panel_style)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quick_pick_grid_dimensions() {
assert_eq!(QUICK_PICK_GRID.len(), 5);
for row in &QUICK_PICK_GRID {
assert_eq!(row.len(), 9);
}
}
#[test]
fn test_aci_1_9() {
let colors = aci_1_9();
assert_eq!(colors.len(), 9);
assert_eq!(colors[0], (255, 0, 0)); // Red
assert_eq!(colors[1], (255, 255, 0)); // Yellow
assert_eq!(colors[2], (0, 255, 0)); // Green
assert_eq!(colors[3], (0, 255, 255)); // Cyan
assert_eq!(colors[4], (0, 0, 255)); // Blue
assert_eq!(colors[5], (255, 0, 255)); // Magenta
assert_eq!(colors[6], (255, 255, 255)); // White
assert_eq!(colors[7], (128, 128, 128)); // Dark Gray
assert_eq!(colors[8], (192, 192, 192)); // Light Gray
}
#[test]
fn test_panel_geometry() {
assert_eq!(PANEL_W, 202.0);
let grid_width = 9.0 * SWATCH_SIZE + 8.0 * SWATCH_GAP;
assert_eq!(grid_width, 178.0);
let padding_h = (PANEL_W - grid_width) / 2.0;
assert_eq!(padding_h, 12.0);
}
}
+1 -17
View File
@@ -73,12 +73,6 @@ const TOOLS: &[Tool] = &[
icon: include_bytes!("../../../assets/icons/region.svg"), icon: include_bytes!("../../../assets/icons/region.svg"),
options: &[], options: &[],
}, },
Tool {
command: "WIPEOUT",
label: "Wipeout",
icon: include_bytes!("../../../assets/icons/wipeout.svg"),
options: &[],
},
Tool { Tool {
command: "BOUNDARY", command: "BOUNDARY",
label: "Boundary", label: "Boundary",
@@ -107,16 +101,6 @@ const TOOLS: &[Tool] = &[
("DDPTYPE", "Point Style"), ("DDPTYPE", "Point Style"),
], ],
}, },
Tool {
command: "REVCLOUD",
label: "Revision Cloud",
icon: include_bytes!("../../../assets/icons/revcloud.svg"),
options: &[
("REVCLOUD_RECTANGULAR", "Rectangular"),
("REVCLOUD_POLYGONAL", "Polygonal"),
("REVCLOUD_FREEHAND", "Freehand"),
],
},
]; ];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -305,7 +289,7 @@ mod tests {
#[test] #[test]
fn extension_submenus_resolve_to_their_parent_panel() { fn extension_submenus_resolve_to_their_parent_panel() {
assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); assert_eq!(parent_panel("MULTIPOINT"), Some(PANEL_ID));
assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension"));
assert_eq!(parent_panel("unknown"), None); assert_eq!(parent_panel("unknown"), None);
} }
+12 -20
View File
@@ -23,6 +23,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem};
mod widgets; mod widgets;
mod draw_panel; mod draw_panel;
mod modify_panel; mod modify_panel;
mod color_dropdown;
use widgets::{StyleContext, *}; use widgets::{StyleContext, *};
mod collapse; mod collapse;
use collapse::{CollapsePanels, Panel}; use collapse::{CollapsePanels, Panel};
@@ -739,6 +740,7 @@ impl Ribbon {
redo_labels: &[String], redo_labels: &[String],
win: (f32, f32), win: (f32, f32),
is_start: bool, is_start: bool,
recent_colors: &[AcadColor],
) -> Option<Element<'_, Message>> { ) -> Option<Element<'_, Message>> {
if is_start { if is_start {
return None; return None;
@@ -820,7 +822,7 @@ impl Ribbon {
} }
if open_id == PROP_COLOR_ID { if open_id == PROP_COLOR_ID {
return self.prop_color_overlay(win); return self.prop_color_overlay(win, recent_colors);
} }
if open_id == PROP_LINETYPE_ID { if open_id == PROP_LINETYPE_ID {
return self.prop_linetype_overlay(win); return self.prop_linetype_overlay(win);
@@ -1169,27 +1171,17 @@ impl Ribbon {
))) )))
} }
fn prop_color_overlay(&self, win: (f32, f32)) -> Option<Element<'_, Message>> { fn prop_color_overlay(
let picker = crate::ui::color_select::color_list( &self,
crate::ui::color_select::ColorExtras { win: (f32, f32),
by_layer: true, recent_colors: &[AcadColor],
by_block: true, ) -> Option<Element<'_, Message>> {
..Default::default() let panel = color_dropdown::color_dropdown_panel(self.active_color, recent_colors);
},
Message::RibbonColorChanged,
Message::OpenColorWindow(
crate::app::ColorPickTarget::Ribbon,
self.active_color,
),
);
let panel = container(picker) let (align_right, h_pad, top) =
.style(popup_panel_style) self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0);
.width(Length::Fixed(200.0));
let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0);
Some(dropdown_backdrop(position_ribbon_dropdown( Some(dropdown_backdrop(position_ribbon_dropdown(
panel.into(), panel,
align_right, align_right,
h_pad, h_pad,
top, top,
+41 -6
View File
@@ -37,6 +37,47 @@ pub(super) const TOOLS: &[Tool] = &[
icon: include_bytes!("../../../assets/icons/modify_align.svg"), icon: include_bytes!("../../../assets/icons/modify_align.svg"),
options: &[], options: &[],
}, },
// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM:
// one-shot bounding-box alignment of the current selection — distinct
// from "ALIGN" above (point-picked 3D placement). No point prompts, so
// each is a plain one-click Tool rather than a dropdown submenu, matching
// this panel's existing flat-list convention.
Tool {
command: "ALIGNLEFT",
label: "Align Left",
icon: include_bytes!("../../../assets/icons/align_left.svg"),
options: &[],
},
Tool {
command: "ALIGNHCENTER",
label: "Align Horizontal Centers",
icon: include_bytes!("../../../assets/icons/align_hcenter.svg"),
options: &[],
},
Tool {
command: "ALIGNRIGHT",
label: "Align Right",
icon: include_bytes!("../../../assets/icons/align_right.svg"),
options: &[],
},
Tool {
command: "ALIGNTOP",
label: "Align Top",
icon: include_bytes!("../../../assets/icons/align_top.svg"),
options: &[],
},
Tool {
command: "ALIGNVCENTER",
label: "Align Vertical Centers",
icon: include_bytes!("../../../assets/icons/align_vcenter.svg"),
options: &[],
},
Tool {
command: "ALIGNBOTTOM",
label: "Align Bottom",
icon: include_bytes!("../../../assets/icons/align_bottom.svg"),
options: &[],
},
Tool { Tool {
command: "BREAK", command: "BREAK",
label: "Break", label: "Break",
@@ -67,12 +108,6 @@ pub(super) const TOOLS: &[Tool] = &[
icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"),
options: &[], options: &[],
}, },
Tool {
command: "OVERKILL",
label: "Delete Duplicate Objects",
icon: include_bytes!("../../../assets/icons/modify_overkill.svg"),
options: &[],
},
Tool { Tool {
command: "DRAWORDER_FRONT", command: "DRAWORDER_FRONT",
label: "Draw Order", label: "Draw Order",
+96 -95
View File
@@ -1,13 +1,11 @@
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
pub mod status_menu;
pub mod statusbar_config; pub mod statusbar_config;
pub mod statusbar_menu; pub mod statusbar_menu;
pub mod status_menu;
use iced::widget::tooltip::Position as TipPos; use iced::widget::tooltip::Position as TipPos;
use iced::widget::{ use iced::widget::{button, column, container, mouse_area, row, text, text_input, tooltip};
button, column, container, mouse_area, row, text, text_input, tooltip,
};
use iced::{Background, Border, Color, Element, Length, Theme}; use iced::{Background, Border, Color, Element, Length, Theme};
use iced_aw::ContextMenu; use iced_aw::ContextMenu;
use std::sync::Arc; use std::sync::Arc;
@@ -23,10 +21,10 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input";
use crate::app::Message; use crate::app::Message;
use crate::snap::Snapper; use crate::snap::Snapper;
use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill};
use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry;
use crate::ui::wrap_bar::WrapBar;
use crate::t; use crate::t;
use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry;
use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill};
use crate::ui::wrap_bar::WrapBar;
/// Height of one status-bar row. Matches the drawing-tab strip above it so the /// Height of one status-bar row. Matches the drawing-tab strip above it so the
/// three horizontal strips — tabs, status bar, command line — line up, and it /// three horizontal strips — tabs, status bar, command line — line up, and it
@@ -125,16 +123,16 @@ impl StatusBar {
// Which pills the user has chosen to show on the bar. // Which pills the user has chosen to show on the bar.
config: &'a StatusBarConfig, config: &'a StatusBarConfig,
menu_data: StatusMenuData<'a>, menu_data: StatusMenuData<'a>,
// Remaining degrees of freedom for the current sketch scope. // Remaining degrees of freedom for the current parametric scope.
// `None` when the scope // `None` when the scope
// has no `SketchConstraintSet` yet (nothing constrained), so the // has no `ParametricConstraintSet` yet (nothing constrained), so the
// badge stays invisible until it's actually relevant. // badge stays invisible until it's actually relevant.
sketch_dof: Option<usize>, parametric_dof: Option<usize>,
// Number of redundant/conflicting constraints in the current scope. // Number of redundant/conflicting constraints in the current scope.
// Zero hides // Zero hides
// the pill entirely, so an ordinarily/fully-constrained drawing sees // the pill entirely, so an ordinarily/fully-constrained drawing sees
// no new clutter. // no new clutter.
sketch_conflicts: usize, parametric_conflicts: usize,
// What is drawing the scene. Only a degraded verdict shows anything. // What is drawing the scene. Only a degraded verdict shows anything.
gpu_status: &'a crate::scene::pipeline::GpuStatus, gpu_status: &'a crate::scene::pipeline::GpuStatus,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -157,9 +155,7 @@ impl StatusBar {
} else { } else {
crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0) crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)
}; };
let menu_button = button(menu_icon) let menu_button = button(menu_icon).style(button::subtle).padding([4, 8]);
.style(button::subtle)
.padding([4, 8]);
let menu_btn = if is_start { let menu_btn = if is_start {
tip( tip(
menu_button.into(), menu_button.into(),
@@ -270,9 +266,9 @@ impl StatusBar {
); );
} }
// Not a `StatusPill` (so not user-hideable yet): invisible until the // Not a `StatusPill` (so not user-hideable yet): invisible until the
// current scope actually has a SketchConstraintSet, so a drawing // current scope actually has a ParametricConstraintSet, so a drawing
// that never uses parametric constraints sees no new clutter. // that never uses parametric constraints sees no new clutter.
if let Some(dof) = sketch_dof { if let Some(dof) = parametric_dof {
let pill = if dof == 0 { let pill = if dof == 0 {
success_pill(crate::tf!("DOF: {dof}").into_owned()) success_pill(crate::tf!("DOF: {dof}").into_owned())
} else { } else {
@@ -284,21 +280,21 @@ impl StatusBar {
if dof == 0 { if dof == 0 {
t!("Fully constrained — no remaining degrees of freedom") t!("Fully constrained — no remaining degrees of freedom")
} else { } else {
t!("Remaining degrees of freedom in the current sketch's persistent constraints") t!("Remaining degrees of freedom in the current parametric constraint scope")
}, },
) )
.into(), .into(),
); );
} }
// Each click removes one flagged constraint. // Each click removes one flagged constraint.
if sketch_conflicts > 0 { if parametric_conflicts > 0 {
pills.push( pills.push(
tip( tip(
action_pill( action_pill(
crate::tf!("⚠ {sketch_conflicts} conflicting").into_owned(), crate::tf!("⚠ {parametric_conflicts} conflicting").into_owned(),
Message::ResolveOneSketchConflict, Message::ResolveOneParametricConflict,
), ),
t!("One or more constraints in this sketch conflict or are redundant\nClick to remove one and re-solve"), t!("One or more parametric constraints conflict or are redundant\nClick to remove one and re-solve"),
) )
.into(), .into(),
); );
@@ -315,7 +311,11 @@ impl StatusBar {
if vis(StatusPill::Lwt) { if vis(StatusPill::Lwt) {
pills.push( pills.push(
tip( tip(
toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), toggle_pill(
crate::ui::icons::ST_LWT,
lineweight_display,
Message::ToggleLineweightDisplay,
),
t!("Show Lineweight\nLWDISPLAY"), t!("Show Lineweight\nLWDISPLAY"),
) )
.into(), .into(),
@@ -462,10 +462,7 @@ impl StatusBar {
t!("Isolate Objects\nClick for Isolate / Hide / End"), t!("Isolate Objects\nClick for Isolate / Hide / End"),
tooltip_hidden, tooltip_hidden,
), ),
crate::ui::popup::isolate_popup::menu_entries( crate::ui::popup::isolate_popup::menu_entries(has_selection, isolation_active),
has_selection,
isolation_active,
),
160.0, 160.0,
) )
.into(), .into(),
@@ -474,7 +471,11 @@ impl StatusBar {
if vis(StatusPill::QuickProps) { if vis(StatusPill::QuickProps) {
pills.push( pills.push(
tip( tip(
toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), toggle_pill(
crate::ui::icons::ST_QUICKPROPS,
quick_properties,
Message::ToggleQuickProperties,
),
t!("Quick Properties\nFloating panel on selection"), t!("Quick Properties\nFloating panel on selection"),
) )
.into(), .into(),
@@ -504,7 +505,11 @@ impl StatusBar {
if vis(StatusPill::SelCycle) { if vis(StatusPill::SelCycle) {
pills.push( pills.push(
tip( tip(
toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), toggle_pill(
crate::ui::icons::ST_SELCYCLE,
selection_cycling,
Message::ToggleSelectionCycling,
),
t!("Selection Cycling\nRepeat-click to step through overlapping objects"), t!("Selection Cycling\nRepeat-click to step through overlapping objects"),
) )
.into(), .into(),
@@ -522,7 +527,11 @@ impl StatusBar {
if vis(StatusPill::CleanScreen) { if vis(StatusPill::CleanScreen) {
pills.push( pills.push(
tip( tip(
toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), toggle_pill(
crate::ui::icons::ST_CLEANSCREEN,
clean_screen,
Message::ToggleCleanScreen,
),
t!("Clean Screen\nHide ribbon and panels"), t!("Clean Screen\nHide ribbon and panels"),
) )
.into(), .into(),
@@ -607,13 +616,13 @@ impl StatusBar {
.style(|theme: &Theme| { .style(|theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
container::Style { container::Style {
background: Some(Background::Color(palette.background.base.color)), background: Some(Background::Color(palette.background.base.color)),
border: Border { border: Border {
color: palette.background.neutral.color, color: palette.background.neutral.color,
width: 1.0, width: 1.0,
radius: 0.0.into(), radius: 0.0.into(),
}, },
..Default::default() ..Default::default()
} }
}) })
.width(Length::Fill) .width(Length::Fill)
@@ -634,7 +643,12 @@ impl StatusBar {
/// drawing set to architectural units read its coordinates in decimals, and one /// drawing set to architectural units read its coordinates in decimals, and one
/// asking for two places got four. `format_length` is the same helper the /// asking for two places got four. `format_length` is the same helper the
/// properties panel formats through, so both now say a length the same way. /// properties panel formats through, so both now say a length the same way.
fn format_coords(cursor: glam::DVec3, last: Option<glam::DVec3>, mode: i16, picking: bool) -> String { fn format_coords(
cursor: glam::DVec3,
last: Option<glam::DVec3>,
mode: i16,
picking: bool,
) -> String {
use crate::entities::common::format_length as len; use crate::entities::common::format_length as len;
let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z)); let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z));
match mode { match mode {
@@ -666,11 +680,14 @@ fn format_coords(cursor: glam::DVec3, last: Option<glam::DVec3>, mode: i16, pick
// ── Customization handle ────────────────────────────────────────────────── // ── Customization handle ──────────────────────────────────────────────────
fn customize_btn() -> Element<'static, Message> { fn customize_btn() -> Element<'static, Message> {
button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)) button(crate::ui::icons::themed_secondary(
.on_press(Message::StatusMenuTooltipHidden(true)) crate::ui::icons::MENU,
.style(button::subtle) 16.0,
.padding([4, 8]) ))
.into() .on_press(Message::StatusMenuTooltipHidden(true))
.style(button::subtle)
.padding([4, 8])
.into()
} }
// ── Tooltip helper ──────────────────────────────────────────────────────── // ── Tooltip helper ────────────────────────────────────────────────────────
@@ -689,11 +706,7 @@ fn menu_tip<'a>(
label: std::borrow::Cow<'static, str>, label: std::borrow::Cow<'static, str>,
hidden: bool, hidden: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let content = if hidden { let content = if hidden { content } else { tip(content, label) };
content
} else {
tip(content, label)
};
mouse_area(content) mouse_area(content)
.on_exit(Message::StatusMenuTooltipHidden(false)) .on_exit(Message::StatusMenuTooltipHidden(false))
@@ -763,21 +776,21 @@ fn split_pill<'a>(
.style(move |theme: &Theme| { .style(move |theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
container::Style { container::Style {
background: Some(Background::Color(if active { background: Some(Background::Color(if active {
palette.primary.weak.color palette.primary.weak.color
} else {
palette.background.weakest.color
})),
border: Border {
color: if active {
palette.primary.base.color
} else { } else {
palette.background.neutral.color palette.background.weakest.color
})),
border: Border {
color: if active {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: 1.0,
radius: 2.0.into(),
}, },
width: 1.0, ..Default::default()
radius: 2.0.into(),
},
..Default::default()
} }
}) })
.padding([4, 6]) .padding([4, 6])
@@ -880,8 +893,8 @@ fn osnap_btn<'a>(
}; };
let main = tip( let main = tip(
mouse_area(snap_icon) mouse_area(snap_icon)
.on_press(Message::ToggleSnapEnabled) .on_press(Message::ToggleSnapEnabled)
.into(), .into(),
t!("Object Snap: toggle on/off\nF3"), t!("Object Snap: toggle on/off\nF3"),
); );
@@ -926,14 +939,10 @@ fn layout_tab_context_menu(name: String) -> Element<'static, Message> {
.on_press(Message::LayoutDelete(name)) .on_press(Message::LayoutDelete(name))
.interaction(iced::mouse::Interaction::Pointer); .interaction(iced::mouse::Interaction::Pointer);
container( container(column![rename, delete].spacing(0).width(160))
column![rename, delete] .style(container::bordered_box)
.spacing(0) .padding([4, 0])
.width(160), .into()
)
.style(container::bordered_box)
.padding([4, 0])
.into()
} }
/// A layout tab button. /// A layout tab button.
@@ -1020,12 +1029,7 @@ fn space_tab<'a>(
let report_key = format!("{report_key_prefix}:{label}"); let report_key = format!("{report_key_prefix}:{label}");
let tab = mouse_area(display).on_press(switch_msg); let tab = mouse_area(display).on_press(switch_msg);
let tab: Element<'a, Message> = if has_context_menu { let tab: Element<'a, Message> = if has_context_menu {
crate::ui::wrap_bar::ReorderTab::layout( crate::ui::wrap_bar::ReorderTab::layout(label.clone(), reorderable_layouts, tab).into()
label.clone(),
reorderable_layouts,
tab,
)
.into()
} else { } else {
tab.into() tab.into()
}; };
@@ -1035,11 +1039,7 @@ fn space_tab<'a>(
} else { } else {
tab tab
}; };
crate::ui::wrap_bar::PosReport::owned( crate::ui::wrap_bar::PosReport::owned(report_key, tab).into()
report_key,
tab,
)
.into()
} }
} }
@@ -1051,7 +1051,7 @@ fn space_tab<'a>(
fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> { fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> {
let is_model_tab = current_layout == "Model"; let is_model_tab = current_layout == "Model";
// Labels and styling follow AutoCAD convention: // Labels and styling follow the conventional drafting layout:
// PAPER = currently in paper-space editing // PAPER = currently in paper-space editing
// MODEL = currently in model-space editing (either the Model tab or MSPACE) // MODEL = currently in model-space editing (either the Model tab or MSPACE)
let (label, active, on_press) = if is_model_tab { let (label, active, on_press) = if is_model_tab {
@@ -1089,9 +1089,9 @@ fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Mes
fn status_pill(label: impl Into<String>) -> Element<'static, Message> { fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
container(text(label.into()).size(12)) container(text(label.into()).size(12))
.style(container::bordered_box) .style(container::bordered_box)
.padding([4, 8]) .padding([4, 8])
.into() .into()
} }
/// A success-colored status pill used when no degrees of freedom remain. /// A success-colored status pill used when no degrees of freedom remain.
@@ -1123,7 +1123,11 @@ fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
container::Style { container::Style {
background: Some(Background::Color(palette.success.weak.color)), background: Some(Background::Color(palette.success.weak.color)),
text_color: Some(palette.success.weak.text), text_color: Some(palette.success.weak.text),
border: Border { color: palette.success.base.color, width: 1.0, radius: 4.0.into() }, border: Border {
color: palette.success.base.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default() ..Default::default()
} }
}) })
@@ -1141,10 +1145,10 @@ fn popup_pill(label: impl Into<String>) -> Element<'static, Message> {
fn action_pill(label: impl Into<String>, msg: Message) -> Element<'static, Message> { fn action_pill(label: impl Into<String>, msg: Message) -> Element<'static, Message> {
let label = label.into(); let label = label.into();
button(text(label).size(12)) button(text(label).size(12))
.on_press(msg) .on_press(msg)
.style(button::subtle) .style(button::subtle)
.padding([4, 7]) .padding([4, 7])
.into() .into()
} }
// ── Scale display ───────────────────────────────────────────────────────── // ── Scale display ─────────────────────────────────────────────────────────
@@ -1196,13 +1200,10 @@ fn active_scale_label(
.iter() .iter()
.find(|(_, anno_scale, vp_scale)| { .find(|(_, anno_scale, vp_scale)| {
if is_model { if is_model {
(annotation_scale - *anno_scale).abs() (annotation_scale - *anno_scale).abs() < 0.001 * annotation_scale.max(0.001)
< 0.001 * annotation_scale.max(0.001)
} else { } else {
viewport_scale viewport_scale
.map(|current| { .map(|current| (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001))
(current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001)
})
.unwrap_or(false) .unwrap_or(false)
} }
}) })
+143
View File
@@ -0,0 +1,143 @@
use crate::app::settings::AutoConstrainSettings;
use crate::app::Message;
use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space};
use iced::{Element, Fill};
pub fn view_window<'a>(
settings: &'a AutoConstrainSettings,
selected_row: usize,
distance_input: &'a str,
angle_input: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let heading = row![
text(crate::t!("Priority")).width(70),
text(crate::t!("Constraint Type")).width(Fill),
text(crate::t!("Apply")).width(60),
]
.spacing(8);
let mut list = column![heading].spacing(3);
for (index, kind) in settings.priority.iter().copied().enumerate() {
let enabled = settings.enabled.contains(&kind);
let label = row![
text(format!("{}", index + 1)).width(70),
text(crate::t!(kind.label())).width(Fill),
checkbox(enabled)
.on_toggle(move |_| Message::AutoConstrainToggleKind(kind))
.width(60),
]
.spacing(8)
.align_y(iced::Center);
list = list.push(
button(label)
.on_press(Message::AutoConstrainSelectRow(index))
.style(if selected_row == index {
button::primary
} else {
button::text
})
.width(Fill)
.padding([5, 8]),
);
}
let reorder = column![
button(text(crate::t!("Move Up")))
.on_press_maybe((selected_row > 0).then_some(Message::AutoConstrainMoveUp))
.width(Fill),
button(text(crate::t!("Move Down")))
.on_press_maybe(
(selected_row + 1 < settings.priority.len())
.then_some(Message::AutoConstrainMoveDown)
)
.width(Fill),
Space::new().height(8),
button(text(crate::t!("Select All")))
.on_press(Message::AutoConstrainSelectAll)
.width(Fill),
button(text(crate::t!("Clear All")))
.on_press(Message::AutoConstrainClearAll)
.width(Fill),
button(text(crate::t!("Reset")))
.on_press(Message::AutoConstrainReset)
.width(Fill),
]
.spacing(6)
.width(125);
let types = row![
container(scrollable(list).height(260))
.padding(6)
.width(Fill),
reorder
]
.spacing(12);
let conditions = column![
checkbox(settings.tangent_must_share_point)
.label(crate::t!("Tangent objects must share an intersection point"))
.on_toggle(|_| Message::AutoConstrainToggleTangentPoint),
checkbox(settings.perpendicular_must_intersect)
.label(crate::t!("Perpendicular objects must share an intersection point"))
.on_toggle(|_| Message::AutoConstrainTogglePerpendicularIntersection),
]
.spacing(8);
let tolerances = column![
text(crate::t!("Tolerances")).size(14),
row![
text(crate::t!("Distance")).width(130),
text_input("0.05", distance_input)
.on_input(Message::AutoConstrainDistanceChanged)
.width(150),
]
.align_y(iced::Center),
row![
text(crate::t!("Angle")).width(130),
text_input("1", angle_input)
.on_input(Message::AutoConstrainAngleChanged)
.width(150),
text("°"),
]
.align_y(iced::Center),
]
.spacing(7);
let actions = row![
Space::new().width(Fill),
button(text(crate::t!("OK")))
.on_press(Message::AutoConstrainOk)
.style(button::primary)
.padding([6, 18]),
button(text(crate::t!("Apply")))
.on_press(Message::AutoConstrainApply)
.style(button::secondary)
.padding([6, 18]),
button(text(crate::t!("Cancel")))
.on_press(Message::AutoConstrainCancel)
.style(button::secondary)
.padding([6, 18]),
]
.spacing(8)
.align_y(iced::Center);
container(
column![
text(crate::t!("Auto Constrain")).size(16),
types,
conditions,
tolerances,
Space::new().height(Fill),
actions,
]
.spacing(14)
.width(sizing.width)
.height(sizing.height),
)
.padding([14, 16])
.width(sizing.width)
.height(sizing.height)
.style(container::rounded_box)
.into()
}
+47 -9
View File
@@ -111,19 +111,44 @@ pub fn view_window<'a>(
}; };
// ── Tab Bar ────────────────────────────────────────────────────────── // ── Tab Bar ──────────────────────────────────────────────────────────
// Real tab strip: a single non-wrapping row of tab-shaped buttons with a
// divider underneath, matching the style-manager editor shell. The row
// never wraps — it clips on overflow so "Selection Cycling" can't spill
// onto a second row.
let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| { let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| {
let is_active = state.active_tab == tab; let is_active = state.active_tab == tab;
button(text(label).size(11.5)) button(text(label).size(11))
.on_press(Message::DraftingSettingsTabChanged(tab)) .on_press(Message::DraftingSettingsTabChanged(tab))
.style(if is_active { .style(move |theme: &Theme, status| {
button::primary let palette = theme.palette();
} else { let pair = match (is_active, status) {
button::secondary (true, _) => palette.primary.strong,
(
false,
button::Status::Hovered | button::Status::Pressed,
) => palette.background.strong,
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: iced::border::Radius {
top_left: 4.0,
top_right: 4.0,
bottom_right: 0.0,
bottom_left: 0.0,
},
},
..Default::default()
}
}) })
.padding([5, 9]) .padding([4, 8])
}; };
let tab_bar = row![ let tab_row = row![
tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")), tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")),
tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")), tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")),
tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")), tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")),
@@ -132,8 +157,21 @@ pub fn view_window<'a>(
tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")), tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")),
tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")), tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")),
] ]
.spacing(4) .spacing(2)
.wrap(); .width(Fill)
.clip(true);
let tab_divider = container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color,
)),
..Default::default()
});
let tab_bar = column![tab_row, tab_divider].spacing(0).width(Fill);
// ── Tab 1: Snap and Grid ───────────────────────────────────────────── // ── Tab 1: Snap and Grid ─────────────────────────────────────────────
let snap_and_grid_view = { let snap_and_grid_view = {
+1
View File
@@ -5,6 +5,7 @@ pub mod layer_state_manager;
pub mod drawing_units; pub mod drawing_units;
pub mod geometric_tolerance; pub mod geometric_tolerance;
pub mod drafting_settings; pub mod drafting_settings;
pub mod auto_constrain_settings;
pub mod layer_translator; pub mod layer_translator;
pub mod plot; pub mod plot;
pub mod print_all; pub mod print_all;
+6 -4
View File
@@ -9,7 +9,7 @@
//! Unlike the alias editor, each row also shows a live-computed resolved //! Unlike the alias editor, each row also shows a live-computed resolved
//! value or error (`preview`, below) — built fresh from the whole buffer on //! value or error (`preview`, below) — built fresh from the whole buffer on
//! every render, not cached: the same "cheap enough to recompute from //! every render, not cached: the same "cheap enough to recompute from
//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use //! scratch" philosophy `ParameterTable`/`parametric_solve.rs` already use
//! throughout this project, and it means the preview reflects circular/ //! throughout this project, and it means the preview reflects circular/
//! undefined-reference problems across rows immediately, before Apply. //! undefined-reference problems across rows immediately, before Apply.
@@ -393,7 +393,9 @@ mod tests {
#[test] #[test]
fn usage_lines_names_the_constraint_kind_and_its_entities() { fn usage_lines_names_the_constraint_kind_and_its_entities() {
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use crate::scene::parametric_constraints::{
ConstraintKind, ParametricRef, ParametricScope,
};
let mut scene = Scene::new(); let mut scene = Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line( let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points( acadrust::entities::Line::from_points(
@@ -402,10 +404,10 @@ mod tests {
), ),
)); ));
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(crate::scene::named_parameters::DrivingValue::Named( Some(crate::scene::named_parameters::DrivingValue::Named(
"gap".to_string(), "gap".to_string(),
)), )),
-17
View File
@@ -145,7 +145,6 @@ impl<T> fmt::Display for Labelled<T> {
pub fn view_window<'a>( pub fn view_window<'a>(
default_save_format: &'a str, default_save_format: &'a str,
file_assoc_enabled: bool, file_assoc_enabled: bool,
write_dwg_native_constraints: bool,
show_constraint_values: bool, show_constraint_values: bool,
ui_theme: &'a UiThemeConfig, ui_theme: &'a UiThemeConfig,
theme_color_inputs: &'a [String; 6], theme_color_inputs: &'a [String; 6],
@@ -352,22 +351,6 @@ pub fn view_window<'a>(
.size(11) .size(11)
.width(sizing.width), .width(sizing.width),
Space::new().height(14), Space::new().height(14),
row![
iced::widget::checkbox(write_dwg_native_constraints)
.on_toggle(Message::WriteDwgNativeConstraintsChanged)
.size(15),
text(crate::t!("Write native constraint objects on save")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Saves sketch constraints as native drawing objects alongside this app's own format. \
Off by default because it adds file size. Existing native objects stay synchronized."
))
.size(11)
.width(sizing.width),
Space::new().height(14),
row![ row![
iced::widget::checkbox(show_constraint_values) iced::widget::checkbox(show_constraint_values)
.on_toggle(Message::ShowConstraintValuesChanged) .on_toggle(Message::ShowConstraintValuesChanged)
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
Synthetic regression fixture for #1266. Generated with ezdxf/Pillow from basic
geometry; it contains no external references or customer drawing content.
Layout1 has an INSERT of SyntheticImage at (2, 4). That block contains one
OLE2FRAME whose compound-file CONTENTS stream embeds a 64 x 32 BMP: red on
the left, blue on the right, with a green diagonal from top-left to bottom-right.
The frame occupies (0, 0) to (2, 1) in the block. Text and a triangle provide
vector controls for manually opening the fixture and checking Plot Preview.
@@ -1,7 +1,9 @@
use acadrust::entities::EntityType; use acadrust::entities::EntityType;
use acadrust::types::{Handle, Vector3}; use acadrust::types::{Handle, Vector3};
use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::named_parameters::DrivingValue;
use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::parametric_constraints::{
ConstraintKind, ParametricRef, ParametricScope,
};
use OpenCADStudio::scene::{ChangeKind, Scene}; use OpenCADStudio::scene::{ChangeKind, Scene};
fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle {
@@ -62,13 +64,6 @@ fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) {
} }
} }
fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) {
match scene.document.get_entity(handle).expect("entity exists") {
EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle),
other => panic!("expected an Arc, got {other:?}"),
}
}
fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle {
scene.add_entity(EntityType::Ellipse( scene.add_entity(EntityType::Ellipse(
acadrust::entities::Ellipse::from_center_axes( acadrust::entities::Ellipse::from_center_axes(
@@ -92,10 +87,10 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() {
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Horizontal, ConstraintKind::Horizontal,
vec![SketchRef::whole(line)], vec![ParametricRef::whole(line)],
None, None,
); );
@@ -118,7 +113,7 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() {
// constraint-referenced params, so an untouched one was invisible to // constraint-referenced params, so an untouched one was invisible to
// `diagnose` entirely before this was fixed). // `diagnose` entirely before this was fixed).
let dof = scene let dof = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.and_then(|s| s.dof); .and_then(|s| s.dof);
assert_eq!( assert_eq!(
dof, dof,
@@ -127,6 +122,77 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() {
); );
} }
#[test]
fn horizontal_constraint_levels_a_polyline_segment() {
let mut scene = Scene::new();
let polyline = scene.add_entity(EntityType::LwPolyline(
acadrust::entities::LwPolyline::from_points(vec![
acadrust::types::Vector2::new(0.0, 0.0),
acadrust::types::Vector2::new(5.0, 2.0),
acadrust::types::Vector2::new(10.0, 7.0),
]),
));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::segment(polyline, 1)],
None,
);
scene.bump_entities(&[(polyline, ChangeKind::Modified)]);
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
panic!("expected a lightweight polyline");
};
assert!(
(polyline.vertices[1].location.y - polyline.vertices[2].location.y).abs() < 1e-6,
"the selected segment should be horizontal"
);
}
#[test]
fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() {
let mut source = acadrust::entities::LwPolyline::new();
source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 0.5);
source.add_point(acadrust::types::Vector2::new(5.0, 2.0));
let mut scene = Scene::new();
let polyline = scene.add_entity(EntityType::LwPolyline(source));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::segment(polyline, 0)],
None,
);
scene.bump_entities(&[(polyline, ChangeKind::Modified)]);
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
panic!("expected a lightweight polyline");
};
assert_eq!(polyline.vertices[0].location.y, 0.0);
assert_eq!(polyline.vertices[1].location.y, 2.0);
assert_eq!(polyline.vertices[0].bulge, 0.5);
}
#[test]
fn horizontal_constraint_aligns_two_selected_points() {
let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0);
let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (a_start, _) = line_endpoints(&scene, a);
let (b_start, _) = line_endpoints(&scene, b);
assert!((a_start.y - b_start.y).abs() < 1e-6);
}
#[test] #[test]
fn parallel_constraint_rotates_the_other_line_when_one_moves() { fn parallel_constraint_rotates_the_other_line_when_one_moves() {
let mut scene = Scene::new(); let mut scene = Scene::new();
@@ -134,10 +200,10 @@ fn parallel_constraint_rotates_the_other_line_when_one_moves() {
let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Parallel, ConstraintKind::Parallel,
vec![SketchRef::whole(a), SketchRef::whole(b)], vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None, None,
); );
@@ -163,10 +229,10 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit(
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Literal(20.0)), Some(DrivingValue::Literal(20.0)),
); );
@@ -194,10 +260,10 @@ fn distance_constraint_resolves_a_named_parameter_reference() {
.unwrap(); .unwrap();
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Named("target_len".to_string())), Some(DrivingValue::Named("target_len".to_string())),
); );
@@ -236,10 +302,10 @@ fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Named("does_not_exist".to_string())), Some(DrivingValue::Named("does_not_exist".to_string())),
); );
@@ -260,10 +326,10 @@ fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() {
let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Tangent, ConstraintKind::Tangent,
vec![SketchRef::whole(circle), SketchRef::whole(line)], vec![ParametricRef::whole(circle), ParametricRef::whole(line)],
None, None,
); );
scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]);
@@ -284,10 +350,10 @@ fn tangent_constraint_between_two_circles_solves_to_external_tangency() {
let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5 let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Tangent, ConstraintKind::Tangent,
vec![SketchRef::whole(a), SketchRef::whole(b)], vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None, None,
); );
scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]);
@@ -309,10 +375,10 @@ fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() {
let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Coincident, ConstraintKind::Coincident,
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)],
None, None,
); );
@@ -335,10 +401,10 @@ fn unrelated_entity_edit_does_not_touch_constrained_geometry() {
let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0); let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Horizontal, ConstraintKind::Horizontal,
vec![SketchRef::whole(line)], vec![ParametricRef::whole(line)],
None, None,
); );
@@ -363,20 +429,24 @@ fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() {
let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint
let parallel_id = scene let parallel_id = scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Parallel, ConstraintKind::Parallel,
vec![SketchRef::whole(a), SketchRef::whole(b)], vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None, None,
); );
let horizontal_id = scene let horizontal_id = scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); .add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(c)],
None,
);
scene.erase_entities(&[a]); scene.erase_entities(&[a]);
let set = scene let set = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.expect("scope still exists"); .expect("scope still exists");
assert!( assert!(
set.get(parallel_id).is_none(), set.get(parallel_id).is_none(),
@@ -404,16 +474,16 @@ fn copying_two_constrained_entities_carries_their_constraint_along() {
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Parallel, ConstraintKind::Parallel,
vec![SketchRef::whole(a), SketchRef::whole(b)], vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None, None,
); );
// Solve once so the source pair is actually parallel before copying. // Solve once so the source pair is actually parallel before copying.
scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]);
let constraints_before = scene let constraints_before = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap() .unwrap()
.constraints .constraints
.len(); .len();
@@ -425,7 +495,7 @@ fn copying_two_constrained_entities_carries_their_constraint_along() {
let (new_a, new_b) = (new_handles[0], new_handles[1]); let (new_a, new_b) = (new_handles[0], new_handles[1]);
let constraints_after = scene let constraints_after = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap() .unwrap()
.constraints .constraints
.len(); .len();
@@ -463,25 +533,33 @@ fn copying_two_constrained_entities_carries_their_constraint_along() {
// The "one edit that ripples through a constraint still records as one undo // The "one edit that ripples through a constraint still records as one undo
// step" case needs `Scene::record_undo_before`, which is `pub(crate)` (an // step" case needs `Scene::record_undo_before`, which is `pub(crate)` (an
// integration test crate can't reach it) — covered instead as an internal // integration test crate can't reach it) — covered instead as an internal
// unit test in `src/scene/sketch_solve.rs`. // unit test in `src/scene/parametric_solve.rs`.
#[test] #[test]
fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { fn a_duplicated_horizontal_constraint_is_reported_as_redundant() {
// `solve_scope` resolves the redundant row back to the // `solve_scope` resolves the redundant row back to the
// `ConstraintId` a `ConflictResolverPanel` would name. // `ConstraintId` a `ConflictResolverPanel` would name.
use cadkernel_constraints::diagnosis::RedundancyKind; use cadkernel_constraints::diagnosis::RedundancyKind;
use OpenCADStudio::scene::sketch_constraints::ConstraintKind; use OpenCADStudio::scene::parametric_constraints::ConstraintKind;
let mut scene = Scene::new(); let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
let first = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); let first = set.add(
let second = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); // exact duplicate ConstraintKind::Horizontal,
vec![ParametricRef::whole(a)],
None,
);
let second = set.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(a)],
None,
); // exact duplicate
scene.bump_entities(&[(a, ChangeKind::Modified)]); scene.bump_entities(&[(a, ChangeKind::Modified)]);
let set = scene let set = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
set.conflicts.len(), set.conflicts.len(),
@@ -499,26 +577,26 @@ fn a_duplicated_horizontal_constraint_is_reported_as_redundant() {
#[test] #[test]
fn two_conflicting_distance_targets_are_reported_as_conflicting() { fn two_conflicting_distance_targets_are_reported_as_conflicting() {
use cadkernel_constraints::diagnosis::RedundancyKind; use cadkernel_constraints::diagnosis::RedundancyKind;
use OpenCADStudio::scene::sketch_constraints::ConstraintKind; use OpenCADStudio::scene::parametric_constraints::ConstraintKind;
let mut scene = Scene::new(); let mut scene = Scene::new();
let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
let first = set.add( let first = set.add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)],
Some(DrivingValue::Literal(20.0)), Some(DrivingValue::Literal(20.0)),
); );
let second = set.add( let second = set.add(
ConstraintKind::Distance, ConstraintKind::Distance,
vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)],
Some(DrivingValue::Literal(50.0)), Some(DrivingValue::Literal(50.0)),
); );
scene.bump_entities(&[(a, ChangeKind::Modified)]); scene.bump_entities(&[(a, ChangeKind::Modified)]);
let set = scene let set = scene
.sketch_constraint_set(SketchScope::ModelSpace) .parametric_constraint_set(ParametricScope::ModelSpace)
.unwrap(); .unwrap();
assert_eq!(set.conflicts.len(), 1); assert_eq!(set.conflicts.len(), 1);
let (flagged_id, kind) = set.conflicts[0]; let (flagged_id, kind) = set.conflicts[0];
@@ -548,10 +626,10 @@ fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() {
let b = add_circle(&mut scene, 5.0, 5.0, 1.0); let b = add_circle(&mut scene, 5.0, 5.0, 1.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Concentric, ConstraintKind::Concentric,
vec![SketchRef::center(a), SketchRef::center(b)], vec![ParametricRef::center(a), ParametricRef::center(b)],
None, None,
); );
@@ -578,10 +656,10 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() {
let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::CenterPoint, ConstraintKind::CenterPoint,
vec![SketchRef::point(line, 0), SketchRef::center(circle)], vec![ParametricRef::point(line, 0), ParametricRef::center(circle)],
None, None,
); );
@@ -603,10 +681,10 @@ fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() {
let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Colinear, ConstraintKind::Colinear,
vec![SketchRef::whole(a), SketchRef::whole(b)], vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None, None,
); );
@@ -636,10 +714,10 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() {
let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Midpoint, ConstraintKind::Midpoint,
vec![SketchRef::point(marker, 0), SketchRef::whole(base)], vec![ParametricRef::point(marker, 0), ParametricRef::whole(base)],
None, None,
); );
@@ -655,23 +733,75 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() {
); );
} }
#[test]
fn midpoint_constraint_supports_a_point_entity() {
let mut scene = Scene::new();
let base = add_line(&mut scene, 0.0, 0.0, 10.0, 6.0);
let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at(
Vector3::new(20.0, 20.0, 0.0),
)));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Midpoint,
vec![ParametricRef::point(point, 0), ParametricRef::whole(base)],
None,
);
scene.bump_entities(&[(base, ChangeKind::Modified)]);
let (start, end) = line_endpoints(&scene, base);
let EntityType::Point(point) = scene.document.get_entity(point).unwrap() else {
panic!("expected a point");
};
assert!((point.location.x - (start.x + end.x) / 2.0).abs() < 1e-6);
assert!((point.location.y - (start.y + end.y) / 2.0).abs() < 1e-6);
}
#[test]
fn coincident_constraint_supports_a_block_insertion_point() {
let mut scene = Scene::new();
let line = add_line(&mut scene, 0.0, 0.0, 10.0, 4.0);
let insert = scene.add_entity(EntityType::Insert(acadrust::entities::Insert::new(
"fixture",
Vector3::new(30.0, 20.0, 0.0),
)));
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Coincident,
vec![
ParametricRef::point(line, 1),
ParametricRef::point(insert, 0),
],
None,
);
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (_, end) = line_endpoints(&scene, line);
let EntityType::Insert(insert) = scene.document.get_entity(insert).unwrap() else {
panic!("expected a block reference");
};
assert!((insert.insert_point.x - end.x).abs() < 1e-6);
assert!((insert.insert_point.y - end.y).abs() < 1e-6);
}
#[test] #[test]
fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0);
let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add( set.add(
ConstraintKind::Fixed, ConstraintKind::Fixed,
vec![SketchRef::whole(fixed_line)], vec![ParametricRef::whole(fixed_line)],
None, None,
); );
set.add( set.add(
ConstraintKind::Coincident, ConstraintKind::Coincident,
vec![ vec![
SketchRef::point(fixed_line, 1), ParametricRef::point(fixed_line, 1),
SketchRef::point(moving_line, 0), ParametricRef::point(moving_line, 0),
], ],
None, None,
); );
@@ -705,10 +835,13 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() {
let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::PointOnCurve, ConstraintKind::PointOnCurve,
vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], vec![
ParametricRef::point(marker, 0),
ParametricRef::whole(circle),
],
None, None,
); );
@@ -723,6 +856,95 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() {
); );
} }
#[test]
fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() {
let mut scene = Scene::new();
let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6);
let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::PointOnCurve,
vec![
ParametricRef::point(marker, 0),
ParametricRef::whole(ellipse),
],
None,
);
scene.bump_entities(&[(marker, ChangeKind::Modified)]);
let (center, major, ratio) = ellipse_geom(&scene, ellipse);
let (point, _) = line_endpoints(&scene, marker);
let a = major.length();
let b = a * ratio;
let u = major / a;
let v = Vector3::new(-u.y, u.x, 0.0);
let offset = point - center;
let equation = (offset.dot(&u) / a).powi(2) + (offset.dot(&v) / b).powi(2);
assert!(
(equation - 1.0).abs() < 1e-5,
"point should satisfy the solved ellipse equation: {equation}"
);
}
#[test]
fn tangent_constraint_solves_an_ellipse_and_line() {
let mut scene = Scene::new();
let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6);
let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Tangent,
vec![ParametricRef::whole(ellipse), ParametricRef::whole(line)],
None,
);
scene.bump_entities(&[(line, ChangeKind::Modified)]);
let (center, major, ratio) = ellipse_geom(&scene, ellipse);
let (start, end) = line_endpoints(&scene, line);
let direction = (end - start).normalize();
let normal = Vector3::new(-direction.y, direction.x, 0.0);
let unit_major = major.normalize();
let unit_minor = Vector3::new(-unit_major.y, unit_major.x, 0.0);
let a = major.length();
let b = a * ratio;
let support =
((a * normal.dot(&unit_major)).powi(2) + (b * normal.dot(&unit_minor)).powi(2)).sqrt();
let distance = (center - start).dot(&normal).abs();
assert!(
(distance - support).abs() < 1e-5,
"line should be tangent to the solved ellipse: distance={distance} support={support}"
);
}
#[test]
fn equal_constraint_matches_ellipse_major_axes() {
let mut scene = Scene::new();
let a = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6);
let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Equal,
vec![ParametricRef::whole(a), ParametricRef::whole(b)],
None,
);
scene.bump_entities(&[(b, ChangeKind::Modified)]);
let (_, major_a, _) = ellipse_geom(&scene, a);
let (_, major_b, _) = ellipse_geom(&scene, b);
assert!(
(major_a.length() - major_b.length()).abs() < 1e-5,
"ellipse major radii should match: {} vs {}",
major_a.length(),
major_b.length()
);
}
#[test] #[test]
fn equal_distance_constraint_matches_a_second_point_pairs_separation() { fn equal_distance_constraint_matches_a_second_point_pairs_separation() {
let mut scene = Scene::new(); let mut scene = Scene::new();
@@ -732,14 +954,14 @@ fn equal_distance_constraint_matches_a_second_point_pairs_separation() {
let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::EqualDistance, ConstraintKind::EqualDistance,
vec![ vec![
SketchRef::point(b, 0), ParametricRef::point(b, 0),
SketchRef::point(b, 1), ParametricRef::point(b, 1),
SketchRef::point(a, 0), ParametricRef::point(a, 0),
SketchRef::point(a, 1), ParametricRef::point(a, 1),
], ],
None, None,
); );
@@ -766,21 +988,25 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() {
// scale dividing by zero at the very first solve iteration). // scale dividing by zero at the very first solve iteration).
let b = add_circle(&mut scene, 8.0, 9.0, 1.0); let b = add_circle(&mut scene, 8.0, 9.0, 1.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
// Pin the axis and `a` in place — otherwise they're just as free to move // Pin the axis and `a` in place — otherwise they're just as free to move
// as `b`'s center, and with only 2 equations (MidpointOnLine + // as `b`'s center, and with only 2 equations (MidpointOnLine +
// Perpendicular) against that many more unknowns the solver is free to // Perpendicular) against that many more unknowns the solver is free to
// converge on any of infinitely many valid configurations, not // converge on any of infinitely many valid configurations, not
// necessarily "only b moves, to a's exact mirror" (which is the one // necessarily "only b moves, to a's exact mirror" (which is the one
// deterministic outcome this test actually wants to check). // deterministic outcome this test actually wants to check).
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); set.add(
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); ConstraintKind::Fixed,
vec![ParametricRef::whole(axis)],
None,
);
set.add(ConstraintKind::Fixed, vec![ParametricRef::whole(a)], None);
set.add( set.add(
ConstraintKind::Symmetric, ConstraintKind::Symmetric,
vec![ vec![
SketchRef::center(a), ParametricRef::center(a),
SketchRef::center(b), ParametricRef::center(b),
SketchRef::whole(axis), ParametricRef::whole(axis),
], ],
None, None,
); );
@@ -813,10 +1039,10 @@ fn radius_constraint_on_an_arc_solves_to_the_target() {
let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Radius, ConstraintKind::Radius,
vec![SketchRef::whole(arc)], vec![ParametricRef::whole(arc)],
Some(DrivingValue::Literal(7.5)), Some(DrivingValue::Literal(7.5)),
); );
scene.bump_entities(&[(arc, ChangeKind::Modified)]); scene.bump_entities(&[(arc, ChangeKind::Modified)]);
@@ -835,10 +1061,10 @@ fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() {
let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Tangent, ConstraintKind::Tangent,
vec![SketchRef::whole(arc), SketchRef::whole(line)], vec![ParametricRef::whole(arc), ParametricRef::whole(line)],
None, None,
); );
scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]);
@@ -858,10 +1084,10 @@ fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() {
let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); let circle = add_circle(&mut scene, 10.0, -6.0, 2.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Concentric, ConstraintKind::Concentric,
vec![SketchRef::center(arc), SketchRef::center(circle)], vec![ParametricRef::center(arc), ParametricRef::center(circle)],
None, None,
); );
scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]);
@@ -896,10 +1122,10 @@ fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() {
let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Coincident, ConstraintKind::Coincident,
vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], vec![ParametricRef::point(arc, 0), ParametricRef::point(line, 0)],
None, None,
); );
@@ -932,11 +1158,18 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv
let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI);
let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(anchor)],
None,
);
set.add( set.add(
ConstraintKind::Coincident, ConstraintKind::Coincident,
vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], vec![
ParametricRef::point(arc, 0),
ParametricRef::point(anchor, 0),
],
None, None,
); );
scene.bump_entities(&[(arc, ChangeKind::Modified)]); scene.bump_entities(&[(arc, ChangeKind::Modified)]);
@@ -964,26 +1197,87 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv
} }
#[test] #[test]
fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { fn point_on_curve_constraint_keeps_a_point_within_an_arcs_sweep() {
let mut scene = Scene::new(); let mut scene = Scene::new();
let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI);
let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); let marker = add_line(&mut scene, 0.0, -20.0, 1.0, -20.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::PointOnCurve, ConstraintKind::PointOnCurve,
vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], vec![ParametricRef::point(marker, 0), ParametricRef::whole(arc)],
None, None,
); );
scene.bump_entities(&[(marker, ChangeKind::Modified)]); scene.bump_entities(&[(marker, ChangeKind::Modified)]);
let (center, radius) = arc_geom(&scene, arc); let (center, radius, start_angle, end_angle) =
match scene.document.get_entity(arc).expect("entity exists") {
EntityType::Arc(arc) => (arc.center, arc.radius, arc.start_angle, arc.end_angle),
other => panic!("expected an Arc, got {other:?}"),
};
let (p, _) = line_endpoints(&scene, marker); let (p, _) = line_endpoints(&scene, marker);
let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt();
assert!( assert!(
(dist - radius).abs() < 1e-6, (dist - radius).abs() < 1e-6,
"point should land exactly on the arc's circle: dist={dist} radius={radius}" "point should land exactly on the arc: dist={dist} radius={radius}"
);
assert!(
cadkernel::geom2d::angle_within_arc(
(p.y - center.y).atan2(p.x - center.x),
start_angle,
end_angle,
),
"point must stay within the solved arc sweep: {p:?}"
);
}
#[test]
fn point_on_curve_constraint_keeps_a_point_within_a_polyline_arc_segment() {
let mut source = acadrust::entities::LwPolyline::new();
source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 1.0);
source.add_point(acadrust::types::Vector2::new(10.0, 0.0));
let mut scene = Scene::new();
let polyline = scene.add_entity(EntityType::LwPolyline(source));
let marker = add_line(&mut scene, 5.0, 20.0, 6.0, 20.0);
scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::PointOnCurve,
vec![
ParametricRef::point(marker, 0),
ParametricRef::segment(polyline, 0),
],
None,
);
scene.bump_entities(&[(marker, ChangeKind::Modified)]);
let (point, _) = line_endpoints(&scene, marker);
let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else {
panic!("expected a lightweight polyline");
};
let arc = cadkernel::geom2d::BulgeArc::from_bulge(
[
polyline.vertices[0].location.x,
polyline.vertices[0].location.y,
],
[
polyline.vertices[1].location.x,
polyline.vertices[1].location.y,
],
polyline.vertices[0].bulge,
)
.unwrap();
let distance = (point.x - arc.center[0]).hypot(point.y - arc.center[1]);
assert!((distance - arc.radius).abs() < 1e-6);
assert!(
cadkernel::geom2d::angle_within_arc(
(point.y - arc.center[1]).atan2(point.x - arc.center[0]),
arc.start_angle,
arc.start_angle + arc.sweep,
),
"point must stay within the solved polyline arc segment: {point:?}"
); );
} }
@@ -996,16 +1290,23 @@ fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() {
let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2);
let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(anchor)],
None,
);
set.add( set.add(
ConstraintKind::Coincident, ConstraintKind::Coincident,
vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], vec![
ParametricRef::point(arc, 0),
ParametricRef::point(anchor, 0),
],
None, None,
); );
set.add( set.add(
ConstraintKind::Radius, ConstraintKind::Radius,
vec![SketchRef::whole(arc)], vec![ParametricRef::whole(arc)],
Some(DrivingValue::Literal(6.0)), Some(DrivingValue::Literal(6.0)),
); );
scene.bump_entities(&[(arc, ChangeKind::Modified)]); scene.bump_entities(&[(arc, ChangeKind::Modified)]);
@@ -1032,10 +1333,10 @@ fn diameter_constraint_drives_the_radius_to_half_the_target() {
let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); let circle = add_circle(&mut scene, 0.0, 0.0, 3.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Diameter, ConstraintKind::Diameter,
vec![SketchRef::whole(circle)], vec![ParametricRef::whole(circle)],
Some(DrivingValue::Literal(16.0)), Some(DrivingValue::Literal(16.0)),
); );
scene.bump_entities(&[(circle, ChangeKind::Modified)]); scene.bump_entities(&[(circle, ChangeKind::Modified)]);
@@ -1055,10 +1356,10 @@ fn distance_x_constraint_pins_only_the_x_component() {
let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::DistanceX, ConstraintKind::DistanceX,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Literal(10.0)), Some(DrivingValue::Literal(10.0)),
); );
scene.bump_entities(&[(line, ChangeKind::Modified)]); scene.bump_entities(&[(line, ChangeKind::Modified)]);
@@ -1083,10 +1384,10 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() {
let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Normal, ConstraintKind::Normal,
vec![SketchRef::whole(circle), SketchRef::whole(line)], vec![ParametricRef::whole(circle), ParametricRef::whole(line)],
None, None,
); );
scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]);
@@ -1102,42 +1403,6 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() {
); );
} }
// Phase 4a of the same plan: `ArcLength` registers an arc's
// `start_angle`/`end_angle` separately from its center/radius (which still
// flow through the existing `EntityGeom::Circle` aliasing), so it can
// combine with a `Radius` constraint on the same arc in one solve.
#[test]
fn arc_length_constraint_drives_the_swept_length_to_the_target() {
let mut scene = Scene::new();
// radius 5, a 90° span (length ≈ 7.85) — deliberately not the target.
let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
set.add(
ConstraintKind::Radius,
vec![SketchRef::whole(arc)],
Some(DrivingValue::Literal(5.0)),
);
set.add(
ConstraintKind::ArcLength,
vec![SketchRef::whole(arc)],
Some(DrivingValue::Literal(10.0)),
);
scene.bump_entities(&[(arc, ChangeKind::Modified)]);
let (radius, start_angle, end_angle) = arc_angles(&scene, arc);
assert!(
(radius - 5.0).abs() < 1e-6,
"Radius constraint should still hold: got {radius}"
);
let length = radius * (end_angle - start_angle);
assert!(
(length - 10.0).abs() < 1e-5,
"swept length should equal the ArcLength target: got {length}"
);
}
// Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- // Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor-
// radius (converted from acadrust's center + major-axis-vector + ratio // radius (converted from acadrust's center + major-axis-vector + ratio
// parametrization). Only center-based whole-entity constraints are wired // parametrization). Only center-based whole-entity constraints are wired
@@ -1152,10 +1417,13 @@ fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together(
let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); let circle = add_circle(&mut scene, 10.0, -6.0, 2.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Concentric, ConstraintKind::Concentric,
vec![SketchRef::center(ellipse), SketchRef::center(circle)], vec![
ParametricRef::center(ellipse),
ParametricRef::center(circle),
],
None, None,
); );
scene.bump_entities(&[ scene.bump_entities(&[
@@ -1178,10 +1446,13 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() {
let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::CenterPoint, ConstraintKind::CenterPoint,
vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], vec![
ParametricRef::point(line, 0),
ParametricRef::center(ellipse),
],
None, None,
); );
@@ -1215,10 +1486,13 @@ fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() {
let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Concentric, ConstraintKind::Concentric,
vec![SketchRef::center(ellipse), SketchRef::center(circle)], vec![
ParametricRef::center(ellipse),
ParametricRef::center(circle),
],
None, None,
); );
scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); scene.bump_entities(&[(ellipse, ChangeKind::Modified)]);
@@ -1257,10 +1531,13 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m
let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); let circle = add_circle(&mut scene, 10.0, 7.0, 1.5);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::Concentric, ConstraintKind::Concentric,
vec![SketchRef::center(ellipse), SketchRef::center(circle)], vec![
ParametricRef::center(ellipse),
ParametricRef::center(circle),
],
None, None,
); );
scene.bump_entities(&[ scene.bump_entities(&[
@@ -1294,11 +1571,18 @@ fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() {
let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5);
let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0);
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace);
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); set.add(
ConstraintKind::Fixed,
vec![ParametricRef::whole(ellipse)],
None,
);
set.add( set.add(
ConstraintKind::CenterPoint, ConstraintKind::CenterPoint,
vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], vec![
ParametricRef::point(line, 0),
ParametricRef::center(ellipse),
],
None, None,
); );
@@ -1328,10 +1612,10 @@ fn distance_y_constraint_pins_only_the_y_component() {
let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0);
scene scene
.sketch_constraint_set_mut(SketchScope::ModelSpace) .parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add( .add(
ConstraintKind::DistanceY, ConstraintKind::DistanceY,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
Some(DrivingValue::Literal(-3.0)), Some(DrivingValue::Literal(-3.0)),
); );
scene.bump_entities(&[(line, ChangeKind::Modified)]); scene.bump_entities(&[(line, ChangeKind::Modified)]);
+512
View File
@@ -0,0 +1,512 @@
//! #1266: decoded IMAGE/OLE content must survive the shared PDF/preview/print path.
#![cfg(not(target_arch = "wasm32"))]
use acadrust::entities::{Insert, Viewport, Wipeout};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use std::sync::Arc;
use OpenCADStudio::io::pdf_export::{
export_pdf, export_pdf_pages, PdfPageInput, PdfPlotOptions, PlotContent, PlotGroupSplits,
PlotImage, PlotWire,
};
use OpenCADStudio::scene::model::image_model::{ImageModel, ImageQuadVertex};
use OpenCADStudio::scene::{Scene, WireModel};
fn embedded_scene() -> Scene {
let mut scene = Scene::new();
scene.document = OpenCADStudio::io::load_bytes(
"embedded-image-print.dxf",
include_bytes!("fixtures/embedded-image-print.dxf").to_vec(),
)
.expect("synthetic embedded bitmap DXF");
scene.images =
OpenCADStudio::scene::build_derived_caches_with_progress(&scene.document, &|_| {}, None)
.images;
scene.set_current_layout("Layout1".into());
scene
}
fn page(images: Vec<PlotImage>) -> PdfPageInput {
PdfPageInput {
content: PlotContent {
images,
..Default::default()
},
paper_w: 100.0,
paper_h: 100.0,
offset_x: 0.0,
offset_y: 0.0,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: PdfPlotOptions::default(),
plot_style: None,
}
}
fn export(pages: &[PdfPageInput]) -> Vec<u8> {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"ocs1266-{}-{}.pdf",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let [page] = pages {
export_pdf(page, &path).expect("export bitmap PDF");
} else {
export_pdf_pages(pages, &path, None).expect("export bitmap PDF pages");
}
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(path).unwrap();
bytes
}
struct Raster {
pixels: Vec<u8>,
width: usize,
height: usize,
scale: f32,
}
impl Raster {
fn from_pdf(bytes: &[u8], page: usize) -> Self {
Self::at_scale(bytes, page, 2.0)
}
fn at_scale(bytes: &[u8], page: usize, scale: f32) -> Self {
let pdf = hayro::hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap();
let pixmap = hayro::render(
&pdf.pages()[page],
&hayro::RenderCache::new(),
&hayro::hayro_interpret::InterpreterSettings::default(),
&hayro::RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: hayro::vello_cpu::color::palette::css::WHITE,
..Default::default()
},
);
Self {
pixels: pixmap.data_as_u8_slice().to_vec(),
width: u32::from(pixmap.width()) as usize,
height: u32::from(pixmap.height()) as usize,
scale,
}
}
fn assert_color(&self, x_mm: f32, y_mm: f32, expected: [u8; 3]) {
let x = (x_mm * 72.0 / 25.4 * self.scale) as usize;
let y = self.height - 1 - (y_mm * 72.0 / 25.4 * self.scale) as usize;
let actual = &self.pixels[(y * self.width + x) * 4..][..3];
assert!(
actual
.iter()
.zip(expected)
.all(|(&a, b)| a.abs_diff(b) < 25),
"at ({x_mm}, {y_mm}): expected {expected:?}, got {actual:?}"
);
}
}
fn image() -> PlotImage {
let corners = [
[10.0, 10.0, 0.0],
[50.0, 10.0, 0.0],
[50.0, 50.0, 0.0],
[10.0, 50.0, 0.0],
];
let uv = [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]];
// Four distinct quadrants detect flips, reflections, and diagonal seams.
let pixels = (0..32)
.flat_map(|y| {
(0..32).flat_map(move |x| match (x < 16, y < 16) {
(true, true) => [255, 0, 0, 255],
(false, true) => [0, 0, 255, 255],
(true, false) => [0, 255, 0, 255],
(false, false) => [255, 255, 0, 255],
})
})
.collect();
PlotImage {
image: ImageModel {
render_instance: None,
file_path: "synthetic".into(),
pixels: Arc::new(pixels),
width: 32,
height: 32,
opacity: 1.0,
corners,
corners_low: [[0.0; 3]; 4],
draw_depth: 0.5,
verts: [0, 1, 2, 0, 2, 3]
.into_iter()
.map(|i| ImageQuadVertex {
pos: corners[i],
pos_low: [0.0; 3],
uv: uv[i],
})
.collect(),
},
clips: Vec::new(),
}
}
#[test]
fn embedded_ole_inside_insert_reaches_rendered_pdf() {
let scene = embedded_scene();
let images = scene.paper_plot_images();
assert_eq!(images.len(), 1);
assert_eq!((images[0].image.width, images[0].image.height), (64, 32));
assert!(scene
.images
.values()
.any(|source| Arc::ptr_eq(&source.pixels, &images[0].image.pixels)));
let mut input = page(images);
input.scale = 10.0;
let raster = Raster::from_pdf(&export(&[input]), 0);
raster.assert_color(23.0, 42.0, [255, 0, 0]);
raster.assert_color(37.0, 48.0, [0, 0, 255]);
raster.assert_color(25.0, 47.5, [0, 255, 0]);
raster.assert_color(45.0, 45.0, [255, 255, 255]);
}
#[test]
fn rotated_nonuniform_insert_preserves_pixel_orientation() {
let mut scene = embedded_scene();
let handle = scene
.document
.entities()
.find_map(|e| match e {
EntityType::Insert(i) if i.block_name == "SyntheticImage" => Some(i.common.handle),
_ => None,
})
.unwrap();
let Some(EntityType::Insert(insert)) = scene.document.get_entity_mut(handle) else {
panic!()
};
insert.insert_point = Vector3::new(60.0, 20.0, 0.0);
insert.rotation = std::f64::consts::FRAC_PI_2;
insert.set_x_scale(20.0);
insert.set_y_scale(10.0);
let raster = Raster::from_pdf(&export(&[page(scene.paper_plot_images())]), 0);
raster.assert_color(58.0, 26.0, [255, 0, 0]);
raster.assert_color(52.0, 54.0, [0, 0, 255]);
raster.assert_color(52.5, 30.0, [0, 255, 0]);
raster.assert_color(65.0, 40.0, [255, 255, 255]);
}
#[test]
fn clipping_page_transform_and_shared_resources_survive_serialization() {
let mut first = image();
// A visible-region triangle (IMAGE clip), plus inherited and viewport clips.
first.image.verts.truncate(3);
first
.clips
.push(vec![[15.0, 5.0], [55.0, 5.0], [55.0, 45.0], [15.0, 45.0]]);
first
.clips
.push(vec![[5.0, 15.0], [45.0, 15.0], [45.0, 55.0], [5.0, 55.0]]);
let mut second = page(vec![first.clone()]);
second.rotation_deg = 90;
second.scale = 0.5;
second.offset_x = 10.0;
second.offset_y = 10.0;
let bytes = export(&[page(vec![first]), second]);
let parsed = printpdf::PdfDocument::parse(
&bytes,
&printpdf::PdfParseOptions::default(),
&mut Vec::new(),
)
.unwrap();
let uses: Vec<_> = parsed
.pages
.iter()
.flat_map(|p| p.ops.iter())
.filter_map(|op| {
if let printpdf::Op::UseXobject { id, .. } = op {
Some(id)
} else {
None
}
})
.collect();
assert_eq!(
uses.len(),
2,
"one image draw per affine quad, without a diagonal seam"
);
assert_eq!(uses[0], uses[1], "pages should share one image resource");
let raster = Raster::from_pdf(&bytes, 0);
raster.assert_color(40.0, 20.0, [255, 255, 0]);
for (x, y) in [(12.0, 20.0), (48.0, 20.0), (20.0, 40.0), (40.0, 12.0)] {
raster.assert_color(x, y, [255, 255, 255]);
}
Raster::from_pdf(&bytes, 1).assert_color(85.0, 25.0, [255, 255, 0]);
}
#[test]
fn image_alpha_and_plot_transparency_are_preserved() {
let mut plot = image();
plot.image.opacity = 0.5;
// Intrinsic transparent texels remain transparent with either plot setting.
let pixels = Arc::make_mut(&mut plot.image.pixels);
for y in 16..32 {
for x in 0..16 {
pixels[(y * 32 + x) * 4 + 3] = 0;
}
}
let opaque = page(vec![plot.clone()]);
let mut transparent = page(vec![plot]);
transparent.options.transparency = true;
let bytes = export(&[opaque, transparent]);
let off = Raster::from_pdf(&bytes, 0);
off.assert_color(20.0, 40.0, [255, 0, 0]);
off.assert_color(20.0, 20.0, [255, 255, 255]);
let on = Raster::from_pdf(&bytes, 1);
on.assert_color(20.0, 40.0, [255, 128, 128]);
on.assert_color(20.0, 20.0, [255, 255, 255]);
}
#[test]
fn depth_and_paper_model_groups_order_images_with_wipeouts_and_wires() {
let mut scene = Scene::new();
let wipeout = Wipeout::polygonal(
&[
Vector2::new(10.0, 30.0),
Vector2::new(30.0, 30.0),
Vector2::new(30.0, 50.0),
Vector2::new(10.0, 50.0),
],
0.0,
);
scene.add_entity(EntityType::Wipeout(wipeout));
let mut input = page(vec![image()]);
input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
assert_eq!(input.content.wipeouts.len(), 1);
input.content.wipeouts[0].draw_depth = 0.8;
let mut wire = WireModel::solid(
"over image".into(),
vec![[35.0, 15.0, 0.0], [45.0, 15.0, 0.0]],
[0.0, 0.0, 0.0, 1.0],
false,
);
wire.line_weight_px = 8.0;
input.content.wires = Arc::new(vec![PlotWire {
wire,
draw_depth: 0.9,
}]);
let raster = Raster::from_pdf(&export(&[input]), 0);
raster.assert_color(20.0, 40.0, [255, 255, 255]);
raster.assert_color(40.0, 40.0, [0, 0, 255]);
raster.assert_color(40.0, 15.0, [0, 0, 0]);
// First group wins by depth internally, second group still paints last.
let mut input = page(vec![image()]);
input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
input.content.wipeouts[0].draw_depth = 0.9;
input.content.group_splits = PlotGroupSplits {
wipeouts: 1,
..Default::default()
};
Raster::from_pdf(&export(&[input]), 0).assert_color(20.0, 40.0, [255, 0, 0]);
}
#[test]
fn viewport_projection_clips_images_and_respects_frozen_and_nonplot_layers() {
let mut scene = embedded_scene();
scene.set_current_layout("Model".into());
scene.ensure_layer("Bitmap");
let mut insert = Insert::new("SyntheticImage", Vector3::new(0.0, 0.0, 0.0));
insert.common.layer = "Bitmap".into();
insert.set_x_scale(20.0);
insert.set_y_scale(20.0);
scene.add_entity(EntityType::Insert(insert));
scene.set_current_layout("Layout1".into());
let mut viewport = Viewport::new();
viewport.center = Vector3::new(50.0, 50.0, 0.0);
viewport.width = 20.0;
viewport.height = 20.0;
viewport.view_height = 20.0;
viewport.view_center = Vector3::new(20.0, 10.0, 0.0);
viewport.id = 2;
viewport.status.is_on = true;
let handle = scene.add_entity(EntityType::Viewport(viewport));
let (_, _, _, images) = scene.viewport_plot_fills();
assert_eq!(images.len(), 1);
let raster = Raster::from_pdf(&export(&[page(images)]), 0);
raster.assert_color(42.0, 43.0, [255, 0, 0]);
raster.assert_color(58.0, 57.0, [0, 0, 255]);
raster.assert_color(35.0, 45.0, [255, 255, 255]);
let layer = scene.document.layers.get("Bitmap").unwrap().handle;
let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else {
panic!()
};
vp.frozen_layers.push(layer);
assert!(scene.viewport_plot_fills().3.is_empty());
let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else {
panic!()
};
vp.frozen_layers.clear();
scene
.document
.layers
.get_mut("Bitmap")
.unwrap()
.is_plottable = false;
assert!(scene.viewport_plot_fills().3.is_empty());
}
#[test]
fn linked_image_uses_decoded_pixels_and_its_image_clip_without_reopening() {
use acadrust::entities::{ClipBoundary, RasterImage};
let path = std::env::temp_dir().join(format!("ocs1266-linked-{}.png", std::process::id()));
let source = image();
image::save_buffer(&path, &source.image.pixels, 32, 32, image::ColorType::Rgba8).unwrap();
let mut raster = RasterImage::new(
path.to_str().unwrap(),
Vector3::new(10.0, 10.0, 0.0),
32.0,
32.0,
);
raster.u_vector = Vector3::new(1.25, 0.0, 0.0);
raster.v_vector = Vector3::new(0.0, 1.25, 0.0);
raster.clipping_enabled = true;
raster.clip_boundary =
ClipBoundary::rectangular(Vector2::new(0.0, 0.0), Vector2::new(16.0, 32.0));
let mut scene = Scene::new();
let handle = scene.add_entity(EntityType::RasterImage(raster));
scene.populate_images_from_document();
std::fs::remove_file(path).unwrap();
let images = scene.paper_plot_images();
assert_eq!(images.len(), 1);
assert!(Arc::ptr_eq(
&images[0].image.pixels,
&scene.images[&handle].pixels
));
let rendered = Raster::from_pdf(&export(&[page(images)]), 0);
rendered.assert_color(20.0, 40.0, [255, 0, 0]);
rendered.assert_color(20.0, 20.0, [0, 255, 0]);
rendered.assert_color(40.0, 40.0, [255, 255, 255]);
}
#[test]
fn equal_sized_sources_remain_distinct_across_pages() {
let pages = {
let red = image();
let mut cyan = image();
Arc::make_mut(&mut cyan.image.pixels)
.chunks_exact_mut(4)
.for_each(|pixel| pixel.copy_from_slice(&[0, 255, 255, 255]));
assert!(!Arc::ptr_eq(&red.image.pixels, &cyan.image.pixels));
vec![
page(vec![red.clone()]),
page(vec![cyan.clone()]),
page(vec![red]),
page(vec![cyan]),
]
};
let bytes = export(&pages);
let parsed = printpdf::PdfDocument::parse(
&bytes,
&printpdf::PdfParseOptions::default(),
&mut Vec::new(),
)
.unwrap();
let uses: Vec<_> = parsed
.pages
.iter()
.flat_map(|page| &page.ops)
.filter_map(|op| {
if let printpdf::Op::UseXobject { id, .. } = op {
Some(id)
} else {
None
}
})
.collect();
assert_eq!(uses.len(), 4);
assert_ne!(uses[0], uses[1]);
assert_eq!(uses[0], uses[2]);
assert_eq!(uses[1], uses[3]);
for (index, expected) in [[255, 0, 0], [0, 255, 255], [255, 0, 0], [0, 255, 255]]
.into_iter()
.enumerate()
{
Raster::from_pdf(&bytes, index).assert_color(20.0, 40.0, expected);
}
}
#[test]
fn affine_triangle_clip_has_no_shared_edge_seam() {
let mut plot = image();
Arc::make_mut(&mut plot.image.pixels)
.chunks_exact_mut(4)
.for_each(|pixel| pixel.copy_from_slice(&[0, 0, 0, 128]));
let mut sheared = plot.clone();
let transform = |[x, y, z]: [f32; 3]| [x + 0.27 * y + 5.0, 0.19 * x + y + 3.0, z];
sheared
.image
.corners
.iter_mut()
.for_each(|p| *p = transform(*p));
sheared
.image
.verts
.iter_mut()
.for_each(|v| v.pos = transform(v.pos));
let pages = [page(vec![plot]), page(vec![sheared])];
let bytes = export(&pages);
for (index, page) in pages.iter().enumerate() {
let raster = Raster::at_scale(&bytes, index, 8.0);
let corners = page.content.images[0].image.corners;
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
let determinant = u[0] * v[1] - u[1] * v[0];
let mut checked = 0;
for y in 0..raster.height {
for x in 0..raster.width {
let dx = (x as f32 + 0.5) / raster.scale * 25.4 / 72.0 - corners[0][0];
let dy = (raster.height as f32 - y as f32 - 0.5) / raster.scale * 25.4 / 72.0
- corners[0][1];
let a = (dx * v[1] - dy * v[0]) / determinant;
let b = (u[0] * dy - u[1] * dx) / determinant;
if (0.15..0.85).contains(&a) && (0.15..0.85).contains(&b) {
let rgb = &raster.pixels[(y * raster.width + x) * 4..][..3];
assert!(rgb.iter().all(|&c| c.abs_diff(127) <= 2),
"page {index}, pixel ({x}, {y}): shared-edge gap or double coverage: {rgb:?}");
checked += 1;
}
}
}
assert!(checked > 100_000);
}
}
#[test]
fn malformed_bitmap_reports_an_error_without_replacing_the_pdf() {
let path = std::env::temp_dir().join(format!("ocs1266-invalid-{}.pdf", std::process::id()));
std::fs::write(&path, b"existing PDF").unwrap();
let mut pixels = image();
pixels.image.width += 1;
let mut coordinates = image();
coordinates.image.corners[0][0] = f32::NAN;
let mut mapping = image();
mapping.image.corners[2][0] += 10.0;
mapping
.image
.verts
.iter_mut()
.for_each(|vertex| vertex.uv = [0.0, 0.0]);
for (plot, reason) in [
(pixels, "dimensions"),
(coordinates, "coordinates"),
(mapping, "texture mapping"),
] {
let error = export_pdf(&page(vec![plot]), &path).unwrap_err();
assert!(
error.contains("Page 1") && error.contains(reason),
"{error}"
);
assert_eq!(std::fs::read(&path).unwrap(), b"existing PDF");
}
std::fs::remove_file(path).unwrap();
}
+26 -35
View File
@@ -11,7 +11,9 @@
use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::entities::{Dimension, DimensionLinear, Text};
use acadrust::types::Vector3; use acadrust::types::Vector3;
use acadrust::EntityType; use acadrust::EntityType;
use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; use OpenCADStudio::io::pdf_export::{
export_pdf, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire,
};
use OpenCADStudio::scene::Scene; use OpenCADStudio::scene::Scene;
#[test] #[test]
@@ -60,49 +62,38 @@ fn text_and_dim_reach_pdf_export() {
.collect(); .collect();
let p_text = dir.join("with_text.pdf"); let p_text = dir.join("with_text.pdf");
export_pdf( let mut page = PdfPageInput {
&plot_wires, content: PlotContent {
&[], wires: std::sync::Arc::new(plot_wires),
&[], ..Default::default()
210.0, },
297.0, paper_w: 210.0,
0.0, paper_h: 297.0,
0.0, offset_x: 0.0,
0, offset_y: 0.0,
1.0, rotation_deg: 0,
None, scale: 1.0,
&p_text, clip: None,
None, options: PdfPlotOptions::default(),
PdfPlotOptions::default(), plot_style: None,
) };
.expect("export with text"); export_pdf(&page, &p_text).expect("export with text");
let with_text = std::fs::read(&p_text).expect("read pdf"); let with_text = std::fs::read(&p_text).expect("read pdf");
assert!(with_text.starts_with(b"%PDF"), "not a PDF"); assert!(with_text.starts_with(b"%PDF"), "not a PDF");
let stripped: Vec<_> = plot_wires let stripped: Vec<_> = page
.into_iter() .content
.wires
.iter()
.cloned()
.map(|mut w| { .map(|mut w| {
w.wire.text_verts.clear(); w.wire.text_verts.clear();
w w
}) })
.collect(); .collect();
let p_bare = dir.join("no_text.pdf"); let p_bare = dir.join("no_text.pdf");
export_pdf( page.content.wires = std::sync::Arc::new(stripped);
&stripped, export_pdf(&page, &p_bare).expect("export without text");
&[],
&[],
210.0,
297.0,
0.0,
0.0,
0,
1.0,
None,
&p_bare,
None,
PdfPlotOptions::default(),
)
.expect("export without text");
let no_text = std::fs::read(&p_bare).expect("read pdf"); let no_text = std::fs::read(&p_bare).expect("read pdf");
assert!( assert!(
@@ -1,159 +0,0 @@
// Proves a SketchConstraintSet serialized into an XRecord entry
// block record's extension dictionary, survives a real save/reload through
// both supported file formats byte-for-byte.
use acadrust::entities::EntityType;
use acadrust::objects::{XRecordEntry, XRecordValue};
use acadrust::types::{Handle, Vector3};
use OpenCADStudio::scene::named_parameters::DrivingValue;
use OpenCADStudio::scene::sketch_constraints::{
ConstraintKind, SketchConstraintSet, SketchRef, SketchScope,
};
use OpenCADStudio::scene::Scene;
const RECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS_TEST";
fn sample_set(scope: SketchScope) -> SketchConstraintSet {
let mut set = SketchConstraintSet::new(scope);
set.add(
ConstraintKind::Coincident,
vec![
SketchRef::point(Handle::new(101), 0),
SketchRef::point(Handle::new(102), 1),
],
None,
);
set.add(
ConstraintKind::Distance,
vec![SketchRef::whole(Handle::new(103))],
Some(DrivingValue::Literal(25.0)),
);
set.add(
ConstraintKind::Angle,
vec![
SketchRef::whole(Handle::new(104)),
SketchRef::whole(Handle::new(105)),
],
Some(DrivingValue::Literal(45.0)),
);
set.add(
ConstraintKind::Radius,
vec![SketchRef::whole(Handle::new(106))],
Some(DrivingValue::Literal(3.5)),
);
set
}
/// Writes `set` into a fresh XRecord Chunk under `owner`'s extension
/// dictionary, round-trips the whole document through `ext` (`"dxf"` or
/// anything else for DWG), and returns the `SketchConstraintSet` read back
/// out of the reloaded document.
fn roundtrip_through(
scene: &Scene,
owner: Handle,
set: &SketchConstraintSet,
ext: &str,
) -> SketchConstraintSet {
let mut doc = scene.document.clone();
let bytes = bincode::serialize(set).expect("serialize SketchConstraintSet");
doc.ensure_xrecord(owner, RECORD_KEY);
let record = doc
.xrecord_mut(owner, RECORD_KEY)
.expect("xrecord_mut after ensure_xrecord");
record
.entries
.push(XRecordEntry::new(310, XRecordValue::Chunk(bytes)));
let saved = OpenCADStudio::io::save_to_bytes(&doc, ext, doc.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let reloaded = OpenCADStudio::io::load_bytes(&format!("roundtrip.{ext}"), saved)
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
let record = reloaded
.xrecord(owner, RECORD_KEY)
.unwrap_or_else(|| panic!("XRecord {RECORD_KEY:?} missing after {ext} round-trip"));
let chunk = record
.entries
.iter()
.find_map(|entry| match &entry.value {
XRecordValue::Chunk(bytes) => Some(bytes.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("no Chunk entry survived the {ext} round-trip"));
bincode::deserialize::<SketchConstraintSet>(&chunk)
.unwrap_or_else(|e| panic!("deserialize round-tripped {ext} blob: {e}"))
}
#[test]
fn sketch_constraint_set_survives_a_dxf_roundtrip() {
let scene = Scene::new();
let owner = scene.document.header.model_space_block_handle;
assert!(
!owner.is_null(),
"a fresh document must already have a model-space block handle"
);
let original = sample_set(SketchScope::ModelSpace);
let restored = roundtrip_through(&scene, owner, &original, "dxf");
assert_eq!(restored.scope, original.scope);
assert_eq!(restored.constraints.len(), original.constraints.len());
for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) {
assert_eq!(r.id, o.id);
assert_eq!(r.kind, o.kind);
assert_eq!(r.refs, o.refs);
assert_eq!(r.driving_param, o.driving_param);
assert_eq!(r.enabled, o.enabled);
}
}
#[test]
fn sketch_constraint_set_survives_a_dwg_roundtrip() {
let scene = Scene::new();
let owner = scene.document.header.model_space_block_handle;
let original = sample_set(SketchScope::ModelSpace);
let restored = roundtrip_through(&scene, owner, &original, "dwg");
assert_eq!(restored.scope, original.scope);
assert_eq!(restored.constraints.len(), original.constraints.len());
for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) {
assert_eq!(r.id, o.id);
assert_eq!(r.kind, o.kind);
assert_eq!(r.refs, o.refs);
assert_eq!(r.driving_param, o.driving_param);
}
}
/// A block-record-scoped set (not model space) — confirms the XRecord
/// extension-dictionary mechanism works for an arbitrary owner handle, not
/// just the one every document already has.
#[test]
fn sketch_constraint_set_survives_roundtrip_when_owned_by_a_block_record() {
let mut scene = Scene::new();
// A real block definition via the app's own `create_block_from_entities`
// (needs at least one source entity) — a bare, entity-less BlockRecord
// table entry turned out not to survive a DXF round-trip at all (the
// writer prunes it as unreferenced), so this exercises the actual shape
// `SketchScope::Block` will see in practice.
let line_handle = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
let br_handle = scene
.create_block_from_entities(
&[line_handle],
"OCS_TEST_BLOCK",
&acadrust::types::Transform::identity(),
&acadrust::types::Transform::identity(),
)
.expect("create a block definition to own the constraint set");
let original = sample_set(SketchScope::Block(br_handle));
let restored = roundtrip_through(&scene, br_handle, &original, "dxf");
assert_eq!(restored.scope, SketchScope::Block(br_handle));
assert_eq!(restored.constraints.len(), original.constraints.len());
}